Compare commits

...

24 Commits

Author SHA1 Message Date
8c998d7423 feat: a sprint calendar
A Calendario tab lays the project's sprints out month by month as bands,
with the releases and the tickets due on each day. Owners and admins plan
a sprint by clicking the day it starts, and the whole plan downloads as an
iCalendar file. Tickets can be searched by due-date range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:20:28 +02:00
feea58d0e8 feat: a public showcase of each project
A public endpoint sums up a public project for websites: epics with their
dates and progress, the latest releases with the notes of shipped ones,
the sprint under way and open tickets labelled good-first-issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:13:56 +02:00
bec39da46d feat: a trash for tickets
Deleting a ticket now sends it to the trash, for whoever opened it or an
admin: it disappears from boards, searches and statistics, cannot change,
and comes back from the ticket page or the trash page for 30 days. After
that it is deleted for good with the usual cascade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:47:30 +02:00
71b807b94b feat: due-date reminders
Every hour the API reminds whoever has a ticket, and whoever follows it,
the day before its due date, on the day and once when it is overdue, in
the app and by e-mail for those who asked. Cards on the board flag what
is due soon or late.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:36:19 +02:00
d6a434ca1d feat: global search and keyboard shortcuts
Ctrl/⌘ K (or /) opens a search over the projects and tickets of all my
projects, driven by the keyboard. g plus a letter moves between project
pages, c creates a ticket on the board, e edits the open ticket and ?
lists every shortcut. My work can now be searched by text through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:32:07 +02:00
4148335457 feat: bulk edit tickets
Select several tickets in the backlog and change their status, assignee,
sprint, priority or labels at once. Each ticket goes through the normal
rules and may refuse on its own; the result says which did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:26:53 +02:00
5863d773be feat: templates per ticket type
Each ticket type can carry a starting description and a checklist, edited
in the project settings. New tickets of that type get the description
when they come without one and always get the checklist; the new ticket
form says what the template will add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:58:37 +02:00
8994590b54 feat: automation rules
Owners and admins can set rules of the kind «when a ticket is created,
moves, gets assigned, gets a comment or its pull request merges; if it is
of this type, priority, area or label; then move it, assign it, label it,
change its priority, comment or put it in the active sprint». Rules act as
TaskStream within the workflow, run once per event and trigger no others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:53:31 +02:00
1076205acc feat: import and export tickets as CSV
Every ticket of a project can be downloaded as CSV, and a CSV exported
from TaskStream or from Jira can be imported: types, statuses, priorities
and people are matched by id or name, the rest falls back with a warning,
and parents are rebuilt. The settings page previews an import before it
creates anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:43:02 +02:00
d43fc620e9 feat: outgoing webhooks
Owners and admins can point a project at other systems: a signed JSON
POST (HMAC-SHA256 of the body) when tickets, comments, releases or sprints
change, filtered by event or family. Deliveries are retried a couple of
times, the last one is shown, and a ping can be sent from the settings.
Private addresses are refused unless WEBHOOKS_ALLOW_PRIVATE is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:36:19 +02:00
046d77c813 feat: link commits, branches and pull requests to tickets
A project can turn on a webhook for GitHub, Gitea or GitLab. Pushes and
pull or merge requests that name a ticket key show on that ticket under
Desarrollo, with the state of each pull request; the secret is shown and
rotated from the project settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:28:12 +02:00
24b37b6628 feat: release notes
Each release can carry notes in Markdown, written before or after it
ships. A draft is generated from its finished tickets grouped by type, to
edit and copy from the releases page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:19:23 +02:00
45a3dc1e16 feat: a roadmap of epics over time
Tickets get a start date next to their due date, both editable on the
ticket page. The Hoja de ruta tab lays the epics out over the months as
bars filled with their progress, with sprints and releases as landmarks
and a line for today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:14:32 +02:00
09bd8c19a3 feat: work-in-progress limits and swimlanes on the board
Each status can carry a WIP limit in the project settings; the board shows
it next to the count, marks a column that goes over it and says so when a
card is dropped there. The board can also be grouped in rows by person,
epic, priority or type, remembered per browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:09:21 +02:00
6aec774c92 feat: estimate tickets and see the team's velocity
Story points can be set on the ticket page and when creating a ticket,
and show on cards, column headers, subtasks and the backlog. The Sprints
tab charts the points finished per closed sprint and warns when a planned
sprint holds more than the recent average.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:04:11 +02:00
50d3ffd1dd feat: sprints with burndown and carry-over
Plan sprints with dates and a goal, run one at a time per project, and
put tickets in them from the ticket page. The Sprints tab follows the one
under way with its progress and a burndown in tickets or points against
the ideal line. Closing a sprint sends what was left to the next one or
back to the backlog; the board can be filtered by sprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:59:01 +02:00
d2e31789a7 fix: keep the user menu and the bell in the header slot
With two elements in the signed-in branch, neither was projected into the
shell's user slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:58:27 +02:00
298383f19f feat: saved board views and richer board filters
The board now filters by text, type, priority and unassigned tickets as
well. Any combination can be saved as a named view, private or shared with
the project, and opened again with one click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:38 +02:00
0606f53026 feat: an ordered backlog
A Backlog tab lists the tickets still to start in the order the team will
take them; drag them, or use the arrows, to reorder. Moving a ticket only
rewrites that ticket's rank, read from the event store, and board columns
follow the same order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:01:44 +02:00
d4da33d4e8 feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep
at most (epic → ticket → subtask). The ticket page lists its subtasks with
their progress, creates new ones and moves the ticket under an epic; a new
Épicas tab shows every epic and how far it has got. Each ticket also gets a
checklist for the small steps that don't deserve a ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:52:43 +02:00
75323115fb fix: make the follow button on a ticket work
The store never sent the follow and unfollow requests to the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:06:48 +02:00
88928b402f feat: mentions and following tickets
Mention project members in comments with @, follow and unfollow tickets,
and get told when something changes on the tickets you follow. Commenting
on a ticket starts following it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:41:32 +02:00
efa06a0723 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>
2026-10-08 09:19:59 +02:00
0b65c1c912 feat: attachments on tickets and comments
Files are uploaded over REST, stored on disk (a volume in Docker) and
recorded on the ticket as events; the bytes are written first, so no
attachment ever points at a missing file. Only images, video, audio, PDF
and plain text are shown inline, everything with nosniff and a sandbox CSP.
The web shows thumbnails, uploads by drag and drop or by pasting a
screenshot, and attaches files to new comments.

Also adds `nx run api:contracts`, which writes schema.gql and
openapi.json without starting the API or any database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:35:33 +02:00
341 changed files with 47131 additions and 4541 deletions

View File

@@ -36,6 +36,22 @@ GRAPHQL_PLAYGROUND=true
SWAGGER_ENABLED=true
OPENAPI_FILE=apps/api/openapi.json
# Files attached to tickets: folder (a volume in Docker) and size limit per file.
ATTACHMENTS_DIR=data/attachments
ATTACHMENTS_MAX_MB=25
# Optional e-mail copies of notifications. Without SMTP_URL nothing is sent.
SMTP_URL=
MAIL_FROM=
WEB_URL=http://localhost:4200
# Outgoing webhooks refuse hosts on this machine or its private network unless true.
WEBHOOKS_ALLOW_PRIVATE=false
# Due-date reminders (hourly). The day changes at midnight in this time zone.
REMINDERS_ENABLED=true
REMINDERS_TIMEZONE=Europe/Madrid
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
NEXUS_TOKEN=

3
.gitignore vendored
View File

@@ -53,3 +53,6 @@ Thumbs.db
__screenshots__/
vitest.config.*.timestamp*!apps/web/.env
# Attached files in development (ATTACHMENTS_DIR)
/data

View File

@@ -30,6 +30,9 @@ ENV NODE_ENV=production PORT=3000
COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
COPY --from=build-api /workspace/dist/apps/api/main.js ./
# Attached files (a volume in docker-compose), writable by the user that runs the API.
RUN mkdir -p /data/attachments && chown node:node /data/attachments
ENV ATTACHMENTS_DIR=/data/attachments
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,10 @@
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/api/src",
"projectType": "application",
"tags": [
"scope:api",
"type:app"
],
"tags": ["scope:api", "type:app"],
"targets": {
"prune-lockfile": {
"dependsOn": [
"build"
],
"dependsOn": ["build"],
"cache": true,
"executor": "@nx/js:prune-lockfile",
"outputs": [
@@ -23,32 +18,23 @@
}
},
"copy-workspace-modules": {
"dependsOn": [
"build"
],
"dependsOn": ["build"],
"cache": true,
"outputs": [
"{workspaceRoot}/dist/apps/api/workspace_modules"
],
"outputs": ["{workspaceRoot}/dist/apps/api/workspace_modules"],
"executor": "@nx/js:copy-workspace-modules",
"options": {
"buildTarget": "build"
}
},
"prune": {
"dependsOn": [
"prune-lockfile",
"copy-workspace-modules"
],
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
"executor": "nx:noop"
},
"serve": {
"continuous": true,
"executor": "@nx/js:node",
"defaultConfiguration": "development",
"dependsOn": [
"build"
],
"dependsOn": ["build"],
"options": {
"buildTarget": "api:build",
"runBuildTargetDependencies": false
@@ -73,6 +59,13 @@
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
},
"cache": false
},
"contracts": {
"executor": "nx:run-commands",
"options": {
"command": "node -r ./apps/api/scripts/register.cjs apps/api/scripts/contracts.ts"
},
"cache": false
}
}
}

View File

@@ -13,6 +13,11 @@ type Activity {
type: String!
}
input AddChecklistItemInput {
issueId: ID!
text: String!
}
type AreaOption {
archived: Boolean!
id: String!
@@ -25,12 +30,134 @@ input AreaOptionInput {
name: String!
}
"""
A file on a ticket. Download it from the REST API: GET /api/issues/{issueId}/attachments/{id}
"""
type Attachment {
"""The comment it was attached to, if any"""
commentId: ID
fileName: String!
id: ID!
mimeType: String!
"""Bytes"""
size: Int!
uploadedAt: String!
uploadedBy: UserRef!
}
"""When this happens to a ticket, if it is like this, do that"""
type Automation {
action: AutomationActionSpec!
active: Boolean!
conditions: AutomationConditions!
createdAt: String!
createdBy: UserRef!
id: ID!
"""Why the last run did nothing"""
lastError: String
lastRunAt: String
name: String!
projectId: ID!
runs: Int!
trigger: AutomationTriggerSpec!
}
"""What a rule does to the ticket"""
enum AutomationAction {
ADD_COMMENT
ADD_LABEL
ADD_TO_ACTIVE_SPRINT
ASSIGN
ASSIGN_REPORTER
SET_PRIORITY
SET_STATUS
}
input AutomationActionInput {
kind: AutomationAction!
value: String
}
type AutomationActionSpec {
kind: AutomationAction!
"""Status, member id, label, priority or comment text"""
value: String
}
type AutomationConditions {
areas: [String!]
label: String
priorities: [IssuePriority!]
types: [String!]
}
input AutomationConditionsInput {
areas: [String!]
label: String
priorities: [IssuePriority!]
types: [String!]
}
"""What starts a rule"""
enum AutomationTrigger {
COMMENT_ADDED
ISSUE_ASSIGNED
ISSUE_CREATED
PULL_REQUEST_MERGED
STATUS_CHANGED
}
input AutomationTriggerInput {
kind: AutomationTrigger!
status: String
}
type AutomationTriggerSpec {
kind: AutomationTrigger!
"""STATUS_CHANGED: only into this status"""
status: String
}
type BoardColumn {
issues: [Issue!]!
status: String!
total: Int!
}
type BulkIssueResult {
error: String
issueId: ID!
ok: Boolean!
}
type BulkResult {
results: [BulkIssueResult!]!
updated: Int!
}
input BulkUpdateIssuesInput {
addLabels: [String!]
assigneeId: ID
issueIds: [ID!]!
"""Out of their release"""
noRelease: Boolean
"""Back to the backlog"""
noSprint: Boolean
priority: IssuePriority
releaseId: ID
sprintId: ID
status: String
"""Unassign everything"""
unassign: Boolean
}
input ChangeIssueStatusInput {
issueId: ID!
@@ -41,6 +168,23 @@ input ChangeIssueStatusInput {
status: String!
}
"""A step inside a ticket that does not deserve a ticket of its own"""
type ChecklistItem {
done: Boolean!
id: ID!
text: String!
}
"""How far the tickets hanging from one have got"""
type ChildProgress {
"""Done or cancelled"""
finished: Int!
finishedStoryPoints: Int!
percent: Int!
storyPoints: Int!
total: Int!
}
"""
Writes are acknowledged immediately; the read model catches up asynchronously. Wait for a subscription event with version >= this version to see the change.
"""
@@ -95,6 +239,14 @@ type CountByStatus {
key: String!
}
input CreateAutomationInput {
action: AutomationActionInput!
conditions: AutomationConditionsInput
name: String!
projectId: ID!
trigger: AutomationTriggerInput!
}
input CreateIssueInput {
"""An area id"""
area: String
@@ -105,9 +257,15 @@ input CreateIssueInput {
"""Figma frame for UI changes"""
figmaUrl: String
labels: [String!]
"""Epic or ticket of the same project the new one breaks down"""
parentId: ID
priority: IssuePriority
projectId: ID!
releaseId: ID
"""When work should start (roadmap), YYYY-MM-DD"""
startDate: String
storyPoints: Int
title: String!
@@ -135,6 +293,24 @@ input CreateReleaseInput {
targetDate: String
}
input CreateSprintInput {
"""YYYY-MM-DD, included"""
endDate: String!
goal: String
name: String!
projectId: ID!
"""YYYY-MM-DD"""
startDate: String!
}
input CreateWebhookInput {
"""Event names, family.* or *"""
events: [String!]!
projectId: ID!
url: String!
}
type CreatedResolved {
created: Int!
resolved: Int!
@@ -148,6 +324,34 @@ type CumulativeFlow {
series: [FlowSeries!]!
}
"""A commit, branch or pull request that mentions the ticket"""
type DevLink {
at: String!
author: String
kind: DevLinkKind!
"""github, gitea or gitlab"""
provider: String!
"""Short SHA, branch or PR number"""
ref: String!
state: DevLinkState
title: String!
url: String!
}
enum DevLinkKind {
BRANCH
COMMIT
PULL_REQUEST
}
enum DevLinkState {
CLOSED
MERGED
OPEN
}
type DurationStats {
averageDays: Float
count: Int!
@@ -156,11 +360,30 @@ type DurationStats {
p85Days: Float
}
"""A top-level ticket that groups others, with their progress"""
type Epic {
issue: Issue!
progress: ChildProgress!
}
type FlowSeries {
counts: [Int!]!
status: String!
}
"""
Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it
"""
type GitIntegration {
enabled: Boolean!
"""Webhook secret; only owners and admins see it"""
secret: String
"""Path of the webhook on this API"""
webhookPath: String!
}
type Issue {
"""Statuses this ticket can move to next (project's workflow)"""
allowedTransitions: [String!]!
@@ -168,10 +391,15 @@ type Issue {
"""An area id"""
area: String
assignee: UserRef
attachments: [Attachment!]!
checklist: [ChecklistItem!]!
commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
createdAt: String!
description: String
"""Commits, branches and pull requests that mention it"""
devLinks: [DevLink!]!
dueDate: String
figmaUrl: String
@@ -187,11 +415,23 @@ type Issue {
linkedIssues: [Issue!]!
links: [IssueLink!]!
number: Int!
"""The epic or ticket this one breaks down"""
parent: IssueParent
priority: IssuePriority!
projectId: ID!
projectKey: String!
"""
Place in the backlog and in its board column (ascending, compare as text)
"""
rank: String!
release: ReleaseRef
reporter: UserRef!
sprint: SprintRef
"""When work should start (roadmap)"""
startDate: String
"""A status id of the project's settings"""
status: String!
@@ -199,10 +439,16 @@ type Issue {
storyPoints: Int
title: String!
"""In the trash since then; null when it is not"""
trashedAt: String
"""A ticket type id of the project's settings"""
type: String!
updatedAt: String!
version: Int!
"""People following the ticket"""
watchers: [UserRef!]!
}
type IssueChange {
@@ -216,13 +462,27 @@ input IssueFilterInput {
areas: [String!]
assigneeId: ID
categories: [StatusCategory!]
"""Due on or after this day (YYYY-MM-DD)"""
dueFrom: String
"""Due on or before this day (YYYY-MM-DD)"""
dueTo: String
labels: [String!]
"""Only tickets in no sprint"""
noSprint: Boolean
"""Only the tickets hanging from this one"""
parentId: ID
priorities: [IssuePriority!]
projectId: ID!
releaseId: ID
"""Matches key, title or description"""
search: String
sort: IssueSort = RECENT
sprintId: ID
statuses: [String!]
types: [String!]
unassigned: Boolean
@@ -249,6 +509,13 @@ type IssuePage {
total: Int!
}
"""The ticket another one hangs from"""
type IssueParent {
id: ID!
key: String!
title: String!
}
enum IssuePriority {
CRITICAL
HIGH
@@ -257,12 +524,33 @@ enum IssuePriority {
MEDIUM
}
"""RECENT: last updated first. RANK: backlog order."""
enum IssueSort {
RANK
RECENT
}
"""What a new ticket of the type starts with"""
type IssueTemplate {
"""Steps added to every new ticket"""
checklist: [String!]!
"""Used when the ticket comes without a description"""
description: String
}
input IssueTemplateInput {
checklist: [String!]! = []
description: String
}
type IssueTypeOption {
archived: Boolean!
color: String!
icon: String!
id: String!
name: String!
template: IssueTemplate
}
input IssueTypeOptionInput {
@@ -271,6 +559,7 @@ input IssueTypeOptionInput {
icon: String!
id: String!
name: String!
template: IssueTemplateInput
}
enum LinkDirection {
@@ -297,6 +586,8 @@ type Me {
}
type Mutation {
"""Add a step to a ticket's checklist"""
addChecklistItem(input: AddChecklistItemInput!): CommandResult!
addComment(body: String!, issueId: ID!): CommandResult!
"""Add a Pireon user to the project"""
@@ -306,13 +597,33 @@ type Mutation {
"""Assign (or unassign with null) a project member"""
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
"""
The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own
"""
bulkUpdateIssues(input: BulkUpdateIssuesInput!): BulkResult!
"""Move a ticket through the workflow (incl. QA)"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
"""
Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.
"""
closeSprint(carryOverToSprintId: ID, sprintId: ID!): CommandResult!
"""
Add a rule. It acts as TaskStream, within the workflow, and what it does triggers no other rule.
"""
createAutomation(input: CreateAutomationInput!): Automation!
createIssue(input: CreateIssueInput!): CommandResult!
createProject(input: CreateProjectInput!): CommandResult!
createRelease(input: CreateReleaseInput!): CommandResult!
"""Plan a sprint (owners and admins)"""
createSprint(input: CreateSprintInput!): CommandResult!
createWebhook(input: CreateWebhookInput!): WebhookCreated!
deleteAutomation(automationId: ID!): Boolean!
"""Author or project admin"""
deleteComment(commentId: ID!): CommandResult!
@@ -320,21 +631,79 @@ type Mutation {
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
"""
deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
"""Delete a view: whoever saved it, or an admin if it is shared"""
deleteSavedFilter(id: ID!): Boolean!
"""Delete a sprint that never started; its tickets go back to the backlog"""
deleteSprint(sprintId: ID!): CommandResult!
deleteWebhook(webhookId: ID!): Boolean!
disableGitIntegration(projectId: ID!): Boolean!
editComment(body: String!, commentId: ID!): CommandResult!
"""Turn the Git webhook on, with a new secret (also to rotate it)"""
enableGitIntegration(projectId: ID!): GitIntegration!
"""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.
"""
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
"""Move a ticket in the backlog, between two others of the same project"""
rankIssue(input: RankIssueInput!): CommandResult!
"""
Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.
"""
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""Back from the trash"""
restoreIssue(issueId: ID!): CommandResult!
"""Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID!
"""
Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.
"""
setIssueParent(issueId: ID!, parentId: ID): CommandResult!
"""Put a ticket in a release (null removes it)"""
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
"""Put a ticket in a sprint (null: back to the backlog)"""
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
"""Start a planned sprint (only one under way per project)"""
startSprint(sprintId: ID!): CommandResult!
"""Send a ping now and tell how it went"""
testWebhook(webhookId: ID!): WebhookDelivery!
"""
To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days
"""
trashIssue(issueId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Stop following a ticket"""
unwatchIssue(issueId: ID!): CommandResult!
"""Rename, pause or resume"""
updateAutomation(active: Boolean, automationId: ID!, name: String): Automation!
"""Reword, tick or untick a checklist step"""
updateChecklistItem(input: UpdateChecklistItemInput!): CommandResult!
updateIssue(input: UpdateIssueInput!): CommandResult!
updateNotificationSettings(email: Boolean!): NotificationSettings!
updateProject(input: UpdateProjectInput!): CommandResult!
"""
@@ -342,6 +711,60 @@ type Mutation {
"""
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
updateRelease(input: UpdateReleaseInput!): CommandResult!
updateSprint(input: UpdateSprintInput!): CommandResult!
updateWebhook(input: UpdateWebhookInput!): Webhook!
"""
Follow a ticket: be told when it moves, is reassigned or gets a comment
"""
watchIssue(issueId: ID!): CommandResult!
"""
Write the release notes (Markdown; null removes them). Also after shipping.
"""
writeReleaseNotes(notes: String, releaseId: ID!): 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 {
@@ -351,7 +774,20 @@ type Project {
analytics(days: Int! = 90): ProjectAnalytics!
"""Kanban board, one column per status"""
board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]!
board(
area: String
assigneeId: ID
labels: [String!]
perColumn: Int! = 50
priorities: [IssuePriority!]
releaseId: ID
"""Matches key, title or description"""
search: String
sprintId: ID
types: [String!]
unassigned: Boolean
): [BoardColumn!]!
createdAt: String!
description: String
id: ID!
@@ -462,6 +898,13 @@ type Query {
limit: Int! = 50
projectId: ID!
): [Activity!]!
automations(projectId: ID!): [Automation!]!
"""
A project's epics: top-level tickets that group others, with the progress of those
"""
epics(projectId: ID!): [Epic!]!
gitIntegration(projectId: ID!): GitIntegration!
issue(
"""Issue id or key (e.g. "TS-42")"""
idOrKey: String!
@@ -474,10 +917,23 @@ type Query {
"""
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
"""
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
"""Matches key, title or description (global search)"""
search: String
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(
@@ -489,12 +945,37 @@ type Query {
publicProjects: [Project!]!
release(id: ID!): Release!
"""My views of the project's board, then the shared ones"""
savedFilters(projectId: ID!): [SavedFilter!]!
sprint(id: ID!): Sprint!
"""
A project's sprints: the one under way, the planned ones, then the closed ones
"""
sprints(projectId: ID!): [Sprint!]!
"""A project's tickets in the trash, newest first"""
trash(projectId: ID!): [Issue!]!
"""Planned releases of all my projects, soonest target date first"""
upcomingReleases(limit: Int! = 10): [Release!]!
user(id: String!): User
"""Search users by name or email (to assign / add members)"""
users(limit: Int! = 20, search: String! = ""): [User!]!
"""Event names a webhook can subscribe to (also family.* and *)"""
webhookEvents: [String!]!
webhooks(projectId: ID!): [Webhook!]!
}
input RankIssueInput {
"""The ticket that ends up right above (null: the top)"""
aboveIssueId: ID
"""The ticket that ends up right below (null: the bottom)"""
belowIssueId: ID
issueId: ID!
}
type Release {
@@ -507,6 +988,14 @@ type Release {
"""Tickets included in this release"""
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
name: String!
"""What shipped, told for people (Markdown)"""
notes: String
"""
A Markdown draft of the notes from its finished tickets, by type (not saved)
"""
notesDraft: String!
progress: ReleaseProgress!
projectId: ID!
releasedAt: String
@@ -550,6 +1039,118 @@ enum ReleaseStatus {
RELEASED
}
input SaveFilterInput {
criteria: SavedFilterCriteriaInput!
name: String!
projectId: ID!
"""Visible to the whole project (members, not viewers, share)"""
shared: Boolean! = false
}
"""
A named view of the board: private to whoever saved it, or shared with the project
"""
type SavedFilter {
createdAt: String!
criteria: SavedFilterCriteria!
id: ID!
name: String!
owner: UserRef!
projectId: ID!
shared: Boolean!
}
"""What a saved view narrows the board to; empty: everything"""
type SavedFilterCriteria {
areas: [String!]
assigneeId: ID
labels: [String!]
priorities: [IssuePriority!]
releaseId: ID
search: String
types: [String!]
unassigned: Boolean
}
input SavedFilterCriteriaInput {
areas: [String!]
assigneeId: ID
labels: [String!]
priorities: [IssuePriority!]
releaseId: ID
search: String
types: [String!]
unassigned: Boolean
}
"""A time-boxed iteration: the tickets the team commits to"""
type Sprint {
burndown: SprintBurndown!
closedAt: String
createdAt: String!
"""Last day (included), YYYY-MM-DD"""
endDate: String!
goal: String
id: ID!
"""Tickets in this sprint, in backlog order"""
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
name: String!
progress: SprintProgress!
projectId: ID!
"""First day, YYYY-MM-DD"""
startDate: String!
startedAt: String
status: SprintStatus!
version: Int!
}
"""
Per day of the sprint: tickets in it, still open, and their points (null: days to come). `ideal` falls from the first day to zero.
"""
type SprintBurndown {
dates: [String!]!
ideal: [Float!]!
remaining: [Int]!
remainingPoints: [Int]!
scope: [Int]!
sprintId: ID!
}
type SprintChange {
action: String!
actorId: ID!
"""Null: deleted"""
sprint: Sprint
sprintId: ID!
version: Int!
}
type SprintProgress {
"""Done or cancelled"""
finished: Int!
finishedStoryPoints: Int!
percent: Int!
storyPoints: Int!
total: Int!
}
type SprintRef {
id: ID!
name: String!
}
"""PLANNED, ACTIVE (one per project at a time) or CLOSED"""
enum SprintStatus {
ACTIVE
CLOSED
PLANNED
}
"""
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
"""
@@ -581,8 +1182,12 @@ 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!
sprintChanged(projectId: ID!): SprintChange!
}
type Throughput {
@@ -591,6 +1196,13 @@ type Throughput {
week: String!
}
input UpdateChecklistItemInput {
done: Boolean
issueId: ID!
itemId: ID!
text: String
}
input UpdateIssueInput {
"""An area id"""
area: String
@@ -602,6 +1214,9 @@ input UpdateIssueInput {
issueId: ID!
labels: [String!]
priority: IssuePriority
"""When work should start (roadmap), YYYY-MM-DD"""
startDate: String
storyPoints: Int
title: String
type: String
@@ -621,6 +1236,21 @@ input UpdateReleaseInput {
targetDate: String
}
input UpdateSprintInput {
endDate: String
goal: String
name: String
sprintId: ID!
startDate: String
}
input UpdateWebhookInput {
active: Boolean
events: [String!]
url: String
webhookId: ID!
}
"""User managed by the Pireon identity server"""
type User {
email: String!
@@ -634,6 +1264,39 @@ type UserRef {
name: String!
}
"""
Where the project tells another system what happened (signed POST, HMAC-SHA256)
"""
type Webhook {
active: Boolean!
createdAt: String!
createdBy: UserRef!
"""Event names, family.* or *"""
events: [String!]!
id: ID!
lastDelivery: WebhookDelivery
projectId: ID!
url: String!
}
type WebhookCreated {
id: ID!
"""Signs every delivery. Shown only now: keep it."""
secret: String!
}
type WebhookDelivery {
at: String!
error: String
event: String!
ok: Boolean!
"""HTTP status"""
status: Int
}
type WorkflowStatus {
"""No new tickets or transitions into it"""
archived: Boolean!
@@ -648,6 +1311,11 @@ type WorkflowStatus {
"""Entering it needs a reason"""
requiresReason: Boolean!
"""
Work-in-progress limit: most tickets the column should hold (a warning, not a lock)
"""
wipLimit: Int
}
input WorkflowStatusInput {
@@ -657,4 +1325,7 @@ input WorkflowStatusInput {
id: String!
name: String!
requiresReason: Boolean! = false
"""Work-in-progress limit (null: none)"""
wipLimit: Int
}

View File

@@ -0,0 +1,117 @@
/**
* Writes apps/api/schema.gql and apps/api/openapi.json without starting the
* API or touching a database: `npx nx run api:contracts`.
*
* The running API writes the same files on start in development; this is for
* when no database is at hand — a CI job, a laptop without Docker running.
*/
import 'reflect-metadata';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { InjectionToken, Module, Provider, Type } from '@nestjs/common';
import { MODULE_METADATA } from '@nestjs/common/constants';
import { NestFactory } from '@nestjs/core';
import {
GRAPHQL_SDL_FILE_HEADER,
GraphQLSchemaBuilderModule,
GraphQLSchemaFactory,
} from '@nestjs/graphql';
import { IdentityPresentersModule } from '@task-stream/api/identity/presenters';
import { TrackerPresentersModule } from '@task-stream/api/tracker/presenters';
import { lexicographicSortSchema, printSchema } from 'graphql';
import { AppModule } from '../src/app/app.module';
import { buildOpenApiDocument } from '../src/app/openapi';
/** The GraphQL resolvers a presenters module declares. */
function resolversOf(module: Type): Type[] {
const providers: unknown[] =
Reflect.getMetadata(MODULE_METADATA.PROVIDERS, module) ?? [];
return providers.filter(
(provider): provider is Type =>
typeof provider === 'function' &&
// `@Resolver()` without a type (subscriptions) sets no type, only a name.
Reflect.getMetadataKeys(provider).some((key) =>
String(key).startsWith('graphql:resolver'),
),
);
}
async function graphqlSchema(): Promise<string> {
const app = await NestFactory.create(GraphQLSchemaBuilderModule, {
logger: false,
});
await app.init();
const schema = await app
.get(GraphQLSchemaFactory)
.create([
...resolversOf(IdentityPresentersModule),
...resolversOf(TrackerPresentersModule),
]);
await app.close();
// Same output as GraphQLModule's autoSchemaFile with sortSchema.
return GRAPHQL_SDL_FILE_HEADER + printSchema(lexicographicSortSchema(schema));
}
/** The REST controllers a module declares. */
function controllersOf(module: Type): Type[] {
return Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, module) ?? [];
}
/**
* Empty stand-ins for whatever the controllers' constructors ask for: Swagger
* reads the controllers' metadata and never calls them.
*/
function standIns(controllers: Type[]): Provider[] {
const tokens = new Set<unknown>();
for (const controller of controllers) {
const types: unknown[] =
Reflect.getMetadata('design:paramtypes', controller) ?? [];
const injected: { index: number; param: unknown }[] =
Reflect.getMetadata('self:paramtypes', controller) ?? [];
types.forEach((type, index) =>
tokens.add(injected.find((i) => i.index === index)?.param ?? type),
);
}
return [...tokens].map((token) => ({
provide: token as InjectionToken,
useValue: {},
}));
}
async function openApi(): Promise<string> {
// Only the controllers, with stand-ins for their dependencies: nothing
// connects to Postgres, Mongo, Redis or Pireon.
const controllers = [
...controllersOf(AppModule),
...controllersOf(IdentityPresentersModule),
...controllersOf(TrackerPresentersModule),
];
@Module({ controllers, providers: standIns(controllers) })
class ContractsModule {}
const app = await NestFactory.create(ContractsModule, { logger: false });
app.setGlobalPrefix('api');
const document = buildOpenApiDocument(app, {
pireonBaseUrl: process.env['PIREON_BASE_URL'] || 'https://oauth.pireon.dev',
scopes: (process.env['PIREON_SCOPES'] || 'users:read')
.split(/[\s,]+/)
.filter(Boolean),
passwordGrant: process.env['PIREON_PASSWORD_GRANT'] !== 'false',
});
return JSON.stringify(document, null, 2) + '\n';
}
async function main() {
const root = process.cwd();
writeFileSync(join(root, 'apps/api/schema.gql'), await graphqlSchema());
writeFileSync(join(root, 'apps/api/openapi.json'), await openApi());
}
main().then(
() => process.exit(0),
(error) => {
console.error(error);
process.exit(1);
},
);

View File

@@ -0,0 +1,21 @@
/*
* Lets Node run the TypeScript sources of the monorepo directly: compiles
* them on the fly (decorator metadata included, which Nest needs) and
* resolves the `@task-stream/*` aliases of tsconfig.base.json.
*/
const { join } = require('node:path');
const root = join(__dirname, '../../..');
require('ts-node').register({
project: join(root, 'apps/api/tsconfig.app.json'),
transpileOnly: true,
compilerOptions: {
module: 'commonjs',
moduleResolution: 'node10',
ignoreDeprecations: '6.0',
},
});
require('tsconfig-paths').register({
baseUrl: root,
paths: require(join(root, 'tsconfig.base.json')).compilerOptions.paths,
});

View File

@@ -42,4 +42,14 @@ export const configSchema = Joi.object({
SWAGGER_ENABLED: Joi.boolean().default(true),
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
// 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(),
});

View File

@@ -1,6 +1,6 @@
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
import {
PIREON_BEARER,
PIREON_OAUTH,
@@ -35,19 +35,19 @@ show the previous version (or 404 right after a create).
For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws).
`.trim();
/**
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
*/
export function setupOpenApi(app: INestApplication): void {
const config = app.get(ConfigService);
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
export interface OpenApiSettings {
pireonBaseUrl: string;
scopes: string[];
passwordGrant: boolean;
}
const pireon =
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev';
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
.split(/[\s,]+/)
.filter(Boolean);
/** The OpenAPI document of the REST controllers. Needs no database. */
export function buildOpenApiDocument(
app: INestApplication,
settings: OpenApiSettings,
): OpenAPIObject {
const pireon = settings.pireonBaseUrl;
const scopes = settings.scopes;
const scopeDocs = Object.fromEntries(
scopes.map((scope) => [
scope,
@@ -76,7 +76,7 @@ export function setupOpenApi(app: INestApplication): void {
refreshUrl: tokenUrl,
scopes: scopeDocs,
},
...(config.get<boolean>('PIREON_PASSWORD_GRANT')
...(settings.passwordGrant
? {
password: {
tokenUrl,
@@ -125,6 +125,27 @@ export function setupOpenApi(app: INestApplication): void {
}
}
return document;
}
/**
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
*/
export function setupOpenApi(app: INestApplication): void {
const config = app.get(ConfigService);
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
.split(/[\s,]+/)
.filter(Boolean);
const document = buildOpenApiDocument(app, {
pireonBaseUrl:
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev',
scopes,
passwordGrant: !!config.get<boolean>('PIREON_PASSWORD_GRANT'),
});
const file = config.get<string>('OPENAPI_FILE');
if (file) {
writeFileSync(

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@ server {
root /usr/share/nginx/html;
location /api/ {
# Attachments: the API takes files up to ATTACHMENTS_MAX_MB (25 MB by default).
client_max_body_size 30m;
proxy_pass http://task-stream-api:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -28,6 +28,12 @@ describe('appRoutes', () => {
'',
'board',
'issues/:issueKey',
'backlog',
'sprints',
'calendar',
'roadmap',
'epics',
'trash',
'releases',
'dashboard',
'analytics',

View File

@@ -11,6 +11,10 @@ import { filter, map } from 'rxjs';
import { AuthFacade } from '@task-stream/web/identity/application';
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
import { AppShellComponent } from '@task-stream/web/shared/ui';
import {
CommandPalette,
NotificationsBell,
} from '@task-stream/web/tracker/presenters';
@Component({
selector: 'ts-root',
@@ -21,6 +25,8 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
RouterLinkActive,
AppShellComponent,
UserMenuComponent,
NotificationsBell,
CommandPalette,
],
template: `
<ts-app-shell>
@@ -44,7 +50,12 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
Explorar
</a>
@if (auth.isAuthenticated()) {
<ts-user-menu shellUser />
<!-- One projectable node per @if branch, or neither reaches the slot. -->
<ng-container shellUser>
<ts-command-palette />
<ts-notifications-bell />
<ts-user-menu />
</ng-container>
} @else if (auth.settled() && !onLogin()) {
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
>Iniciar sesión</a

View File

@@ -56,6 +56,20 @@ services:
# document is still served at /api/docs-json.
GRAPHQL_SCHEMA_FILE: ''
OPENAPI_FILE: ''
ATTACHMENTS_DIR: /data/attachments
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:-}
# Outgoing webhooks to private addresses (off: they could reach internal services).
WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false}
# Due-date reminders, checked every hour.
REMINDERS_ENABLED: ${REMINDERS_ENABLED:-true}
REMINDERS_TIMEZONE: ${REMINDERS_TIMEZONE:-Europe/Madrid}
volumes:
# Files attached to tickets: lose this volume and the tickets point at nothing.
- attachments-data:/data/attachments
depends_on:
postgres: { condition: service_healthy }
mongo: { condition: service_healthy }
@@ -125,6 +139,7 @@ services:
retries: 10
volumes:
attachments-data:
postgres-data:
mongo-data:
redis-data:

View File

@@ -2,23 +2,45 @@
## Qué resuelve
| Necesidad | Cómo se resuelve |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 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 |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
| 🔗 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 |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
| Necesidad | Cómo se resuelve |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por

View File

@@ -137,12 +137,14 @@ nunca ve los eventos guardados (su publisher es el event store), las sagas las e
evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
| Saga | Evento | Comandos |
| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban |
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
| Saga | Evento | Comandos |
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
Reglas que cumplen:

View File

@@ -142,20 +142,124 @@ stateDiagram-v2
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
Las notas de versión (`notes`, Markdown) se escriben también después de publicar (no una vez archivada);
`notesDraft` / `GET /api/releases/{id}/notes/draft` propone un borrador con los tickets hechos agrupados por tipo
(los de QA quedan fuera).
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
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.
- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles
(épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es
cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o
cancelados, y puntos). Si se borra el padre, sus hijos vuelven al primer nivel.
- **Orden del backlog**: cada ticket tiene un `rank` (texto que se compara carácter a carácter). Mover uno
(`rankIssue`, `PUT /api/issues/{issue}/rank`) solo reescribe ese ticket: recibe un rango entre el de quien queda
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
- **Integración con Git**: un owner o admin la activa en la configuración del proyecto y obtiene un secreto. El host
llama a `POST /api/webhooks/git/{proyecto}` con los eventos _push_ y _pull/merge request_; GitHub y Gitea firman el
cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía como `X-Gitlab-Token`. Cada commit, rama o PR que nombre una
clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket (`IssueDevLinkRecordedEvent`,
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
event store.
- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de
fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas
versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta
`good-first-issue`. Sin token, con `Cache-Control: public, max-age=300`; un proyecto privado responde 404.
- **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/
admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede
cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` /
`GET /api/projects/{project}/trash` los lista. A los `TRASH_DAYS` días (30) se borra del todo, con la cascada de
siempre: comentarios, enlaces y subtareas de vuelta al primer nivel.
- **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o
`unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
la respuesta dice cuáles.
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).
- **Automatizaciones**: owners y admins crean reglas (hasta 50 por proyecto, por GraphQL: `automations`,
`createAutomation`, `updateAutomation`, `deleteAutomation`). Disparador: ticket creado, cambio de estado (opcionalmente
a uno concreto), asignación, comentario o PR fusionada (integración Git). Condiciones: tipo, prioridad, área o
etiqueta. Acción: mover a un estado, asignar (a alguien o a quien lo abrió), añadir una etiqueta, poner una
prioridad, comentar o meter en el sprint en curso. Actúan como `system` («TaskStream») y respetando el flujo: si la
transición no está permitida, la regla no hace nada y lo deja en `lastError`. Lo que hace una regla no dispara
otras (no hay cadenas ni bucles) y cada regla corre una sola vez por evento aunque se reentregue
(`automation_runs`). Viven en MongoDB (`automations`), fuera del event store.
- **Webhooks salientes**: owners y admins dan de alta URLs (hasta 20 por proyecto) suscritas a eventos
(`issue.created`, `issue.status_changed`, `comment.added`, `release.shipped`, `sprint.closed`… — la lista en
`webhookEvents` / `GET /api/webhook-events`), a familias (`issue.*`) o a todo (`*`). Cada envío es un `POST` JSON con
`event`, `deliveryId`, `occurredAt`, `actorId`, `project`, `issue` (si va de un ticket) y `data` (los campos del evento),
y las cabeceras `X-TaskStream-Event`, `X-TaskStream-Delivery` y `X-TaskStream-Signature: sha256=<HMAC del cuerpo con el
secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (no ante un 4xx), con 5 s de espera
máxima; se guarda el último envío. Salen en segundo plano y no son duraderos: si la API se reinicia a mitad, ese
envío se pierde. No llaman a direcciones privadas salvo con `WEBHOOKS_ALLOW_PRIVATE=true`.
- **Importar y exportar**: `GET /api/projects/{project}/export.csv` da todos los tickets (UTF-8 con BOM; las celdas
que una hoja de cálculo ejecutaría como fórmula empiezan por `'`). `POST /api/projects/{project}/import` (owners y
admins, multipart `file`, hasta 5 MB y 2000 filas) lee ese mismo CSV o una exportación CSV de Jira: tipos, estados y
personas se buscan por id o por nombre (los de Jira por su significado: _Story_ → Feature, _Highest_ → crítica,
categoría _In Progress_ → primer estado en curso…), lo que no encaja se queda con un valor por defecto y un aviso, y
los padres se reconstruyen al final. Con `dryRun=true` solo se cuenta lo que se crearía. Cada ticket importado es una
creación normal (con sus eventos), en el estado que traía si está activo.
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
bloqueo: la API acepta pasarse y el tablero marca la columna.
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
adónde van los tickets sin terminar: a otro sprint abierto del proyecto o al backlog; los terminados se quedan como
registro. Solo se borran sprints que no han empezado (sus tickets vuelven al backlog).
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
y se borran con el proyecto.
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
### 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` |
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en
`REMINDERS_TIMEZONE`). Cada aviso tiene id `reminder:<ticket>:<fecha>:<fase>:<persona>`, así que no se repite aunque
varias instancias lo comprueben a la vez.
Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento: la primera razón de la tabla gana
(una mención antes que «comentó en tu ticket», y eso antes que «un ticket que sigues»).
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). 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
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
| Agregado | Eventos |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueDeletedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
| Agregado | Eventos |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueDevLinkRecordedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
@@ -174,6 +278,7 @@ puras (`application/src/lib/analytics`):
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
| Burndown de un sprint | Por día del sprint: tickets en él (sin cancelados), cuántos siguen abiertos y sus puntos; línea ideal hasta cero |
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
@@ -187,31 +292,41 @@ que funcionan también sin token.
### Queries
| Query | Devuelve |
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro |
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
| `publicProjects` | Proyectos públicos (sin token) |
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
| Query | Devuelve |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro |
| `myIssues(assignedToMe, statuses, categories, search, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
| `publicProjects` | Proyectos públicos (sin token) |
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, fecha límite entre `dueFrom` y `dueTo`, texto); `sort: RANK` para el orden del backlog |
| `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir |
| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto |
| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
### Mutations
Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
| Comentarios | `addComment`, `editComment`, `deleteComment` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
| Ámbito | Mutations |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
| Comentarios | `addComment`, `editComment`, `deleteComment` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
### Subscriptions
@@ -221,6 +336,7 @@ Todas devuelven `CommandResult { id, key, version }`.
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `releaseChanged(projectId)` | Cambia una release |
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
@@ -407,15 +523,28 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
| 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) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
| Recurso | Rutas |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/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`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
plano, y todo con `X-Content-Type-Options: nosniff` y una CSP `sandbox`: un HTML o un SVG subido se descarga,
nunca se ejecuta en el origen de la API.
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que

View File

@@ -32,6 +32,7 @@ la web y la API comparten origen igual que en Docker.
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
| `npx nx run-many -t build` | Builds de la API y la web |
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
| `npx nx run api:contracts` | Escribe `schema.gql` y `openapi.json` sin arrancar la API ni bases de datos |
| `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) |
| `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` |
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
@@ -70,7 +71,8 @@ Ejemplo: **marcar un ticket como bloqueado con un motivo**. El recorrido es siem
por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`.
- Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura).
3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service.
4. Arrancar la API para regenerar `apps/api/schema.gql`.
4. Regenerar `apps/api/schema.gql` y `apps/api/openapi.json`: arrancando la API, o sin bases de datos con
`npx nx run api:contracts`.
### Web

View File

@@ -70,36 +70,46 @@ donde los lee Compose.
## 7.2 Variables de entorno de la API
| Variable | Por defecto | Descripción |
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
| `PORT` | `3000` | Puerto HTTP |
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
| `MONGO_URL` | — (obligatoria) | Read model |
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream |
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI |
| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña |
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
| Variable | Por defecto | Descripción |
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | `3000` | Puerto HTTP |
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
| `MONGO_URL` | — (obligatoria) | Read model |
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream |
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI |
| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña |
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` |
| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada |
| `MAIL_FROM` | `TaskStream <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) |
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |
| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido |
| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios |
| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo |
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
## 7.3 Escalado
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`.
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`. La excepción son los **adjuntos**: se
guardan en disco (`ATTACHMENTS_DIR`), así que varias réplicas tienen que compartir ese volumen.
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
@@ -111,7 +121,8 @@ La web no tiene configuración de entorno: llama a la API en su mismo origen (`/
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
- Copias de seguridad de PostgreSQL: es la fuente de verdad.
- Copias de seguridad de PostgreSQL: es la fuente de verdad. Y del volumen de adjuntos (`attachments-data`): los
eventos dicen qué archivos hay, pero los bytes solo están ahí.
## 7.5 Reconstruir el read model

View File

@@ -71,3 +71,16 @@ presentación se prueba sin plantillas. **Coste**: una clase más por store, cas
El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa
`apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución.
### Adjuntos en disco, notificaciones fuera del event store
Los **adjuntos** se registran como eventos del ticket, pero los bytes van a un puerto `AttachmentStorage` (disco,
`ATTACHMENTS_DIR`): un event store no es sitio para megabytes. Se escribe el archivo antes que el evento, y si el
evento falla se borra el archivo, así que nunca hay un adjunto apuntando a nada. **Coste**: el volumen hay que
copiarlo y compartirlo entre réplicas.
Las **notificaciones** sí salen de eventos, pero no son eventos: si un aviso está leído, o si alguien quiere copia por
correo, solo le importa a esa persona y no necesita historia. Se guardan en MongoDB por destinatario y se marcan como
leídas sin pasar por un agregado. Si se reconstruye el read model, se pueden regenerar desde los eventos (quedarían
como no leídas). **Alternativa descartada**: un agregado «bandeja» por persona, con eventos `NotificationRead`; mucha
ceremonia para un estado que nadie audita.

View File

@@ -7,4 +7,20 @@ export * from './lib/comments.service';
export * from './lib/releases.service';
export * from './lib/changes.service';
export * from './lib/workflows.service';
export * from './lib/notifications.service';
export * from './lib/notifications';
export * from './lib/saved-filters.service';
export * from './lib/sprints.service';
export * from './lib/git.service';
export * from './lib/webhooks.service';
export * from './lib/transfer.service';
export * from './lib/automations.service';
export * from './lib/automations/automations';
export * from './lib/automations/automation-rules';
export * from './lib/transfer/import-export';
export * from './lib/webhooks/webhooks';
export * from './lib/webhooks/webhook-events';
export * from './lib/git/git';
export * from './lib/git/git-payloads';
export * from './lib/saved-filters/saved-filters';
export * from './lib/tracker-application.module';

View File

@@ -4,10 +4,13 @@ import {
ReleaseReadModel,
ReleaseStatus,
StatusCategory as C,
SprintReadModel,
SprintStatus,
} from '@task-stream/api/tracker/domain';
import {
computeProjectAnalytics,
computeReleaseBurnup,
computeSprintBurndown,
weekOf,
} from './analytics';
@@ -182,3 +185,62 @@ describe('release burnup', () => {
expect(at(burnup.done, '2026-10-14')).toBe(2);
});
});
describe('sprint burndown', () => {
const sprint = {
id: 's1',
projectId: 'p',
startDate: '2026-10-12',
endDate: '2026-10-16',
status: SprintStatus.ACTIVE,
closedAt: null,
} as SprintReadModel;
const into = (issueId: string, at: string): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'sprint',
sprintId: 's1',
});
// X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday.
// X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon.
const issues = [
{ ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 },
{
...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED),
storyPoints: 5,
},
{ ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 },
] as IssueReadModel[];
const timeline = [
step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
into('x', '2026-10-12T08:00:00Z'),
into('y', '2026-10-12T08:00:00Z'),
into('z', '2026-10-13T10:00:00Z'),
step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE),
step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED),
];
it('counts open tickets and points per day, and leaves the future blank', () => {
const burndown = computeSprintBurndown({
sprint,
issues,
timeline,
now: new Date('2026-10-14T12:00:00Z'),
});
expect(burndown.dates).toEqual([
'2026-10-12',
'2026-10-13',
'2026-10-14',
'2026-10-15',
'2026-10-16',
]);
expect(burndown.scope).toEqual([2, 3, 2, null, null]);
expect(burndown.remaining).toEqual([2, 2, 1, null, null]);
expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]);
expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]);
});
});

View File

@@ -5,6 +5,8 @@ import {
ProjectAnalyticsReadModel,
ReleaseBurnupReadModel,
ReleaseReadModel,
SprintBurndownReadModel,
SprintReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
@@ -313,3 +315,85 @@ export function computeReleaseBurnup(
done,
};
}
export interface SprintBurndownInput {
sprint: SprintReadModel;
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
now: Date;
}
/**
* Per day of the sprint (start to end date, UTC): tickets in it, how many
* are still open and their points, and the ideal line. Points are today's
* estimates (their history is not kept). Days after today, or after the
* sprint closed, are null.
*/
export function computeSprintBurndown(
input: SprintBurndownInput,
): SprintBurndownReadModel {
const { sprint } = input;
const steps = stepsByIssue(input.issues, input.timeline);
const points = new Map(input.issues.map((i) => [i.id, i.storyPoints ?? 0]));
const membership = new Map<
string,
{ at: number; sprintId: string | null }[]
>();
for (const entry of input.timeline) {
if (entry.kind !== 'sprint') continue;
const list = membership.get(entry.issueId) ?? [];
list.push({ at: Date.parse(entry.at), sprintId: entry.sprintId ?? null });
membership.set(entry.issueId, list);
}
for (const list of membership.values()) list.sort((a, b) => a.at - b.at);
const start = Date.parse(`${sprint.startDate}T00:00:00Z`);
const end = Date.parse(`${sprint.endDate}T00:00:00Z`);
const last = Math.min(
sprint.closedAt ? Date.parse(sprint.closedAt) : input.now.getTime(),
end + DAY - 1,
);
const dayEnds = days(start, end + DAY - 1);
const scope: (number | null)[] = [];
const remaining: (number | null)[] = [];
const remainingPoints: (number | null)[] = [];
for (const time of dayEnds) {
// The day under way counts up to now; the ones to come are unknown.
const at = Math.min(time, last);
if (time - DAY >= last) {
scope.push(null);
remaining.push(null);
remainingPoints.push(null);
continue;
}
let inScope = 0;
let open = 0;
let openPoints = 0;
for (const [issueId, list] of membership) {
const current = list.filter((m) => m.at <= at).at(-1);
if (current?.sprintId !== sprint.id) continue;
const step = stepAt(steps.get(issueId) ?? [], at);
if (!step || step.category === StatusCategory.CANCELLED) continue;
inScope++;
if (step.category !== StatusCategory.DONE) {
open++;
openPoints += points.get(issueId) ?? 0;
}
}
scope.push(inScope);
remaining.push(open);
remainingPoints.push(openPoints);
}
const first = remaining[0] ?? 0;
const span = Math.max(dayEnds.length - 1, 1);
return {
sprintId: sprint.id,
dates: dayEnds.map(dayOf),
scope,
remaining,
remainingPoints,
ideal: dayEnds.map(
(_, i) => Math.round((first * (span - i) * 100) / span) / 100,
),
};
}

View File

@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
AutomationInput,
CreateAutomationCommand,
DeleteAutomationCommand,
GetAutomationsQuery,
UpdateAutomationCommand,
} from './automations/automations';
import { Automation } from './ports/automations.repository';
/** The project's automation rules (owners and admins). */
@Injectable()
export class AutomationsService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
list(viewerId: string, projectId: string): Promise<Automation[]> {
return this.queryBus.execute(new GetAutomationsQuery(viewerId, projectId));
}
create(
actorId: string,
projectId: string,
rule: AutomationInput,
): Promise<Automation> {
return this.commandBus.execute(
new CreateAutomationCommand(actorId, projectId, rule),
);
}
update(
actorId: string,
automationId: string,
changes: { name?: string; active?: boolean },
): Promise<Automation> {
return this.commandBus.execute(
new UpdateAutomationCommand(actorId, automationId, changes),
);
}
delete(actorId: string, automationId: string): Promise<void> {
return this.commandBus.execute(
new DeleteAutomationCommand(actorId, automationId),
);
}
}

View File

@@ -0,0 +1,112 @@
import {
defaultProjectSettings,
DevLinkKind,
DevLinkState,
IssueDevLinkRecordedEvent,
IssuePriority,
IssueStatusChangedEvent,
ProjectWorkflow,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import {
AutomationActionKind,
AutomationTriggerKind,
normalizeRule,
ruleApplies,
triggerOf,
} from './automation-rules';
const workflow = new ProjectWorkflow(defaultProjectSettings());
const bug = {
type: 'BUG',
priority: IssuePriority.HIGH,
area: 'FRONTEND',
labels: ['ui'],
};
describe('automation rules', () => {
it('reads triggers from events, merged pull requests included', () => {
expect(
triggerOf(
new IssueStatusChangedEvent('u', 'i1', 'p', 'TODO', 'IN_QA', null),
),
).toEqual({
kind: AutomationTriggerKind.STATUS_CHANGED,
issueId: 'i1',
status: 'IN_QA',
});
const merged = new IssueDevLinkRecordedEvent(SYSTEM_ACTOR_ID, 'i2', 'p', {
kind: DevLinkKind.PULL_REQUEST,
url: 'https://x/pr/1',
title: 'x',
ref: '#1',
state: DevLinkState.MERGED,
provider: 'github',
author: null,
at: '',
});
expect(triggerOf(merged)?.kind).toBe(
AutomationTriggerKind.PULL_REQUEST_MERGED,
);
});
it('checks the trigger, its status and every condition', () => {
const rule = {
trigger: { kind: AutomationTriggerKind.STATUS_CHANGED, status: 'IN_QA' },
conditions: { types: ['BUG'], label: 'ui' },
};
const into = (status: string) => ({
kind: AutomationTriggerKind.STATUS_CHANGED,
status,
});
expect(ruleApplies(rule, into('IN_QA'), bug)).toBe(true);
expect(ruleApplies(rule, into('DONE'), bug)).toBe(false);
expect(ruleApplies(rule, into('IN_QA'), { ...bug, labels: [] })).toBe(
false,
);
expect(ruleApplies(rule, into('IN_QA'), { ...bug, type: 'TASK' })).toBe(
false,
);
});
it('refuses actions without a usable value', () => {
const rule = (kind: AutomationActionKind, value: string | null) => ({
name: 'R',
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
conditions: {},
action: { kind, value },
});
const member = (id: string) => id === 'u-ana';
expect(
normalizeRule(
rule(AutomationActionKind.ADD_LABEL, ' Triage '),
workflow,
member,
).action,
).toEqual({
kind: AutomationActionKind.ADD_LABEL,
value: 'triage',
});
expect(() =>
normalizeRule(
rule(AutomationActionKind.SET_STATUS, 'NOPE'),
workflow,
member,
),
).toThrow();
expect(() =>
normalizeRule(
rule(AutomationActionKind.ASSIGN, 'u-bob'),
workflow,
member,
),
).toThrow();
expect(
normalizeRule(
rule(AutomationActionKind.ASSIGN_REPORTER, 'x'),
workflow,
member,
).action.value,
).toBeNull();
});
});

View File

@@ -0,0 +1,187 @@
import {
CommentAddedEvent,
DevLinkKind,
DevLinkState,
Issue,
IssueAssignedEvent,
IssueCreatedEvent,
IssueDevLinkRecordedEvent,
IssuePriority,
IssueStatusChangedEvent,
ProjectWorkflow,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { assertThat } from '@task-stream/api/shared/domain';
export enum AutomationTriggerKind {
ISSUE_CREATED = 'ISSUE_CREATED',
/** Optionally only into `status`. */
STATUS_CHANGED = 'STATUS_CHANGED',
ISSUE_ASSIGNED = 'ISSUE_ASSIGNED',
COMMENT_ADDED = 'COMMENT_ADDED',
PULL_REQUEST_MERGED = 'PULL_REQUEST_MERGED',
}
export enum AutomationActionKind {
/** `value`: a status id. */
SET_STATUS = 'SET_STATUS',
/** `value`: a member's id. */
ASSIGN = 'ASSIGN',
ASSIGN_REPORTER = 'ASSIGN_REPORTER',
/** `value`: the label. */
ADD_LABEL = 'ADD_LABEL',
/** `value`: a priority. */
SET_PRIORITY = 'SET_PRIORITY',
/** `value`: the text. */
ADD_COMMENT = 'ADD_COMMENT',
ADD_TO_ACTIVE_SPRINT = 'ADD_TO_ACTIVE_SPRINT',
}
export interface AutomationTrigger {
kind: AutomationTriggerKind;
/** STATUS_CHANGED only: the status entered (null: any). */
status?: string | null;
}
/** All of them must hold; an empty list means "any". */
export interface AutomationConditions {
types?: string[];
priorities?: IssuePriority[];
areas?: string[];
/** The ticket carries this label. */
label?: string | null;
}
export interface AutomationAction {
kind: AutomationActionKind;
value?: string | null;
}
/** What an event means for automations, if anything. */
export function triggerOf(
event: TrackerEvent,
): { kind: AutomationTriggerKind; issueId: string; status?: string } | null {
if (event instanceof IssueCreatedEvent)
return {
kind: AutomationTriggerKind.ISSUE_CREATED,
issueId: event.aggregateId,
};
if (event instanceof IssueStatusChangedEvent)
return {
kind: AutomationTriggerKind.STATUS_CHANGED,
issueId: event.aggregateId,
status: event.to,
};
if (event instanceof IssueAssignedEvent && event.assigneeId)
return {
kind: AutomationTriggerKind.ISSUE_ASSIGNED,
issueId: event.aggregateId,
};
if (event instanceof CommentAddedEvent)
return {
kind: AutomationTriggerKind.COMMENT_ADDED,
issueId: event.comment.issueId,
};
if (
event instanceof IssueDevLinkRecordedEvent &&
event.link.kind === DevLinkKind.PULL_REQUEST &&
event.link.state === DevLinkState.MERGED
)
return {
kind: AutomationTriggerKind.PULL_REQUEST_MERGED,
issueId: event.aggregateId,
};
return null;
}
/** Whether a rule wants this trigger on this ticket. */
export function ruleApplies(
rule: { trigger: AutomationTrigger; conditions: AutomationConditions },
trigger: { kind: AutomationTriggerKind; status?: string },
issue: Pick<Issue, 'type' | 'priority' | 'area' | 'labels'>,
): boolean {
if (rule.trigger.kind !== trigger.kind) return false;
if (
rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED &&
rule.trigger.status &&
rule.trigger.status !== trigger.status
)
return false;
const { types, priorities, areas, label } = rule.conditions;
if (types?.length && !types.includes(issue.type)) return false;
if (priorities?.length && !priorities.includes(issue.priority)) return false;
if (areas?.length && !(issue.area && areas.includes(issue.area)))
return false;
if (label && !issue.labels.includes(label.toLowerCase())) return false;
return true;
}
/** Checks a rule against the project's workflow; returns it tidied. */
export function normalizeRule(
rule: {
name: string;
trigger: AutomationTrigger;
conditions: AutomationConditions;
action: AutomationAction;
},
workflow: ProjectWorkflow,
isMember: (userId: string) => boolean,
) {
const name = rule.name.trim();
assertThat(
name.length > 0 && name.length <= 80,
'A rule needs a name (up to 80 characters)',
);
assertThat(
Object.values(AutomationTriggerKind).includes(rule.trigger.kind),
'Unknown trigger',
);
const status = rule.trigger.status || null;
if (status) assertThat(!!workflow.status(status), `Unknown status ${status}`);
const { kind, value } = rule.action;
assertThat(
Object.values(AutomationActionKind).includes(kind),
'Unknown action',
);
const needs: Partial<Record<AutomationActionKind, (v: string) => boolean>> = {
[AutomationActionKind.SET_STATUS]: (v) => !!workflow.status(v),
[AutomationActionKind.ASSIGN]: (v) => isMember(v),
[AutomationActionKind.ADD_LABEL]: (v) =>
v.trim().length > 0 && v.length <= 40,
[AutomationActionKind.SET_PRIORITY]: (v) =>
(Object.values(IssuePriority) as string[]).includes(v),
[AutomationActionKind.ADD_COMMENT]: (v) =>
v.trim().length > 0 && v.length <= 2000,
};
const check = needs[kind];
if (check)
assertThat(
!!value && check(value),
`The action ${kind} needs a valid value`,
);
const list = <T>(values?: T[] | null) =>
values?.length ? [...new Set(values)] : undefined;
return {
name,
trigger: {
kind: rule.trigger.kind,
...(rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED
? { status }
: {}),
},
conditions: {
types: list(rule.conditions.types),
priorities: list(rule.conditions.priorities),
areas: list(rule.conditions.areas),
label: rule.conditions.label?.trim().toLowerCase() || undefined,
},
action: {
kind,
value: check
? kind === AutomationActionKind.ADD_LABEL
? value!.trim().toLowerCase()
: value
: null,
},
};
}

View File

@@ -0,0 +1,376 @@
import { randomUUID } from 'node:crypto';
import { Logger } from '@nestjs/common';
import {
Command,
CommandBus,
CommandHandler,
EventPublisher,
EventsHandler,
ICommandHandler,
IEventHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
CommentAddedEvent,
CommentFactory,
Issue,
IssueAssignedEvent,
IssueCreatedEvent,
IssueDevLinkRecordedEvent,
IssuePriority,
IssueStatusChangedEvent,
Project,
SYSTEM_ACTOR_ID,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import {
Automation,
AutomationsRepository,
} from '../ports/automations.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UserDirectory } from '../ports/user-directory';
import {
AutomationAction,
AutomationActionKind,
AutomationConditions,
AutomationTrigger,
normalizeRule,
ruleApplies,
triggerOf,
} from './automation-rules';
/** A project keeps at most this many rules. */
export const MAX_AUTOMATIONS = 50;
export interface AutomationInput {
name: string;
trigger: AutomationTrigger;
conditions: AutomationConditions;
action: AutomationAction;
}
export class CreateAutomationCommand extends Command<Automation> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly rule: AutomationInput,
) {
super();
}
}
export class UpdateAutomationCommand extends Command<Automation> {
constructor(
public readonly actorId: string,
public readonly automationId: string,
public readonly changes: { name?: string; active?: boolean },
) {
super();
}
}
export class DeleteAutomationCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly automationId: string,
) {
super();
}
}
/** Event handler only: one rule's action on one ticket, as TaskStream. */
export class RunAutomationCommand extends Command<string | null> {
constructor(
public readonly automation: Automation,
public readonly issueId: string,
) {
super();
}
}
export class GetAutomationsQuery extends Query<Automation[]> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}
async function managedProject(
access: WriteAccess,
projectId: string,
actorId: string,
): Promise<Project> {
const project = await access.project(projectId);
project.assertCanManage(actorId);
return project;
}
async function managedAutomation(
automations: AutomationsRepository,
access: WriteAccess,
automationId: string,
actorId: string,
): Promise<Automation> {
const automation = await automations.findById(automationId);
if (!automation) throw new NotFoundError('Automation', automationId);
await managedProject(access, automation.projectId, actorId);
return automation;
}
@CommandHandler(CreateAutomationCommand)
export class CreateAutomationCommandHandler
implements ICommandHandler<CreateAutomationCommand>
{
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
private readonly users: UserDirectory,
) {}
async execute(command: CreateAutomationCommand): Promise<Automation> {
const project = await managedProject(
this.access,
command.projectId,
command.actorId,
);
if (
(await this.automations.findByProject(project.id)).length >=
MAX_AUTOMATIONS
)
throw new BusinessRuleError(
`A project keeps at most ${MAX_AUTOMATIONS} rules`,
);
const rule = normalizeRule(command.rule, project.workflow, (id) =>
project.isMember(id),
);
const users = await this.users.refs([command.actorId]);
const automation: Automation = {
id: randomUUID(),
projectId: project.id,
...rule,
active: true,
createdBy: users.of(command.actorId),
createdAt: new Date().toISOString(),
runs: 0,
lastRunAt: null,
lastError: null,
};
await this.automations.insert(automation);
return automation;
}
}
@CommandHandler(UpdateAutomationCommand)
export class UpdateAutomationCommandHandler
implements ICommandHandler<UpdateAutomationCommand>
{
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
) {}
async execute(command: UpdateAutomationCommand): Promise<Automation> {
const automation = await managedAutomation(
this.automations,
this.access,
command.automationId,
command.actorId,
);
const changes: { name?: string; active?: boolean } = {};
if (command.changes.name !== undefined) {
const name = command.changes.name.trim();
if (!name || name.length > 80)
throw new BusinessRuleError(
'A rule needs a name (up to 80 characters)',
);
changes.name = name;
}
if (command.changes.active !== undefined)
changes.active = command.changes.active;
await this.automations.update(automation.id, changes);
return { ...automation, ...changes };
}
}
@CommandHandler(DeleteAutomationCommand)
export class DeleteAutomationCommandHandler
implements ICommandHandler<DeleteAutomationCommand>
{
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
) {}
async execute(command: DeleteAutomationCommand): Promise<void> {
const automation = await managedAutomation(
this.automations,
this.access,
command.automationId,
command.actorId,
);
await this.automations.delete(automation.id);
}
}
/**
* Applies the action straight on the aggregates, as TaskStream (the system
* actor): the rule was set up by someone who manages the project, and the
* workflow still has the last word. Returns why nothing happened, if so.
*/
@CommandHandler(RunAutomationCommand)
export class RunAutomationCommandHandler
implements ICommandHandler<RunAutomationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findSprints: FindSprintsRepository,
private readonly commentFactory: CommentFactory,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: RunAutomationCommand): Promise<string | null> {
const issue = await this.access.live(command.issueId, Issue);
const project = issue
? await this.access.live(issue.projectId, Project)
: null;
if (!issue || !project) return 'The ticket is gone';
const { kind, value } = command.automation.action;
const actor = SYSTEM_ACTOR_ID;
switch (kind) {
case AutomationActionKind.SET_STATUS:
if (!value || issue.status === value) return null;
issue.changeStatus(actor, value, 'Automatización', project.workflow);
break;
case AutomationActionKind.ASSIGN:
case AutomationActionKind.ASSIGN_REPORTER: {
const assignee =
kind === AutomationActionKind.ASSIGN
? (value ?? null)
: issue.reporterId;
if (!assignee || !project.isMember(assignee))
return 'That person is no longer a member';
issue.assign(actor, assignee);
break;
}
case AutomationActionKind.ADD_LABEL:
if (!value || issue.labels.includes(value)) return null;
issue.updateDetails(
actor,
{ labels: [...issue.labels, value] },
project.workflow,
);
break;
case AutomationActionKind.SET_PRIORITY:
issue.updateDetails(
actor,
{ priority: value as IssuePriority },
project.workflow,
);
break;
case AutomationActionKind.ADD_TO_ACTIVE_SPRINT: {
const active = await this.findSprints.findActive(project.id);
if (!active) return 'No sprint is under way';
issue.setSprint(actor, active.id);
break;
}
case AutomationActionKind.ADD_COMMENT: {
const comment = this.commentFactory.create({
issueId: issue.id,
projectId: issue.projectId,
authorId: actor,
body: value ?? '',
});
this.eventPublisher.mergeObjectContext(comment);
await comment.commitAndWait();
return null;
}
}
await issue.commitAndWait();
return null;
}
}
@QueryHandler(GetAutomationsQuery)
export class GetAutomationsQueryHandler
implements IQueryHandler<GetAutomationsQuery, Automation[]>
{
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
) {}
async execute(query: GetAutomationsQuery): Promise<Automation[]> {
await managedProject(this.access, query.projectId, query.viewerId);
return this.automations.findByProject(query.projectId);
}
}
/**
* Runs the project's rules on what people do. What rules themselves do (as
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
* per event, also when the event is delivered again.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueAssignedEvent,
CommentAddedEvent,
IssueDevLinkRecordedEvent,
)
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly logger = new Logger(AutomationsEventHandler.name);
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
) {}
async handle(event: TrackerEvent) {
// Git reports come as the system too, and a merge is worth reacting to.
if (
event.actorId === SYSTEM_ACTOR_ID &&
!(event instanceof IssueDevLinkRecordedEvent)
)
return;
const trigger = triggerOf(event);
if (!trigger) return;
const rules = await this.automations.findActive(event.projectId);
if (!rules.length) return;
const issue = await this.access.live(trigger.issueId, Issue);
if (!issue) return;
for (const rule of rules) {
if (!ruleApplies(rule, trigger, issue)) continue;
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
if (!(await this.automations.claimRun(rule.id, key))) continue;
let error: string | null;
try {
error = await this.commandBus.execute(
new RunAutomationCommand(rule, trigger.issueId),
);
} catch (failure) {
error = failure instanceof Error ? failure.message : String(failure);
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
}
await this.automations.recordRun(rule.id, error);
}
}
}
export const AUTOMATION_HANDLERS = [
CreateAutomationCommandHandler,
UpdateAutomationCommandHandler,
DeleteAutomationCommandHandler,
RunAutomationCommandHandler,
GetAutomationsQueryHandler,
AutomationsEventHandler,
];

View File

@@ -8,6 +8,7 @@ import {
IssueChange,
ProjectChange,
ReleaseChange,
SprintChange,
} from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.query';
@@ -52,6 +53,14 @@ export class ChangesService {
return this.changeFeed.releases(projectId);
}
async watchSprints(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<SprintChange>> {
await this.assertCanView(viewerId, projectId);
return this.changeFeed.sprints(projectId);
}
async watchProject(
viewerId: string,
projectId: string,

View File

@@ -0,0 +1,57 @@
import { randomUUID } from 'node:crypto';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage';
import { AttachFileCommand } from './attach-file.command';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
/**
* Stores the bytes first and records them on the ticket after: a file with no
* record is cleaned up here, never a record with no file.
*/
@CommandHandler(AttachFileCommand)
export class AttachFileCommandHandler
implements ICommandHandler<AttachFileCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly storage: AttachmentStorage,
) {}
async execute(command: AttachFileCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
if (command.commentId) {
const comment = await this.access.comment(command.commentId);
if (comment.issueId !== issue.id) {
throw new BusinessRuleError(
`That comment belongs to another ticket, not ${issue.key}`,
);
}
}
const id = randomUUID();
const key = attachmentKey(issue.projectId, issue.id, id);
issue.attach(command.actorId, {
id,
fileName: command.file.fileName,
mimeType: command.file.mimeType,
size: command.file.content.length,
uploadedBy: command.actorId,
uploadedAt: new Date().toISOString(),
commentId: command.commentId,
});
await this.storage.put(key, command.file.content);
try {
await issue.commitAndWait();
} catch (error) {
await this.storage.remove(key);
throw error;
}
return ack(issue);
}
}

View File

@@ -0,0 +1,20 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export interface IncomingFile {
fileName: string;
mimeType: string;
content: Buffer;
}
export class AttachFileCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly file: IncomingFile,
/** Places it under one of the ticket's comments. */
public readonly commentId: string | null = null,
) {
super();
}
}

View File

@@ -0,0 +1,115 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
} from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import { IssuePriority } from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './assign-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { SetIssueSprintCommand } from './sprint.commands';
import { UpdateIssueCommand } from './update-issue.command';
import { WriteAccess } from './write-access';
/** At most this many tickets per bulk change. */
export const MAX_BULK = 100;
/** What to change on every ticket; absent fields stay as they are. */
export interface BulkChange {
status?: string;
/** null unassigns. */
assigneeId?: string | null;
/** null: back to the backlog. */
sprintId?: string | null;
/** null: out of its release. */
releaseId?: string | null;
priority?: IssuePriority;
/** Added to the ticket's labels. */
addLabels?: string[];
}
export interface BulkResult {
updated: number;
results: { issueId: string; ok: boolean; error: string | null }[];
}
/**
* The same change on several tickets. Each ticket goes through the normal
* commands (permissions, workflow, open sprints and releases), one after
* another; one refusing does not stop the rest.
*/
export class BulkUpdateIssuesCommand extends Command<BulkResult> {
constructor(
public readonly actorId: string,
public readonly issueIds: string[],
public readonly change: BulkChange,
) {
super();
}
}
@CommandHandler(BulkUpdateIssuesCommand)
export class BulkUpdateIssuesCommandHandler
implements ICommandHandler<BulkUpdateIssuesCommand>
{
constructor(
private readonly commandBus: CommandBus,
private readonly access: WriteAccess,
) {}
async execute(command: BulkUpdateIssuesCommand): Promise<BulkResult> {
const ids = [...new Set(command.issueIds)];
assertThat(ids.length > 0, 'Pick at least one ticket');
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
const change = command.change;
assertThat(
Object.values(change).some((v) => v !== undefined),
'Nothing to change',
);
const actor = command.actorId;
const result: BulkResult = { updated: 0, results: [] };
for (const issueId of ids) {
try {
if (change.status !== undefined)
await this.run(
new ChangeIssueStatusCommand(actor, issueId, change.status),
);
if (change.assigneeId !== undefined)
await this.run(
new AssignIssueCommand(actor, issueId, change.assigneeId),
);
if (change.sprintId !== undefined)
await this.run(
new SetIssueSprintCommand(actor, issueId, change.sprintId),
);
if (change.releaseId !== undefined)
await this.run(
new SetIssueReleaseCommand(actor, issueId, change.releaseId),
);
const details: { priority?: IssuePriority; labels?: string[] } = {};
if (change.priority) details.priority = change.priority;
if (change.addLabels?.length) {
const issue = await this.access.issue(issueId);
details.labels = [...issue.labels, ...change.addLabels];
}
if (Object.keys(details).length)
await this.run(new UpdateIssueCommand(actor, issueId, details));
result.updated++;
result.results.push({ issueId, ok: true, error: null });
} catch (error) {
result.results.push({
issueId,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
private run(command: object): Promise<unknown> {
return this.commandBus.execute(command as never);
}
}

View File

@@ -0,0 +1,72 @@
import { randomUUID } from 'node:crypto';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
AddChecklistItemCommand,
RemoveChecklistItemCommand,
UpdateChecklistItemCommand,
} from './change-checklist.command';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
type ChecklistCommand =
| AddChecklistItemCommand
| UpdateChecklistItemCommand
| RemoveChecklistItemCommand;
/** Every change to a ticket's checklist: whoever can edit the ticket may. */
@CommandHandler(AddChecklistItemCommand)
export class AddChecklistItemCommandHandler
implements ICommandHandler<AddChecklistItemCommand>
{
constructor(private readonly access: WriteAccess) {}
execute(command: AddChecklistItemCommand): Promise<CommandResult> {
return change(this.access, command, (issue) =>
issue.addChecklistItem(command.actorId, randomUUID(), command.text),
);
}
}
@CommandHandler(UpdateChecklistItemCommand)
export class UpdateChecklistItemCommandHandler
implements ICommandHandler<UpdateChecklistItemCommand>
{
constructor(private readonly access: WriteAccess) {}
execute(command: UpdateChecklistItemCommand): Promise<CommandResult> {
return change(this.access, command, (issue) =>
issue.updateChecklistItem(
command.actorId,
command.itemId,
command.changes,
),
);
}
}
@CommandHandler(RemoveChecklistItemCommand)
export class RemoveChecklistItemCommandHandler
implements ICommandHandler<RemoveChecklistItemCommand>
{
constructor(private readonly access: WriteAccess) {}
execute(command: RemoveChecklistItemCommand): Promise<CommandResult> {
return change(this.access, command, (issue) =>
issue.removeChecklistItem(command.actorId, command.itemId),
);
}
}
async function change(
access: WriteAccess,
command: ChecklistCommand,
apply: (issue: Awaited<ReturnType<WriteAccess['issue']>>) => void,
): Promise<CommandResult> {
const { issue } = await access.issueForContributor(
command.issueId,
command.actorId,
);
apply(issue);
await issue.commitAndWait();
return ack(issue);
}

View File

@@ -0,0 +1,34 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class AddChecklistItemCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly text: string,
) {
super();
}
}
/** Rewords, ticks or unticks a checklist step. */
export class UpdateChecklistItemCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly itemId: string,
public readonly changes: { text?: string; done?: boolean },
) {
super();
}
}
export class RemoveChecklistItemCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly itemId: string,
) {
super();
}
}

View File

@@ -25,6 +25,21 @@ import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
import { DropLinksToIssueCommandHandler } from './drop-links-to-issue.command-handler';
import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler';
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
import { AttachFileCommandHandler } from './attach-file.command-handler';
import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
import { RankIssueCommandHandler } from './rank-issue.command-handler';
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
import { TRASH_HANDLERS } from './trash';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import {
AddChecklistItemCommandHandler,
RemoveChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,
} from './change-checklist.command-handler';
export const COMMAND_HANDLERS = [
CreateProjectCommandHandler,
@@ -55,4 +70,18 @@ export const COMMAND_HANDLERS = [
DropLinksToIssueCommandHandler,
DetachIssueFromReleaseCommandHandler,
UnassignIssueCommandHandler,
AttachFileCommandHandler,
DetachFileCommandHandler,
WatchIssueCommandHandler,
SetIssueParentCommandHandler,
RankIssueCommandHandler,
WriteReleaseNotesCommandHandler,
...SPRINT_COMMAND_HANDLERS,
BulkUpdateIssuesCommandHandler,
...TRASH_HANDLERS,
AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,
RemoveChecklistItemCommandHandler,
// Saga only: the parent was deleted.
DetachFromParentCommandHandler,
];

View File

@@ -12,29 +12,62 @@ import {
} from '@task-stream/api/shared/domain';
import {
Comment,
Issue,
IssueStatus,
Project,
IssueType,
ProjectRole,
TRACKER_FACTORIES,
defaultProjectSettings,
} from '@task-stream/api/tracker/domain';
import {
FakeUserDirectory,
InMemoryAttachmentStorage,
InMemoryEventStore,
InMemoryEventStorePublisher,
InMemoryIssueNumbers,
InMemoryProjectKeys,
InMemoryReleaseNames,
InMemoryReleaseIssues,
InMemoryTrackerStreams,
} from '../../testing/in-memory';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import {
CreateSprintCommand,
SetIssueSprintCommand,
StartSprintCommand,
} from './sprint.commands';
import { IssueTree } from './issue-tree';
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
import { RunAutomationCommand } from '../automations/automations';
import { RunAutomationCommandHandler } from '../automations/automations';
import {
AutomationActionKind,
AutomationTriggerKind,
} from '../automations/automation-rules';
import { Automation } from '../ports/automations.repository';
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { DevLinkKind } from '@task-stream/api/tracker/domain';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { RankIssueCommand } from './rank-issue.command';
import {
AddChecklistItemCommand,
UpdateChecklistItemCommand,
} from './change-checklist.command';
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
import { ReleaseNamesRepository } from '../ports/release-names.repository';
import { UserDirectory } from '../ports/user-directory';
import { AttachmentStorage } from '../ports/attachment-storage';
import { AddCommentCommand } from './add-comment.command';
import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command';
import { AttachFileCommand } from './attach-file.command';
import { DetachFileCommand } from './detach-file.command';
import { WatchIssueCommand } from './watch-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { COMMAND_HANDLERS } from './command-handlers';
import { CommandResult } from './command-result';
@@ -45,9 +78,20 @@ import { DeleteCommentCommand } from './delete-comment.command';
import { PublishReleaseCommand } from './publish-release.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access';
import { RestoreIssueCommand, TrashIssueCommand } from './trash';
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
import { ReadAccess } from '../queries/read-access';
import { FindProjectsRepository } from '../ports/find-projects.repository';
async function setup() {
const store = new InMemoryEventStore();
const keys = new Map<string, { id: string; projectId: string }>();
const sprints = {
active: null as { id: string; name: string } | null,
findActive: async () => sprints.active,
};
const files = new InMemoryAttachmentStorage();
const users = new FakeUserDirectory()
.add('owner')
.add('dev')
@@ -58,10 +102,20 @@ async function setup() {
imports: [CqrsModule.forRoot()],
providers: [
...COMMAND_HANDLERS,
...GIT_HANDLERS,
RunAutomationCommandHandler,
...TRACKER_FACTORIES,
WriteAccess,
IssueTree,
ReadAccess,
{
provide: FindProjectsRepository,
useValue: { findRole: async () => null, findById: async () => null },
},
{ provide: GitIntegrationsRepository, useValue: {} },
AggregateRehydrator,
{ provide: EventStore, useValue: store },
{ provide: AttachmentStorage, useValue: files },
{ provide: UserDirectory, useValue: users },
{ provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },
{ provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers },
@@ -70,6 +124,26 @@ async function setup() {
provide: ReleaseIssuesRepository,
useValue: new InMemoryReleaseIssues(store),
},
{
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
// Read-model lookups: the sprint under way is set by each test.
{ provide: FindSprintsRepository, useValue: sprints },
{
provide: FindIssuesRepository,
useValue: {
// Tickets by key, as the read model would answer (filled by tests).
findByKey: async (key: string) => keys.get(key) ?? null,
sprintProgress: async () => ({
total: 0,
finished: 0,
percent: 0,
storyPoints: 0,
finishedStoryPoints: 0,
}),
},
},
],
}).compile();
await moduleRef.init();
@@ -78,7 +152,14 @@ async function setup() {
const commandBus = moduleRef.get(CommandBus);
const run = (command: object): Promise<CommandResult> =>
commandBus.execute(command as never);
return { store, run, rehydrator: moduleRef.get(AggregateRehydrator) };
return {
store,
files,
sprints,
keys,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
};
}
async function projectWithTeam(run: (c: object) => Promise<CommandResult>) {
@@ -178,6 +259,432 @@ describe('tracker commands (event sourced)', () => {
).rejects.toThrow(BusinessRuleError);
});
describe('attachments', () => {
const png = {
fileName: 'captura.png',
mimeType: 'image/png',
content: Buffer.from('fake png'),
};
it('stores the file and records it on the ticket', async () => {
const { run, files, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await run(new AttachFileCommand('qa', issue.id, png));
const rebuilt = await rehydrator.rehydrate(issue.id, Issue);
expect(rebuilt.attachments).toHaveLength(1);
expect(rebuilt.attachments[0]).toMatchObject({
fileName: 'captura.png',
size: 8,
uploadedBy: 'qa',
});
expect([...files.files.keys()]).toEqual([
`${projectId}/${issue.id}/${rebuilt.attachments[0].id}`,
]);
});
it('keeps no file when the ticket cannot take it', async () => {
const { run, files } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await expect(
run(new AttachFileCommand('pm', issue.id, png)),
).rejects.toThrow(ForbiddenError);
await expect(
run(
new AttachFileCommand('qa', issue.id, {
...png,
content: Buffer.alloc(0),
}),
),
).rejects.toThrow();
expect(files.files.size).toBe(0);
});
it('lets the uploader or an admin remove it, and deletes the bytes', async () => {
const { run, files, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await run(new AttachFileCommand('qa', issue.id, png));
const [attachment] = (await rehydrator.rehydrate(issue.id, Issue))
.attachments;
await expect(
run(new DetachFileCommand('dev', issue.id, attachment.id)),
).rejects.toThrow(ForbiddenError);
await run(new DetachFileCommand('owner', issue.id, attachment.id));
expect(files.files.size).toBe(0);
});
});
it('lets any member, viewers included, follow a ticket for themselves', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await run(new WatchIssueCommand('pm', issue.id, 'pm'));
await run(new WatchIssueCommand('dev', issue.id, 'dev'));
await run(new WatchIssueCommand('dev', issue.id, 'dev', false));
expect((await rehydrator.rehydrate(issue.id, Issue)).watchers).toEqual([
'pm',
]);
await expect(
run(new WatchIssueCommand('dev', issue.id, 'pm', false)),
).rejects.toThrow(ForbiddenError);
await expect(
run(new WatchIssueCommand('gone', issue.id, 'gone')),
).rejects.toThrow(ForbiddenError);
});
describe('epics, subtasks and checklists', () => {
const create = (
run: (c: object) => Promise<CommandResult>,
projectId: string,
title: string,
parentId?: string,
) =>
run(
new CreateIssueCommand(
'dev',
projectId,
IssueType.TASK,
title,
{},
null,
null,
parentId,
),
);
it('nests tickets three levels deep at most, without loops', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const epic = await create(run, projectId, 'Checkout');
const story = await create(run, projectId, 'Pay by card', epic.id);
const subtask = await create(run, projectId, 'Stripe form', story.id);
const loose = await create(run, projectId, 'Loose ticket');
expect((await rehydrator.rehydrate(subtask.id, Issue)).parentId).toBe(
story.id,
);
// A fourth level, and a loop.
await expect(
create(run, projectId, 'Too deep', subtask.id),
).rejects.toThrow(/at most 2 levels/);
await expect(
run(new SetIssueParentCommand('dev', epic.id, subtask.id)),
).rejects.toThrow(BusinessRuleError);
// A ticket with subtasks cannot go under one that has a parent.
await run(new SetIssueParentCommand('dev', loose.id, epic.id));
await expect(
run(new SetIssueParentCommand('dev', story.id, loose.id)),
).rejects.toThrow(/at most 2 levels/);
// ...but it can move to another epic, and leave.
await run(new SetIssueParentCommand('dev', loose.id, null));
await run(new SetIssueParentCommand('dev', story.id, loose.id));
expect((await rehydrator.rehydrate(story.id, Issue)).parentId).toBe(
loose.id,
);
await expect(
run(new SetIssueParentCommand('pm', story.id, null)),
).rejects.toThrow(ForbiddenError);
});
it('keeps a checklist on the ticket', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await create(run, projectId, 'Release 1.0');
await run(new AddChecklistItemCommand('dev', issue.id, 'Changelog'));
await run(new AddChecklistItemCommand('qa', issue.id, 'Smoke test'));
const [first] = (await rehydrator.rehydrate(issue.id, Issue)).checklist;
await run(
new UpdateChecklistItemCommand('qa', issue.id, first.id, {
done: true,
}),
);
const { checklist } = await rehydrator.rehydrate(issue.id, Issue);
expect(checklist.map((i) => [i.text, i.done])).toEqual([
['Changelog', true],
['Smoke test', false],
]);
await expect(
run(new AddChecklistItemCommand('pm', issue.id, 'Nope')),
).rejects.toThrow(ForbiddenError);
});
});
it('orders the backlog from the ranks in the event store', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const ids = [];
for (const title of ['A', 'B', 'C'])
ids.push(
(
await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}),
)
).id,
);
const [a, b, c] = ids;
// C to the top, then B between C and A: C, B, A.
await run(new RankIssueCommand('dev', c, null, a));
await run(new RankIssueCommand('dev', b, c, a));
const ranks = await Promise.all(
[c, b, a].map(
async (id) => (await rehydrator.rehydrate(id, Issue)).backlogRank,
),
);
expect([...ranks].sort()).toEqual(ranks);
// Neighbours given the wrong way round: the order changed meanwhile.
await expect(run(new RankIssueCommand('dev', a, b, c))).rejects.toThrow(
BusinessRuleError,
);
await expect(run(new RankIssueCommand('pm', a, null, c))).rejects.toThrow(
ForbiddenError,
);
});
it('plans and starts sprints, one under way at a time, and fills them', async () => {
const { run, rehydrator, sprints } = await setup();
const projectId = await projectWithTeam(run);
const plan = (name: string) =>
run(
new CreateSprintCommand(
'owner',
projectId,
name,
'2026-10-12',
'2026-10-23',
),
);
const first = await plan('Sprint 1');
const second = await plan('Sprint 2');
await expect(
run(
new CreateSprintCommand(
'dev',
projectId,
'X',
'2026-10-12',
'2026-10-23',
),
),
).rejects.toThrow(ForbiddenError);
await run(new StartSprintCommand('owner', first.id));
sprints.active = { id: first.id, name: 'Sprint 1' };
await expect(
run(new StartSprintCommand('owner', second.id)),
).rejects.toThrow(/Sprint 1 is under way/);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
);
await run(new SetIssueSprintCommand('dev', issue.id, first.id));
expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe(
first.id,
);
await expect(
run(new SetIssueSprintCommand('pm', issue.id, null)),
).rejects.toThrow(ForbiddenError);
});
it('links commits and pull requests to the tickets they name', async () => {
const { run, rehydrator, keys } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pay', {}),
);
keys.set(issue.key ?? '', { id: issue.id, projectId });
const commit = {
text: `${issue.key}: card form (refs TS-999, OTHER-1)`,
link: {
kind: DevLinkKind.COMMIT,
url: 'https://git.example.org/acme/web/commit/abc',
title: 'card form',
ref: 'abc',
state: null,
provider: 'gitea',
author: 'Ana',
at: '2026-10-08T10:00:00Z',
},
};
expect(
await run(new RecordGitMentionsCommand(projectId, [commit, commit])),
).toBe(1);
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
});
it('runs automation actions as TaskStream, within the workflow', async () => {
const { run, rehydrator, store } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Crash', {}),
);
const rule = (kind: AutomationActionKind, value: string | null) =>
({
id: 'r1',
projectId,
name: 'R',
active: true,
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
conditions: {},
action: { kind, value },
}) as Automation;
await run(
new RunAutomationCommand(
rule(AutomationActionKind.SET_STATUS, 'TODO'),
issue.id,
),
);
await run(
new RunAutomationCommand(
rule(AutomationActionKind.ADD_LABEL, 'triage'),
issue.id,
),
);
await run(
new RunAutomationCommand(
rule(AutomationActionKind.ASSIGN_REPORTER, null),
issue.id,
),
);
await run(
new RunAutomationCommand(
rule(AutomationActionKind.ADD_COMMENT, 'Gracias'),
issue.id,
),
);
const rebuilt = await rehydrator.rehydrate(issue.id, Issue);
expect([rebuilt.status, rebuilt.labels, rebuilt.assigneeId]).toEqual([
'TODO',
['triage'],
'dev',
]);
expect(store.types()).toContain('CommentAddedEvent');
// The workflow still decides: TODO cannot jump to DONE.
await expect(
run(
new RunAutomationCommand(
rule(AutomationActionKind.SET_STATUS, 'DONE'),
issue.id,
),
),
).rejects.toThrow(BusinessRuleError);
expect(
await run(
new RunAutomationCommand(
rule(AutomationActionKind.ADD_TO_ACTIVE_SPRINT, null),
issue.id,
),
),
).toBe('No sprint is under way');
});
it("fills a new ticket with its type's template", async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const settings = defaultProjectSettings();
settings.issueTypes[0].template = {
description: '## Pasos para reproducir',
checklist: ['Probar en Safari', 'Adjuntar captura'],
};
await run(new UpdateProjectSettingsCommand('owner', projectId, settings));
const plain = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
const written = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash 2', {
description: 'Ya lo explico yo',
}),
);
const first = await rehydrator.rehydrate(plain.id, Issue);
expect(first.description).toBe('## Pasos para reproducir');
expect(first.checklist.map((i) => i.text)).toEqual([
'Probar en Safari',
'Adjuntar captura',
]);
expect((await rehydrator.rehydrate(written.id, Issue)).description).toBe(
'Ya lo explico yo',
);
});
it('changes several tickets at once and says which refused', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const ids: string[] = [];
for (const title of ['A', 'B'])
ids.push(
(
await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}),
)
).id,
);
for (const status of ['TODO', 'IN_PROGRESS'])
await run(new ChangeIssueStatusCommand('dev', ids[1], status));
// A is in BACKLOG: the workflow does not let it jump to review.
const result = (await run(
new BulkUpdateIssuesCommand('dev', [...ids, ids[1]], {
status: 'IN_REVIEW',
assigneeId: 'qa',
addLabels: ['sprint-14'],
}),
)) as unknown as BulkResult;
expect(result.updated).toBe(1);
expect(result.results.map((r) => [r.issueId, r.ok])).toEqual([
[ids[0], false],
[ids[1], true],
]);
const b = await rehydrator.rehydrate(ids[1], Issue);
expect([b.status, b.assigneeId, b.labels]).toEqual([
'IN_REVIEW',
'qa',
['sprint-14'],
]);
await expect(
run(new BulkUpdateIssuesCommand('dev', ids, {})),
).rejects.toThrow(/Nothing to change/);
});
it('sends tickets to the trash and back; trashed ones cannot change', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await expect(run(new TrashIssueCommand('dev', issue.id))).rejects.toThrow(
ForbiddenError,
);
await run(new TrashIssueCommand('qa', issue.id));
await expect(
run(new AssignIssueCommand('owner', issue.id, 'dev')),
).rejects.toThrow(/trash/);
await run(new RestoreIssueCommand('owner', issue.id));
await run(new AssignIssueCommand('owner', issue.id, 'dev'));
expect((await rehydrator.rehydrate(issue.id, Issue)).trashedAt).toBeNull();
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -1,8 +1,10 @@
import { randomUUID } from 'node:crypto';
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { IssueFactory } from '@task-stream/api/tracker/domain';
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { CommandResult, ack } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { IssueTree } from './issue-tree';
import { WriteAccess } from './write-access';
@CommandHandler(CreateIssueCommand)
@@ -14,6 +16,7 @@ export class CreateIssueCommandHandler
private readonly issueFactory: IssueFactory,
private readonly issueNumbers: IssueNumbersRepository,
private readonly eventPublisher: EventPublisher,
private readonly tree: IssueTree,
) {}
async execute(command: CreateIssueCommand): Promise<CommandResult> {
@@ -27,10 +30,24 @@ export class CreateIssueCommandHandler
this.access.assertAssignable(project, command.assigneeId);
if (command.releaseId)
await this.access.openReleaseOf(project, command.releaseId);
const parent = command.parentId
? await this.tree.candidate(command.parentId)
: null;
if (parent)
assertCanNest(
{ id: '', key: 'The new ticket', projectId: project.id },
parent,
0,
);
const template = command.useTemplate
? workflow.templateOf(command.type)
: null;
const issue = this.issueFactory.create(
{
...command.details,
description:
command.details.description?.trim() || template?.description || null,
projectId: project.id,
projectKey: project.key,
number: await this.issueNumbers.next(project.id),
@@ -38,10 +55,14 @@ export class CreateIssueCommandHandler
title: command.title,
reporterId: command.actorId,
assigneeId: command.assigneeId,
status: command.initialStatus,
},
workflow,
);
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
if (parent) issue.setParent(command.actorId, parent);
for (const step of template?.checklist ?? [])
issue.addChecklistItem(command.actorId, randomUUID(), step);
this.eventPublisher.mergeObjectContext(issue);
await issue.commitAndWait();

View File

@@ -12,6 +12,12 @@ export class CreateIssueCommand extends Command<CommandResult> {
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
public readonly assigneeId?: string | null,
public readonly releaseId?: string | null,
/** Epic or ticket the new one breaks down. */
public readonly parentId?: string | null,
/** Imports only: start in this (active) status instead of the first one. */
public readonly initialStatus?: string | null,
/** Fill in the type's template (imports bring their own content). */
public readonly useTemplate = true,
) {
super();
}

View File

@@ -0,0 +1,35 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ProjectRole } from '@task-stream/api/tracker/domain';
import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage';
import { CommandResult, ack } from './command-result';
import { DetachFileCommand } from './detach-file.command';
import { WriteAccess } from './write-access';
/** Removes the record first; the bytes go once nothing points at them. */
@CommandHandler(DetachFileCommand)
export class DetachFileCommandHandler
implements ICommandHandler<DetachFileCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly storage: AttachmentStorage,
) {}
async execute(command: DetachFileCommand): Promise<CommandResult> {
const { issue, project } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
const role = project.roleOf(command.actorId);
issue.detach(
command.actorId,
command.attachmentId,
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
);
await issue.commitAndWait();
await this.storage.remove(
attachmentKey(issue.projectId, issue.id, command.attachmentId),
);
return ack(issue);
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class DetachFileCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly attachmentId: string,
) {
super();
}
}

View File

@@ -0,0 +1,20 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Issue, Project } from '@task-stream/api/tracker/domain';
import { DetachFromParentCommand } from './detach-from-parent.command';
import { WriteAccess } from './write-access';
@CommandHandler(DetachFromParentCommand)
export class DetachFromParentCommandHandler
implements ICommandHandler<DetachFromParentCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: DetachFromParentCommand): Promise<void> {
const issue = await this.access.live(command.issueId, Issue);
// Gone (or its whole project is going), or it moved elsewhere already.
if (!issue || issue.parentId !== command.parentId) return;
if (!(await this.access.live(issue.projectId, Project))) return;
issue.setParent(command.actorId, null);
await issue.commitAndWait();
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
/** Saga only: the parent of a ticket was deleted, so it goes back to the top. */
export class DetachFromParentCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly parentId: string,
) {
super();
}
}

View File

@@ -26,3 +26,14 @@ export * from './purge-comment.command';
export * from './drop-links-to-issue.command';
export * from './detach-issue-from-release.command';
export * from './unassign-issue.command';
export * from './attach-file.command';
export * from './detach-file.command';
export * from './watch-issue.command';
export * from './set-issue-parent.command';
export * from './detach-from-parent.command';
export * from './change-checklist.command';
export * from './rank-issue.command';
export * from './sprint.commands';
export * from './write-release-notes.command';
export * from './bulk-update-issues';
export * from './trash';

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import {
Issue,
MAX_HIERARCHY_DEPTH,
ParentCandidate,
} from '@task-stream/api/tracker/domain';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
import { WriteAccess } from './write-access';
/**
* Walks the epic → ticket → subtask tree on the write side (the event store),
* so nesting rules never depend on a read model that may lag behind.
*/
@Injectable()
export class IssueTree {
constructor(
private readonly access: WriteAccess,
private readonly streams: TrackerStreamsRepository,
) {}
/** `parentId` as a would-be parent: with its ancestors, closest first. */
async candidate(parentId: string): Promise<ParentCandidate> {
const parent = await this.access.issue(parentId);
const ancestorIds: string[] = [];
let next = parent.parentId;
// One more than allowed is enough to tell a tree is too deep (or loops).
while (next && ancestorIds.length <= MAX_HIERARCHY_DEPTH) {
ancestorIds.push(next);
next = (await this.access.live(next, Issue))?.parentId ?? null;
}
return {
id: parent.id,
key: parent.key,
projectId: parent.projectId,
ancestorIds,
};
}
/** Tickets hanging from `issueId` right now. */
async childrenOf(issueId: string): Promise<Issue[]> {
const everUnder = await this.streams.issueIdsEverUnder(issueId);
const issues = await Promise.all(
everUnder.map((id) => this.access.live(id, Issue)),
);
return issues.filter(
(issue): issue is Issue => !!issue && issue.parentId === issueId,
);
}
/** Levels of tickets below `issueId` (0: none). */
async heightOf(issueId: string, depth = 0): Promise<number> {
if (depth > MAX_HIERARCHY_DEPTH) return 0;
const children = await this.childrenOf(issueId);
if (children.length === 0) return 0;
const heights = await Promise.all(
children.map((child) => this.heightOf(child.id, depth + 1)),
);
return 1 + Math.max(...heights);
}
}

View File

@@ -0,0 +1,27 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { RankIssueCommand } from './rank-issue.command';
import { WriteAccess } from './write-access';
@CommandHandler(RankIssueCommand)
export class RankIssueCommandHandler
implements ICommandHandler<RankIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: RankIssueCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
// The neighbours' ranks come from their streams, not from the screen.
const [above, below] = await Promise.all(
[command.aboveIssueId, command.belowIssueId].map((id) =>
id ? this.access.issue(id) : null,
),
);
issue.moveBetween(command.actorId, above, below);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,17 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/**
* Moves a ticket in the backlog, between the one that ends up above it and the
* one that ends up below (null: the top or the bottom).
*/
export class RankIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly aboveIssueId: string | null,
public readonly belowIssueId: string | null,
) {
super();
}
}

View File

@@ -0,0 +1,33 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { IssueTree } from './issue-tree';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { WriteAccess } from './write-access';
@CommandHandler(SetIssueParentCommand)
export class SetIssueParentCommandHandler
implements ICommandHandler<SetIssueParentCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly tree: IssueTree,
) {}
async execute(command: SetIssueParentCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
if (command.parentId === null) {
issue.setParent(command.actorId, null);
} else if (command.parentId !== issue.parentId) {
const [parent, height] = await Promise.all([
this.tree.candidate(command.parentId),
this.tree.heightOf(issue.id),
]);
issue.setParent(command.actorId, parent, height);
}
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** Places a ticket under an epic or another ticket (`parentId` null: out). */
export class SetIssueParentCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly parentId: string | null,
) {
super();
}
}

View File

@@ -0,0 +1,219 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import {
Issue,
Project,
Sprint,
SprintFactory,
SprintStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { CommandResult, ack } from './command-result';
import {
CarryOverIssueCommand,
CloseSprintCommand,
CreateSprintCommand,
DeleteSprintCommand,
SetIssueSprintCommand,
StartSprintCommand,
UpdateSprintCommand,
} from './sprint.commands';
import { WriteAccess } from './write-access';
/* Sprints are planned and run by whoever manages the project; any member puts tickets in them. */
@CommandHandler(CreateSprintCommand)
export class CreateSprintCommandHandler
implements ICommandHandler<CreateSprintCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly sprintFactory: SprintFactory,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: CreateSprintCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.assertCanManage(command.actorId);
const sprint = this.sprintFactory.create(command.actorId, {
projectId: project.id,
name: command.name,
goal: command.goal,
startDate: command.startDate,
endDate: command.endDate,
});
this.eventPublisher.mergeObjectContext(sprint);
await sprint.commitAndWait();
return ack(sprint);
}
}
/** The sprint, checking the actor manages its project. */
async function managedSprint(
access: WriteAccess,
sprintId: string,
actorId: string,
): Promise<Sprint> {
const sprint = await access.sprint(sprintId);
(await access.project(sprint.projectId)).assertCanManage(actorId);
return sprint;
}
@CommandHandler(UpdateSprintCommand)
export class UpdateSprintCommandHandler
implements ICommandHandler<UpdateSprintCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: UpdateSprintCommand): Promise<CommandResult> {
const sprint = await managedSprint(
this.access,
command.sprintId,
command.actorId,
);
sprint.update(command.actorId, command.changes);
await sprint.commitAndWait();
return ack(sprint);
}
}
@CommandHandler(StartSprintCommand)
export class StartSprintCommandHandler
implements ICommandHandler<StartSprintCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findSprints: FindSprintsRepository,
) {}
async execute(command: StartSprintCommand): Promise<CommandResult> {
const sprint = await managedSprint(
this.access,
command.sprintId,
command.actorId,
);
const active = await this.findSprints.findActive(sprint.projectId);
sprint.start(
command.actorId,
active && active.id !== sprint.id ? active : null,
);
await sprint.commitAndWait();
return ack(sprint);
}
}
@CommandHandler(CloseSprintCommand)
export class CloseSprintCommandHandler
implements ICommandHandler<CloseSprintCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
) {}
async execute(command: CloseSprintCommand): Promise<CommandResult> {
const sprint = await managedSprint(
this.access,
command.sprintId,
command.actorId,
);
const target = command.carryOverTo
? await this.access.sprint(command.carryOverTo)
: null;
const progress = await this.findIssues.sprintProgress(sprint.id);
sprint.close(command.actorId, target, progress.total - progress.finished);
await sprint.commitAndWait();
return ack(sprint);
}
}
@CommandHandler(DeleteSprintCommand)
export class DeleteSprintCommandHandler
implements ICommandHandler<DeleteSprintCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: DeleteSprintCommand): Promise<CommandResult | void> {
if (command.cascade) {
// Saga: idempotent, the sprint may be gone already.
const sprint = await this.access.live(command.sprintId, Sprint);
if (!sprint) return;
sprint.delete(command.actorId, true);
await sprint.commitAndWait();
return;
}
const sprint = await managedSprint(
this.access,
command.sprintId,
command.actorId,
);
sprint.delete(command.actorId);
await sprint.commitAndWait();
return ack(sprint);
}
}
@CommandHandler(SetIssueSprintCommand)
export class SetIssueSprintCommandHandler
implements ICommandHandler<SetIssueSprintCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: SetIssueSprintCommand): Promise<CommandResult> {
const { issue, project } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
// A closed sprint keeps its tickets: neither side may be closed.
if (issue.sprintId) await this.access.openSprintOf(project, issue.sprintId);
if (command.sprintId)
await this.access.openSprintOf(project, command.sprintId);
issue.setSprint(command.actorId, command.sprintId);
await issue.commitAndWait();
return ack(issue);
}
}
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
@CommandHandler(CarryOverIssueCommand)
export class CarryOverIssueCommandHandler
implements ICommandHandler<CarryOverIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: CarryOverIssueCommand): Promise<void> {
const issue = await this.access.live(command.issueId, Issue);
// Gone, or it left that sprint meanwhile: nothing to do.
if (!issue || issue.sprintId !== command.fromSprintId) return;
const project = await this.access.live(issue.projectId, Project);
if (!project) return;
if (
command.onlyUnfinished &&
FINISHED.includes(project.workflow.categoryOf(issue.status))
)
return;
if (command.toSprintId) {
const target = await this.access.live(command.toSprintId, Sprint);
// The target closed or vanished meanwhile: back to the backlog instead.
const open =
target && !target.deleted && target.status !== SprintStatus.CLOSED;
issue.setSprint(command.actorId, open ? command.toSprintId : null);
} else {
issue.setSprint(command.actorId, null);
}
await issue.commitAndWait();
}
}
export const SPRINT_COMMAND_HANDLERS = [
CreateSprintCommandHandler,
UpdateSprintCommandHandler,
StartSprintCommandHandler,
CloseSprintCommandHandler,
DeleteSprintCommandHandler,
SetIssueSprintCommandHandler,
// Saga only.
CarryOverIssueCommandHandler,
];

View File

@@ -0,0 +1,85 @@
import { Command } from '@nestjs/cqrs';
import { SprintDetails } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class CreateSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly name: string,
public readonly startDate: string,
public readonly endDate: string,
public readonly goal: string | null = null,
) {
super();
}
}
export class UpdateSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly sprintId: string,
public readonly changes: SprintDetails,
) {
super();
}
}
export class StartSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly sprintId: string,
) {
super();
}
}
/** Ends a sprint; unfinished tickets go to `carryOverTo` (null: backlog). */
export class CloseSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly sprintId: string,
public readonly carryOverTo: string | null,
) {
super();
}
}
/** A planned sprint (people), or any sprint of a deleted project (`cascade`, saga). */
export class DeleteSprintCommand extends Command<CommandResult | void> {
constructor(
public readonly actorId: string,
public readonly sprintId: string,
public readonly cascade = false,
) {
super();
}
}
/** Puts a ticket in a sprint (null: back to the backlog). */
export class SetIssueSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly sprintId: string | null,
) {
super();
}
}
/**
* Saga only: a sprint closed (or was deleted), so a ticket of it moves to
* `toSprintId` (null: backlog). With `onlyUnfinished`, finished ones stay as
* the record of what the sprint achieved.
*/
export class CarryOverIssueCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly fromSprintId: string,
public readonly toSprintId: string | null,
public readonly onlyUnfinished: boolean,
) {
super();
}
}

View File

@@ -0,0 +1,103 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
} from '@nestjs/cqrs';
import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { CommandResult, ack } from './command-result';
import { DeleteIssueCommand } from './delete-issue.command';
import { WriteAccess } from './write-access';
/** Days a ticket stays in the trash before it is deleted for good. */
export const TRASH_DAYS = 30;
export class TrashIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
) {
super();
}
}
export class RestoreIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
) {
super();
}
}
/** Scheduler only: deletes for good what went to the trash before `before`. */
export class EmptyTrashCommand extends Command<number> {
constructor(public readonly before: string) {
super();
}
}
@CommandHandler(TrashIssueCommand)
export class TrashIssueCommandHandler
implements ICommandHandler<TrashIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: TrashIssueCommand): Promise<CommandResult> {
const { issue, project } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
const role = project.roleOf(command.actorId);
issue.trash(command.actorId, role === 'OWNER' || role === 'ADMIN');
await issue.commitAndWait();
return ack(issue);
}
}
@CommandHandler(RestoreIssueCommand)
export class RestoreIssueCommandHandler
implements ICommandHandler<RestoreIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: RestoreIssueCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
project.assertCanContribute(command.actorId);
const role = project.roleOf(command.actorId);
issue.restore(command.actorId, role === 'OWNER' || role === 'ADMIN');
await issue.commitAndWait();
return ack(issue);
}
}
/**
* Each deletion is the usual one: comments purged, links dropped and
* subtasks back to the top, by the sagas.
*/
@CommandHandler(EmptyTrashCommand)
export class EmptyTrashCommandHandler
implements ICommandHandler<EmptyTrashCommand>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly commandBus: CommandBus,
) {}
async execute(command: EmptyTrashCommand): Promise<number> {
const old = await this.findIssues.findTrashedBefore(command.before);
for (const issue of old)
await this.commandBus.execute(
new DeleteIssueCommand(SYSTEM_ACTOR_ID, issue.id),
);
return old.length;
}
}
export const TRASH_HANDLERS = [
TrashIssueCommandHandler,
RestoreIssueCommandHandler,
EmptyTrashCommandHandler,
];

View File

@@ -0,0 +1,33 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { CommandResult, ack } from './command-result';
import { WatchIssueCommand } from './watch-issue.command';
import { WriteAccess } from './write-access';
/**
* Following is for members, viewers included: reading a ticket is enough to
* want to hear about it. People only follow or unfollow for themselves.
*/
@CommandHandler(WatchIssueCommand)
export class WatchIssueCommandHandler
implements ICommandHandler<WatchIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: WatchIssueCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
if (command.actorId !== command.userId) {
throw new ForbiddenError('You can only follow tickets yourself');
}
if (!project.isMember(command.userId)) {
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
}
if (command.watching) issue.watch(command.actorId, command.userId);
else issue.unwatch(command.actorId, command.userId);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** `userId` starts (`watching`) or stops following a ticket. */
export class WatchIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly userId: string,
public readonly watching = true,
) {
super();
}
}

View File

@@ -10,6 +10,7 @@ import {
Issue,
Project,
Release,
Sprint,
} from '@task-stream/api/tracker/domain';
import { UserDirectory } from '../ports/user-directory';
@@ -38,6 +39,22 @@ export class WriteAccess {
return this.load(id, Release);
}
sprint(id: string): Promise<Sprint> {
return this.load(id, Sprint);
}
/** Sprint of the same project that still takes tickets (not closed). */
async openSprintOf(project: Project, sprintId: string): Promise<Sprint> {
const sprint = await this.sprint(sprintId);
if (sprint.projectId !== project.id) {
throw new BusinessRuleError(
`Sprint ${sprint.name} belongs to another project`,
);
}
sprint.assertAcceptsIssues();
return sprint;
}
comment(id: string): Promise<Comment> {
return this.load(id, Comment);
}
@@ -50,6 +67,10 @@ export class WriteAccess {
const issue = await this.issue(issueId);
const project = await this.project(issue.projectId);
project.assertCanContribute(actorId);
if (issue.trashedAt)
throw new BusinessRuleError(
`${issue.key} is in the trash: restore it to change it`,
);
return { issue, project };
}

View File

@@ -0,0 +1,21 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { WriteReleaseNotesCommand } from './write-release-notes.command';
import { WriteAccess } from './write-access';
@CommandHandler(WriteReleaseNotesCommand)
export class WriteReleaseNotesCommandHandler
implements ICommandHandler<WriteReleaseNotesCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: WriteReleaseNotesCommand): Promise<CommandResult> {
const release = await this.access.release(command.releaseId);
(await this.access.project(release.projectId)).assertCanManage(
command.actorId,
);
release.writeNotes(command.actorId, command.notes);
await release.commitAndWait();
return ack(release);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** Writes (or with null, removes) a release's notes. */
export class WriteReleaseNotesCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
public readonly notes: string | null,
) {
super();
}
}

View File

@@ -3,6 +3,15 @@ import {
CommentDeletedEvent,
CommentEditedEvent,
IssueAssignedEvent,
IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent,
IssueChecklistItemAddedEvent,
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent,
IssueDevLinkRecordedEvent,
IssueTrashedEvent,
IssueRestoredEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueLinkType,
@@ -19,9 +28,16 @@ import {
ProjectUpdatedEvent,
ProjectWorkflow,
ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
IssueSprintChangedEvent,
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintUpdatedEvent,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
@@ -31,6 +47,8 @@ export interface SummaryContext {
issueKey: string;
projectKey: string;
releaseName: string;
/** '' when unknown (e.g. the sprint was deleted). */
sprintName: string;
/** The project's workflow: names of statuses and types. */
workflow: ProjectWorkflow;
}
@@ -70,6 +88,7 @@ const FIELD: Record<string, string> = {
labels: 'las etiquetas',
figmaUrl: 'el enlace de Figma',
storyPoints: 'los puntos',
startDate: 'la fecha de inicio',
dueDate: 'la fecha límite',
};
@@ -135,6 +154,51 @@ export function describeEvent(
: `${actor} sacó ${issueKey} de su release`;
}
if (event instanceof IssueAttachmentAddedEvent)
return `${actor} adjuntó ${event.attachment.fileName} a ${issueKey}`;
if (event instanceof IssueAttachmentRemovedEvent)
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
if (event instanceof IssueSprintChangedEvent) {
const sprint = ctx.sprintName ? `el sprint ${ctx.sprintName}` : 'un sprint';
return event.sprintId
? `${actor} metió ${issueKey} en ${sprint}`
: `${actor} sacó ${issueKey} de ${sprint}`;
}
if (event instanceof IssueTrashedEvent)
return `${actor} movió ${issueKey} a la papelera`;
if (event instanceof IssueRestoredEvent)
return `${actor} recuperó ${issueKey} de la papelera`;
if (event instanceof IssueDevLinkRecordedEvent) {
const { kind, ref, state, author } = event.link;
const who = author ? ` (${author})` : '';
if (kind === 'COMMIT') return `Commit ${ref} sobre ${issueKey}${who}`;
if (kind === 'BRANCH') return `Rama ${ref} para ${issueKey}${who}`;
const verb =
state === 'MERGED'
? 'fusionada'
: state === 'CLOSED'
? 'cerrada'
: 'abierta';
return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
}
if (event instanceof IssueParentChangedEvent) {
return event.parentKey
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
: `${actor} sacó ${issueKey} de su ticket padre`;
}
if (event instanceof IssueChecklistItemAddedEvent)
return `${actor} añadió «${event.item.text}» a la lista de ${issueKey}`;
if (event instanceof IssueChecklistItemUpdatedEvent) {
if (event.changes.done === true)
return `${actor} completó un paso de la lista de ${issueKey}`;
if (event.changes.done === false)
return `${actor} reabrió un paso de la lista de ${issueKey}`;
return `${actor} cambió un paso de la lista de ${issueKey}`;
}
if (event instanceof IssueChecklistItemRemovedEvent)
return `${actor} quitó «${event.text}» de la lista de ${issueKey}`;
if (event instanceof CommentAddedEvent)
return `${actor} comentó en ${issueKey}`;
if (event instanceof CommentEditedEvent)
@@ -148,7 +212,26 @@ export function describeEvent(
return `${actor} actualizó la release ${ctx.releaseName}`;
if (event instanceof ReleaseShippedEvent)
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
if (event instanceof ReleaseNotesWrittenEvent)
return event.notes
? `${actor} escribió las notas de la release ${ctx.releaseName}`
: `${actor} quitó las notas de la release ${ctx.releaseName}`;
if (event instanceof ReleaseArchivedEvent)
return `${actor} archivó la release ${ctx.releaseName}`;
const sprint = ctx.sprintName || 'un sprint';
if (event instanceof SprintCreatedEvent)
return `${actor} planificó el sprint ${event.sprint.name}`;
if (event instanceof SprintUpdatedEvent)
return `${actor} actualizó el sprint ${sprint}`;
if (event instanceof SprintStartedEvent)
return `${actor} empezó el sprint ${sprint} 🏁`;
if (event instanceof SprintClosedEvent) {
const left = event.unfinishedIssues
? `; ${event.unfinishedIssues} ticket(s) sin terminar pasan ${event.carryOverTo ? 'al siguiente sprint' : 'al backlog'}`
: '';
return `${actor} cerró el sprint ${sprint}${left}`;
}
if (event instanceof SprintDeletedEvent)
return `${actor} borró el sprint ${sprint}`;
return `${actor}: ${event.constructor.name}`;
}

View File

@@ -5,6 +5,15 @@ import {
CommentDeletedEvent,
CommentEditedEvent,
IssueAssignedEvent,
IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent,
IssueChecklistItemAddedEvent,
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent,
IssueDevLinkRecordedEvent,
IssueTrashedEvent,
IssueRestoredEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueReleaseChangedEvent,
@@ -17,6 +26,13 @@ import {
ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent,
ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
IssueSprintChangedEvent,
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintUpdatedEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
@@ -31,6 +47,7 @@ import { ChangeFeed } from '../ports/change-feed';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
import { UserDirectory } from '../ports/user-directory';
import { describeEvent } from './activity-summary';
@@ -51,6 +68,15 @@ import { versionOf } from './read-model-notifier';
IssueLinkedEvent,
IssueUnlinkedEvent,
IssueReleaseChangedEvent,
IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent,
IssueParentChangedEvent,
IssueDevLinkRecordedEvent,
IssueTrashedEvent,
IssueRestoredEvent,
IssueChecklistItemAddedEvent,
IssueChecklistItemUpdatedEvent,
IssueChecklistItemRemovedEvent,
CommentAddedEvent,
CommentEditedEvent,
CommentDeletedEvent,
@@ -58,6 +84,13 @@ import { versionOf } from './read-model-notifier';
ReleaseUpdatedEvent,
ReleaseShippedEvent,
ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
IssueSprintChangedEvent,
SprintCreatedEvent,
SprintUpdatedEvent,
SprintStartedEvent,
SprintClosedEvent,
SprintDeletedEvent,
)
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
constructor(
@@ -65,6 +98,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findSprints: FindSprintsRepository,
private readonly users: UserDirectory,
private readonly changeFeed: ChangeFeed,
) {}
@@ -81,6 +115,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
const release = releaseId
? await this.findReleases.findById(releaseId)
: null;
const sprint = await this.sprintOf(event);
const project = await this.findProjects.findById(event.projectId);
// The project was deleted (cascade in progress): no feed to write to.
if (!project) return;
@@ -106,6 +141,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
issueKey: issueKey ?? '',
projectKey: project?.key ?? '',
releaseName: release?.name ?? '',
sprintName: sprint,
workflow: new ProjectWorkflow(
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
),
@@ -117,6 +153,17 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
}
}
/** Name of the sprint the event is about ('' when it is gone). */
private async sprintOf(event: TrackerEvent): Promise<string> {
if (event instanceof SprintCreatedEvent) return event.sprint.name;
const id = event.constructor.name.startsWith('Sprint')
? event.aggregateId
: event instanceof IssueSprintChangedEvent
? (event.sprintId ?? event.previousSprintId)
: null;
return id ? ((await this.findSprints.findById(id))?.name ?? '') : '';
}
private issueIdOf(event: TrackerEvent): string | null {
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
if (event instanceof CommentAddedEvent) return event.comment.issueId;

View File

@@ -0,0 +1,20 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueDeletedEvent } from '@task-stream/api/tracker/domain';
import {
AttachmentStorage,
issueAttachmentsPrefix,
} from '../ports/attachment-storage';
/** A deleted ticket takes its files with it. */
@EventsHandler(IssueDeletedEvent)
export class AttachmentCleanupEventHandler
implements IEventHandler<IssueDeletedEvent>
{
constructor(private readonly storage: AttachmentStorage) {}
async handle(event: IssueDeletedEvent) {
await this.storage.removePrefix(
issueAttachmentsPrefix(event.projectId, event.aggregateId),
);
}
}

View File

@@ -24,6 +24,18 @@ import { ProjectSettingsChangedEventHandler } from './project-settings-changed.e
import { ProjectDeletedEventHandler } from './project-deleted.event-handler';
import { IssueDeletedEventHandler } from './issue-deleted.event-handler';
import { ReleaseDeletedEventHandler } from './release-deleted.event-handler';
import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event-handler';
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler';
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
import { SprintEventHandler } from './sprint.event-handler';
import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler';
import { IssueDevLinkRecordedEventHandler } from './issue-dev-link-recorded.event-handler';
import { IssueTrashEventHandler } from './issue-trash.event-handler';
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
@@ -52,6 +64,18 @@ export const EVENT_HANDLERS = [
ReleaseCreatedEventHandler,
ReleaseShippedEventHandler,
ReleaseUpdatedEventHandler,
IssueAttachmentAddedEventHandler,
IssueAttachmentRemovedEventHandler,
AttachmentCleanupEventHandler,
IssueWatchersEventHandler,
IssueParentChangedEventHandler,
IssueChecklistEventHandler,
IssueRankedEventHandler,
SprintEventHandler,
ReleaseNotesWrittenEventHandler,
IssueDevLinkRecordedEventHandler,
IssueTrashEventHandler,
IssueSprintChangedEventHandler,
];
export { ReadModelNotifier };

View File

@@ -0,0 +1,31 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueAttachmentAddedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueAttachmentAddedEvent)
export class IssueAttachmentAddedEventHandler
implements IEventHandler<IssueAttachmentAddedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueAttachmentAddedEvent) {
const { attachment } = event;
const users = await this.users.refs([attachment.uploadedBy]);
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.upsertMaterializedIssueRepository.addAttachment(
event.aggregateId,
{ ...attachment, uploadedBy: users.of(attachment.uploadedBy) },
);
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,27 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueAttachmentRemovedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueAttachmentRemovedEvent)
export class IssueAttachmentRemovedEventHandler
implements IEventHandler<IssueAttachmentRemovedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueAttachmentRemovedEvent) {
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.upsertMaterializedIssueRepository.removeAttachment(
event.aggregateId,
event.attachmentId,
);
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,48 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
IssueChecklistItemAddedEvent,
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
type ChecklistEvent =
| IssueChecklistItemAddedEvent
| IssueChecklistItemUpdatedEvent
| IssueChecklistItemRemovedEvent;
@EventsHandler(
IssueChecklistItemAddedEvent,
IssueChecklistItemUpdatedEvent,
IssueChecklistItemRemovedEvent,
)
export class IssueChecklistEventHandler
implements IEventHandler<ChecklistEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ChecklistEvent) {
const repository = this.upsertMaterializedIssueRepository;
await repository.upsert({
id: event.aggregateId,
updatedAt: event.occurredAt,
version: versionOf(event),
});
if (event instanceof IssueChecklistItemAddedEvent) {
await repository.addChecklistItem(event.aggregateId, event.item);
} else if (event instanceof IssueChecklistItemUpdatedEvent) {
await repository.updateChecklistItem(
event.aggregateId,
event.itemId,
event.changes,
);
} else {
await repository.removeChecklistItem(event.aggregateId, event.itemId);
}
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -2,6 +2,7 @@ import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
IssueCreatedEvent,
implicitRank,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -48,9 +49,13 @@ export class IssueCreatedEventHandler
reporter: users.of(issue.reporterId),
assignee: users.ofNullable(issue.assigneeId),
release: release ? { id: release.id, name: release.name } : null,
parent: null,
sprint: null,
rank: implicitRank(issue.number),
labels: issue.labels,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
startDate: issue.startDate ?? null,
dueDate: issue.dueDate,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,

View File

@@ -0,0 +1,27 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueDevLinkRecordedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueDevLinkRecordedEvent)
export class IssueDevLinkRecordedEventHandler
implements IEventHandler<IssueDevLinkRecordedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueDevLinkRecordedEvent) {
// Code moving is not an edit of the ticket: `updatedAt` stays.
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
version: versionOf(event),
});
await this.upsertMaterializedIssueRepository.putDevLink(
event.aggregateId,
event.link,
);
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,39 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueParentChangedEvent } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueParentChangedEvent)
export class IssueParentChangedEventHandler
implements IEventHandler<IssueParentChangedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findIssues: FindIssuesRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueParentChangedEvent) {
const parent = event.parentId
? await this.findIssues.findById(event.parentId)
: null;
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
parent: event.parentId
? {
id: event.parentId,
key: parent?.key ?? event.parentKey ?? '',
title: parent?.title ?? '',
}
: null,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
// Both parents list their subtasks: they changed too.
for (const id of [event.parentId, event.previousParentId]) {
if (id) await this.notifier.issue(id, event);
}
}
}

View File

@@ -0,0 +1,23 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueRankedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueRankedEvent)
export class IssueRankedEventHandler
implements IEventHandler<IssueRankedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueRankedEvent) {
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
rank: event.rank,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,31 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueSprintChangedEvent } from '@task-stream/api/tracker/domain';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueSprintChangedEvent)
export class IssueSprintChangedEventHandler
implements IEventHandler<IssueSprintChangedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findSprints: FindSprintsRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueSprintChangedEvent) {
const sprint = event.sprintId
? await this.findSprints.findById(event.sprintId)
: null;
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
sprint: event.sprintId
? { id: event.sprintId, name: sprint?.name ?? '' }
: null,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -3,6 +3,7 @@ import {
DEFAULT_PROJECT_SETTINGS,
IssueCreatedEvent,
IssueReleaseChangedEvent,
IssueSprintChangedEvent,
IssueStatusChangedEvent,
ProjectWorkflow,
TrackerEvent,
@@ -14,12 +15,13 @@ import { versionOf } from './read-model-notifier';
/**
* The life of every ticket, step by step: where it went (status + category)
* and which release it joined. Statistics are computed from it.
* and which release and sprint it joined. Statistics are computed from it.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueReleaseChangedEvent,
IssueSprintChangedEvent,
)
export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
constructor(
@@ -66,6 +68,14 @@ export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
kind: 'release',
releaseId: event.releaseId,
});
return;
}
if (event instanceof IssueSprintChangedEvent) {
await this.timeline.record({
...base,
kind: 'sprint',
sprintId: event.sprintId,
});
}
}

View File

@@ -0,0 +1,27 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
IssueRestoredEvent,
IssueTrashedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueTrashedEvent, IssueRestoredEvent)
export class IssueTrashEventHandler
implements IEventHandler<IssueTrashedEvent | IssueRestoredEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueTrashedEvent | IssueRestoredEvent) {
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
trashedAt: event instanceof IssueTrashedEvent ? event.occurredAt : null,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -19,6 +19,12 @@ export class IssueUpdatedEventHandler
updatedAt: event.occurredAt,
version: versionOf(event),
});
// Its subtasks show its title.
if (event.changes.title !== undefined)
await this.upsertMaterializedIssueRepository.renameParent(
event.aggregateId,
event.changes.title,
);
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,40 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
IssueUnwatchedEvent,
IssueWatchedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueWatchedEvent, IssueUnwatchedEvent)
export class IssueWatchersEventHandler
implements IEventHandler<IssueWatchedEvent | IssueUnwatchedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueWatchedEvent | IssueUnwatchedEvent) {
// Following is not a change to the ticket: `updatedAt` stays.
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
version: versionOf(event),
});
if (event instanceof IssueWatchedEvent) {
const users = await this.users.refs([event.userId]);
await this.upsertMaterializedIssueRepository.addWatcher(
event.aggregateId,
users.of(event.userId),
);
} else {
await this.upsertMaterializedIssueRepository.removeWatcher(
event.aggregateId,
event.userId,
);
}
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -5,6 +5,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
/** Position of the event in its stream (stamped by the event store). */
export function versionOf(event: TrackerEvent): number {
@@ -23,6 +24,7 @@ export class ReadModelNotifier {
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findSprints: FindSprintsRepository,
) {}
async project(
@@ -63,6 +65,16 @@ export class ReadModelNotifier {
await this.changeFeed.publishRelease({ ...this.meta(event), release });
}
async sprint(sprintId: string, event: TrackerEvent): Promise<void> {
const sprint = await this.findSprints.findById(sprintId);
await this.changeFeed.publishSprint({
...this.meta(event),
sprintId,
projectId: event.projectId,
sprint,
});
}
private meta(event: TrackerEvent) {
return {
action: event.constructor.name,

View File

@@ -0,0 +1,23 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ReleaseNotesWrittenEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ReleaseNotesWrittenEvent)
export class ReleaseNotesWrittenEventHandler
implements IEventHandler<ReleaseNotesWrittenEvent>
{
constructor(
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ReleaseNotesWrittenEvent) {
await this.upsertMaterializedReleaseRepository.upsert({
id: event.aggregateId,
notes: event.notes,
version: versionOf(event),
});
await this.notifier.release(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,65 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintStatus,
SprintUpdatedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UpsertMaterializedSprintRepository } from '../ports/upsert-materialized-sprint.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
type SprintEvent =
| SprintCreatedEvent
| SprintUpdatedEvent
| SprintStartedEvent
| SprintClosedEvent
| SprintDeletedEvent;
/** The sprints view (`sprints_view`), one document per sprint. */
@EventsHandler(
SprintCreatedEvent,
SprintUpdatedEvent,
SprintStartedEvent,
SprintClosedEvent,
SprintDeletedEvent,
)
export class SprintEventHandler implements IEventHandler<SprintEvent> {
constructor(
private readonly sprints: UpsertMaterializedSprintRepository,
private readonly issues: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: SprintEvent) {
const version = versionOf(event);
const id = event.aggregateId;
if (event instanceof SprintCreatedEvent) {
await this.sprints.upsert({ ...event.sprint, version });
} else if (event instanceof SprintUpdatedEvent) {
await this.sprints.upsert({ id, ...event.changes, version });
// Tickets show its name.
if (event.changes.name)
await this.issues.renameSprint(id, event.changes.name);
} else if (event instanceof SprintStartedEvent) {
await this.sprints.upsert({
id,
status: SprintStatus.ACTIVE,
startedAt: event.occurredAt,
version,
});
} else if (event instanceof SprintClosedEvent) {
await this.sprints.upsert({
id,
status: SprintStatus.CLOSED,
closedAt: event.occurredAt,
version,
});
} else {
await this.sprints.remove(id);
}
await this.notifier.sprint(id, event);
}
}

View File

@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import {
DisableGitIntegrationCommand,
EnableGitIntegrationCommand,
GetGitIntegrationQuery,
GitIntegration,
RecordGitMentionsCommand,
} from './git/git';
import {
gitMentionsOf,
gitProviderOf,
verifyGitSignature,
} from './git/git-payloads';
import { FindProjectsRepository } from './ports/find-projects.repository';
import { GitIntegrationsRepository } from './ports/git-integrations.repository';
export interface GitDelivery {
/** Project id or key, from the webhook URL. */
project: string;
headers: Record<string, string | undefined>;
rawBody: Buffer | undefined;
body: Record<string, unknown>;
}
/** Links commits, branches and pull requests to the tickets they name. */
@Injectable()
export class GitService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository,
private readonly integrations: GitIntegrationsRepository,
) {}
integration(viewerId: string, projectId: string): Promise<GitIntegration> {
return this.queryBus.execute(
new GetGitIntegrationQuery(viewerId, projectId),
);
}
enable(actorId: string, projectId: string): Promise<GitIntegration> {
return this.commandBus.execute(
new EnableGitIntegrationCommand(actorId, projectId),
);
}
disable(actorId: string, projectId: string): Promise<void> {
return this.commandBus.execute(
new DisableGitIntegrationCommand(actorId, projectId),
);
}
/**
* A webhook delivery. Unknown project, integration off or a bad signature
* all look the same from outside (403): nothing to learn by probing.
*/
async receive(delivery: GitDelivery): Promise<{ recorded: number }> {
const project =
(await this.findProjects.findById(delivery.project)) ??
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
const secret = project
? await this.integrations.secretOf(project.id)
: null;
const source = gitProviderOf(delivery.headers);
if (
!project ||
!secret ||
!source ||
!verifyGitSignature(
source.provider,
delivery.headers,
delivery.rawBody,
secret,
)
)
throw new ForbiddenError(
'Unknown project, integration off or bad signature',
);
const mentions = gitMentionsOf(
source.provider,
source.event,
delivery.body,
);
if (!mentions.length) return { recorded: 0 };
const recorded: number = await this.commandBus.execute(
new RecordGitMentionsCommand(project.id, mentions),
);
return { recorded };
}
}

View File

@@ -0,0 +1,88 @@
import { createHmac } from 'node:crypto';
import { DevLinkKind, DevLinkState } from '@task-stream/api/tracker/domain';
import {
gitMentionsOf,
gitProviderOf,
verifyGitSignature,
} from './git-payloads';
describe('git payloads', () => {
const body = Buffer.from('{"a":1}');
const hmac = createHmac('sha256', 's3cret').update(body).digest('hex');
it('tells the host apart and checks its signature', () => {
expect(gitProviderOf({ 'x-gitea-event': 'push' })).toEqual({
provider: 'gitea',
event: 'push',
});
expect(gitProviderOf({})).toBeNull();
expect(
verifyGitSignature(
'github',
{ 'x-hub-signature-256': `sha256=${hmac}` },
body,
's3cret',
),
).toBe(true);
expect(
verifyGitSignature('gitea', { 'x-gitea-signature': hmac }, body, 'other'),
).toBe(false);
expect(
verifyGitSignature(
'gitlab',
{ 'x-gitlab-token': 's3cret' },
body,
's3cret',
),
).toBe(true);
});
it('reads the commits and the branch of a push', () => {
const mentions = gitMentionsOf('gitea', 'push', {
ref: 'refs/heads/web-7-checkout',
repository: { html_url: 'https://git.example.org/acme/web/' },
pusher: { login: 'ana' },
commits: [
{
id: 'abcdef1234567',
message: 'WEB-7: card form\n\nlong text',
url: 'https://git.example.org/acme/web/commit/abcdef1234567',
author: { name: 'Ana' },
timestamp: '2026-10-08T10:00:00Z',
},
],
});
expect(mentions.map((m) => [m.link.kind, m.link.ref, m.text])).toEqual([
[DevLinkKind.COMMIT, 'abcdef12', 'WEB-7: card form\n\nlong text'],
[DevLinkKind.BRANCH, 'web-7-checkout', 'web-7-checkout'],
]);
expect(mentions[1].link.url).toBe(
'https://git.example.org/acme/web/src/branch/web-7-checkout',
);
});
it('reads pull and merge requests with their state', () => {
const [pr] = gitMentionsOf('github', 'pull_request', {
pull_request: {
html_url: 'https://github.com/acme/web/pull/12',
number: 12,
title: 'Pay by card',
head: { ref: 'web-7' },
state: 'closed',
merged: true,
},
});
expect(pr.link).toMatchObject({ ref: '#12', state: DevLinkState.MERGED });
expect(pr.text).toContain('web-7');
const [mr] = gitMentionsOf('gitlab', 'Merge Request Hook', {
object_attributes: {
url: 'https://gitlab.com/a/b/-/merge_requests/3',
iid: 3,
title: 'WEB-9',
state: 'opened',
},
});
expect(mr.link).toMatchObject({ ref: '!3', state: DevLinkState.OPEN });
expect(gitMentionsOf('github', 'ping', {})).toEqual([]);
});
});

View File

@@ -0,0 +1,178 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import {
DevLink,
DevLinkKind,
DevLinkState,
} from '@task-stream/api/tracker/domain';
export type GitProvider = 'github' | 'gitea' | 'gitlab';
/** Code the host told us about, and the text where ticket keys may appear. */
export interface GitMention {
text: string;
link: DevLink;
}
type Json = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
/** Which host sent it, from its event header. */
export function gitProviderOf(
headers: Record<string, string | undefined>,
): { provider: GitProvider; event: string } | null {
if (headers['x-gitea-event'])
return { provider: 'gitea', event: headers['x-gitea-event'] };
if (headers['x-github-event'])
return { provider: 'github', event: headers['x-github-event'] };
if (headers['x-gitlab-event'])
return { provider: 'gitlab', event: headers['x-gitlab-event'] };
return null;
}
const same = (a: string, b: string) =>
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
/**
* GitHub and Gitea sign the raw body (HMAC-SHA256, hex); GitLab sends the
* secret itself as a token.
*/
export function verifyGitSignature(
provider: GitProvider,
headers: Record<string, string | undefined>,
rawBody: Buffer | undefined,
secret: string,
): boolean {
if (provider === 'gitlab')
return (
!!headers['x-gitlab-token'] && same(headers['x-gitlab-token'], secret)
);
const received = (
provider === 'github'
? headers['x-hub-signature-256']
: headers['x-gitea-signature']
)?.replace(/^sha256=/, '');
if (!received || !rawBody) return false;
return same(
received,
createHmac('sha256', secret).update(rawBody).digest('hex'),
);
}
const branchOf = (ref: unknown) =>
typeof ref === 'string' && ref.startsWith('refs/heads/')
? ref.slice('refs/heads/'.length)
: null;
/** Commits of a push, and the branch they went to (all three hosts). */
function fromPush(
provider: GitProvider,
body: Json,
repoUrl: string,
): GitMention[] {
const mentions: GitMention[] = [];
const branch = branchOf(body['ref']);
for (const commit of (body['commits'] ?? []) as Json[]) {
if (!commit?.['url'] || !commit['id']) continue;
const message = String(commit['message'] ?? '');
mentions.push({
text: message,
link: {
kind: DevLinkKind.COMMIT,
url: String(commit['url']),
title: message,
ref: String(commit['id']).slice(0, 8),
state: null,
provider,
author: commit['author']?.['name'] ?? null,
at: String(commit['timestamp'] ?? new Date().toISOString()),
},
});
}
if (branch && repoUrl) {
const path =
provider === 'gitlab'
? '/-/tree/'
: provider === 'gitea'
? '/src/branch/'
: '/tree/';
mentions.push({
text: branch,
link: {
kind: DevLinkKind.BRANCH,
url: `${repoUrl}${path}${branch}`,
title: branch,
ref: branch,
state: null,
provider,
author:
body['pusher']?.['name'] ??
body['pusher']?.['login'] ??
body['user_name'] ??
null,
at: new Date().toISOString(),
},
});
}
return mentions;
}
function prState(merged: boolean, state: string): DevLinkState {
if (merged || state === 'merged') return DevLinkState.MERGED;
return state === 'closed' ? DevLinkState.CLOSED : DevLinkState.OPEN;
}
/**
* What a webhook delivery is about: commits and branches of a push, or a
* pull/merge request. Anything else (pings, tags, issues…) is nothing.
*/
export function gitMentionsOf(
provider: GitProvider,
event: string,
body: Json,
): GitMention[] {
const repoUrl = String(
body['repository']?.['html_url'] ?? body['project']?.['web_url'] ?? '',
).replace(/\/$/, '');
if (event === 'push' || event === 'Push Hook')
return fromPush(provider, body, repoUrl);
if (provider !== 'gitlab' && event === 'pull_request') {
const pr = body['pull_request'] as Json | undefined;
if (!pr?.['html_url']) return [];
const title = String(pr['title'] ?? '');
return [
{
text: `${title} ${pr['head']?.['ref'] ?? ''} ${pr['body'] ?? ''}`,
link: {
kind: DevLinkKind.PULL_REQUEST,
url: String(pr['html_url']),
title,
ref: `#${pr['number'] ?? ''}`,
state: prState(!!pr['merged'], String(pr['state'] ?? 'open')),
provider,
author: pr['user']?.['login'] ?? null,
at: String(pr['updated_at'] ?? new Date().toISOString()),
},
},
];
}
if (provider === 'gitlab' && event === 'Merge Request Hook') {
const mr = body['object_attributes'] as Json | undefined;
if (!mr?.['url']) return [];
const title = String(mr['title'] ?? '');
return [
{
text: `${title} ${mr['source_branch'] ?? ''} ${mr['description'] ?? ''}`,
link: {
kind: DevLinkKind.PULL_REQUEST,
url: String(mr['url']),
title,
ref: `!${mr['iid'] ?? ''}`,
state: prState(false, String(mr['state'] ?? 'opened')),
provider,
author: body['user']?.['username'] ?? null,
at: String(mr['updated_at'] ?? new Date().toISOString()),
},
},
];
}
return [];
}

View File

@@ -0,0 +1,157 @@
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
Issue,
ProjectRole,
SYSTEM_ACTOR_ID,
issueKeysIn,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { ReadAccess } from '../queries/read-access';
import { GitMention } from './git-payloads';
export interface GitIntegration {
enabled: boolean;
/** Only for whoever manages the project. */
secret: string | null;
}
/** Turns the integration on with a new secret (or rotates it). */
export class EnableGitIntegrationCommand extends Command<GitIntegration> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
export class DisableGitIntegrationCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
/** Webhook only: code from a verified delivery, linked to the tickets it names. */
export class RecordGitMentionsCommand extends Command<number> {
constructor(
public readonly projectId: string,
public readonly mentions: GitMention[],
) {
super();
}
}
export class GetGitIntegrationQuery extends Query<GitIntegration> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}
@CommandHandler(EnableGitIntegrationCommand)
export class EnableGitIntegrationCommandHandler
implements ICommandHandler<EnableGitIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(command: EnableGitIntegrationCommand): Promise<GitIntegration> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const secret = randomBytes(24).toString('hex');
await this.integrations.save(command.projectId, secret);
return { enabled: true, secret };
}
}
@CommandHandler(DisableGitIntegrationCommand)
export class DisableGitIntegrationCommandHandler
implements ICommandHandler<DisableGitIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(command: DisableGitIntegrationCommand): Promise<void> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
await this.integrations.remove(command.projectId);
}
}
@CommandHandler(RecordGitMentionsCommand)
export class RecordGitMentionsCommandHandler
implements ICommandHandler<RecordGitMentionsCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
) {}
/** How many tickets got something new. Unknown keys are ignored. */
async execute(command: RecordGitMentionsCommand): Promise<number> {
const project = await this.access.project(command.projectId);
let recorded = 0;
for (const mention of command.mentions) {
for (const key of issueKeysIn(mention.text, project.key)) {
const found = await this.findIssues.findByKey(key);
if (!found || found.projectId !== project.id) continue;
const issue = await this.access.live(found.id, Issue);
if (!issue) continue;
const before = issue.getUncommittedEvents().length;
issue.recordDevLink(SYSTEM_ACTOR_ID, mention.link);
if (issue.getUncommittedEvents().length === before) continue;
await issue.commitAndWait();
recorded++;
}
}
return recorded;
}
}
@QueryHandler(GetGitIntegrationQuery)
export class GetGitIntegrationQueryHandler
implements IQueryHandler<GetGitIntegrationQuery, GitIntegration>
{
constructor(
private readonly access: ReadAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(query: GetGitIntegrationQuery): Promise<GitIntegration> {
const role = await this.access.assertCanView(
query.projectId,
query.viewerId,
);
const secret = await this.integrations.secretOf(query.projectId);
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
return { enabled: !!secret, secret: manages ? secret : null };
}
}
export const GIT_HANDLERS = [
EnableGitIntegrationCommandHandler,
DisableGitIntegrationCommandHandler,
RecordGitMentionsCommandHandler,
GetGitIntegrationQueryHandler,
];

View File

@@ -2,11 +2,32 @@ import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ActivityReadModel,
EpicReadModel,
IssueDetails,
IssueLinkType,
IssueReadModel,
} from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './commands/assign-issue.command';
import {
AttachFileCommand,
IncomingFile,
} from './commands/attach-file.command';
import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command';
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
import { RankIssueCommand } from './commands/rank-issue.command';
import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash';
import { GetTrashQuery } from './queries/get-trash.query';
import {
BulkChange,
BulkResult,
BulkUpdateIssuesCommand,
} from './commands/bulk-update-issues';
import {
AddChecklistItemCommand,
RemoveChecklistItemCommand,
UpdateChecklistItemCommand,
} from './commands/change-checklist.command';
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
import { CommandResult } from './commands/command-result';
import { CreateIssueCommand } from './commands/create-issue.command';
@@ -16,9 +37,14 @@ import { UnlinkIssuesCommand } from './commands/unlink-issues.command';
import { UpdateIssueCommand } from './commands/update-issue.command';
import { IssueFilter, Page } from './ports/find-issues.repository';
import { GetActivityQuery } from './queries/get-activity.query';
import {
AttachmentDownload,
GetAttachmentQuery,
} from './queries/get-attachment.query';
import { GetIssueQuery } from './queries/get-issue.query';
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
import { SearchIssuesQuery } from './queries/search-issues.query';
import { GetEpicsQuery } from './queries/get-epics.query';
import {
MyIssuesFilter,
SearchMyIssuesQuery,
@@ -31,6 +57,8 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
title: string;
assigneeId?: string | null;
releaseId?: string | null;
/** Epic or ticket the new one breaks down. */
parentId?: string | null;
}
export interface IssueLinkInput {
@@ -47,7 +75,15 @@ export class IssuesService {
) {}
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
const { projectId, type, title, assigneeId, releaseId, ...details } = input;
const {
projectId,
type,
title,
assigneeId,
releaseId,
parentId,
...details
} = input;
return this.commandBus.execute(
new CreateIssueCommand(
actorId,
@@ -57,6 +93,7 @@ export class IssuesService {
details,
assigneeId,
releaseId,
parentId,
),
);
}
@@ -126,6 +163,50 @@ export class IssuesService {
);
}
/** Attaches a file to the ticket, or to one of its comments. */
attach(
actorId: string,
issueId: string,
file: IncomingFile,
commentId: string | null = null,
): Promise<CommandResult> {
return this.commandBus.execute(
new AttachFileCommand(actorId, issueId, file, commentId),
);
}
detach(
actorId: string,
issueId: string,
attachmentId: string,
): Promise<CommandResult> {
return this.commandBus.execute(
new DetachFileCommand(actorId, issueId, attachmentId),
);
}
/** The actor follows (or stops following) the ticket. */
watch(
actorId: string,
issueId: string,
watching: boolean,
): Promise<CommandResult> {
return this.commandBus.execute(
new WatchIssueCommand(actorId, issueId, actorId, watching),
);
}
/** A ticket's file, with its bytes, for whoever can read the ticket. */
download(
viewerId: string | null,
issueId: string,
attachmentId: string,
): Promise<AttachmentDownload> {
return this.queryBus.execute(
new GetAttachmentQuery(viewerId, issueId, attachmentId),
);
}
findOne(viewerId: string | null, idOrKey: string): Promise<IssueReadModel> {
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
}
@@ -134,6 +215,91 @@ export class IssuesService {
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
}
/** Places the ticket under `parentId` (null: back to the top). */
setParent(
actorId: string,
issueId: string,
parentId: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new SetIssueParentCommand(actorId, issueId, parentId),
);
}
/** Moves a ticket in the backlog between `above` and `below` (null: an end). */
rank(
actorId: string,
issueId: string,
above: string | null,
below: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new RankIssueCommand(actorId, issueId, above, below),
);
}
/** The same change on several tickets; each one may succeed or not. */
bulkUpdate(
actorId: string,
issueIds: string[],
change: BulkChange,
): Promise<BulkResult> {
return this.commandBus.execute(
new BulkUpdateIssuesCommand(actorId, issueIds, change),
);
}
/** To the trash (whoever opened it, or a manager); back with `restore`. */
trash(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
}
restore(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
}
trashed(
viewerId: string | null,
projectId: string,
): Promise<IssueReadModel[]> {
return this.queryBus.execute(new GetTrashQuery(viewerId, projectId));
}
addChecklistItem(
actorId: string,
issueId: string,
text: string,
): Promise<CommandResult> {
return this.commandBus.execute(
new AddChecklistItemCommand(actorId, issueId, text),
);
}
updateChecklistItem(
actorId: string,
issueId: string,
itemId: string,
changes: { text?: string; done?: boolean },
): Promise<CommandResult> {
return this.commandBus.execute(
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
);
}
removeChecklistItem(
actorId: string,
issueId: string,
itemId: string,
): Promise<CommandResult> {
return this.commandBus.execute(
new RemoveChecklistItemCommand(actorId, issueId, itemId),
);
}
epics(viewerId: string | null, projectId: string): Promise<EpicReadModel[]> {
return this.queryBus.execute(new GetEpicsQuery(viewerId, projectId));
}
search(
viewerId: string | null,
filter: IssueFilter,

View File

@@ -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);
}
}

View File

@@ -0,0 +1,4 @@
export * from './notifier';
export * from './notifications.queries';
export * from './notifications.commands';
export * from './reminders';

View File

@@ -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,
});
}
}

View File

@@ -0,0 +1,185 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
CommentAddedEvent,
IssueAssignedEvent,
IssueCreatedEvent,
IssueStatusChangedEvent,
mentionedUserIds,
NotificationKind,
ProjectMemberAddedEvent,
StatusCategory,
TrackerEvent,
withoutMentionMarkup,
} 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 = withoutMentionMarkup(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 is mentioned, whoever a ticket is assigned to, whoever reported or
* works on it when it moves or gets a comment, whoever follows it, whoever
* joins a project. The order is the order of importance: each person gets
* one notification per event, for the first reason that applies.
*/
@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 watcherIds =
'watchers' in issue ? issue.watchers.map((w) => w.id) : [];
const memberIds = new Set(project.members.map((m) => m.user.id));
const drafts: NotificationDraft[] = [];
if (event instanceof IssueCreatedEvent && assigneeId) {
drafts.push({
userId: assigneeId,
kind: NotificationKind.ASSIGNED,
summary: `${actor} te asignó ${ticket}`,
});
}
if (event instanceof IssueAssignedEvent) {
const assignee = event.assigneeId
? (await this.users.refs([event.assigneeId])).of(event.assigneeId).name
: null;
if (event.assigneeId) {
drafts.push({
userId: event.assigneeId,
kind: NotificationKind.ASSIGNED,
summary: `${actor} te asignó ${ticket}`,
});
}
for (const userId of watcherIds) {
drafts.push({
userId,
kind: NotificationKind.WATCHED,
summary: assignee
? `${actor} asignó ${ticket} a ${assignee}`
: `${actor} dejó sin asignar ${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)}` : '';
const summary = failedQa
? `${actor} devolvió ${ticket} desde QA${reason}`
: `${actor} movió ${ticket} a ${to}${reason}`;
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({
userId,
kind: failedQa
? NotificationKind.QA_FAILED
: NotificationKind.STATUS_CHANGED,
summary,
});
}
for (const userId of watcherIds) {
drafts.push({ userId, kind: NotificationKind.WATCHED, summary });
}
}
if (event instanceof CommentAddedEvent) {
const said = `«${excerpt(event.comment.body)}»`;
// Only members: a mention cannot reach someone who cannot read the ticket.
for (const userId of mentionedUserIds(event.comment.body)) {
if (!memberIds.has(userId)) continue;
drafts.push({
userId,
kind: NotificationKind.MENTIONED,
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
});
}
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({
userId,
kind: NotificationKind.COMMENTED,
summary: `${actor} comentó en ${issue.key}: ${said}`,
});
}
for (const userId of watcherIds) {
drafts.push({
userId,
kind: NotificationKind.WATCHED,
summary: `${actor} comentó en ${issue.key}, que sigues: ${said}`,
});
}
}
await this.notifier.deliver(
event,
{
projectId: project.id,
projectKey: project.key,
issueId: issue.id,
issueKey: issue.key,
},
drafts,
);
}
}

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,222 @@
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')
.add('pm');
const issue = {
id: 'i-1',
key: 'WEB-1',
title: 'Login roto',
reporter: { id: 'qa', name: 'QA' },
assignee: { id: 'bob', name: 'Bob' },
watchers: [
{ id: 'pm', name: 'PM' },
{ 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',
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
user: { id, name: id },
role: ProjectRole.MEMBER,
})),
}),
} 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],
// Bob follows the ticket too, but is told once: as the assignee.
['pm', NotificationKind.WATCHED],
['pm', NotificationKind.WATCHED],
['bob', NotificationKind.WATCHED],
]);
expect(published).toHaveLength(4);
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],
['pm', NotificationKind.WATCHED],
]);
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', 'pm']);
});
it('tells a mentioned member first, and nobody outside the project', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new CommentAddedEvent('bob', {
id: 'c-2',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'bob',
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
}),
1,
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['pm', NotificationKind.MENTIONED],
['qa', NotificationKind.COMMENTED],
]);
expect(repo.all[0].summary).toContain('«@PM mira esto');
});
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,
]);
});
});

View File

@@ -0,0 +1,118 @@
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);
await this.send(
wanted.map((draft) => ({
id: `${event.aggregateId}:${versionOf(event)}:${draft.userId}`,
userId: draft.userId,
kind: draft.kind,
...context,
actor,
summary: draft.summary,
readAt: null,
createdAt: event.occurredAt,
})),
);
}
/**
* Stores, pushes and e-mails ready-made notifications. Their id makes them
* idempotent: one that is already there is not sent again.
*/
async send(notifications: NotificationReadModel[]): Promise<void> {
const delivered: NotificationReadModel[] = [];
for (const notification of notifications) {
// 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}`,
);
}
}
}
}

View File

@@ -0,0 +1,43 @@
import {
IssueReadModel,
NotificationKind,
} from '@task-stream/api/tracker/domain';
import { dueStage, nextDay, remindersFor } from './reminders';
const issue = (id: string, dueDate: string, watchers: string[] = []) =>
({
id,
key: `WEB-${id}`,
title: `T${id}`,
projectId: 'p',
projectKey: 'WEB',
dueDate,
assignee: { id: 'ana', name: 'Ana' },
watchers: watchers.map((w) => ({ id: w, name: w })),
}) as IssueReadModel;
describe('due reminders', () => {
it('tells the stage of a due date', () => {
expect(nextDay('2026-12-31')).toBe('2027-01-01');
expect(dueStage('2026-10-09', '2026-10-08')).toBe('tomorrow');
expect(dueStage('2026-10-08', '2026-10-08')).toBe('today');
expect(dueStage('2026-10-01', '2026-10-08')).toBe('overdue');
expect(dueStage('2026-10-20', '2026-10-08')).toBeNull();
});
it('reminds whoever has it and whoever follows it, once per stage', () => {
const reminders = remindersFor(
[issue('1', '2026-10-08', ['ana', 'bob']), issue('2', '2026-10-30')],
'2026-10-08',
'2026-10-08T07:00:00Z',
);
expect(reminders.map((r) => [r.userId, r.summary])).toEqual([
['ana', 'WEB-1 vence hoy: T1'],
['bob', 'WEB-1 vence hoy: T1'],
]);
expect(reminders[0]).toMatchObject({
kind: NotificationKind.REMINDER,
id: 'reminder:1:2026-10-08:today:ana',
});
});
});

View File

@@ -0,0 +1,111 @@
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
IssueReadModel,
NotificationKind,
NotificationReadModel,
SYSTEM_ACTOR_ID,
SYSTEM_ACTOR_NAME,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { Notifier } from './notifier';
/** Where a due date stands, from the reminder's point of view. */
export type DueStage = 'tomorrow' | 'today' | 'overdue';
const DAY = 86_400_000;
/** `YYYY-MM-DD` one day after `day`. */
export function nextDay(day: string): string {
return new Date(Date.parse(`${day}T00:00:00Z`) + DAY)
.toISOString()
.slice(0, 10);
}
export function dueStage(dueDate: string, today: string): DueStage | null {
const due = dueDate.slice(0, 10);
if (due < today) return 'overdue';
if (due === today) return 'today';
return due === nextDay(today) ? 'tomorrow' : null;
}
const day = (iso: string) =>
new Date(`${iso.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
/**
* One reminder per ticket, due date and stage — the day before, on the day,
* and once when it is overdue — for whoever has it and whoever follows it.
* Ids make it idempotent: running it every hour, on every instance, is fine.
*/
export function remindersFor(
issues: IssueReadModel[],
today: string,
now: string,
): NotificationReadModel[] {
const out: NotificationReadModel[] = [];
for (const issue of issues) {
if (!issue.dueDate) continue;
const stage = dueStage(issue.dueDate, today);
if (!stage) continue;
const summary =
stage === 'tomorrow'
? `${issue.key} vence mañana: ${issue.title}`
: stage === 'today'
? `${issue.key} vence hoy: ${issue.title}`
: `${issue.key} venció el ${day(issue.dueDate)} y sigue abierto: ${issue.title}`;
const people = new Set(
[issue.assignee?.id, ...(issue.watchers ?? []).map((w) => w.id)].filter(
(id): id is string => !!id,
),
);
for (const userId of people)
out.push({
id: `reminder:${issue.id}:${issue.dueDate.slice(0, 10)}:${stage}:${userId}`,
userId,
kind: NotificationKind.REMINDER,
projectId: issue.projectId,
projectKey: issue.projectKey,
issueId: issue.id,
issueKey: issue.key,
actor: { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME },
summary,
readAt: null,
createdAt: now,
});
}
return out;
}
/** Scheduler only: tells people about tickets due tomorrow, today or overdue. */
export class SendDueRemindersCommand extends Command<number> {
constructor(
/** `YYYY-MM-DD` in the installation's time zone. */
public readonly today: string,
) {
super();
}
}
@CommandHandler(SendDueRemindersCommand)
export class SendDueRemindersCommandHandler
implements ICommandHandler<SendDueRemindersCommand>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly notifier: Notifier,
) {}
async execute(command: SendDueRemindersCommand): Promise<number> {
const due = await this.findIssues.findDueBy(nextDay(command.today));
const reminders = remindersFor(
due,
command.today,
new Date().toISOString(),
);
await this.notifier.send(reminders);
return reminders.length;
}
}

View File

@@ -0,0 +1,24 @@
import type { Readable } from 'node:stream';
/** Where the bytes of attached files live (the tickets only record them). */
export abstract class AttachmentStorage {
abstract put(key: string, content: Buffer): Promise<void>;
/** Fails with NotFoundError when there is nothing at `key`. */
abstract read(key: string): Promise<Readable>;
/** Idempotent: removing what is not there is fine. */
abstract remove(key: string): Promise<void>;
/** Everything under `prefix` (a ticket's files, when it is deleted). */
abstract removePrefix(prefix: string): Promise<void>;
}
/** One folder per project and ticket, so a ticket's files go together. */
export const attachmentKey = (
projectId: string,
issueId: string,
attachmentId: string,
): string => `${projectId}/${issueId}/${attachmentId}`;
export const issueAttachmentsPrefix = (
projectId: string,
issueId: string,
): string => `${projectId}/${issueId}/`;

View File

@@ -0,0 +1,42 @@
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
import {
AutomationAction,
AutomationConditions,
AutomationTrigger,
} from '../automations/automation-rules';
/** «When this happens to a ticket, if it is like this, do that.» */
export interface Automation {
id: string;
projectId: string;
name: string;
active: boolean;
trigger: AutomationTrigger;
conditions: AutomationConditions;
action: AutomationAction;
createdBy: UserRefReadModel;
createdAt: string;
runs: number;
lastRunAt: string | null;
/** Why the last run did nothing (a move the workflow does not allow…). */
lastError: string | null;
}
/** Configuration, not history: kept outside the event store. */
export abstract class AutomationsRepository {
abstract findByProject(projectId: string): Promise<Automation[]>;
abstract findActive(projectId: string): Promise<Automation[]>;
abstract findById(id: string): Promise<Automation | null>;
abstract insert(automation: Automation): Promise<void>;
abstract update(
id: string,
changes: Partial<Pick<Automation, 'name' | 'active'>>,
): Promise<void>;
abstract delete(id: string): Promise<void>;
/**
* Takes the right to run `ruleId` for one event (false: it already ran, the
* event was delivered again).
*/
abstract claimRun(ruleId: string, eventKey: string): Promise<boolean>;
abstract recordRun(id: string, error: string | null): Promise<void>;
}

View File

@@ -1,9 +1,11 @@
import {
ActivityReadModel,
CommentReadModel,
NotificationReadModel,
IssueReadModel,
ProjectReadModel,
ReleaseReadModel,
SprintReadModel,
} from '@task-stream/api/tracker/domain';
/** Every realtime message says which event caused it and the resulting version. */
@@ -29,6 +31,13 @@ export interface ReleaseChange extends Change {
release: ReleaseReadModel;
}
export interface SprintChange extends Change {
/** Null when the sprint was deleted. */
sprint: SprintReadModel | null;
sprintId: string;
projectId: string;
}
export interface ProjectChange extends Change {
project: ProjectReadModel;
}
@@ -42,18 +51,27 @@ export abstract class ChangeFeed {
abstract publishComment(change: CommentChange): Promise<void>;
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
abstract publishRelease(change: ReleaseChange): Promise<void>;
abstract publishSprint(change: SprintChange): Promise<void>;
/** @param notifyUserIds users whose project list changed (joined / left). */
abstract publishProject(
change: ProjectChange,
notifyUserIds?: string[],
): Promise<void>;
abstract publishNotification(
notification: NotificationReadModel,
): Promise<void>;
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
abstract activity(
projectId: string,
): AsyncIterableIterator<ActivityReadModel>;
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
abstract sprints(projectId: string): AsyncIterableIterator<SprintChange>;
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
abstract notifications(
userId: string,
): AsyncIterableIterator<NotificationReadModel>;
}

View File

@@ -1,11 +1,18 @@
import {
EpicReadModel,
IssuePriority,
IssueReadModel,
ProjectStatsReadModel,
ReleaseProgressReadModel,
SprintProgressReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
export enum IssueSort {
RECENT = 'RECENT',
RANK = 'RANK',
}
export interface IssueFilter {
projectId: string;
/** Status, type and area ids of the project's settings. */
@@ -18,6 +25,16 @@ export interface IssueFilter {
unassigned?: boolean;
releaseId?: string;
labels?: string[];
/** Only the tickets hanging from this one. */
parentId?: string;
sprintId?: string;
/** Due between these days (`YYYY-MM-DD`, both included). */
dueFrom?: string;
dueTo?: string;
/** Only tickets in no sprint (wins over `sprintId`). */
noSprint?: boolean;
/** RECENT (default): last updated first. RANK: backlog order. */
sort?: IssueSort;
search?: string;
limit: number;
offset: number;
@@ -29,6 +46,7 @@ export interface CrossProjectIssueFilter {
assigneeId?: string;
statuses?: string[];
categories?: StatusCategory[];
search?: string;
limit: number;
offset: number;
}
@@ -49,8 +67,17 @@ export abstract class FindIssuesRepository {
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
/** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */
abstract findDueBy(day: string): Promise<IssueReadModel[]>;
/** The project's tickets in the trash, newest first. */
abstract findTrashed(projectId: string): Promise<IssueReadModel[]>;
/** Tickets that went to the trash before `before` (ISO), any project. */
abstract findTrashedBefore(before: string): Promise<IssueReadModel[]>;
/** Every ticket of a project (statistics). */
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
/** Top-level tickets with others hanging from them, by key. */
abstract epics(projectId: string): Promise<EpicReadModel[]>;
abstract sprintProgress(sprintId: string): Promise<SprintProgressReadModel>;
abstract releaseProgress(
releaseId: string,
): Promise<ReleaseProgressReadModel>;

View File

@@ -0,0 +1,9 @@
import { SprintReadModel } from '@task-stream/api/tracker/domain';
export abstract class FindSprintsRepository {
abstract findById(id: string): Promise<SprintReadModel | null>;
/** Active first, then planned by start date, then closed (newest first). */
abstract findByProject(projectId: string): Promise<SprintReadModel[]>;
/** The sprint under way in the project, if any. */
abstract findActive(projectId: string): Promise<SprintReadModel | null>;
}

View File

@@ -0,0 +1,9 @@
/**
* Per-project secret that Git hosts sign their webhooks with. Configuration,
* not history: kept outside the event store.
*/
export abstract class GitIntegrationsRepository {
abstract secretOf(projectId: string): Promise<string | null>;
abstract save(projectId: string, secret: string): Promise<void>;
abstract remove(projectId: string): Promise<void>;
}

View File

@@ -17,3 +17,12 @@ export * from './release-issues.repository';
export * from './upsert-materialized-timeline.repository';
export * from './find-timeline.repository';
export * from './tracker-streams.repository';
export * from './attachment-storage';
export * from './notifications.repository';
export * from './notification-mailer';
export * from './saved-filters.repository';
export * from './find-sprints.repository';
export * from './upsert-materialized-sprint.repository';
export * from './git-integrations.repository';
export * from './outgoing-webhooks.repository';
export * from './automations.repository';

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -0,0 +1,48 @@
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
export interface WebhookDelivery {
at: string;
event: string;
/** HTTP status, null when it never answered. */
status: number | null;
ok: boolean;
error: string | null;
}
/** Where a project tells other systems what happened in it. */
export interface OutgoingWebhook {
id: string;
projectId: string;
url: string;
/** Signs every delivery (HMAC-SHA256); shown once, when created. */
secret: string;
/** Event names, `issue.*` or `*`. */
events: string[];
active: boolean;
createdBy: UserRefReadModel;
createdAt: string;
lastDelivery: WebhookDelivery | null;
}
/** Configuration, not history: kept outside the event store. */
export abstract class OutgoingWebhooksRepository {
abstract findByProject(projectId: string): Promise<OutgoingWebhook[]>;
abstract findActive(projectId: string): Promise<OutgoingWebhook[]>;
abstract findById(id: string): Promise<OutgoingWebhook | null>;
abstract insert(webhook: OutgoingWebhook): Promise<void>;
abstract update(
id: string,
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
): Promise<void>;
abstract delete(id: string): Promise<void>;
abstract recordDelivery(id: string, delivery: WebhookDelivery): Promise<void>;
}
/** Sends one delivery. Throws when the receiver cannot be reached. */
export abstract class WebhookSender {
abstract send(request: {
url: string;
body: string;
headers: Record<string, string>;
}): Promise<{ status: number }>;
}

View File

@@ -0,0 +1,14 @@
import { SavedFilterReadModel } from '@task-stream/api/tracker/domain';
/** Saved board views: per person (or shared with a project), no history. */
export abstract class SavedFiltersRepository {
/** Whoever's own views of the project, then the shared ones, by name. */
abstract findVisible(
projectId: string,
userId: string,
): Promise<SavedFilterReadModel[]>;
abstract findById(id: string): Promise<SavedFilterReadModel | null>;
abstract countOwned(projectId: string, userId: string): Promise<number>;
abstract insert(filter: SavedFilterReadModel): Promise<void>;
abstract delete(id: string): Promise<void>;
}

Some files were not shown because too many files have changed in this diff Show More