Compare commits

..

20 Commits

Author SHA1 Message Date
41f81cd2b0 fix(web): a poll asks its own question instead of a stock one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:45:07 +02:00
f36c31e66d fix(web): a poll says how it is published, and needs no text of its own
With a poll open the button reads «Publicar encuesta» and two options are
enough. The «Más» menu keeps the page's gutter on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:20:00 +02:00
425db24eea fix(web): the ticket's header fits, with «Más» for the less frequent actions
The reminder and what is new are pi-notes; the poll editor's buttons align.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:17:24 +02:00
285bd5c454 feat: what is new since my last visit, and turning a comment into a ticket
Opening a ticket notes the visit and answers the one before (issue_visits),
so the page marks others' comments and changes since. A comment can become
a ticket of its own, as a subtask or linked; it remembers where it went.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:05:44 +02:00
429b52e6f5 fix(web): polls, recordings, reviews and reminders use @pi/ui's fields and buttons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:37 +02:00
3c211f0e56 test(web): the routes spec knows the public release notes page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:37 +02:00
909edc9f4d feat(web): record the screen or the voice from a comment, and play it there
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:58:18 +02:00
c27aa4b1d7 feat(web): the roadmap shows dependencies on other projects
Tickets of other projects that block a dated epic, or that it depends
on, now show under «De otros proyectos» with a dashed bar, their arrows,
and the same warning when the dates clash («EPI-1 empieza el 25 oct,
pero BACK-1 (otro proyecto), que la bloquea, acaba el 5 nov»). Both
halves of a relation are read, since one recorded from the other project
only leaves its inward half here. The ones without dates, or out of
reach, are named under the chart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:12:37 +02:00
79b8094011 feat(web): project health as a traffic light, with its causes
The project dashboard opens with «Salud del proyecto»: good, attention or
at risk, the worst of its signals, each with the cause and the tickets
behind it: stuck work, overdue and blocked tickets, critical or several
high bugs, QA sending back 30 % or more (with enough verdicts), releases
late or at risk by the forecast, columns over their WIP limit. Worked
out in the web from the insights it already reads (projectHealth). The
portfolio shows each project's light too, with its causes on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:08:21 +02:00
8ed16f133e feat: a public notes page per release, for clients
On the releases page, «Página pública» (owners and admins) opens a
secret link to a page with the release's notes, readable without an
account, also for private projects: the notes written, or the draft
from its finished tickets while there are none (without repeating the
title the page already shows). The link can be copied, renewed (the old
one stops working) or withdrawn. Stored in Mongo (`release_note_shares`).

API: shareReleaseNotes / unshareReleaseNotes / releaseNotesShare,
publicReleaseNotes(token); GET/PUT/DELETE /api/releases/{id}/public-notes,
GET /api/shared/release-notes/{token}. Web: /notes/:token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:57:40 +02:00
3a46a236e4 feat: quick polls in comments that end in a decision
«📊 Añadir una encuesta» adds 2 to 6 options to a new comment. Members,
viewers too, vote by clicking an option (one vote each, which they can
change or take back) and see each option's votes as a bar. Whoever
started it, or an admin, closes it with «Cerrar y decidir»: the most
voted wins (none on a tie) and a «Decidido: …» reply marked as a
decision shows above the ticket. Events of the comment (voted, closed).

API: addComment(…, poll), votePoll, closePoll, Comment.poll;
POST /api/comments/{id}/poll/vote and …/poll/close. MCP: add_comment
poll, vote_poll, close_poll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:48:12 +02:00
93d028e870 feat: ask a teammate to review a ticket
«Pedir revisión» on a ticket asks a member (with an optional note): they
are notified and see it under «Te piden revisión» in my work until they
answer «Todo bien» or «Pedir cambios» (with what to change) from the
ticket's Revisiones box; whoever asked hears the answer and can withdraw
an open request. Events of the ticket (requested, answered, cancelled),
so they show in its history and the activity feed. Viewers can be asked
and answer; not oneself, nor twice the same person with one pending.

API: requestReview / answerReview / cancelReview, Issue.reviews,
myIssues(reviewing); POST /api/issues/{issue}/reviews,
POST …/reviews/{id}/answer, DELETE …/reviews/{id}. MCP: request_review,
answer_review, my_issues reviewing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:38:30 +02:00
172c53d21d feat(web): the board works from the keyboard
With a card focused (Tab reaches them): arrows move between cards, also
across swimlanes' columns; Shift+←/→ moves the card to the next column
with the same rules as dragging it (allowed transitions, WIP warning,
reason when the status asks for one) and keeps the focus on it; p, e
and a open its priority, estimate or assignee; Enter opens it. The «?»
help lists them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:20:30 +02:00
dfb5f973f4 feat: «Mi día», the few tickets I pick for today
A home panel with the tickets picked for today (10 at most), crossed out
as they get done and counted («1 de 3 terminados»), my open tickets to
add with one click, and what was left unfinished on my last day to carry
over. «☀ Mi día» on a ticket adds it or takes it out. The day is the
person's own (YYYY-MM-DD from the browser); private, no history (Mongo
`my_days`).

API: myDay(day), addToMyDay, removeFromMyDay; GET /api/me/day,
PUT/DELETE /api/me/day/{issue}. MCP: my_day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:18:10 +02:00
b88296c22b feat: «Recordármelo», a reminder of a ticket only for me
On a ticket, «Recordármelo» offers in an hour, this evening, tomorrow or
next Monday at 9, in a week, or any date and time, with an optional note.
The ticket shows the waiting one («Te lo recordaré el mié 21 a las 9:00»)
to change or drop. Every minute a scheduler tells the ones whose time
came as a REMINDER notification (bell, push, e-mail), once, and forgets
those whose ticket went to the trash or out of reach. One per person and
ticket, private, no history (Mongo `issue_reminders`).

API: setReminder / clearReminder, Issue.myReminder, myReminders;
PUT/DELETE /api/issues/{issue}/reminder, GET /api/me/reminders. MCP:
remind_me.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:08:50 +02:00
d79ef418d3 feat: follow an epic with everything under it
«Seguir todo» on a ticket with subtasks (and on each epic of the Epics
page) tells you of it and of every ticket hanging from it, at any depth,
also the ones added later: their moves, assignments, comments and plan
changes, as a follower of each, plus when a ticket is placed under it.
Guests of a private project are left out. Personal, no history (Mongo
`tree_follows`).

API: followTree / unfollowTree, Issue.followingTree, PUT/DELETE
/api/issues/{issue}/follow-tree. MCP: watch_issue with tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:59:04 +02:00
179d6a5e94 fix(web): bulk changes of more than 100 tickets go in batches
The list shows up to 200 tickets and «select all» picks them all, but
the API takes 100 per bulk change: above that the whole change failed
(«At most 100 tickets at once») and nothing changed. The gateway now
sends batches of 100 one after another and adds up the results, for the
list and the backlog alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:34:09 +02:00
199b60e4ce feat: conversations on a ticket are settled, and you see who is typing
A top-level comment and its replies can be resolved by whoever can
change the ticket (CommentThreadResolvedEvent), with an optional
conclusion added as a reply marked as a decision, so it shows above the
ticket. A settled conversation folds to one line (topic, who settled it
and the conclusion) and can be unfolded or reopened; the header counts
the open ones, and finishing the ticket with one open asks first.

«Ana está escribiendo…» shows in the conversation being answered, at
once: a `typing` mutation publishes to a Redis channel per ticket
(`typing` subscription), never stored. In replies, Enter sends and
Shift+Enter breaks the line, except inside lists and code.

API: resolveThread / PUT-DELETE /api/comments/{id}/resolution, Comment
.resolved/resolvedBy/resolvedAt, typing mutation and subscription. MCP:
resolve_thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:35:46 +02:00
c0b0417b6d fix(api): automations without conditions list and run again
Mongo leaves empty objects out, so a rule saved with no conditions came
back without them: the automations list failed («Cannot return null for
non-nullable field Automation.conditions») and the rules engine threw
on it, so those rules never ran. The repository now gives them an empty
object, as the views of «my work» already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:53:56 +02:00
33c593521e feat: statuses and ticket types take any colour from a colour picker
Settings shows a native colour picker (the palette as suggestions)
instead of the list of eleven names. The API takes a palette name, as
before, or any #rrggbb; picking a palette colour still saves its name,
so it keeps its hand-made dark version. A free colour gets one variant
per theme, computed with the `color` library (moved towards white or
black until it contrasts 3:1 with the surface) and applied with
light-dark(): a pale yellow darkens on white, a navy lightens on dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:08:47 +02:00
162 changed files with 17887 additions and 383 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -304,12 +304,23 @@ type Comment {
editedAt: String editedAt: String
id: ID! id: ID!
issueId: ID! issueId: ID!
poll: CommentPoll
"""In the order they are offered; only the ones somebody chose""" """In the order they are offered; only the ones somebody chose"""
reactions: [CommentReaction!]! reactions: [CommentReaction!]!
"""The top-level comment it answers; null for a top-level one""" """The top-level comment it answers; null for a top-level one"""
replyTo: ID replyTo: ID
"""A conversation (top-level comment and its replies) that was settled"""
resolved: Boolean!
resolvedAt: String
"""Who settled it"""
resolvedBy: UserRef
"""Tickets made from it («Convertir en ticket»), oldest first"""
spawned: [CommentSpawn!]!
version: Int! version: Int!
} }
@@ -329,6 +340,15 @@ type CommentPage {
total: Int! total: Int!
} }
"""A quick poll in a comment"""
type CommentPoll {
closed: Boolean!
options: [PollOption!]!
"""Index of the winning option once closed (null on a tie)"""
winner: Int
}
"""One emoji on a comment""" """One emoji on a comment"""
type CommentReaction { type CommentReaction {
emoji: String! emoji: String!
@@ -337,6 +357,12 @@ type CommentReaction {
users: [UserRef!]! users: [UserRef!]!
} }
"""A ticket made from a comment"""
type CommentSpawn {
id: ID!
key: String!
}
type CountByAssignee { type CountByAssignee {
count: Int! count: Int!
@@ -684,6 +710,9 @@ type Issue {
dueDate: String dueDate: String
figmaUrl: String figmaUrl: String
"""Whether I follow it with everything under it (false without a token)"""
followingTree: Boolean!
"""History of this ticket""" """History of this ticket"""
history(limit: Int! = 50): [Activity!]! history(limit: Int! = 50): [Activity!]!
id: ID! id: ID!
@@ -695,6 +724,9 @@ type Issue {
"""Linked tickets I can see""" """Linked tickets I can see"""
linkedIssues: [Issue!]! linkedIssues: [Issue!]!
links: [IssueLink!]! links: [IssueLink!]!
"""My waiting reminder on it (null without one or a token)"""
myReminder: IssueReminder
number: Int! number: Int!
"""The epic or ticket this one breaks down""" """The epic or ticket this one breaks down"""
@@ -712,6 +744,9 @@ type Issue {
rank: String! rank: String!
release: ReleaseRef release: ReleaseRef
reporter: UserRef! reporter: UserRef!
"""Reviews asked, open and answered, oldest first"""
reviews: [IssueReview!]!
sprint: SprintRef sprint: SprintRef
"""When work should start (roadmap)""" """When work should start (roadmap)"""
@@ -835,6 +870,27 @@ enum IssuePriority {
MEDIUM MEDIUM
} }
"""My own reminder on a ticket («remind me on Thursday»)"""
type IssueReminder {
"""When it tells me (ISO, UTC)"""
at: String!
note: String
}
"""A review someone asked on the ticket"""
type IssueReview {
answer: String
answeredAt: String
id: ID!
note: String
requestedAt: String!
requester: UserRef!
reviewer: UserRef!
"""Null while waiting for an answer"""
verdict: ReviewVerdict
}
"""RECENT: last updated first. RANK: backlog order.""" """RECENT: last updated first. RANK: backlog order."""
enum IssueSort { enum IssueSort {
RANK RANK
@@ -866,6 +922,8 @@ type IssueTypeOption {
input IssueTypeOptionInput { input IssueTypeOptionInput {
archived: Boolean! = false archived: Boolean! = false
"""A palette colour (follows the theme) or #rrggbb"""
color: String! color: String!
icon: String! icon: String!
id: String! id: String!
@@ -925,7 +983,14 @@ type Mutation {
""" """
Comment on a ticket, or answer a comment (replyTo; one level of replies) Comment on a ticket, or answer a comment (replyTo; one level of replies)
""" """
addComment(body: String!, issueId: ID!, replyTo: ID): CommandResult! addComment(
body: String!
issueId: ID!
"""A quick poll: 2 to 6 options"""
poll: [String!]
replyTo: ID
): CommandResult!
""" """
Add a repository with its own secret (the first turns the integration on) Add a repository with its own secret (the first turns the integration on)
@@ -935,6 +1000,12 @@ type Mutation {
"""Add a Pireon user to the project""" """Add a Pireon user to the project"""
addProjectMember(input: ProjectMemberInput!): CommandResult! addProjectMember(input: ProjectMemberInput!): CommandResult!
"""Add tickets to «Mi día» (10 at most)"""
addToMyDay(day: String!, issueIds: [ID!]!): Boolean!
"""Answer a review I was asked for: all good, or changes"""
answerReview(answer: String, issueId: ID!, reviewId: ID!, verdict: ReviewVerdict!): CommandResult!
""" """
Archive the project (owner only): everything stays readable, nothing changes until it is unarchived Archive the project (owner only): everything stays readable, nothing changes until it is unarchived
""" """
@@ -955,10 +1026,21 @@ type Mutation {
""" """
bulkUpdateIssues(input: BulkUpdateIssuesInput!): BulkResult! bulkUpdateIssues(input: BulkUpdateIssuesInput!): BulkResult!
"""Withdraw a review request I made (or, as admin, any)"""
cancelReview(issueId: ID!, reviewId: ID!): CommandResult!
"""Move a ticket through the workflow (incl. QA)""" """Move a ticket through the workflow (incl. QA)"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
"""Drop my reminder on a ticket"""
clearReminder(issueId: ID!): Boolean!
"""
Close a poll (whoever started it, or an admin); with decide, the winner stays as a decision of the ticket
"""
closePoll(commentId: ID!, decide: Boolean! = true): CommandResult!
""" """
Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null. Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.
""" """
@@ -1007,6 +1089,11 @@ type Mutation {
duplicateIssue(issueId: ID!): CommandResult! duplicateIssue(issueId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult! editComment(body: String!, commentId: ID!): CommandResult!
"""
Follow a ticket with everything under it (an epic): its changes and those of every ticket hanging from it, also future ones
"""
followTree(issueId: ID!): Boolean!
"""I left this ticket's page""" """I left this ticket's page"""
leavePresence(issueId: ID!): Boolean! leavePresence(issueId: ID!): Boolean!
@@ -1043,10 +1130,23 @@ type Mutation {
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult! removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult! removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
"""Take a ticket out of «Mi día»"""
removeFromMyDay(day: String!, issueId: ID!): Boolean!
"""Remove a repository; what it linked stays on the tickets""" """Remove a repository; what it linked stays on the tickets"""
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean! removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""
Ask a member to review a ticket (with an optional note): they are told and see it in their work
"""
requestReview(issueId: ID!, note: String, reviewerId: ID!): CommandResult!
"""
Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket
"""
resolveThread(commentId: ID!, conclusion: String, resolved: Boolean! = true): CommandResult!
"""Back from the trash""" """Back from the trash"""
restoreIssue(issueId: ID!): CommandResult! restoreIssue(issueId: ID!): CommandResult!
@@ -1081,9 +1181,24 @@ type Mutation {
"""Put a ticket in a sprint (null: back to the backlog)""" """Put a ticket in a sprint (null: back to the backlog)"""
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult! setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
"""
Remind me of a ticket at a date and time (a notification), with an optional note. One per ticket: it replaces the waiting one
"""
setReminder(
"""ISO date-time, in the future"""
at: String!
issueId: ID!
note: String
): IssueReminder!
"""Turn the link on or change its blocks; renew for a new secret""" """Turn the link on or change its blocks; renew for a new secret"""
shareDashboard(input: ShareDashboardInput!): DashboardShare! shareDashboard(input: ShareDashboardInput!): DashboardShare!
"""
Turn a release's public notes page on; renew makes a new link (the old one stops working)
"""
shareReleaseNotes(releaseId: ID!, renew: Boolean! = false): ReleaseNotesShare!
"""Start a planned sprint (only one under way per project)""" """Start a planned sprint (only one under way per project)"""
startSprint(sprintId: ID!): CommandResult! startSprint(sprintId: ID!): CommandResult!
@@ -1103,8 +1218,33 @@ type Mutation {
""" """
trashIssue(issueId: ID!): CommandResult! trashIssue(issueId: ID!): CommandResult!
"""
Make a ticket of a comment: its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO). Answers the new ticket. Whoever can change tickets
"""
turnCommentIntoIssue(
asSubtask: Boolean! = false
commentId: ID!
title: String!
"""One of the project's ticket type ids"""
type: String!
): CommandResult!
"""
I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing`
"""
typing(
issueId: ID!
"""The conversation being answered; null: a new comment"""
threadId: ID
): Boolean!
"""Bring an archived project back (owner only)""" """Bring an archived project back (owner only)"""
unarchiveProject(projectId: ID!): CommandResult! unarchiveProject(projectId: ID!): CommandResult!
"""Stop following a ticket with everything under it"""
unfollowTree(issueId: ID!): Boolean!
unlinkIssues(input: LinkIssuesInput!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Unpin a ticket""" """Unpin a ticket"""
@@ -1113,6 +1253,9 @@ type Mutation {
"""The link stops working""" """The link stops working"""
unshareDashboard(projectId: ID!): Boolean! unshareDashboard(projectId: ID!): Boolean!
"""A release's public notes link stops working"""
unshareReleaseNotes(releaseId: ID!): Boolean!
"""Push off on this device""" """Push off on this device"""
unsubscribePush(endpoint: String!): Boolean! unsubscribePush(endpoint: String!): Boolean!
@@ -1136,11 +1279,21 @@ type Mutation {
updateSprint(input: UpdateSprintInput!): CommandResult! updateSprint(input: UpdateSprintInput!): CommandResult!
updateWebhook(input: UpdateWebhookInput!): Webhook! updateWebhook(input: UpdateWebhookInput!): Webhook!
"""
I opened this ticket now: answers when I had opened it before (null: never), to show what is new since
"""
visitIssue(issueId: ID!): String
""" """
Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one
""" """
voteIssue(issueId: ID!, on: Boolean!): CommandResult! voteIssue(issueId: ID!, on: Boolean!): CommandResult!
"""
Vote in a comment's poll (option index), or take the vote back (null). Members, viewers too
"""
votePoll(commentId: ID!, option: Int): CommandResult!
""" """
Follow a ticket: be told when it moves, is reassigned or gets a comment Follow a ticket: be told when it moves, is reassigned or gets a comment
""" """
@@ -1152,6 +1305,22 @@ type Mutation {
writeReleaseNotes(notes: String, releaseId: ID!): CommandResult! writeReleaseNotes(notes: String, releaseId: ID!): CommandResult!
} }
"""«Mi día»: the tickets I picked for today"""
type MyDay {
"""YYYY-MM-DD, in my time zone"""
day: String!
"""In order, done ones included"""
issues: [Issue!]!
leftover: MyDayLeftover
}
"""What was left unfinished on the latest earlier day"""
type MyDayLeftover {
day: String!
issues: [Issue!]!
}
type MyNotifications { type MyNotifications {
items: [Notification!]! items: [Notification!]!
@@ -1231,6 +1400,8 @@ enum NotificationKind {
QA_FAILED QA_FAILED
REMINDER REMINDER
REPLIED REPLIED
REVIEW_ANSWERED
REVIEW_REQUESTED
STATUS_CHANGED STATUS_CHANGED
WATCHED WATCHED
} }
@@ -1263,6 +1434,11 @@ type OptionCount {
value: String! value: String!
} }
type PollOption {
text: String!
voters: [UserRef!]!
}
""" """
One of my active projects at a glance: open work, risks, sprint, releases and epics One of my active projects at a glance: open work, risks, sprint, releases and epics
""" """
@@ -1495,6 +1671,23 @@ type ProposalsFunnel {
waiting: Int! waiting: Int!
} }
"""A release's notes, for anyone with its link"""
type PublicReleaseNotes {
description: String
"""Markdown"""
notes: String!
projectKey: String!
projectName: String!
releaseName: String!
releasedAt: String
status: ReleaseStatus!
targetDate: String
"""False: drawn from its finished tickets (none written yet)"""
written: Boolean!
}
"""A browser's Web Push subscription (PushSubscription.toJSON())""" """A browser's Web Push subscription (PushSubscription.toJSON())"""
input PushSubscriptionInput { input PushSubscriptionInput {
auth: String! auth: String!
@@ -1567,6 +1760,14 @@ type Query {
"""My calendar URL, if I made one""" """My calendar URL, if I made one"""
myCalendarFeed: CalendarFeed myCalendarFeed: CalendarFeed
"""
«Mi día»: the tickets I picked for today, and what was left on my last day
"""
myDay(
"""Today, YYYY-MM-DD in my time zone"""
day: String!
): MyDay!
""" """
The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now
""" """
@@ -1591,6 +1792,9 @@ type Query {
"""Only these of my projects""" """Only these of my projects"""
projectIds: [ID!] projectIds: [ID!]
"""Only the tickets I was asked to review (unanswered)"""
reviewing: Boolean! = false
"""Matches key, title or description (global search)""" """Matches key, title or description (global search)"""
search: String search: String
statuses: [String!] statuses: [String!]
@@ -1610,6 +1814,9 @@ type Query {
"""Projects where I am a member""" """Projects where I am a member"""
myProjects: [Project!]! myProjects: [Project!]!
"""My waiting reminders on tickets, soonest first"""
myReminders: [ReminderOnIssue!]!
"""My saved views of my work, across my projects""" """My saved views of my work, across my projects"""
myViews: [MyView!]! myViews: [MyView!]!
notificationSettings: NotificationSettings! notificationSettings: NotificationSettings!
@@ -1627,8 +1834,14 @@ type Query {
"""Projects anyone can read, also without signing in""" """Projects anyone can read, also without signing in"""
publicProjects: [Project!]! publicProjects: [Project!]!
"""A release's notes, for anyone with its link"""
publicReleaseNotes(token: String!): PublicReleaseNotes
release(id: ID!): Release! release(id: ID!): Release!
"""A release's public notes link, if any (owners and admins)"""
releaseNotesShare(releaseId: ID!): ReleaseNotesShare
"""My views of the project's board, then the shared ones""" """My views of the project's board, then the shared ones"""
savedFilters(projectId: ID!): [SavedFilter!]! savedFilters(projectId: ID!): [SavedFilter!]!
@@ -1726,6 +1939,15 @@ type ReleaseForecast {
verdict: String! verdict: String!
} }
"""A release's public notes link (owners and admins)"""
type ReleaseNotesShare {
createdAt: String!
releaseId: ID!
"""The secret of the link: /notes/{token}"""
token: String!
}
type ReleaseProgress { type ReleaseProgress {
byStatus: [CountByStatus!]! byStatus: [CountByStatus!]!
finished: Int! finished: Int!
@@ -1744,6 +1966,18 @@ enum ReleaseStatus {
RELEASED RELEASED
} }
"""One of my waiting reminders, with its ticket"""
type ReminderOnIssue {
issue: Issue!
reminder: IssueReminder!
}
"""APPROVED: all good. CHANGES: something to change"""
enum ReviewVerdict {
APPROVED
CHANGES
}
"""Tickets to look at now (worst first)""" """Tickets to look at now (worst first)"""
type Risks { type Risks {
blocked: [InsightIssue!]! blocked: [InsightIssue!]!
@@ -1986,6 +2220,11 @@ type Subscription {
projectChanged(projectId: ID!): ProjectChange! projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange! releaseChanged(projectId: ID!): ReleaseChange!
sprintChanged(projectId: ID!): SprintChange! sprintChanged(projectId: ID!): SprintChange!
"""
Who is writing on a ticket, as they type (one's own come too: leave them out)
"""
typing(issueId: ID!): Typing!
} }
type Suggestion { type Suggestion {
@@ -2036,6 +2275,16 @@ type Throughput {
week: String! week: String!
} }
"""Someone writing on a ticket right now (not stored)"""
type Typing {
at: String!
issueId: ID!
"""The conversation being answered; null: a new comment"""
threadId: ID
user: UserRef!
}
input UpdateChecklistItemInput { input UpdateChecklistItemInput {
done: Boolean done: Boolean
issueId: ID! issueId: ID!
@@ -2161,7 +2410,7 @@ type WorkflowStatus {
archived: Boolean! archived: Boolean!
category: StatusCategory! category: StatusCategory!
"""Colour token: slate, violet, green…""" """A palette colour (slate, violet, green…, follows the theme) or #rrggbb"""
color: String! color: String!
"""Stable id stored on tickets, e.g. IN_REVIEW""" """Stable id stored on tickets, e.g. IN_REVIEW"""
@@ -2180,6 +2429,8 @@ type WorkflowStatus {
input WorkflowStatusInput { input WorkflowStatusInput {
archived: Boolean! = false archived: Boolean! = false
category: StatusCategory! category: StatusCategory!
"""A palette colour (follows the theme) or #rrggbb"""
color: String! color: String!
id: String! id: String!
name: String! name: String!

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,12 @@ export class IssuesResolver {
.describe( .describe(
'Only the tickets the user follows (use with assignedToMe false)', 'Only the tickets the user follows (use with assignedToMe false)',
), ),
reviewing: z
.boolean()
.default(false)
.describe(
'Only the tickets the user was asked to review and has not answered (use with assignedToMe false)',
),
categories: z categories: z
.array(CATEGORY) .array(CATEGORY)
.optional() .optional()
@@ -110,6 +116,7 @@ export class IssuesResolver {
params: { params: {
assignedToMe: boolean; assignedToMe: boolean;
watching: boolean; watching: boolean;
reviewing: boolean;
categories?: z.infer<typeof CATEGORY>[]; categories?: z.infer<typeof CATEGORY>[];
priorities?: z.infer<typeof PRIORITY>[]; priorities?: z.infer<typeof PRIORITY>[];
bugsOnly?: boolean; bugsOnly?: boolean;
@@ -125,6 +132,7 @@ export class IssuesResolver {
query: { query: {
assignedToMe: params.assignedToMe, assignedToMe: params.assignedToMe,
watching: params.watching, watching: params.watching,
reviewing: params.reviewing,
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'], category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
priority: params.priorities, priority: params.priorities,
bugsOnly: params.bugsOnly, bugsOnly: params.bugsOnly,
@@ -290,6 +298,18 @@ export class IssuesResolver {
by: r.users.map((u) => u.name), by: r.users.map((u) => u.name),
})), })),
decision: c.decision || undefined, decision: c.decision || undefined,
resolved: c.resolved || undefined,
poll: c.poll
? {
options: c.poll.options.map((o, index) => ({
index,
text: o.text,
votes: o.voters.length,
})),
closed: c.poll.closed,
winner: c.poll.winner,
}
: undefined,
})) }))
: undefined; : undefined;
const decisions = ( const decisions = (
@@ -331,6 +351,14 @@ export class IssuesResolver {
url: d.url, url: d.url,
})), })),
watchers: issue.watchers.map((w) => w.name), watchers: issue.watchers.map((w) => w.name),
reviews: issue.reviews.map((r) => ({
id: r.id,
requester: r.requester.name,
reviewer: r.reviewer.name,
note: r.note,
verdict: r.verdict,
answer: r.answer,
})),
attachments: issue.attachments.length, attachments: issue.attachments.length,
trashedAt: issue.trashedAt, trashedAt: issue.trashedAt,
createdAt: issue.createdAt, createdAt: issue.createdAt,
@@ -495,18 +523,108 @@ export class IssuesResolver {
.string() .string()
.optional() .optional()
.describe('Id of the comment it answers (see get_issue)'), .describe('Id of the comment it answers (see get_issue)'),
poll: z
.array(z.string().min(1).max(80))
.min(2)
.max(6)
.optional()
.describe('A quick poll in the comment: 2 to 6 options'),
}), }),
annotations: WRITE, annotations: WRITE,
}) })
comment( comment(
{ issue, body, replyTo }: { issue: string; body: string; replyTo?: string }, {
issue,
body,
replyTo,
poll,
}: { issue: string; body: string; replyTo?: string; poll?: string[] },
ctx: McpContext, ctx: McpContext,
) { ) {
return toolResult(() => return toolResult(() =>
this.api.data( this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/comments', { this.api.as(ctx).POST('/api/issues/{issue}/comments', {
params: { path: { issue } }, params: { path: { issue } },
body: { body, replyTo }, body: { body, replyTo, poll },
}),
),
);
}
@Tool({
name: 'vote_poll',
description:
"Vote in a comment's poll (option index from get_issue), or take the vote back (option null).",
paramsSchema: z.object({
commentId: z.string().min(1),
option: z.number().int().min(0).nullable(),
}),
annotations: { ...WRITE, idempotentHint: true },
})
votePoll(
{ commentId, option }: { commentId: string; option: number | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/poll/vote', {
params: { path: { commentId } },
body: { option },
}),
),
);
}
@Tool({
name: 'close_poll',
description:
'Close a poll the user started (or as admin); with decide (default), the winner stays as a decision of the ticket.',
paramsSchema: z.object({
commentId: z.string().min(1),
decide: z.boolean().default(true),
}),
annotations: WRITE,
})
closePoll(
{ commentId, decide }: { commentId: string; decide: boolean },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/poll/close', {
params: { path: { commentId } },
body: { decide },
}),
),
);
}
@Tool({
name: 'turn_comment_into_issue',
description:
"Make a ticket of a comment that turned out to be work of its own: its text becomes the description, as a subtask of the comment's ticket (asSubtask) or linked to it. Read type ids with get_project. Answers the new ticket's key.",
paramsSchema: z.object({
commentId: z.string().min(1),
title: z.string().min(1).max(200),
type: z.string().min(1).describe("One of the project's ticket type ids"),
asSubtask: z.boolean().default(false),
}),
annotations: WRITE,
})
turnCommentIntoIssue(
{
commentId,
title,
type,
asSubtask,
}: { commentId: string; title: string; type: string; asSubtask: boolean },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/issue', {
params: { path: { commentId } },
body: { title, type, asSubtask },
}), }),
), ),
); );
@@ -564,19 +682,29 @@ export class IssuesResolver {
@Tool({ @Tool({
name: 'watch_issue', name: 'watch_issue',
description: description:
'Follow a ticket (notifications of its changes and comments) or stop following it.', 'Follow a ticket (notifications of its changes and comments) or stop following it. With tree: true, an epic (or any ticket with subtasks) with everything under it, also tickets added later.',
paramsSchema: z.object({ paramsSchema: z.object({
issue: issueRef, issue: issueRef,
follow: z.boolean().default(true), follow: z.boolean().default(true),
tree: z.boolean().default(false),
}), }),
annotations: { ...WRITE, idempotentHint: true }, annotations: { ...WRITE, idempotentHint: true },
}) })
watch( watch(
{ issue, follow }: { issue: string; follow: boolean }, { issue, follow, tree }: { issue: string; follow: boolean; tree: boolean },
ctx: McpContext, ctx: McpContext,
) { ) {
const client = this.api.as(ctx); const client = this.api.as(ctx);
const options = { params: { path: { issue } } }; const options = { params: { path: { issue } } };
if (tree)
return toolResult(async () => {
await this.api.data(
follow
? client.PUT('/api/issues/{issue}/follow-tree', options)
: client.DELETE('/api/issues/{issue}/follow-tree', options),
);
return { issue, followingTree: follow };
});
return toolResult(() => return toolResult(() =>
this.api.data( this.api.data(
follow follow
@@ -772,6 +900,172 @@ export class IssuesResolver {
); );
} }
@Tool({
name: 'request_review',
description:
'Ask a member of the project to review a ticket (with an optional note): they are told and see it in my_issues with reviewing. reviewer is a user id (get_project lists members).',
paramsSchema: z.object({
issue: issueRef,
reviewer: z.string().min(1),
note: z.string().max(1000).optional(),
}),
annotations: WRITE,
})
requestReview(
params: { issue: string; reviewer: string; note?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/reviews', {
params: { path: { issue: params.issue } },
body: { reviewer: params.reviewer, note: params.note },
}),
),
);
}
@Tool({
name: 'answer_review',
description:
'Answer a review the user was asked for: APPROVED (all good) or CHANGES, with an optional answer. Review ids come with get_issue.',
paramsSchema: z.object({
issue: issueRef,
reviewId: z.string().min(1),
verdict: z.enum(['APPROVED', 'CHANGES']),
answer: z.string().max(1000).optional(),
}),
annotations: WRITE,
})
answerReview(
params: {
issue: string;
reviewId: string;
verdict: 'APPROVED' | 'CHANGES';
answer?: string;
},
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/reviews/{review}/answer', {
params: { path: { issue: params.issue, review: params.reviewId } },
body: { verdict: params.verdict, answer: params.answer },
}),
),
);
}
@Tool({
name: 'my_day',
description:
'«Mi día»: the tickets I picked for today (done ones marked), and what was left unfinished on my last day. Optionally add or remove one first. day is today in my time zone, YYYY-MM-DD.',
paramsSchema: z.object({
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
add: issueRef.optional(),
remove: issueRef.optional(),
}),
annotations: WRITE,
})
myDay(
params: { day: string; add?: string; remove?: string },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const query = { day: params.day };
return toolResult(async () => {
if (params.add)
await this.api.data(
client.PUT('/api/me/day/{issue}', {
params: { path: { issue: params.add }, query },
}),
);
if (params.remove)
await this.api.data(
client.DELETE('/api/me/day/{issue}', {
params: { path: { issue: params.remove }, query },
}),
);
const myDay = await this.api.data(
client.GET('/api/me/day', { params: { query } }),
);
return {
day: myDay.day,
issues: myDay.issues.map(issueLine),
leftover: myDay.leftover && {
day: myDay.leftover.day,
issues: myDay.leftover.issues.map(issueLine),
},
};
});
}
@Tool({
name: 'remind_me',
description:
'Remind me of a ticket at a date and time (a notification), with an optional note; one per ticket, it replaces the waiting one. clear: true drops it. Times are ISO with offset, e.g. 2026-10-23T09:00:00+02:00.',
paramsSchema: z.object({
issue: issueRef,
at: z.string().optional(),
note: z.string().max(280).optional(),
clear: z.boolean().default(false),
}),
annotations: { ...WRITE, idempotentHint: true },
})
remindMe(
params: { issue: string; at?: string; note?: string; clear: boolean },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const path = { issue: params.issue };
return toolResult(async () => {
if (params.clear) {
await this.api.data(
client.DELETE('/api/issues/{issue}/reminder', { params: { path } }),
);
return { issue: params.issue, reminder: null };
}
if (!params.at) throw new Error('at is required unless clear is true');
return this.api.data(
client.PUT('/api/issues/{issue}/reminder', {
params: { path },
body: { at: params.at, note: params.note },
}),
);
});
}
@Tool({
name: 'resolve_thread',
description:
'Settle a conversation (a top-level comment and its replies), optionally with a conclusion that is added as a reply marked as a decision; or reopen it (resolved: false). Whoever can change the ticket. Comment ids come with get_issue.',
paramsSchema: z.object({
commentId: z.string().min(1),
resolved: z.boolean().default(true),
conclusion: z.string().max(20000).optional(),
}),
annotations: { ...WRITE, idempotentHint: true },
})
resolveThread(
params: { commentId: string; resolved: boolean; conclusion?: string },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const path = { commentId: params.commentId };
return toolResult(() =>
this.api.data(
params.resolved
? client.PUT('/api/comments/{commentId}/resolution', {
params: { path },
body: { conclusion: params.conclusion },
})
: client.DELETE('/api/comments/{commentId}/resolution', {
params: { path },
}),
),
);
}
@Tool({ @Tool({
name: 'search_decisions', name: 'search_decisions',
description: description:

View File

@@ -25,6 +25,7 @@ describe('appRoutes', () => {
'portfolio', 'portfolio',
'explore', 'explore',
'share/:token', 'share/:token',
'notes/:token',
'cartera', 'cartera',
'explorar', 'explorar',
'p/:key', 'p/:key',
@@ -32,12 +33,13 @@ describe('appRoutes', () => {
expect(children[0].canActivate).toContain(authGuard); expect(children[0].canActivate).toContain(authGuard);
expect(children[1].canActivate).toContain(authGuard); expect(children[1].canActivate).toContain(authGuard);
expect(children[2].canActivate).toBeUndefined(); expect(children[2].canActivate).toBeUndefined();
// The page for clients needs no session: its link is the key. // The pages for clients need no session: their link is the key.
expect(children[3].canActivate).toBeUndefined(); expect(children[3].canActivate).toBeUndefined();
expect(children[6].canActivate).toBeUndefined(); expect(children[4].canActivate).toBeUndefined();
expect(children[7].canActivate).toBeUndefined();
// The Spanish paths of shared links lead to the English ones. // The Spanish paths of shared links lead to the English ones.
expect(children[4].redirectTo).toBe('portfolio'); expect(children[5].redirectTo).toBe('portfolio');
expect(children[5].redirectTo).toBe('explore'); expect(children[6].redirectTo).toBe('explore');
}); });
it('gives each page its own state, not the root', async () => { it('gives each page its own state, not the root', async () => {

View File

@@ -409,7 +409,8 @@ h3 {
// A field's label floats above its box; other fields leave room for it // A field's label floats above its box; other fields leave room for it
// below them (their hint line), a segmented control does not. // below them (their hint line), a segmented control does not.
> pi-segmented-control { > pi-segmented-control,
> p:has(+ pi-text-field, + pi-textarea, + pi-select-field, + pi-date-field) {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
} }

View File

@@ -2,49 +2,51 @@
## Qué resuelve ## Qué resuelve
| Necesidad | Cómo se resuelve | | Necesidad | Cómo se resuelve |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | | 🐛 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 | | 🌍 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 | | 🗑️ 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 | | 🧩 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`) | | 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | | ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | | 🧪 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) | | 🚀 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 | | 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar; y una página pública por release para los clientes, con enlace secreto |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar) y `@menciones`; el autor edita, el admin modera | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, convertir un comentario en ticket, lo nuevo desde tu última visita marcado, encuestas rápidas que acaban en decisión, «está escribiendo…» en vivo y `@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 | | 🔎 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 |
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar | | 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria | | ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
| 🚦 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 | | ☀ Mi día | Cada mañana eliges los tickets de hoy; se tachan al terminar y lo que quede se pasa al día siguiente; y «Recordármelo» en cualquier ticket |
| 🎲 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 | | 🚦 Salud | Un semáforo por proyecto (en su panel y en la cartera) que dice qué va mal y por qué: atascados, vencidos, bugs urgentes, QA, releases tarde |
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 🚦 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 |
| 📅 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 | | 🎲 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 |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 🗑️ 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 | | 📅 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 |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | | 🗑️ 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 |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras | | ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | | ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, en el tablero las flechas recorren las tarjetas y Shift+←/→ las cambia de columna, `e` edita, `?` los lista |
| 🌐 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`) | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo | | 🌐 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`) |
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| ⏰ 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 | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
| 📎 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 | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» | | ⏰ 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 |
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | | 📎 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; grabar la pantalla o la voz desde el comentario y verlo ahí mismo |
| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo | | 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint | | 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él |
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | | 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
| 📦 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 | | 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
| 🏠 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 | | 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | | 📦 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 |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | | 🏠 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 |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | | 📊 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** 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 ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por

View File

@@ -124,7 +124,8 @@ Reglas:
- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un - Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un
tipo activo. tipo activo.
- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo. - Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo.
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos: - Colores: uno de la paleta (`slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`,
`pink`), que tiene su versión para modo oscuro, o cualquier `#rrggbb`. Iconos:
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`. `bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en
@@ -238,6 +239,24 @@ stateDiagram-v2
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`, - **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién `POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
eligió cada una. No generan actividad ni avisos. eligió cada una. No generan actividad ni avisos.
- **Encuestas**: un comentario puede llevar una encuesta de 2 a 6 opciones (`addComment(…, poll)`, `poll` en
`POST …/comments`). Los miembros, también los lectores, votan una opción y pueden cambiarla o retirarla
(`votePoll(commentId, option)`, `POST /api/comments/{id}/poll/vote`; `CommentPollVotedEvent`). Quien la abrió, o un
admin, la cierra (`closePoll(commentId, decide)`, `POST …/poll/close`; `CommentPollClosedEvent`): gana la más votada
(con empate, ninguna) y, con `decide`, se añade una respuesta «Decidido: …» marcada como decisión. El comentario da
`poll { options { text voters } closed winner }`.
- **Convertir en ticket**: un comentario que resulta ser trabajo propio se convierte en ticket
(`turnCommentIntoIssue(commentId, title, type, asSubtask)`, `POST /api/comments/{id}/issue`), como subtarea del ticket
del comentario o enlazado a él (`RELATES_TO`), por quien puede cambiar tickets. El texto del comentario es la
descripción (sin la plantilla del tipo), con una línea de dónde sale; el comentario lo recuerda
(`CommentTurnedIntoIssueEvent`) y da `spawned { id key }`. Devuelve el ticket nuevo.
- **Conversaciones**: un comentario raíz y sus respuestas se resuelven (`resolveThread(commentId, resolved, conclusion)`,
`PUT/DELETE /api/comments/{id}/resolution`; evento `CommentThreadResolvedEvent`) por quien puede cambiar el ticket;
una respuesta no se resuelve sola. Si trae `conclusion`, antes de cerrarla se añade como respuesta marcada como
decisión. El comentario da `resolved`, `resolvedBy` y `resolvedAt`. **Escribiendo**: `typing(issueId, threadId)`
(mutation, cada pocos segundos mientras se escribe) avisa al momento por la subscription `typing(issueId)` a quien
tenga el ticket abierto; no se guarda (canal de Redis `tracker.typing.<ticket>`), y cada cliente descarta sus propios
avisos.
- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`, - **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`,
`PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`, `PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`,
`decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la `decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la
@@ -255,7 +274,9 @@ stateDiagram-v2
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por - **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. 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 Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos. existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos. Las imágenes,
los PDF, el texto y el vídeo o audio que reproduce el navegador (mp4, webm, mp3, ogg, wav) se sirven para verse en
la página; el resto, como descarga.
- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles - **É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 (é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 cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o
@@ -301,7 +322,14 @@ stateDiagram-v2
- **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o - **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 `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; uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
la respuesta dice cuáles. la respuesta dice cuáles. La web parte selecciones mayores (la Lista enseña hasta 200) en tandas de 100, una tras
otra, y suma el resultado.
- **Notas públicas de una release**: quien gestiona el proyecto abre un enlace secreto con las notas de una release,
para clientes y sin cuenta (también en proyectos privados): `shareReleaseNotes(releaseId, renew)`,
`unshareReleaseNotes`, `releaseNotesShare`; `GET/PUT/DELETE /api/releases/{id}/public-notes`. La página
(`publicReleaseNotes(token)`, `GET /api/shared/release-notes/{token}`, la web en `/notes/{token}`) enseña las notas
escritas o, mientras no las haya, el borrador de sus tickets terminados (`written: false`). Renovar hace un enlace
nuevo; retirarlo, o borrar la release, lo deja sin efecto. Vive en MongoDB (`release_note_shares`).
- **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`). - **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`).
Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release; Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release;
los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`. los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`.
@@ -416,7 +444,29 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite, sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`, `myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
`?watching=true`; sin sesión, ninguno). **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el `?watching=true`; sin sesión, ninguno). **Revisiones**: quien puede cambiar un ticket pide a un miembro que lo revise, con nota opcional
(`requestReview(issueId, reviewerId, note)`, `POST /api/issues/{issue}/reviews`; evento `IssueReviewRequestedEvent`).
Esa persona, aunque sea lectora, responde «todo bien» o «cambios» con su respuesta (`answerReview`, `POST
…/reviews/{id}/answer`, `IssueReviewAnsweredEvent`); quien la pidió (o un admin) puede retirarla (`cancelReview`, `DELETE
…/reviews/{id}`, `IssueReviewCancelledEvent`). No a uno mismo ni dos veces a la misma persona con una pendiente. Avisos
`REVIEW_REQUESTED` y `REVIEW_ANSWERED`; `Issue.reviews`; «Te piden revisión» es `myIssues(reviewing: true)` /
`GET /api/me/issues?reviewing=true`. **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`,
`removeFromMyDay(day, issueId)`; `GET /api/me/day?day=`, `PUT/DELETE /api/me/day/{issue}?day=`), hasta 10. `day` es
hoy en la zona horaria de quien pregunta (`YYYY-MM-DD`, a dos días como mucho de la del servidor). La respuesta trae
también lo que quedó sin terminar el último día anterior (si fue en la última semana), para pasarlo a hoy. Privado y sin
historial (`my_days`). **Recordármelo**: cada persona pone en un ticket un recordatorio con fecha y hora y nota opcional
(`setReminder(issueId, at, note)`, `clearReminder`, `Issue.myReminder`, `myReminders`; `PUT/DELETE
/api/issues/{issue}/reminder`, `GET /api/me/reminders`): uno por ticket, en el futuro y como mucho a un año. Cada minuto
`IssueReminderScheduler` avisa de los que tocan (`REMINDER`, por la campana, el navegador y el correo; una vez, por su
id) y olvida los de tickets en la papelera o fuera de alcance. Privados y sin historial (`issue_reminders`);
`REMINDERS_ENABLED=false` los apaga junto con los de fecha límite. **Lo nuevo desde tu última visita**: al abrir un
ticket, `visitIssue(issueId)` (`PUT /api/issues/{issue}/visit`) apunta la visita y devuelve la anterior (null la
primera vez); con ella el cliente marca lo que otros han hecho desde entonces. Personal y sin historial (colección
`issue_visits`). **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
`followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo
mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
privado no oyen los tickets que no son suyos. **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`, inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi `PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
@@ -531,29 +581,30 @@ que funcionan también sin token.
Todas devuelven `CommandResult { id, key, version }`. Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations | | Ámbito | Mutations |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `visitIssue`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`; consulta `decisions(projectId, search)` | | Comentarios | `addComment(replyTo, poll)`, `votePoll`, `closePoll`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `turnCommentIntoIssue(commentId, title, type, asSubtask)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
### Subscriptions ### Subscriptions
| Subscription | Se emite cuando | | Subscription | Se emite cuando |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) | | `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) | | `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed | | `typing(issueId)` | Alguien escribe en el ticket (`threadId`: la conversación que responde; `null`, un comentario nuevo). Llegan también los propios |
| `releaseChanged(projectId)` | Cambia una release | | `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) | | `releaseChanged(projectId)` | Cambia una release |
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto | | `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno | | `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 |
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403. Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
@@ -738,25 +789,25 @@ 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`). Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | 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`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | | Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `POST …/reviews`, `POST …/reviews/{id}/answer`, `DELETE …/reviews/{id}`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`, `POST …/poll/vote`, `POST …/poll/close`, `POST …/issue` (convertir en ticket); `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
| 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` | | 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}` | | 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` | | Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | | Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) | | Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | | 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` | | Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | | Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` | | Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | | Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | | 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 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 ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto

View File

@@ -255,8 +255,11 @@ globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desac
Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve
`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un `project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono (`--ts-c-<color>`, con su estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono: `--ts-c-<color>` para
versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla la paleta, con su versión oscura hecha a mano, y para un `#rrggbb` propio, `light-dark()` con una variante por tema
que `ui/colors.ts` calcula con la librería `color` (se acerca al blanco o al negro hasta contrastar 3:1 con la
superficie). En Configuración, cada estado y tipo elige su color con el selector nativo (`<input type="color">`), con
la paleta como sugerencias; si se elige un color de la paleta, se guarda su nombre. Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla
proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría. proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría.
En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos
@@ -297,25 +300,26 @@ visualización de la plataforma:
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos. Cada proyecto lleva su semáforo de salud (`portfolioHealth`), con las causas al pasar por encima |
| `/explore` | Proyectos públicos (sin sesión) | | `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer» | | `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). A la vista, «Votar», «Seguir» y «Editar»; el resto (pedir revisión, mi día, recordármelo, seguir todo, fijar y duplicar) está en el menú «Más» (menú del CDK: se recorre con las flechas y Escape lo cierra). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («Encuesta»; con su pregunta y de 2 a 6 opciones; el botón pasa a «Publicar encuesta» y el texto del comentario es opcional): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión. «🎥 Grabar pantalla» (con la voz, si se da permiso al micrófono) y «🎙 Grabar audio» graban en el navegador hasta 3 minutos y lo adjuntan al comentario, que puede ir sin texto; los vídeos y audios adjuntos se reproducen ahí mismo. Al abrirlo, lo que otros han hecho desde tu última visita: un aviso arriba («Desde tu última visita, hace 2 días: 3 comentarios y 1 cambio de Ana y Bob», con «Ver lo nuevo»), los comentarios nuevos marcados («Nuevo», también en una conversación plegada) y los cambios del historial resaltados. Cada comentario se puede «Convertir en ticket» (subtarea de este o enlazado, con el título sacado de su primera línea): el comentario enlaza al ticket nuevo («→ Convertido en WEB-45») |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases. «Página pública» (quien gestiona): un enlace secreto con las notas para clientes, que se copia, renueva o retira |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea. Los bloqueos con tickets de otros proyectos (en los dos sentidos, `externalDependencies`) salen en «De otros proyectos» con barra discontinua, sus flechas y el mismo aviso si las fechas no encajan; los que no tienen fechas o no se pueden leer se nombran debajo |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo | | `/p/:key/dashboard` | Arriba, la salud del proyecto (`ts-project-health`): un semáforo (bien, atención, en riesgo) con cada causa y sus tickets, calculado en la web (`projectHealth`) con los mismos datos: atascados, vencidos, bloqueados, bugs críticos o altos, lo que devuelve QA, releases que no llegan y columnas sobre su WIP. Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | | `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar | | `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» | | `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» | | `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos | | `/notes/:token` | Notas de una release para sus clientes, sin cuenta: las escritas o las de sus tickets terminados |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de

View File

@@ -52,7 +52,7 @@ flowchart LR
| | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger | | | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger |
| | `get_project_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios | | | `get_project_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios |
| | `get_project_activity` | Actividad reciente, o la de un día (`day`) | | | `get_project_activity` | Actividad reciente, o la de un día (`day`) |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos | | Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos; `reviewing`: los que me piden revisar |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | | | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, decisiones y comentarios | | | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, decisiones y comentarios |
| | `create_issue` | Crear un ticket (también colgado de una épica) | | | `create_issue` | Crear un ticket (también colgado de una épica) |
@@ -62,11 +62,19 @@ flowchart LR
| | `set_issue_release` | Meter en una release o sacar | | | `set_issue_release` | Meter en una release o sacar |
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | | | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | | | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
| | `watch_issue` | Seguir un ticket o dejar de seguirlo | | | `watch_issue` | Seguir un ticket o dejar de seguirlo; con `tree`, una épica con todo lo que cuelga de ella |
| | `vote_issue` | Votar un ticket o quitar el voto | | | `vote_issue` | Votar un ticket o quitar el voto |
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | | | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | | | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla | | | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla |
| | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) |
| | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno |
| | `request_review` | Pedir a alguien del proyecto que revise un ticket |
| | `answer_review` | Responder una revisión: todo bien o cambios |
| | `vote_poll` | Votar en la encuesta de un comentario (o retirar el voto) |
| | `close_poll` | Cerrar una encuesta; la ganadora queda como decisión |
| | `turn_comment_into_issue` | Convertir un comentario en ticket: subtarea del suyo o enlazado; su texto es la descripción |
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos | | | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | | | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | | Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |

View File

@@ -40,6 +40,23 @@ export * from './lib/alerts/alert-payloads';
export * from './lib/alerts/alerts'; export * from './lib/alerts/alerts';
export * from './lib/ports/alert-integrations.repository'; export * from './lib/ports/alert-integrations.repository';
export * from './lib/alerts.service'; export * from './lib/alerts.service';
export * from './lib/follows/tree-follows';
export * from './lib/ports/tree-follows.repository';
export * from './lib/tree-follows.service';
export * from './lib/visits/issue-visits';
export * from './lib/ports/issue-visits.repository';
export * from './lib/issue-visits.service';
export * from './lib/reminders/issue-reminders';
export * from './lib/ports/issue-reminders.repository';
export * from './lib/issue-reminders.service';
export * from './lib/my-day/my-day';
export * from './lib/ports/my-days.repository';
export * from './lib/my-day.service';
export * from './lib/commands/reviews';
export * from './lib/commands/polls';
export * from './lib/commands/turn-comment-into-issue';
export * from './lib/sharing/release-notes-shares';
export * from './lib/ports/release-note-shares.repository';
export * from './lib/tracker-application.module'; export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store'; export * from './lib/ports/presence.store';
export * from './lib/presence.service'; export * from './lib/presence.service';

View File

@@ -9,6 +9,7 @@ import {
ProjectChange, ProjectChange,
ReleaseChange, ReleaseChange,
SprintChange, SprintChange,
Typing,
} from './ports/change-feed'; } from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query'; import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.query'; import { GetIssueQuery } from './queries/get-issue.query';
@@ -53,6 +54,15 @@ export class ChangesService {
return this.changeFeed.comments(issueId); return this.changeFeed.comments(issueId);
} }
/** Who is writing on the ticket (the client leaves itself out). */
async watchTyping(
viewerId: string,
issueId: string,
): Promise<AsyncIterableIterator<Typing>> {
await this.queryBus.execute(new GetIssueQuery(viewerId, issueId)); // access check
return this.changeFeed.typing(issueId);
}
async watchActivity( async watchActivity(
viewerId: string, viewerId: string,
projectId: string, projectId: string,

View File

@@ -38,6 +38,7 @@ export class AddCommentCommandHandler
replyTo: command.replyTo replyTo: command.replyTo
? await this.threadOf(command.replyTo, issue.id) ? await this.threadOf(command.replyTo, issue.id)
: null, : null,
poll: command.poll,
}); });
this.eventPublisher.mergeObjectContext(comment); this.eventPublisher.mergeObjectContext(comment);

View File

@@ -8,6 +8,8 @@ export class AddCommentCommand extends Command<CommandResult> {
public readonly body: string, public readonly body: string,
/** Comment it answers; a reply to a reply joins the same thread. */ /** Comment it answers; a reply to a reply joins the same thread. */
public readonly replyTo: string | null = null, public readonly replyTo: string | null = null,
/** A quick poll's options (2–6), if any. */
public readonly poll: string[] | null = null,
) { ) {
super(); super();
} }

View File

@@ -1,5 +1,9 @@
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler'; import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
import { MarkDecisionCommandHandler } from './mark-decision.command-handler'; import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
import { REVIEW_HANDLERS } from './reviews';
import { POLL_HANDLERS } from './polls';
import { TURN_INTO_ISSUE_HANDLERS } from './turn-comment-into-issue';
import { VoteIssueCommandHandler } from './vote-issue.command-handler'; import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler'; import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler'; import { UpdateProjectCommandHandler } from './update-project.command-handler';
@@ -48,6 +52,10 @@ export const COMMAND_HANDLERS = [
VoteIssueCommandHandler, VoteIssueCommandHandler,
ReactToCommentCommandHandler, ReactToCommentCommandHandler,
MarkDecisionCommandHandler, MarkDecisionCommandHandler,
ResolveThreadCommandHandler,
...REVIEW_HANDLERS,
...POLL_HANDLERS,
...TURN_INTO_ISSUE_HANDLERS,
CreateProjectCommandHandler, CreateProjectCommandHandler,
UpdateProjectCommandHandler, UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler, UpdateProjectSettingsCommandHandler,

View File

@@ -15,10 +15,12 @@ import {
import { import {
Comment, Comment,
Issue, Issue,
IssueLinkType,
IssueStatus, IssueStatus,
Project, Project,
IssuePriority, IssuePriority,
IssueType, IssueType,
ReviewVerdict,
ProjectRole, ProjectRole,
TRACKER_FACTORIES, TRACKER_FACTORIES,
defaultProjectSettings, defaultProjectSettings,
@@ -78,6 +80,14 @@ import { AttachmentStorage } from '../ports/attachment-storage';
import { AddCommentCommand } from './add-comment.command'; import { AddCommentCommand } from './add-comment.command';
import { ReactToCommentCommand } from './react-to-comment.command'; import { ReactToCommentCommand } from './react-to-comment.command';
import { MarkDecisionCommand } from './mark-decision.command'; import { MarkDecisionCommand } from './mark-decision.command';
import { ResolveThreadCommand } from './resolve-thread.command';
import { ClosePollCommand, VotePollCommand } from './polls';
import { TurnCommentIntoIssueCommand } from './turn-comment-into-issue';
import {
AnswerReviewCommand,
CancelReviewCommand,
RequestReviewCommand,
} from './reviews';
import { UpdateProjectCommand } from './update-project.command'; import { UpdateProjectCommand } from './update-project.command';
import { VoteIssueCommand } from './vote-issue.command'; import { VoteIssueCommand } from './vote-issue.command';
import { AddProjectMemberCommand } from './add-project-member.command'; import { AddProjectMemberCommand } from './add-project-member.command';
@@ -943,6 +953,229 @@ describe('tracker commands (event sourced)', () => {
); );
}); });
it('makes a ticket of a comment, as a subtask or linked, and marks the comment', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Pago', {}),
);
const comment = await run(
new AddCommentCommand('qa', issue.id, 'También falla en **Android**'),
);
// Viewers comment and react; making tickets is for the team.
await expect(
run(
new TurnCommentIntoIssueCommand(
'pm',
comment.id,
'Android',
IssueType.BUG,
false,
),
),
).rejects.toThrow(ForbiddenError);
const linked = await run(
new TurnCommentIntoIssueCommand(
'dev',
comment.id,
'Falla en Android',
IssueType.BUG,
false,
),
);
const sub = await run(
new TurnCommentIntoIssueCommand(
'dev',
comment.id,
'Probar en Android',
IssueType.TASK,
true,
),
);
const fromLink = await rehydrator.rehydrate(linked.id, Issue);
expect(fromLink.title).toBe('Falla en Android');
expect(fromLink.description).toBe(
'También falla en **Android**\n\n---\n_Sale de un comentario de user qa en TS-1._',
);
expect(fromLink.links).toEqual([
expect.objectContaining({
type: IssueLinkType.RELATES_TO,
targetIssueId: issue.id,
}),
]);
expect((await rehydrator.rehydrate(sub.id, Issue)).parentId).toBe(issue.id);
expect((await rehydrator.rehydrate(comment.id, Comment)).spawned).toEqual([
{ id: linked.id, key: 'TS-2' },
{ id: sub.id, key: 'TS-3' },
]);
});
it('takes votes in a comment poll and keeps the winner as a decision', async () => {
const { run, rehydrator, store } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pagos', {}),
);
await expect(
run(new AddCommentCommand('dev', issue.id, '¿Cuál?', null, ['Solo'])),
).rejects.toThrow(ValidationError);
const poll = await run(
new AddCommentCommand('dev', issue.id, '¿Cuál?', null, [
' Stripe ',
'Redsys',
]),
);
await run(new VotePollCommand('qa', poll.id, 0));
await run(new VotePollCommand('pm', poll.id, 1));
await run(new VotePollCommand('pm', poll.id, 0));
await run(new VotePollCommand('owner', poll.id, 1));
await expect(run(new VotePollCommand('qa', poll.id, 5))).rejects.toThrow(
ValidationError,
);
// Only whoever started it (or an admin) closes it.
await expect(run(new ClosePollCommand('qa', poll.id))).rejects.toThrow(
ForbiddenError,
);
await run(new ClosePollCommand('dev', poll.id));
const closed = await rehydrator.rehydrate(poll.id, Comment);
expect([closed.poll, closed.pollClosed, closed.pollWinner]).toEqual([
['Stripe', 'Redsys'],
true,
0,
]);
await expect(run(new VotePollCommand('qa', poll.id, 1))).rejects.toThrow(
BusinessRuleError,
);
const decided = store.events.find(
(e) =>
e.type === 'CommentAddedEvent' &&
(e.data as { comment: { body: string } }).comment.body.startsWith(
'Decidido',
),
);
expect((decided?.data as { comment: { body: string } }).comment.body).toBe(
'Decidido: **Stripe** (2 de 3 votos)',
);
expect(
(await rehydrator.rehydrate(decided?.streamId ?? '', Comment)).decision,
).toBe(true);
});
it('asks a teammate to review a ticket, who answers it', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
);
await run(
new RequestReviewCommand('dev', issue.id, 'qa', ' Mira el móvil '),
);
// Not oneself, not twice the same open one, not outsiders.
await expect(
run(new RequestReviewCommand('dev', issue.id, 'dev')),
).rejects.toThrow(ValidationError);
await expect(
run(new RequestReviewCommand('dev', issue.id, 'qa')),
).rejects.toThrow(BusinessRuleError);
await expect(
run(new RequestReviewCommand('dev', issue.id, 'stranger')),
).rejects.toThrow(BusinessRuleError);
// A viewer can be asked and answer, but not ask.
await run(new RequestReviewCommand('dev', issue.id, 'pm'));
await expect(
run(new RequestReviewCommand('pm', issue.id, 'qa')),
).rejects.toThrow(ForbiddenError);
let reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews;
expect(reviews.map((r) => [r.reviewerId, r.note, r.verdict])).toEqual([
['qa', 'Mira el móvil', null],
['pm', null, null],
]);
const [forQa, forPm] = reviews;
await expect(
run(
new AnswerReviewCommand(
'dev',
issue.id,
forQa.id,
ReviewVerdict.APPROVED,
),
),
).rejects.toThrow(ForbiddenError);
await run(
new AnswerReviewCommand(
'qa',
issue.id,
forQa.id,
ReviewVerdict.CHANGES,
'Falla en iOS',
),
);
await expect(
run(
new AnswerReviewCommand(
'qa',
issue.id,
forQa.id,
ReviewVerdict.APPROVED,
),
),
).rejects.toThrow(BusinessRuleError);
await run(new CancelReviewCommand('dev', issue.id, forPm.id));
reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews;
expect(reviews.map((r) => [r.reviewerId, r.verdict, r.answer])).toEqual([
['qa', ReviewVerdict.CHANGES, 'Falla en iOS'],
]);
});
it('settles a conversation, keeping its conclusion as a decision', async () => {
const { run, rehydrator, store } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pagos', {}),
);
const thread = await run(
new AddCommentCommand('qa', issue.id, '¿Stripe o Redsys?'),
);
const reply = await run(
new AddCommentCommand('dev', issue.id, 'Stripe', thread.id),
);
// A reply is not a conversation; a viewer does not settle one.
await expect(
run(new ResolveThreadCommand('dev', reply.id, true)),
).rejects.toThrow(BusinessRuleError);
await expect(
run(new ResolveThreadCommand('pm', thread.id, true)),
).rejects.toThrow(ForbiddenError);
await run(
new ResolveThreadCommand('dev', thread.id, true, ' Vamos con Stripe '),
);
expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe(
true,
);
const conclusion = store.events.find(
(e) =>
e.type === 'CommentAddedEvent' &&
(e.data as { comment: { body: string } }).comment.body ===
'Vamos con Stripe',
);
expect(conclusion).toBeDefined();
const added = await rehydrator.rehydrate(
conclusion?.streamId ?? '',
Comment,
);
expect(added).toMatchObject({ replyTo: thread.id, decision: true });
await run(new ResolveThreadCommand('owner', thread.id, false));
expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe(
false,
);
});
it('takes votes from members, and from anyone once it is public', async () => { it('takes votes from members, and from anyone once it is public', async () => {
const { run, rehydrator } = await setup(); const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run); const projectId = await projectWithTeam(run);

View File

@@ -0,0 +1,108 @@
import {
Command,
CommandHandler,
EventPublisher,
ICommandHandler,
} from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { CommentFactory, ProjectRole } from '@task-stream/api/tracker/domain';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
/*
* A quick poll in a comment («¿Stripe o Redsys?»): members vote, one vote
* each; whoever started it closes it, and the winner can stay as a decision.
*/
export class VotePollCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
/** Null takes the vote back. */
public readonly option: number | null,
) {
super();
}
}
export class ClosePollCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
/** Keep the winner as a decision of the ticket (a reply marked so). */
public readonly decide = true,
) {
super();
}
}
/** Voting is for members, viewers included: like reacting. */
@CommandHandler(VotePollCommand)
export class VotePollCommandHandler
implements ICommandHandler<VotePollCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: VotePollCommand): Promise<CommandResult> {
const comment = await this.access.comment(command.commentId);
const project = await this.access.project(comment.projectId);
if (!project.isMember(command.actorId))
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
this.access.assertSees(
project,
await this.access.issue(comment.issueId),
command.actorId,
);
comment.votePoll(command.actorId, command.option);
await comment.commitAndWait();
return ack(comment);
}
}
@CommandHandler(ClosePollCommand)
export class ClosePollCommandHandler
implements ICommandHandler<ClosePollCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly commentFactory: CommentFactory,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: ClosePollCommand): Promise<CommandResult> {
const comment = await this.access.comment(command.commentId);
const project = await this.access.project(comment.projectId);
if (!project.isMember(command.actorId))
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
const role = project.roleOf(command.actorId);
const voters = Object.keys(comment.pollVotes).length;
const winner = comment.closePoll(
command.actorId,
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
);
await comment.commitAndWait();
if (command.decide && winner !== null && comment.poll) {
const votes = Object.values(comment.pollVotes).filter(
(v) => v === winner,
).length;
// The result, as a decision above the ticket.
const decision = this.commentFactory.create({
issueId: comment.issueId,
projectId: comment.projectId,
authorId: command.actorId,
body: `Decidido: **${comment.poll[winner]}** (${votes} de ${voters} ${voters === 1 ? 'voto' : 'votos'})`,
replyTo: comment.replyTo ?? comment.id,
});
decision.markDecision(command.actorId, true);
this.eventPublisher.mergeObjectContext(decision);
await decision.commitAndWait();
}
return ack(comment);
}
}
export const POLL_HANDLERS = [VotePollCommandHandler, ClosePollCommandHandler];

View File

@@ -0,0 +1,44 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { CommentFactory } from '@task-stream/api/tracker/domain';
import { CommandResult, ack } from './command-result';
import { ResolveThreadCommand } from './resolve-thread.command';
import { WriteAccess } from './write-access';
/** The team settles its conversations: whoever can change the ticket. */
@CommandHandler(ResolveThreadCommand)
export class ResolveThreadCommandHandler
implements ICommandHandler<ResolveThreadCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly commentFactory: CommentFactory,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: ResolveThreadCommand): Promise<CommandResult> {
const thread = await this.access.comment(command.commentId);
if (thread.replyTo)
throw new BusinessRuleError(
'Only a conversation is resolved, not a reply',
);
await this.access.issueForContributor(thread.issueId, command.actorId);
const conclusion = command.conclusion?.trim();
if (command.resolved && conclusion && !thread.resolved) {
// What was settled stays as a decision, shown above the ticket.
const reply = this.commentFactory.create({
issueId: thread.issueId,
projectId: thread.projectId,
authorId: command.actorId,
body: conclusion,
replyTo: thread.id,
});
reply.markDecision(command.actorId, true);
this.eventPublisher.mergeObjectContext(reply);
await reply.commitAndWait();
}
thread.resolveThread(command.actorId, command.resolved);
await thread.commitAndWait();
return ack(thread);
}
}

View File

@@ -0,0 +1,17 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/**
* Settles a conversation (`resolved`) or reopens it. A conclusion, if
* given, is added as a reply marked as a decision before it closes.
*/
export class ResolveThreadCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
public readonly resolved: boolean,
public readonly conclusion: string | null = null,
) {
super();
}
}

View File

@@ -0,0 +1,132 @@
import { randomUUID } from 'node:crypto';
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
BusinessRuleError,
ForbiddenError,
} from '@task-stream/api/shared/domain';
import { ProjectRole, ReviewVerdict } from '@task-stream/api/tracker/domain';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
/*
* «Ana, can you review this?»: a request on a ticket that the reviewer
* answers (all good, or changes wanted). Events of the ticket, so they show
* in its history and in the activity feed.
*/
export class RequestReviewCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly reviewerId: string,
public readonly note: string | null = null,
) {
super();
}
}
export class AnswerReviewCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly reviewId: string,
public readonly verdict: ReviewVerdict,
public readonly answer: string | null = null,
) {
super();
}
}
export class CancelReviewCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly reviewId: string,
) {
super();
}
}
/** The team asks for reviews: whoever can change the ticket. */
@CommandHandler(RequestReviewCommand)
export class RequestReviewCommandHandler
implements ICommandHandler<RequestReviewCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: RequestReviewCommand): Promise<CommandResult> {
const { issue, project } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
// Someone who reads the whole project: a guest would not see the ticket.
const role = project.roleOf(command.reviewerId);
if (!role || (role === ProjectRole.GUEST && !project.isPublic))
throw new BusinessRuleError(
`Only members of ${project.key} can be asked to review`,
);
issue.requestReview(
command.actorId,
randomUUID(),
command.reviewerId,
command.note,
);
await issue.commitAndWait();
return ack(issue);
}
}
/** The person asked answers; reading the ticket is enough (viewers too). */
@CommandHandler(AnswerReviewCommand)
export class AnswerReviewCommandHandler
implements ICommandHandler<AnswerReviewCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: AnswerReviewCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
if (!project.isMember(command.actorId))
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
issue.answerReview(
command.actorId,
command.reviewId,
command.verdict,
command.answer,
);
await issue.commitAndWait();
return ack(issue);
}
}
@CommandHandler(CancelReviewCommand)
export class CancelReviewCommandHandler
implements ICommandHandler<CancelReviewCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: CancelReviewCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
if (!project.isMember(command.actorId))
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
const role = project.roleOf(command.actorId);
issue.cancelReview(
command.actorId,
command.reviewId,
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
);
await issue.commitAndWait();
return ack(issue);
}
}
export const REVIEW_HANDLERS = [
RequestReviewCommandHandler,
AnswerReviewCommandHandler,
CancelReviewCommandHandler,
];

View File

@@ -0,0 +1,94 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
} from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { IssueLinkType } from '@task-stream/api/tracker/domain';
import { UserDirectory } from '../ports/user-directory';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { LinkIssuesCommand } from './link-issues.command';
import { WriteAccess } from './write-access';
/*
* «Convertir en ticket»: a comment that turns out to be work of its own
* («también falla en Android») becomes a ticket, as a subtask of the one it
* was said on or linked to it. Its text is the new description, and the
* comment shows where it went («→ WEB-45»).
*/
export class TurnCommentIntoIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
public readonly title: string,
/** Ticket type id of the project's settings. */
public readonly type: string,
/** Hang it from the ticket of the comment; otherwise, link them. */
public readonly asSubtask: boolean,
) {
super();
}
}
/** Answers the new ticket (id, key, version). */
@CommandHandler(TurnCommentIntoIssueCommand)
export class TurnCommentIntoIssueCommandHandler
implements ICommandHandler<TurnCommentIntoIssueCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
private readonly users: UserDirectory,
) {}
async execute(command: TurnCommentIntoIssueCommand): Promise<CommandResult> {
const comment = await this.access.comment(command.commentId);
const source = await this.access.issue(comment.issueId);
const project = await this.access.project(comment.projectId);
this.access.assertSees(project, source, command.actorId);
// Creating and linking tickets is for the team: those commands check it.
project.assertCanContribute(command.actorId);
if (comment.deleted) throw new BusinessRuleError('Comment was deleted');
const author = (await this.users.refs([comment.authorId])).of(
comment.authorId,
);
const created = await this.commandBus.execute(
new CreateIssueCommand(
command.actorId,
project.id,
command.type,
command.title,
{
description: `${comment.body}\n\n---\n_Sale de un comentario de ${author.name} en ${source.key}._`,
},
null,
null,
command.asSubtask ? source.id : null,
null,
// The comment is the content: no template on top of it.
false,
),
);
if (!command.asSubtask)
await this.commandBus.execute(
new LinkIssuesCommand(
command.actorId,
created.id,
IssueLinkType.RELATES_TO,
source.id,
),
);
comment.turnedInto(command.actorId, {
id: created.id,
key: created.key ?? '',
});
await comment.commitAndWait();
return created;
}
}
export const TURN_INTO_ISSUE_HANDLERS = [TurnCommentIntoIssueCommandHandler];

View File

@@ -11,6 +11,9 @@ import { DeleteCommentCommand } from './commands/delete-comment.command';
import { EditCommentCommand } from './commands/edit-comment.command'; import { EditCommentCommand } from './commands/edit-comment.command';
import { ReactToCommentCommand } from './commands/react-to-comment.command'; import { ReactToCommentCommand } from './commands/react-to-comment.command';
import { MarkDecisionCommand } from './commands/mark-decision.command'; import { MarkDecisionCommand } from './commands/mark-decision.command';
import { TurnCommentIntoIssueCommand } from './commands/turn-comment-into-issue';
import { ResolveThreadCommand } from './commands/resolve-thread.command';
import { ClosePollCommand, VotePollCommand } from './commands/polls';
import { Page } from './ports/find-issues.repository'; import { Page } from './ports/find-issues.repository';
import { GetCommentsQuery } from './queries/get-comments.query'; import { GetCommentsQuery } from './queries/get-comments.query';
import { import {
@@ -30,9 +33,32 @@ export class CommentsService {
issueId: string, issueId: string,
body: string, body: string,
replyTo: string | null = null, replyTo: string | null = null,
poll: string[] | null = null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commands.execute( return this.commands.execute(
new AddCommentCommand(actorId, issueId, body, replyTo), new AddCommentCommand(actorId, issueId, body, replyTo, poll),
);
}
/** Votes in a comment's poll (null takes the vote back). */
votePoll(
actorId: string,
commentId: string,
option: number | null,
): Promise<CommandResult> {
return this.commands.execute(
new VotePollCommand(actorId, commentId, option),
);
}
/** Closes a poll; with `decide`, its winner stays as a decision. */
closePoll(
actorId: string,
commentId: string,
decide = true,
): Promise<CommandResult> {
return this.commands.execute(
new ClosePollCommand(actorId, commentId, decide),
); );
} }
@@ -69,6 +95,40 @@ export class CommentsService {
); );
} }
/**
* Settles a conversation or reopens it; a conclusion stays as a reply
* marked as a decision.
*/
resolveThread(
actorId: string,
commentId: string,
resolved: boolean,
conclusion: string | null = null,
): Promise<CommandResult> {
return this.commands.execute(
new ResolveThreadCommand(actorId, commentId, resolved, conclusion),
);
}
/** Makes a ticket of a comment: a subtask of its ticket, or linked to it. */
turnIntoIssue(
actorId: string,
commentId: string,
title: string,
type: string,
asSubtask: boolean,
): Promise<CommandResult> {
return this.commands.execute(
new TurnCommentIntoIssueCommand(
actorId,
commentId,
title,
type,
asSubtask,
),
);
}
/** A ticket's decisions, oldest first. */ /** A ticket's decisions, oldest first. */
decisions( decisions(
viewerId: string | null, viewerId: string | null,

View File

@@ -9,6 +9,10 @@ import {
IssueChecklistItemRemovedEvent, IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent, IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent, IssueParentChangedEvent,
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueReviewRequestedEvent,
ReviewVerdict,
IssueDevLinkRecordedEvent, IssueDevLinkRecordedEvent,
IssueTrashedEvent, IssueTrashedEvent,
IssueRestoredEvent, IssueRestoredEvent,
@@ -191,6 +195,14 @@ export function describeEvent(
: 'abierta'; : 'abierta';
return `Pull request ${ref} ${verb} para ${issueKey}${who}`; return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
} }
if (event instanceof IssueReviewRequestedEvent)
return `${actor} pidió a ${ctx.user(event.reviewerId)} que revise ${issueKey}`;
if (event instanceof IssueReviewAnsweredEvent)
return event.verdict === ReviewVerdict.APPROVED
? `${actor} dio por bueno ${issueKey} al revisarlo`
: `${actor} pidió cambios en ${issueKey} al revisarlo`;
if (event instanceof IssueReviewCancelledEvent)
return `${actor} retiró una petición de revisión de ${issueKey}`;
if (event instanceof IssueParentChangedEvent) { if (event instanceof IssueParentChangedEvent) {
return event.parentKey return event.parentKey
? `${actor} colocó ${issueKey} bajo ${event.parentKey}` ? `${actor} colocó ${issueKey} bajo ${event.parentKey}`

View File

@@ -11,6 +11,9 @@ import {
IssueChecklistItemRemovedEvent, IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent, IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent, IssueParentChangedEvent,
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueReviewRequestedEvent,
IssueDevLinkRecordedEvent, IssueDevLinkRecordedEvent,
IssueTrashedEvent, IssueTrashedEvent,
IssueRestoredEvent, IssueRestoredEvent,
@@ -75,6 +78,9 @@ import { versionOf } from './read-model-notifier';
IssueAttachmentAddedEvent, IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent, IssueAttachmentRemovedEvent,
IssueParentChangedEvent, IssueParentChangedEvent,
IssueReviewRequestedEvent,
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueDevLinkRecordedEvent, IssueDevLinkRecordedEvent,
IssueTrashedEvent, IssueTrashedEvent,
IssueRestoredEvent, IssueRestoredEvent,
@@ -127,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
event.actorId, event.actorId,
(event as { userId?: string }).userId, (event as { userId?: string }).userId,
(event as { assigneeId?: string }).assigneeId, (event as { assigneeId?: string }).assigneeId,
(event as { reviewerId?: string }).reviewerId,
]; ];
const users = await this.users.refs(mentioned); const users = await this.users.refs(mentioned);
const actorOf = (id: string) => const actorOf = (id: string) =>

View File

@@ -33,6 +33,17 @@ export class CommentAddedEventHandler
decision: false, decision: false,
decidedBy: null, decidedBy: null,
decidedAt: null, decidedAt: null,
poll: comment.poll?.length
? {
options: comment.poll.map((text) => ({ text, voters: [] })),
closed: false,
winner: null,
}
: null,
resolved: false,
resolvedBy: null,
resolvedAt: null,
spawned: [],
createdAt: comment.createdAt, createdAt: comment.createdAt,
editedAt: null, editedAt: null,
version: versionOf(event), version: versionOf(event),

View File

@@ -0,0 +1,48 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
CommentPollClosedEvent,
CommentPollVotedEvent,
} from '@task-stream/api/tracker/domain';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
/** A comment's poll as the read side shows it: who voted each option. */
@EventsHandler(CommentPollVotedEvent, CommentPollClosedEvent)
export class CommentPollEventHandler
implements IEventHandler<CommentPollVotedEvent | CommentPollClosedEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly findComments: FindCommentsRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentPollVotedEvent | CommentPollClosedEvent) {
const comment = await this.findComments.findById(event.aggregateId);
if (!comment?.poll) return;
let poll = comment.poll;
if (event instanceof CommentPollVotedEvent) {
const refs = await this.users.refs(Object.keys(event.votes));
poll = {
...poll,
options: poll.options.map((option, index) => ({
text: option.text,
voters: Object.entries(event.votes)
.filter(([, vote]) => vote === index)
.map(([userId]) => refs.of(userId)),
})),
};
} else {
poll = { ...poll, closed: true, winner: event.winner };
}
await this.upsertMaterializedCommentRepository.upsert({
id: event.aggregateId,
poll,
version: versionOf(event),
});
await this.notifier.comment(event.aggregateId, event.issueId, event);
}
}

View File

@@ -0,0 +1,28 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { CommentThreadResolvedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(CommentThreadResolvedEvent)
export class CommentThreadResolvedEventHandler
implements IEventHandler<CommentThreadResolvedEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentThreadResolvedEvent) {
const refs = await this.users.refs([event.actorId]);
await this.upsertMaterializedCommentRepository.upsert({
id: event.aggregateId,
resolved: event.resolved,
resolvedBy: event.resolved ? refs.of(event.actorId) : null,
resolvedAt: event.resolved ? event.occurredAt : null,
version: versionOf(event),
});
await this.notifier.comment(event.aggregateId, event.issueId, event);
}
}

View File

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

View File

@@ -4,6 +4,10 @@ import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
import { CommentEditedEventHandler } from './comment-edited.event-handler'; import { CommentEditedEventHandler } from './comment-edited.event-handler';
import { CommentReactedEventHandler } from './comment-reacted.event-handler'; import { CommentReactedEventHandler } from './comment-reacted.event-handler';
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler'; import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
import { CommentTurnedIntoIssueEventHandler } from './comment-turned-into-issue.event-handler';
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
import { CommentPollEventHandler } from './comment-poll.event-handler';
import { IssueVotedEventHandler } from './issue-voted.event-handler'; import { IssueVotedEventHandler } from './issue-voted.event-handler';
import { IssueAssignedEventHandler } from './issue-assigned.event-handler'; import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
import { IssueCreatedEventHandler } from './issue-created.event-handler'; import { IssueCreatedEventHandler } from './issue-created.event-handler';
@@ -52,6 +56,10 @@ export const EVENT_HANDLERS = [
CommentEditedEventHandler, CommentEditedEventHandler,
CommentReactedEventHandler, CommentReactedEventHandler,
CommentDecisionMarkedEventHandler, CommentDecisionMarkedEventHandler,
CommentThreadResolvedEventHandler,
CommentTurnedIntoIssueEventHandler,
IssueReviewsEventHandler,
CommentPollEventHandler,
IssueVotedEventHandler, IssueVotedEventHandler,
IssueAssignedEventHandler, IssueAssignedEventHandler,
IssueCreatedEventHandler, IssueCreatedEventHandler,

View File

@@ -0,0 +1,74 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueReviewRequestedEvent,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
type ReviewEvent =
| IssueReviewRequestedEvent
| IssueReviewAnsweredEvent
| IssueReviewCancelledEvent;
/** The ticket's review requests, as its page and «my work» show them. */
@EventsHandler(
IssueReviewRequestedEvent,
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
)
export class IssueReviewsEventHandler implements IEventHandler<ReviewEvent> {
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findIssues: FindIssuesRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ReviewEvent) {
const issue = await this.findIssues.findById(event.aggregateId);
if (!issue) return;
// Events of a stream come in order: the list read is the one before.
let reviews = issue.reviews ?? [];
if (event instanceof IssueReviewRequestedEvent) {
if (reviews.some((r) => r.id === event.reviewId)) return;
const refs = await this.users.refs([event.actorId, event.reviewerId]);
reviews = [
...reviews,
{
id: event.reviewId,
requester: refs.of(event.actorId),
reviewer: refs.of(event.reviewerId),
note: event.note,
requestedAt: event.occurredAt,
verdict: null,
answer: null,
answeredAt: null,
},
];
} else if (event instanceof IssueReviewAnsweredEvent) {
reviews = reviews.map((r) =>
r.id === event.reviewId
? {
...r,
verdict: event.verdict,
answer: event.answer,
answeredAt: event.occurredAt,
}
: r,
);
} else {
reviews = reviews.filter((r) => r.id !== event.reviewId);
}
// A review is not a change to the ticket itself: `updatedAt` stays.
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
reviews,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,82 @@
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* Following a ticket with everything under it (an epic, or a ticket split
* into subtasks): told of its changes and of every ticket that hangs from
* it, at any depth, also the ones created later. Personal, no history
* (like pinned tickets).
*/
export class FollowTreeCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly issueId: string,
/** False: stop following it. */
public readonly on: boolean,
) {
super();
}
}
export class IsFollowingTreeQuery extends Query<boolean> {
constructor(
public readonly userId: string,
public readonly issueId: string,
) {
super();
}
}
@CommandHandler(FollowTreeCommand)
export class FollowTreeCommandHandler
implements ICommandHandler<FollowTreeCommand>
{
constructor(
private readonly follows: TreeFollowsRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: FollowTreeCommand): Promise<void> {
const { userId, issueId } = command;
if (!command.on) return this.follows.unfollow(userId, issueId);
const [issue]: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(userId, [issueId]),
);
// A ticket I cannot read does not exist, as far as I know.
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
await this.follows.follow({
userId,
issueId,
projectId: issue.projectId,
since: new Date().toISOString(),
});
}
}
@QueryHandler(IsFollowingTreeQuery)
export class IsFollowingTreeQueryHandler
implements IQueryHandler<IsFollowingTreeQuery, boolean>
{
constructor(private readonly follows: TreeFollowsRepository) {}
execute(query: IsFollowingTreeQuery): Promise<boolean> {
return this.follows.isFollowing(query.userId, query.issueId);
}
}
export const TREE_FOLLOWS = [
FollowTreeCommandHandler,
IsFollowingTreeQueryHandler,
];

View File

@@ -0,0 +1,53 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { IssueReminder } from './ports/issue-reminders.repository';
import {
ClearIssueReminderCommand,
GetIssueReminderQuery,
GetMyRemindersQuery,
SendIssueRemindersCommand,
SetIssueReminderCommand,
} from './reminders/issue-reminders';
/** «Remind me on Thursday»: one's own reminders on tickets. */
@Injectable()
export class IssueRemindersService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
set(
userId: string,
issueId: string,
at: string,
note: string | null,
): Promise<IssueReminder> {
return this.commands.execute(
new SetIssueReminderCommand(userId, issueId, at, note),
);
}
clear(userId: string, issueId: string): Promise<void> {
return this.commands.execute(
new ClearIssueReminderCommand(userId, issueId),
);
}
of(userId: string, issueId: string): Promise<IssueReminder | null> {
return this.queryBus.execute(new GetIssueReminderQuery(userId, issueId));
}
mine(
userId: string,
): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> {
return this.queryBus.execute(new GetMyRemindersQuery(userId));
}
/** Scheduler only. */
sendDue(): Promise<number> {
return this.commands.execute(new SendIssueRemindersCommand());
}
}

View File

@@ -0,0 +1,14 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { VisitIssueCommand } from './visits/issue-visits';
/** When I last opened a ticket, to tell what is new since. */
@Injectable()
export class IssueVisitsService {
constructor(private readonly commands: CommandRunner) {}
/** Notes that I opened it now; returns my visit before (null: never). */
visit(userId: string, issueId: string): Promise<string | null> {
return this.commands.execute(new VisitIssueCommand(userId, issueId));
}
}

View File

@@ -9,6 +9,7 @@ import {
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
IssueReadModel, IssueReadModel,
ReviewVerdict,
SYSTEM_ACTOR_ID, SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change'; import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
@@ -21,6 +22,11 @@ import {
} from './commands/attach-file.command'; } from './commands/attach-file.command';
import { DetachFileCommand } from './commands/detach-file.command'; import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command'; import { WatchIssueCommand } from './commands/watch-issue.command';
import {
AnswerReviewCommand,
CancelReviewCommand,
RequestReviewCommand,
} from './commands/reviews';
import { SetIssueParentCommand } from './commands/set-issue-parent.command'; import { SetIssueParentCommand } from './commands/set-issue-parent.command';
import { RankIssueCommand } from './commands/rank-issue.command'; import { RankIssueCommand } from './commands/rank-issue.command';
import { DeleteIssueCommand } from './commands/delete-issue.command'; import { DeleteIssueCommand } from './commands/delete-issue.command';
@@ -212,6 +218,41 @@ export class IssuesService {
); );
} }
/** Asks `reviewerId` (a member) to review the ticket. */
requestReview(
actorId: string,
issueId: string,
reviewerId: string,
note: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new RequestReviewCommand(actorId, issueId, reviewerId, note),
);
}
/** The person asked answers: all good, or changes wanted. */
answerReview(
actorId: string,
issueId: string,
reviewId: string,
verdict: ReviewVerdict,
answer: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new AnswerReviewCommand(actorId, issueId, reviewId, verdict, answer),
);
}
cancelReview(
actorId: string,
issueId: string,
reviewId: string,
): Promise<CommandResult> {
return this.commands.execute(
new CancelReviewCommand(actorId, issueId, reviewId),
);
}
/** A ticket's file, with its bytes, for whoever can read the ticket. */ /** A ticket's file, with its bytes, for whoever can read the ticket. */
download( download(
viewerId: string | null, viewerId: string | null,

View File

@@ -0,0 +1,32 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
AddToMyDayCommand,
GetMyDayQuery,
MyDay,
RemoveFromMyDayCommand,
} from './my-day/my-day';
/** «Mi día»: the tickets I picked for today. */
@Injectable()
export class MyDayService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
get(userId: string, day: string): Promise<MyDay> {
return this.queryBus.execute(new GetMyDayQuery(userId, day));
}
add(userId: string, day: string, issueIds: string[]): Promise<void> {
return this.commands.execute(new AddToMyDayCommand(userId, day, issueIds));
}
remove(userId: string, day: string, issueId: string): Promise<void> {
return this.commands.execute(
new RemoveFromMyDayCommand(userId, day, issueId),
);
}
}

View File

@@ -0,0 +1,145 @@
import {
CommandBus,
CqrsModule,
IQueryHandler,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import {
BusinessRuleError,
NotFoundError,
ValidationError,
} from '@task-stream/api/shared/domain';
import {
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { MyDayRecord, MyDaysRepository } from '../ports/my-days.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
import {
AddToMyDayCommand,
GetMyDayQuery,
MAX_MY_DAY,
MY_DAY,
MyDay,
RemoveFromMyDayCommand,
} from './my-day';
class InMemoryDays extends MyDaysRepository {
rows: MyDayRecord[] = [];
async find(userId: string, day: string) {
return this.rows.find((r) => r.userId === userId && r.day === day) ?? null;
}
async latestBefore(userId: string, day: string) {
return (
this.rows
.filter((r) => r.userId === userId && r.day < day)
.sort((a, b) => b.day.localeCompare(a.day))[0] ?? null
);
}
async save(record: MyDayRecord) {
this.rows = [
...this.rows.filter(
(r) => !(r.userId === record.userId && r.day === record.day),
),
record,
];
}
}
/** Ana reads i0…i11; i3 is done. */
const issues = new Map<string, IssueReadModel>();
@QueryHandler(GetIssuesByIdsQuery)
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
async execute(query: GetIssuesByIdsQuery) {
return query.viewerId === 'ana'
? query.ids.flatMap((id) => issues.get(id) ?? [])
: [];
}
}
const day = (offset: number) =>
new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10);
async function setup() {
issues.clear();
for (let i = 0; i < 12; i++)
issues.set(`i${i}`, {
id: `i${i}`,
key: `WEB-${i}`,
statusCategory: i === 3 ? StatusCategory.DONE : StatusCategory.TODO,
trashedAt: null,
} as IssueReadModel);
const rows = new InMemoryDays();
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...MY_DAY,
FakeIssuesByIds,
{ provide: MyDaysRepository, useValue: rows },
],
}).compile();
await moduleRef.init();
const commands = moduleRef.get(CommandBus);
const queries = moduleRef.get(QueryBus);
return {
rows,
commands,
keys: async (d: string) => {
const myDay: MyDay = await queries.execute(new GetMyDayQuery('ana', d));
return {
issues: myDay.issues.map((i) => i.key),
leftover: myDay.leftover?.issues.map((i) => i.key) ?? null,
};
},
};
}
describe('Mi día', () => {
it('keeps the tickets picked for today, in order, once each', async () => {
const { commands, keys } = await setup();
await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i2']));
await commands.execute(new AddToMyDayCommand('ana', day(0), ['i2', 'i3']));
await commands.execute(new RemoveFromMyDayCommand('ana', day(0), 'i1'));
expect((await keys(day(0))).issues).toEqual(['WEB-2', 'WEB-3']);
});
it(`takes ${MAX_MY_DAY} at most, only readable tickets, only around today`, async () => {
const { commands } = await setup();
const many = Array.from({ length: MAX_MY_DAY + 1 }, (_, i) => `i${i}`);
await expect(
commands.execute(new AddToMyDayCommand('ana', day(0), many)),
).rejects.toBeInstanceOf(BusinessRuleError);
await expect(
commands.execute(new AddToMyDayCommand('ana', day(0), ['nope'])),
).rejects.toBeInstanceOf(NotFoundError);
await expect(
commands.execute(new AddToMyDayCommand('ana', day(5), ['i1'])),
).rejects.toBeInstanceOf(ValidationError);
await expect(
commands.execute(new AddToMyDayCommand('ana', '20-10-2026', ['i1'])),
).rejects.toBeInstanceOf(ValidationError);
});
it('offers what was left unfinished on the last day, but not the done ones', async () => {
const { commands, rows, keys } = await setup();
rows.rows.push({
userId: 'ana',
day: day(-1),
issueIds: ['i1', 'i3', 'i4'],
updatedAt: '',
});
expect(await keys(day(0))).toEqual({
issues: [],
leftover: ['WEB-1', 'WEB-4'],
});
// Carried over: no longer offered.
await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i4']));
expect(await keys(day(0))).toEqual({
issues: ['WEB-1', 'WEB-4'],
leftover: null,
});
});
});

View File

@@ -0,0 +1,194 @@
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { MyDayRecord, MyDaysRepository } from '../ports/my-days.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* «Mi día»: the few tickets someone picks each morning for today, and at
* the end what got done. The day is the person's own (`YYYY-MM-DD` from
* their browser). Private, no history.
*/
export const MAX_MY_DAY = 10;
/** How far back what was left counts as «from before». */
const CARRY_OVER_DAYS = 7;
const DAY_FORMAT = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MS = 86_400_000;
const finished = (issue: IssueReadModel) =>
issue.statusCategory === StatusCategory.DONE ||
issue.statusCategory === StatusCategory.CANCELLED;
/** A day of the person's: well formed and near today (time zones apart). */
export function assertDay(day: string, now = Date.now()): void {
const at = Date.parse(`${day}T12:00:00Z`);
assertThat(DAY_FORMAT.test(day) && !Number.isNaN(at), 'A day is YYYY-MM-DD');
assertThat(
Math.abs(at - now) <= 2 * DAY_MS,
'Only today can be planned (in your time zone)',
);
}
export interface MyDay {
day: string;
/** Picked for the day, in order (done ones included). */
issues: IssueReadModel[];
/** What was left unfinished on the latest earlier day, if recent. */
leftover: { day: string; issues: IssueReadModel[] } | null;
}
export class AddToMyDayCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly day: string,
/** Several at once: carrying over what was left. */
public readonly issueIds: string[],
) {
super();
}
}
export class RemoveFromMyDayCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly day: string,
public readonly issueId: string,
) {
super();
}
}
export class GetMyDayQuery extends Query<MyDay> {
constructor(
public readonly userId: string,
public readonly day: string,
) {
super();
}
}
@CommandHandler(AddToMyDayCommand)
export class AddToMyDayCommandHandler
implements ICommandHandler<AddToMyDayCommand>
{
constructor(
private readonly days: MyDaysRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: AddToMyDayCommand): Promise<void> {
assertDay(command.day);
const wanted = [...new Set(command.issueIds)];
const readable: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(command.userId, wanted),
);
const ok = new Set(readable.filter((i) => !i.trashedAt).map((i) => i.id));
// One I cannot read does not exist, as far as I know.
const missing = wanted.find((id) => !ok.has(id));
if (missing) throw new NotFoundError('Issue', missing);
const current = await this.days.find(command.userId, command.day);
const issueIds = [
...(current?.issueIds ?? []),
...wanted.filter((id) => !current?.issueIds.includes(id)),
];
if (issueIds.length > MAX_MY_DAY)
throw new BusinessRuleError(
`Mi día takes ${MAX_MY_DAY} tickets at most: finish or take some out first`,
);
await this.days.save({
userId: command.userId,
day: command.day,
issueIds,
updatedAt: new Date().toISOString(),
});
}
}
@CommandHandler(RemoveFromMyDayCommand)
export class RemoveFromMyDayCommandHandler
implements ICommandHandler<RemoveFromMyDayCommand>
{
constructor(private readonly days: MyDaysRepository) {}
async execute(command: RemoveFromMyDayCommand): Promise<void> {
assertDay(command.day);
const current = await this.days.find(command.userId, command.day);
if (!current?.issueIds.includes(command.issueId)) return;
await this.days.save({
...current,
issueIds: current.issueIds.filter((id) => id !== command.issueId),
updatedAt: new Date().toISOString(),
});
}
}
@QueryHandler(GetMyDayQuery)
export class GetMyDayQueryHandler
implements IQueryHandler<GetMyDayQuery, MyDay>
{
constructor(
private readonly days: MyDaysRepository,
private readonly queryBus: QueryBus,
) {}
async execute(query: GetMyDayQuery): Promise<MyDay> {
assertDay(query.day);
const [today, before] = await Promise.all([
this.days.find(query.userId, query.day),
this.days.latestBefore(query.userId, query.day),
]);
const recent =
before &&
Date.parse(`${query.day}T00:00:00Z`) -
Date.parse(`${before.day}T00:00:00Z`) <=
CARRY_OVER_DAYS * DAY_MS
? before
: null;
const ids = [
...new Set([...(today?.issueIds ?? []), ...(recent?.issueIds ?? [])]),
];
const found: IssueReadModel[] = ids.length
? await this.queryBus.execute(new GetIssuesByIdsQuery(query.userId, ids))
: [];
const byId = new Map(
found.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
);
const pick = (record: MyDayRecord | null) =>
(record?.issueIds ?? []).flatMap((id) => byId.get(id) ?? []);
const issues = pick(today);
const leftover = pick(recent).filter(
(i) => !finished(i) && !today?.issueIds.includes(i.id),
);
return {
day: query.day,
issues,
leftover:
recent && leftover.length
? { day: recent.day, issues: leftover }
: null,
};
}
}
export const MY_DAY = [
AddToMyDayCommandHandler,
RemoveFromMyDayCommandHandler,
GetMyDayQueryHandler,
];

View File

@@ -3,7 +3,11 @@ import {
CommentAddedEvent, CommentAddedEvent,
IssueAssignedEvent, IssueAssignedEvent,
IssueCreatedEvent, IssueCreatedEvent,
IssueParentChangedEvent,
IssuePriority, IssuePriority,
IssueReviewAnsweredEvent,
IssueReviewRequestedEvent,
ReviewVerdict,
IssueSprintChangedEvent, IssueSprintChangedEvent,
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
@@ -24,6 +28,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
import { UserDirectory } from '../ports/user-directory'; import { UserDirectory } from '../ports/user-directory';
import { NotificationDraft, Notifier } from './notifier'; import { NotificationDraft, Notifier } from './notifier';
@@ -61,10 +66,14 @@ const excerpt = (text: string, max = 120) => {
*/ */
@EventsHandler( @EventsHandler(
IssueCreatedEvent, IssueCreatedEvent,
IssueParentChangedEvent,
IssueAssignedEvent, IssueAssignedEvent,
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
IssueSprintChangedEvent, IssueSprintChangedEvent,
IssueParentChangedEvent,
IssueReviewRequestedEvent,
IssueReviewAnsweredEvent,
CommentAddedEvent, CommentAddedEvent,
ProjectMemberAddedEvent, ProjectMemberAddedEvent,
) )
@@ -76,6 +85,7 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly users: UserDirectory, private readonly users: UserDirectory,
private readonly findComments: FindCommentsRepository, private readonly findComments: FindCommentsRepository,
private readonly findSprints: FindSprintsRepository, private readonly findSprints: FindSprintsRepository,
private readonly treeFollows: TreeFollowsRepository,
) {} ) {}
async handle(event: TrackerEvent): Promise<void> { async handle(event: TrackerEvent): Promise<void> {
@@ -118,9 +128,23 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
const assigneeId = const assigneeId =
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null); 'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
const ticket = `${issue.key}: ${issue.title}`; 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 memberIds = new Set(project.members.map((m) => m.user.id));
// Who follows it with everything under it, or any ticket above it.
// Placed under another ticket: its new place counts (the read model may
// not have it yet).
const parentId =
event instanceof IssueParentChangedEvent
? event.parentId
: 'parent' in issue
? (issue.parent?.id ?? null)
: null;
const tree = await this.treeFollowersOf(issue.id, parentId, project);
const watcherIds = [
...new Set([
...('watchers' in issue ? issue.watchers.map((w) => w.id) : []),
...tree.followers,
]),
];
const drafts: NotificationDraft[] = []; const drafts: NotificationDraft[] = [];
// A proposal from the public form: whoever runs the project should know. // A proposal from the public form: whoever runs the project should know.
@@ -142,6 +166,39 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
}); });
} }
} }
if (event instanceof IssueReviewRequestedEvent) {
const note = event.note ? `: «${excerpt(event.note)}»` : '';
drafts.push({
userId: event.reviewerId,
kind: NotificationKind.REVIEW_REQUESTED,
summary: `${actor} te pide que revises ${ticket}${note}`,
});
}
if (event instanceof IssueReviewAnsweredEvent) {
const review = ('reviews' in issue ? (issue.reviews ?? []) : []).find(
(r) => r.id === event.reviewId,
);
const answer = event.answer ? `: «${excerpt(event.answer)}»` : '';
if (review)
drafts.push({
userId: review.requester.id,
kind: NotificationKind.REVIEW_ANSWERED,
summary:
event.verdict === ReviewVerdict.APPROVED
? `${actor} revisó ${ticket} y lo da por bueno${answer}`
: `${actor} revisó ${ticket} y pide cambios${answer}`,
});
}
// A ticket new under a followed one (created there or moved in): the
// only way to hear of it.
if (event instanceof IssueParentChangedEvent && event.parentId) {
for (const userId of tree.followers)
drafts.push({
userId,
kind: NotificationKind.WATCHED,
summary: `${actor} añadió ${ticket} a ${event.parentKey ?? tree.parentKey}`,
});
}
if (event instanceof IssueCreatedEvent && assigneeId) { if (event instanceof IssueCreatedEvent && assigneeId) {
drafts.push({ drafts.push({
userId: assigneeId, userId: assigneeId,
@@ -271,6 +328,40 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
); );
} }
/**
* Those following the ticket with its subtree, or any ticket above it
* (an epic, a parent), who can read it all: members that are not guests
* limited to their own tickets. Also the key of its parent.
*/
private async treeFollowersOf(
issueId: string,
parentId: string | null,
project: {
isPublic: boolean;
members: { user: { id: string }; role: ProjectRole }[];
},
): Promise<{ followers: string[]; parentKey: string | null }> {
const ids = [issueId];
let parentKey: string | null = null;
let next = parentId;
// A few levels: epic → story → subtask is as deep as trees go.
for (let depth = 0; next && depth < 5 && !ids.includes(next); depth++) {
ids.push(next);
const parent = await this.findIssues.findById(next);
parentKey ??= parent?.key ?? null;
next = parent?.parent?.id ?? null;
}
const readers = new Set(
project.members
.filter((m) => project.isPublic || m.role !== ProjectRole.GUEST)
.map((m) => m.user.id),
);
const followers = (await this.treeFollows.followersOf(ids)).filter((id) =>
readers.has(id),
);
return { followers, parentKey };
}
/** /**
* What a follower should hear of a change of priority, due date or * What a follower should hear of a change of priority, due date or
* sprint; null for the other edits (title, labels…), too small to tell. * sprint; null for the other edits (title, labels…), too small to tell.

View File

@@ -5,6 +5,7 @@ import {
IssuePriority, IssuePriority,
IssueSprintChangedEvent, IssueSprintChangedEvent,
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueParentChangedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
NotificationKind, NotificationKind,
NotificationReadModel, NotificationReadModel,
@@ -23,6 +24,7 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
import { NotificationMailer } from '../ports/notification-mailer'; import { NotificationMailer } from '../ports/notification-mailer';
import { NotificationsRepository } from '../ports/notifications.repository'; import { NotificationsRepository } from '../ports/notifications.repository';
import { NotificationsEventHandler } from './notifications.event-handler'; import { NotificationsEventHandler } from './notifications.event-handler';
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
import { Notifier } from './notifier'; import { Notifier } from './notifier';
import { import {
PushPayload, PushPayload,
@@ -62,7 +64,12 @@ const at = <T extends TrackerEvent>(event: T, version = 2): T =>
occurredAt: '2026-10-08T10:00:00.000Z', occurredAt: '2026-10-08T10:00:00.000Z',
}); });
function setup(mailEnabled = false, pushEnabled = false) { function setup(
mailEnabled = false,
pushEnabled = false,
/** Ticket id → who follows it with everything under it. */
follows: Record<string, string[]> = {},
) {
const repo = new InMemoryNotifications(); const repo = new InMemoryNotifications();
const published: NotificationReadModel[] = []; const published: NotificationReadModel[] = [];
const mailed: string[] = []; const mailed: string[] = [];
@@ -74,7 +81,9 @@ function setup(mailEnabled = false, pushEnabled = false) {
.add('ana') .add('ana')
.add('bob') .add('bob')
.add('qa') .add('qa')
.add('pm'); .add('pm')
.add('lia')
.add('gus');
const issue = { const issue = {
id: 'i-1', id: 'i-1',
key: 'WEB-1', key: 'WEB-1',
@@ -85,7 +94,10 @@ function setup(mailEnabled = false, pushEnabled = false) {
{ id: 'pm', name: 'PM' }, { id: 'pm', name: 'PM' },
{ id: 'bob', name: 'Bob' }, { id: 'bob', name: 'Bob' },
], ],
parent: { id: 'e-1', key: 'WEB-10', title: 'Checkout' },
}; };
/** The epic it hangs from. */
const epic = { id: 'e-1', key: 'WEB-10', title: 'Checkout', parent: null };
const notifier = new Notifier( const notifier = new Notifier(
repo, repo,
{ {
@@ -118,15 +130,23 @@ function setup(mailEnabled = false, pushEnabled = false) {
); );
const handler = new NotificationsEventHandler( const handler = new NotificationsEventHandler(
notifier, notifier,
{ findById: async () => issue } as unknown as FindIssuesRepository, {
findById: async (id: string) => (id === 'e-1' ? epic : issue),
} as unknown as FindIssuesRepository,
{ {
findById: async () => ({ findById: async () => ({
id: 'p-1', id: 'p-1',
key: 'WEB', key: 'WEB',
name: 'Web', name: 'Web',
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({ isPublic: false,
members: ['ana', 'bob', 'qa', 'pm', 'lia', 'gus'].map((id) => ({
user: { id, name: id }, user: { id, name: id },
role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER, role:
id === 'ana'
? ProjectRole.OWNER
: id === 'gus'
? ProjectRole.GUEST
: ProjectRole.MEMBER,
})), })),
}), }),
} as unknown as FindProjectsRepository, } as unknown as FindProjectsRepository,
@@ -142,11 +162,49 @@ function setup(mailEnabled = false, pushEnabled = false) {
findById: async (id: string) => findById: async (id: string) =>
id === 's-14' ? { id, name: 'Sprint 14' } : null, id === 's-14' ? { id, name: 'Sprint 14' } : null,
} as unknown as FindSprintsRepository, } as unknown as FindSprintsRepository,
{
followersOf: async (ids: string[]) => [
...new Set(ids.flatMap((id) => follows[id] ?? [])),
],
} as unknown as TreeFollowsRepository,
); );
return { repo, published, mailed, pushed, devices, forgotten, handler }; return { repo, published, mailed, pushed, devices, forgotten, handler };
} }
describe('notifications', () => { describe('notifications', () => {
it('tells whoever follows the epic of what happens to its tickets, not guests', async () => {
const { handler, repo } = setup(false, false, { 'e-1': ['lia', 'gus'] });
await handler.handle(
at(
new IssueStatusChangedEvent(
'ana',
'i-1',
'p-1',
'TODO',
'IN_PROGRESS',
null,
),
),
);
const lia = repo.all.filter((n) => n.userId === 'lia');
expect(lia.map((n) => n.kind)).toEqual([NotificationKind.WATCHED]);
expect(lia[0].summary).toContain('WEB-1');
// A guest only sees their own tickets: not told of the epic's.
expect(repo.all.some((n) => n.userId === 'gus')).toBe(false);
});
it('tells whoever follows the epic of a ticket added to it', async () => {
const { handler, repo } = setup(false, false, { 'e-1': ['lia'] });
await handler.handle(
at(
new IssueParentChangedEvent('ana', 'i-1', 'p-1', 'e-1', 'WEB-10', null),
),
);
expect(repo.all.map((n) => [n.userId, n.summary])).toEqual([
['lia', expect.stringContaining('añadió WEB-1: Login roto a WEB-10')],
]);
});
it('tells whoever a ticket is assigned to, not whoever assigned it', async () => { it('tells whoever a ticket is assigned to, not whoever assigned it', async () => {
const { handler, repo, published } = setup(); const { handler, repo, published } = setup();
await handler.handle( await handler.handle(

View File

@@ -46,6 +46,15 @@ export interface ProjectChange extends Change {
* Live stream of read-model changes: event handlers publish once the read * Live stream of read-model changes: event handlers publish once the read
* model is up to date, GraphQL subscriptions watch. * model is up to date, GraphQL subscriptions watch.
*/ */
/** Someone is writing on a ticket: never stored, gone in a few seconds. */
export interface Typing {
issueId: string;
user: { id: string; name: string };
/** The conversation being answered; null: a new comment. */
threadId: string | null;
at: string;
}
export abstract class ChangeFeed { export abstract class ChangeFeed {
abstract publishIssue(change: IssueChange): Promise<void>; abstract publishIssue(change: IssueChange): Promise<void>;
abstract publishComment(change: CommentChange): Promise<void>; abstract publishComment(change: CommentChange): Promise<void>;
@@ -64,6 +73,8 @@ export abstract class ChangeFeed {
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>; abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>; abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
abstract publishTyping(typing: Typing): Promise<void>;
abstract typing(issueId: string): AsyncIterableIterator<Typing>;
abstract activity( abstract activity(
projectId: string, projectId: string,
): AsyncIterableIterator<ActivityReadModel>; ): AsyncIterableIterator<ActivityReadModel>;

View File

@@ -47,6 +47,8 @@ export interface IssueFilter {
watching?: boolean; watching?: boolean;
/** Only the ones this person follows. */ /** Only the ones this person follows. */
watcherId?: string; watcherId?: string;
/** Only the ones this person was asked to review and has not answered. */
reviewerId?: string;
/** Only the ones this person opened, has or follows (a guest's view). */ /** Only the ones this person opened, has or follows (a guest's view). */
participantId?: string; participantId?: string;
/** RECENT (default): last updated first. RANK: backlog order. */ /** RECENT (default): last updated first. RANK: backlog order. */
@@ -62,6 +64,8 @@ export interface CrossProjectIssueFilter {
assigneeId?: string; assigneeId?: string;
/** Only the ones this person follows. */ /** Only the ones this person follows. */
watcherId?: string; watcherId?: string;
/** Only the ones this person was asked to review and has not answered. */
reviewerId?: string;
statuses?: string[]; statuses?: string[];
categories?: StatusCategory[]; categories?: StatusCategory[];
priorities?: IssuePriority[]; priorities?: IssuePriority[];

View File

@@ -0,0 +1,29 @@
/** «Remind me on Thursday»: one person's, on one ticket. */
export interface IssueReminder {
id: string;
userId: string;
issueId: string;
projectId: string;
/** When to tell (ISO, UTC). */
at: string;
/** What to remember, in one line. */
note: string | null;
createdAt: string;
/** When it was told (null: still waiting). */
sentAt: string | null;
}
export abstract class IssueRemindersRepository {
/** Each person has at most one waiting per ticket: setting it replaces it. */
abstract save(reminder: IssueReminder): Promise<void>;
abstract waitingFor(
userId: string,
issueId: string,
): Promise<IssueReminder | null>;
/** One person's waiting reminders, soonest first. */
abstract waitingOf(userId: string): Promise<IssueReminder[]>;
abstract remove(userId: string, issueId: string): Promise<void>;
/** Waiting ones whose time has come, oldest first. */
abstract due(now: string, limit: number): Promise<IssueReminder[]>;
abstract markSent(id: string, at: string): Promise<void>;
}

View File

@@ -0,0 +1,11 @@
/** When someone last opened a ticket: personal, no history. */
export interface IssueVisit {
userId: string;
issueId: string;
seenAt: string;
}
export abstract class IssueVisitsRepository {
/** Notes this visit; returns when the one before it was (null: never). */
abstract visit(visit: IssueVisit): Promise<string | null>;
}

View File

@@ -0,0 +1,19 @@
/** The tickets someone picked for one day of theirs. */
export interface MyDayRecord {
userId: string;
/** `YYYY-MM-DD` in the person's own time zone. */
day: string;
/** In the order they were picked. */
issueIds: string[];
updatedAt: string;
}
export abstract class MyDaysRepository {
abstract find(userId: string, day: string): Promise<MyDayRecord | null>;
/** The latest day before `day`, if any (to carry over what was left). */
abstract latestBefore(
userId: string,
day: string,
): Promise<MyDayRecord | null>;
abstract save(record: MyDayRecord): Promise<void>;
}

View File

@@ -0,0 +1,16 @@
/** A release's public notes page: whoever has the link reads them. */
export interface ReleaseNotesShare {
releaseId: string;
projectId: string;
/** Random and long; kept as it is so the link can be copied again. */
token: string;
createdAt: string;
}
export abstract class ReleaseNoteSharesRepository {
abstract findByRelease(releaseId: string): Promise<ReleaseNotesShare | null>;
abstract findByToken(token: string): Promise<ReleaseNotesShare | null>;
/** Replaces the release's link, if any. */
abstract save(share: ReleaseNotesShare): Promise<void>;
abstract remove(releaseId: string): Promise<void>;
}

View File

@@ -0,0 +1,16 @@
/** Who follows a ticket with everything under it: personal, no history. */
export interface TreeFollow {
userId: string;
issueId: string;
projectId: string;
since: string;
}
export abstract class TreeFollowsRepository {
/** Following it again keeps the first date. */
abstract follow(follow: TreeFollow): Promise<void>;
abstract unfollow(userId: string, issueId: string): Promise<void>;
abstract isFollowing(userId: string, issueId: string): Promise<boolean>;
/** Everyone following any of these tickets (each once). */
abstract followersOf(issueIds: string[]): Promise<string[]>;
}

View File

@@ -1,5 +1,6 @@
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain'; import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain'; import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { ChangeFeed, Typing } from './ports/change-feed';
import { FindIssuesRepository } from './ports/find-issues.repository'; import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store'; import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { PRESENCE_TTL_MS, PresenceService } from './presence.service'; import { PRESENCE_TTL_MS, PresenceService } from './presence.service';
@@ -40,10 +41,23 @@ const access = {
}, },
} as unknown as ReadAccess; } as unknown as ReadAccess;
/** What would go out to the ticket's open pages. */
const feed = () => {
const published: Typing[] = [];
return {
published,
feed: {
publishTyping: async (typing: Typing) => {
published.push(typing);
},
} as unknown as ChangeFeed,
};
};
describe('PresenceService', () => { describe('PresenceService', () => {
it('tells who else is on a ticket, the ones editing first', async () => { it('tells who else is on a ticket, the ones editing first', async () => {
const store = new MemoryPresence(); const store = new MemoryPresence();
const presence = new PresenceService(store, issues, access); const presence = new PresenceService(store, issues, access, feed().feed);
await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false); await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false);
await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true); await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true);
const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false); const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false);
@@ -62,8 +76,33 @@ describe('PresenceService', () => {
).toEqual([]); ).toEqual([]);
}); });
it('tells at once who is writing, and where, to whoever can read it', async () => {
const { published, feed: changeFeed } = feed();
const presence = new PresenceService(
new MemoryPresence(),
issues,
access,
changeFeed,
);
await presence.typing({ id: 'ana', name: 'Ana' }, 'i1', 'c1');
await presence.typing({ id: 'bob', name: 'Bob' }, 'i1', null);
expect(published.map((t) => [t.issueId, t.user.name, t.threadId])).toEqual([
['i1', 'Ana', 'c1'],
['i1', 'Bob', null],
]);
await expect(
presence.typing({ id: 'eve', name: 'Eve' }, 'i1', null),
).rejects.toBeInstanceOf(ForbiddenError);
expect(published).toHaveLength(2);
});
it('only for whoever can read the ticket', async () => { it('only for whoever can read the ticket', async () => {
const presence = new PresenceService(new MemoryPresence(), issues, access); const presence = new PresenceService(
new MemoryPresence(),
issues,
access,
feed().feed,
);
await expect( await expect(
presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false), presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false),
).rejects.toBeInstanceOf(ForbiddenError); ).rejects.toBeInstanceOf(ForbiddenError);

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { NotFoundError } from '@task-stream/api/shared/domain'; import { NotFoundError } from '@task-stream/api/shared/domain';
import { ChangeFeed } from './ports/change-feed';
import { FindIssuesRepository } from './ports/find-issues.repository'; import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store'; import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { ReadAccess } from './queries/read-access'; import { ReadAccess } from './queries/read-access';
@@ -17,6 +18,7 @@ export class PresenceService {
private readonly store: PresenceStore, private readonly store: PresenceStore,
private readonly findIssues: FindIssuesRepository, private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess, private readonly access: ReadAccess,
private readonly changeFeed: ChangeFeed,
) {} ) {}
/** /**
@@ -54,6 +56,24 @@ export class PresenceService {
); );
} }
/**
* I am writing on the ticket (an answer to `threadId`, or a new comment):
* told at once to whoever has it open, and forgotten.
*/
async typing(
viewer: { id: string; name: string },
issueId: string,
threadId: string | null,
): Promise<void> {
await this.assertCanView(viewer.id, issueId);
await this.changeFeed.publishTyping({
issueId,
user: { id: viewer.id, name: viewer.name },
threadId,
at: new Date().toISOString(),
});
}
/** I left the ticket's page. */ /** I left the ticket's page. */
leave(viewerId: string, issueId: string): Promise<void> { leave(viewerId: string, issueId: string): Promise<void> {
return this.store.leave(issueId, viewerId); return this.store.leave(issueId, viewerId);

View File

@@ -56,6 +56,7 @@ export class SearchMyIssuesQueryHandler
: undefined, : undefined,
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined, assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
watcherId: query.filter.watching ? query.viewerId : undefined, watcherId: query.filter.watching ? query.viewerId : undefined,
reviewerId: query.filter.reviewing ? query.viewerId : undefined,
statuses: query.filter.statuses, statuses: query.filter.statuses,
categories: filter.overdue categories: filter.overdue
? (filter.categories?.length ? filter.categories : open).filter((c) => ? (filter.categories?.length ? filter.categories : open).filter((c) =>

View File

@@ -11,6 +11,8 @@ export interface MyIssuesFilter {
assignedToMe: boolean; assignedToMe: boolean;
/** Only the tickets the viewer follows. */ /** Only the tickets the viewer follows. */
watching?: boolean; watching?: boolean;
/** Only the tickets the viewer was asked to review (unanswered). */
reviewing?: boolean;
statuses?: string[]; statuses?: string[];
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */ /** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
categories?: StatusCategory[]; categories?: StatusCategory[];

View File

@@ -0,0 +1,171 @@
import {
CommandBus,
CqrsModule,
IQueryHandler,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import { NotFoundError, ValidationError } from '@task-stream/api/shared/domain';
import {
IssueReadModel,
NotificationReadModel,
} from '@task-stream/api/tracker/domain';
import { Notifier } from '../notifications/notifier';
import {
IssueReminder,
IssueRemindersRepository,
} from '../ports/issue-reminders.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
import {
GetMyRemindersQuery,
ISSUE_REMINDERS,
SendIssueRemindersCommand,
SetIssueReminderCommand,
} from './issue-reminders';
class InMemoryReminders extends IssueRemindersRepository {
rows: IssueReminder[] = [];
async save(reminder: IssueReminder) {
await this.remove(reminder.userId, reminder.issueId);
this.rows.push(reminder);
}
async waitingFor(userId: string, issueId: string) {
return (
this.rows.find(
(r) => r.userId === userId && r.issueId === issueId && !r.sentAt,
) ?? null
);
}
async waitingOf(userId: string) {
return this.rows
.filter((r) => r.userId === userId && !r.sentAt)
.sort((a, b) => a.at.localeCompare(b.at));
}
async remove(userId: string, issueId: string) {
this.rows = this.rows.filter(
(r) => !(r.userId === userId && r.issueId === issueId && !r.sentAt),
);
}
async due(now: string) {
return this.rows.filter((r) => !r.sentAt && r.at <= now);
}
async markSent(id: string, at: string) {
const row = this.rows.find((r) => r.id === id);
if (row) row.sentAt = at;
}
}
/** Ana reads i1 and i2; i2 goes to the trash in one test. */
const issues = new Map<string, IssueReadModel>();
@QueryHandler(GetIssuesByIdsQuery)
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
async execute(query: GetIssuesByIdsQuery) {
return query.viewerId === 'ana'
? query.ids.flatMap((id) => issues.get(id) ?? [])
: [];
}
}
const inHours = (h: number) =>
new Date(Date.now() + h * 3_600_000).toISOString();
async function setup() {
issues.clear();
for (const id of ['i1', 'i2'])
issues.set(id, {
id,
key: id === 'i1' ? 'WEB-1' : 'WEB-2',
title: 'Pagos',
projectId: 'p1',
projectKey: 'WEB',
trashedAt: null,
} as IssueReadModel);
const rows = new InMemoryReminders();
const sent: NotificationReadModel[] = [];
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...ISSUE_REMINDERS,
FakeIssuesByIds,
{ provide: IssueRemindersRepository, useValue: rows },
{
provide: Notifier,
useValue: {
send: async (n: NotificationReadModel[]) => void sent.push(...n),
},
},
],
}).compile();
await moduleRef.init();
return {
rows,
sent,
commands: moduleRef.get(CommandBus),
queries: moduleRef.get(QueryBus),
};
}
describe('ticket reminders', () => {
it('keeps one per person and ticket, in the future and within a year', async () => {
const { commands, queries } = await setup();
await commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(2)),
);
await commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(5), ' Llamar a QA '),
);
await commands.execute(
new SetIssueReminderCommand('ana', 'i2', inHours(1)),
);
const mine = await queries.execute(new GetMyRemindersQuery('ana'));
expect(mine.map((m: { issue: IssueReadModel }) => m.issue.key)).toEqual([
'WEB-2',
'WEB-1',
]);
expect(mine[1].reminder.note).toBe('Llamar a QA');
await expect(
commands.execute(new SetIssueReminderCommand('ana', 'i1', inHours(-2))),
).rejects.toBeInstanceOf(ValidationError);
await expect(
commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(24 * 400)),
),
).rejects.toBeInstanceOf(ValidationError);
await expect(
commands.execute(new SetIssueReminderCommand('eve', 'i1', inHours(2))),
).rejects.toBeInstanceOf(NotFoundError);
});
it('tells the ones whose time came, once, and forgets those out of reach', async () => {
const { commands, rows, sent } = await setup();
await commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(1), 'Revisar el PR'),
);
await commands.execute(
new SetIssueReminderCommand('ana', 'i2', inHours(1)),
);
await commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(1)),
);
expect(await commands.execute(new SendIssueRemindersCommand())).toBe(0);
issues.set('i2', {
...(issues.get('i2') as IssueReadModel),
trashedAt: 'x',
});
const later = inHours(2);
expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe(
1,
);
expect(sent.map((n) => [n.userId, n.issueKey, n.summary])).toEqual([
['ana', 'WEB-1', 'Recordatorio: WEB-1 · Pagos'],
]);
expect(rows.rows.every((r) => r.sentAt)).toBe(true);
// Run again: nothing left.
expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe(
0,
);
});
});

View File

@@ -0,0 +1,232 @@
import { randomUUID } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { assertThat, NotFoundError } from '@task-stream/api/shared/domain';
import {
IssueReadModel,
NotificationKind,
SYSTEM_ACTOR_ID,
SYSTEM_ACTOR_NAME,
} from '@task-stream/api/tracker/domain';
import { Notifier } from '../notifications/notifier';
import {
IssueReminder,
IssueRemindersRepository,
} from '../ports/issue-reminders.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* «Remind me on Thursday»: a person's own reminder on a ticket, told as a
* notification (bell, push, e-mail) when its time comes. One waiting per
* person and ticket; private, no history.
*/
/** As far ahead as a reminder goes. */
export const MAX_REMINDER_DAYS = 366;
export const MAX_REMINDER_NOTE = 280;
export class SetIssueReminderCommand extends Command<IssueReminder> {
constructor(
public readonly userId: string,
public readonly issueId: string,
/** ISO date-time. */
public readonly at: string,
public readonly note: string | null = null,
) {
super();
}
}
export class ClearIssueReminderCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly issueId: string,
) {
super();
}
}
/** Scheduler only: tells the reminders whose time has come. */
export class SendIssueRemindersCommand extends Command<number> {
constructor(public readonly now: string = new Date().toISOString()) {
super();
}
}
export class GetIssueReminderQuery extends Query<IssueReminder | null> {
constructor(
public readonly userId: string,
public readonly issueId: string,
) {
super();
}
}
/** My waiting reminders with their tickets, soonest first. */
export class GetMyRemindersQuery extends Query<
{ reminder: IssueReminder; issue: IssueReadModel }[]
> {
constructor(public readonly userId: string) {
super();
}
}
@CommandHandler(SetIssueReminderCommand)
export class SetIssueReminderCommandHandler
implements ICommandHandler<SetIssueReminderCommand>
{
constructor(
private readonly reminders: IssueRemindersRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: SetIssueReminderCommand): Promise<IssueReminder> {
const at = Date.parse(command.at);
const now = Date.now();
assertThat(!Number.isNaN(at), 'A reminder needs a date and time');
assertThat(at > now - 60_000, 'A reminder must be in the future');
assertThat(
at <= now + MAX_REMINDER_DAYS * 86_400_000,
`A reminder can be at most a year ahead`,
);
const note = command.note?.replace(/\s+/g, ' ').trim() || null;
assertThat(
!note || note.length <= MAX_REMINDER_NOTE,
`A reminder's note takes ${MAX_REMINDER_NOTE} characters at most`,
);
const [issue]: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(command.userId, [command.issueId]),
);
// A ticket I cannot read does not exist, as far as I know.
if (!issue || issue.trashedAt)
throw new NotFoundError('Issue', command.issueId);
const reminder: IssueReminder = {
id: randomUUID(),
userId: command.userId,
issueId: issue.id,
projectId: issue.projectId,
at: new Date(at).toISOString(),
note,
createdAt: new Date(now).toISOString(),
sentAt: null,
};
await this.reminders.save(reminder);
return reminder;
}
}
@CommandHandler(ClearIssueReminderCommand)
export class ClearIssueReminderCommandHandler
implements ICommandHandler<ClearIssueReminderCommand>
{
constructor(private readonly reminders: IssueRemindersRepository) {}
execute(command: ClearIssueReminderCommand): Promise<void> {
return this.reminders.remove(command.userId, command.issueId);
}
}
@CommandHandler(SendIssueRemindersCommand)
export class SendIssueRemindersCommandHandler
implements ICommandHandler<SendIssueRemindersCommand>
{
constructor(
private readonly reminders: IssueRemindersRepository,
private readonly queryBus: QueryBus,
private readonly notifier: Notifier,
) {}
async execute(command: SendIssueRemindersCommand): Promise<number> {
const due = await this.reminders.due(command.now, 200);
let sent = 0;
for (const reminder of due) {
const [issue]: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(reminder.userId, [reminder.issueId]),
);
// Out of reach or in the trash by now: nothing to remind of.
if (issue && !issue.trashedAt) {
// Its id makes it idempotent: two instances tell it once.
await this.notifier.send([
{
id: `issue-reminder:${reminder.id}`,
userId: reminder.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: reminder.note
? `Recordatorio de ${issue.key}: ${reminder.note}`
: `Recordatorio: ${issue.key} · ${issue.title}`,
readAt: null,
createdAt: command.now,
},
]);
sent++;
}
await this.reminders.markSent(reminder.id, command.now);
}
return sent;
}
}
@QueryHandler(GetIssueReminderQuery)
export class GetIssueReminderQueryHandler
implements IQueryHandler<GetIssueReminderQuery, IssueReminder | null>
{
constructor(private readonly reminders: IssueRemindersRepository) {}
execute(query: GetIssueReminderQuery): Promise<IssueReminder | null> {
return this.reminders.waitingFor(query.userId, query.issueId);
}
}
@QueryHandler(GetMyRemindersQuery)
export class GetMyRemindersQueryHandler
implements
IQueryHandler<
GetMyRemindersQuery,
{ reminder: IssueReminder; issue: IssueReadModel }[]
>
{
constructor(
private readonly reminders: IssueRemindersRepository,
private readonly queryBus: QueryBus,
) {}
async execute(
query: GetMyRemindersQuery,
): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> {
const waiting = await this.reminders.waitingOf(query.userId);
if (!waiting.length) return [];
const issues: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(
query.userId,
waiting.map((r) => r.issueId),
),
);
const byId = new Map(
issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
);
return waiting.flatMap((reminder) => {
const issue = byId.get(reminder.issueId);
return issue ? [{ reminder, issue }] : [];
});
}
}
export const ISSUE_REMINDERS = [
SetIssueReminderCommandHandler,
ClearIssueReminderCommandHandler,
SendIssueRemindersCommandHandler,
GetIssueReminderQueryHandler,
GetMyRemindersQueryHandler,
];

View File

@@ -11,8 +11,16 @@ import {
ShareDashboardCommand, ShareDashboardCommand,
UnshareDashboardCommand, UnshareDashboardCommand,
} from './sharing/dashboard-shares'; } from './sharing/dashboard-shares';
import { ReleaseNotesShare } from './ports/release-note-shares.repository';
import {
GetPublicReleaseNotesQuery,
GetReleaseNotesShareQuery,
PublicReleaseNotes,
ShareReleaseNotesCommand,
UnshareReleaseNotesCommand,
} from './sharing/release-notes-shares';
/** The project's dashboard for clients, behind a secret link. */ /** The project's dashboard and its releases' notes, behind secret links. */
@Injectable() @Injectable()
export class SharingService { export class SharingService {
constructor( constructor(
@@ -48,6 +56,38 @@ export class SharingService {
); );
} }
/** A release's public notes link, if any (owners and admins). */
releaseNotesShare(
viewerId: string,
releaseId: string,
): Promise<ReleaseNotesShare | null> {
return this.queryBus.execute(
new GetReleaseNotesShareQuery(viewerId, releaseId),
);
}
/** Turns a release's public notes on; `renew` makes a new secret. */
shareReleaseNotes(
actorId: string,
releaseId: string,
renew = false,
): Promise<ReleaseNotesShare> {
return this.commands.execute(
new ShareReleaseNotesCommand(actorId, releaseId, renew),
);
}
unshareReleaseNotes(actorId: string, releaseId: string): Promise<void> {
return this.commands.execute(
new UnshareReleaseNotesCommand(actorId, releaseId),
);
}
/** A release's notes for anyone with the link, or null. */
publicReleaseNotes(token: string): Promise<PublicReleaseNotes | null> {
return this.queryBus.execute(new GetPublicReleaseNotesQuery(token));
}
/** What a link shows now, or null if no project has it. */ /** What a link shows now, or null if no project has it. */
shared(token: string): Promise<SharedDashboardReadModel | null> { shared(token: string): Promise<SharedDashboardReadModel | null> {
return this.queryBus.execute(new GetSharedDashboardQuery(token)); return this.queryBus.execute(new GetSharedDashboardQuery(token));

View File

@@ -0,0 +1,160 @@
import { CommandBus, CqrsModule, QueryBus } from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import {
DEFAULT_PROJECT_SETTINGS,
IssueReadModel,
ReleaseReadModel,
ReleaseStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import {
ReleaseNoteSharesRepository,
ReleaseNotesShare,
} from '../ports/release-note-shares.repository';
import {
GetPublicReleaseNotesQuery,
RELEASE_NOTES_SHARES,
ShareReleaseNotesCommand,
UnshareReleaseNotesCommand,
} from './release-notes-shares';
class InMemoryShares extends ReleaseNoteSharesRepository {
rows = new Map<string, ReleaseNotesShare>();
async findByRelease(releaseId: string) {
return this.rows.get(releaseId) ?? null;
}
async findByToken(token: string) {
return [...this.rows.values()].find((s) => s.token === token) ?? null;
}
async save(share: ReleaseNotesShare) {
this.rows.set(share.releaseId, share);
}
async remove(releaseId: string) {
this.rows.delete(releaseId);
}
}
const release: ReleaseReadModel = {
id: 'r1',
projectId: 'p1',
name: 'v2.0',
description: 'Pagos nuevos',
targetDate: '2026-11-15',
status: ReleaseStatus.PLANNED,
releasedAt: null,
notes: null,
createdAt: '',
version: 1,
};
async function setup() {
release.notes = null;
const shares = new InMemoryShares();
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...RELEASE_NOTES_SHARES,
{ provide: ReleaseNoteSharesRepository, useValue: shares },
{
provide: FindReleasesRepository,
useValue: {
findById: async (id: string) => (id === 'r1' ? release : null),
},
},
{
provide: FindProjectsRepository,
useValue: {
findById: async () => ({
id: 'p1',
key: 'WEB',
name: 'Web',
settings: DEFAULT_PROJECT_SETTINGS,
}),
},
},
{
provide: FindIssuesRepository,
useValue: {
search: async () => ({
items: [
{
key: 'WEB-1',
number: 1,
title: 'Pago con Bizum',
type: 'FEATURE',
statusCategory: StatusCategory.DONE,
} as IssueReadModel,
],
total: 1,
}),
},
},
{
provide: WriteAccess,
useValue: {
project: async () => {
const assertManages = (userId: string) => {
if (userId !== 'owner') throw new ForbiddenError('No');
};
return { assertManages, assertCanManage: assertManages };
},
},
},
],
}).compile();
await moduleRef.init();
return {
commands: moduleRef.get(CommandBus),
queries: moduleRef.get(QueryBus),
};
}
describe("a release's public notes", () => {
it('opens for whoever manages the project, and keeps its link until renewed', async () => {
const { commands } = await setup();
await expect(
commands.execute(new ShareReleaseNotesCommand('dev', 'r1')),
).rejects.toBeInstanceOf(ForbiddenError);
const first = await commands.execute(
new ShareReleaseNotesCommand('owner', 'r1'),
);
expect(first.token.length).toBeGreaterThan(30);
expect(
(await commands.execute(new ShareReleaseNotesCommand('owner', 'r1')))
.token,
).toBe(first.token);
const renewed = await commands.execute(
new ShareReleaseNotesCommand('owner', 'r1', true),
);
expect(renewed.token).not.toBe(first.token);
});
it('shows the notes written, or the draft without repeating the title', async () => {
const { commands, queries } = await setup();
const { token } = await commands.execute(
new ShareReleaseNotesCommand('owner', 'r1'),
);
const drafted = await queries.execute(
new GetPublicReleaseNotesQuery(token),
);
expect(drafted).toMatchObject({ releaseName: 'v2.0', written: false });
expect(drafted?.notes).toContain('Pago con Bizum (WEB-1)');
expect(drafted?.notes).not.toContain('## v2.0');
expect(drafted?.notes).not.toContain('Pagos nuevos');
release.notes = '## Novedades\n- Bizum';
expect(
await queries.execute(new GetPublicReleaseNotesQuery(token)),
).toMatchObject({ notes: '## Novedades\n- Bizum', written: true });
await commands.execute(new UnshareReleaseNotesCommand('owner', 'r1'));
expect(
await queries.execute(new GetPublicReleaseNotesQuery(token)),
).toBeNull();
});
});

View File

@@ -0,0 +1,235 @@
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { NotFoundError } from '@task-stream/api/shared/domain';
import {
DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow,
ReleaseStatus,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import {
ReleaseNoteSharesRepository,
ReleaseNotesShare,
} from '../ports/release-note-shares.repository';
import { draftReleaseNotes } from '../releases/release-notes';
/*
* A release's notes as a public page, for clients: a secret link (also for
* private projects) that shows the notes written, or the ones drawn from
* its finished tickets while there are none.
*/
/** What the public page shows. */
export interface PublicReleaseNotes {
projectName: string;
projectKey: string;
releaseName: string;
description: string | null;
status: ReleaseStatus;
targetDate: string | null;
releasedAt: string | null;
/** Markdown. */
notes: string;
/** False: drawn from its finished tickets (nobody wrote them yet). */
written: boolean;
}
/** Turns the link on; with `renew` (or the first time) a new secret. */
export class ShareReleaseNotesCommand extends Command<ReleaseNotesShare> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
public readonly renew = false,
) {
super();
}
}
export class UnshareReleaseNotesCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
) {
super();
}
}
/** The release's link, for whoever manages the project. */
export class GetReleaseNotesShareQuery extends Query<ReleaseNotesShare | null> {
constructor(
public readonly viewerId: string,
public readonly releaseId: string,
) {
super();
}
}
/** The page, for anyone with the link; null for an unknown one. */
export class GetPublicReleaseNotesQuery extends Query<PublicReleaseNotes | null> {
constructor(public readonly token: string) {
super();
}
}
async function releaseOf(
findReleases: FindReleasesRepository,
releaseId: string,
) {
const release = await findReleases.findById(releaseId);
if (!release) throw new NotFoundError('Release', releaseId);
return release;
}
@CommandHandler(ShareReleaseNotesCommand)
export class ShareReleaseNotesCommandHandler
implements ICommandHandler<ShareReleaseNotesCommand>
{
constructor(
private readonly shares: ReleaseNoteSharesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly access: WriteAccess,
) {}
async execute(command: ShareReleaseNotesCommand): Promise<ReleaseNotesShare> {
const release = await releaseOf(this.findReleases, command.releaseId);
(await this.access.project(release.projectId)).assertCanManage(
command.actorId,
);
const current = await this.shares.findByRelease(release.id);
if (current && !command.renew) return current;
const share: ReleaseNotesShare = {
releaseId: release.id,
projectId: release.projectId,
// 32 random bytes: nobody guesses it.
token: randomBytes(32).toString('base64url'),
createdAt: new Date().toISOString(),
};
await this.shares.save(share);
return share;
}
}
@CommandHandler(UnshareReleaseNotesCommand)
export class UnshareReleaseNotesCommandHandler
implements ICommandHandler<UnshareReleaseNotesCommand>
{
constructor(
private readonly shares: ReleaseNoteSharesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly access: WriteAccess,
) {}
async execute(command: UnshareReleaseNotesCommand): Promise<void> {
const release = await releaseOf(this.findReleases, command.releaseId);
(await this.access.project(release.projectId)).assertManages(
command.actorId,
);
await this.shares.remove(release.id);
}
}
@QueryHandler(GetReleaseNotesShareQuery)
export class GetReleaseNotesShareQueryHandler
implements IQueryHandler<GetReleaseNotesShareQuery, ReleaseNotesShare | null>
{
constructor(
private readonly shares: ReleaseNoteSharesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly access: WriteAccess,
) {}
async execute(
query: GetReleaseNotesShareQuery,
): Promise<ReleaseNotesShare | null> {
const release = await releaseOf(this.findReleases, query.releaseId);
(await this.access.project(release.projectId)).assertManages(
query.viewerId,
);
return this.shares.findByRelease(release.id);
}
}
@QueryHandler(GetPublicReleaseNotesQuery)
export class GetPublicReleaseNotesQueryHandler
implements
IQueryHandler<GetPublicReleaseNotesQuery, PublicReleaseNotes | null>
{
constructor(
private readonly shares: ReleaseNoteSharesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
) {}
async execute(
query: GetPublicReleaseNotesQuery,
): Promise<PublicReleaseNotes | null> {
const share = await this.shares.findByToken(query.token);
if (!share) return null;
const [release, project] = await Promise.all([
this.findReleases.findById(share.releaseId),
this.findProjects.findById(share.projectId),
]);
// The release or the project was deleted: the link went with them.
if (!release || !project) return null;
const written = !!release.notes?.trim();
const notes = written
? (release.notes as string)
: withoutHeading(
draftReleaseNotes({
release,
issues: (
await this.findIssues.search({
projectId: release.projectId,
releaseId: release.id,
limit: 500,
offset: 0,
})
).items,
workflow: new ProjectWorkflow(
project.settings ?? DEFAULT_PROJECT_SETTINGS,
),
}),
release.description,
);
return {
projectName: project.name,
projectKey: project.key,
releaseName: release.name,
description: release.description,
status: release.status,
targetDate: release.targetDate,
releasedAt: release.releasedAt,
notes,
written,
};
}
}
/**
* The draft without its title line (and description): the page shows the
* release's name, date and description above it already.
*/
function withoutHeading(draft: string, description: string | null): string {
let lines = draft.split('\n').slice(1);
if (description && lines[0] === '' && lines[1] === description)
lines = lines.slice(2);
return lines.join('\n').trim() + '\n';
}
export const RELEASE_NOTES_SHARES = [
ShareReleaseNotesCommandHandler,
UnshareReleaseNotesCommandHandler,
GetReleaseNotesShareQueryHandler,
GetPublicReleaseNotesQueryHandler,
];

View File

@@ -37,6 +37,15 @@ import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { MY_VIEWS } from './saved-filters/my-views'; import { MY_VIEWS } from './saved-filters/my-views';
import { PINS } from './saved-filters/pins'; import { PINS } from './saved-filters/pins';
import { ALERT_HANDLERS } from './alerts/alerts'; import { ALERT_HANDLERS } from './alerts/alerts';
import { TREE_FOLLOWS } from './follows/tree-follows';
import { ISSUE_VISITS } from './visits/issue-visits';
import { IssueVisitsService } from './issue-visits.service';
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
import { MY_DAY } from './my-day/my-day';
import { RELEASE_NOTES_SHARES } from './sharing/release-notes-shares';
import { MyDayService } from './my-day.service';
import { IssueRemindersService } from './issue-reminders.service';
import { TreeFollowsService } from './tree-follows.service';
import { AlertsService } from './alerts.service'; import { AlertsService } from './alerts.service';
import { PinsService } from './pins.service'; import { PinsService } from './pins.service';
import { SendDueRemindersCommandHandler } from './notifications/reminders'; import { SendDueRemindersCommandHandler } from './notifications/reminders';
@@ -84,6 +93,10 @@ const SERVICES = [
SavedFiltersService, SavedFiltersService,
PinsService, PinsService,
AlertsService, AlertsService,
TreeFollowsService,
IssueVisitsService,
IssueRemindersService,
MyDayService,
SprintsService, SprintsService,
GitService, GitService,
WebhooksService, WebhooksService,
@@ -116,6 +129,11 @@ const SERVICES = [
...MY_VIEWS, ...MY_VIEWS,
...PINS, ...PINS,
...ALERT_HANDLERS, ...ALERT_HANDLERS,
...TREE_FOLLOWS,
...ISSUE_VISITS,
...ISSUE_REMINDERS,
...MY_DAY,
...RELEASE_NOTES_SHARES,
...GIT_HANDLERS, ...GIT_HANDLERS,
...WEBHOOK_HANDLERS, ...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS, ...TRANSFER_HANDLERS,

View File

@@ -0,0 +1,24 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
FollowTreeCommand,
IsFollowingTreeQuery,
} from './follows/tree-follows';
/** Following a ticket with everything under it (an epic). */
@Injectable()
export class TreeFollowsService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
follow(userId: string, issueId: string, on: boolean): Promise<void> {
return this.commands.execute(new FollowTreeCommand(userId, issueId, on));
}
isFollowing(userId: string, issueId: string): Promise<boolean> {
return this.queryBus.execute(new IsFollowingTreeQuery(userId, issueId));
}
}

View File

@@ -0,0 +1,51 @@
import {
Command,
CommandHandler,
ICommandHandler,
QueryBus,
} from '@nestjs/cqrs';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { IssueVisitsRepository } from '../ports/issue-visits.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* «Lo nuevo desde tu última visita»: opening a ticket notes the moment, and
* says when the visit before was, so the client can tell what others did
* meanwhile. Personal, no history (like pinned tickets).
*/
export class VisitIssueCommand extends Command<string | null> {
constructor(
public readonly userId: string,
public readonly issueId: string,
) {
super();
}
}
@CommandHandler(VisitIssueCommand)
export class VisitIssueCommandHandler
implements ICommandHandler<VisitIssueCommand>
{
constructor(
private readonly visits: IssueVisitsRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: VisitIssueCommand): Promise<string | null> {
const { userId, issueId } = command;
const [issue]: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(userId, [issueId]),
);
// A ticket I cannot read does not exist, as far as I know.
if (!issue) throw new NotFoundError('Issue', issueId);
return this.visits.visit({
userId,
issueId,
seenAt: new Date().toISOString(),
});
}
}
export const ISSUE_VISITS = [VisitIssueCommandHandler];

View File

@@ -8,6 +8,7 @@ export * from './lib/issue/issue-hierarchy';
export * from './lib/issue/issue-checklist'; export * from './lib/issue/issue-checklist';
export * from './lib/issue/issue-rank'; export * from './lib/issue/issue-rank';
export * from './lib/issue/dev-link'; export * from './lib/issue/dev-link';
export * from './lib/issue/review';
export * from './lib/project/project-settings'; export * from './lib/project/project-settings';
export * from './lib/comment/comment'; export * from './lib/comment/comment';
export * from './lib/release/release'; export * from './lib/release/release';

View File

@@ -6,6 +6,12 @@ import {
} from '@task-stream/api/shared/domain'; } from '@task-stream/api/shared/domain';
import { CommentAddedEvent } from '../events/comment-added.event'; import { CommentAddedEvent } from '../events/comment-added.event';
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event'; import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event';
import { CommentTurnedIntoIssueEvent } from '../events/comment-turned-into-issue.event';
import {
CommentPollClosedEvent,
CommentPollVotedEvent,
} from '../events/comment-poll.events';
import { CommentDeletedEvent } from '../events/comment-deleted.event'; import { CommentDeletedEvent } from '../events/comment-deleted.event';
import { CommentEditedEvent } from '../events/comment-edited.event'; import { CommentEditedEvent } from '../events/comment-edited.event';
import { CommentReactedEvent } from '../events/comment-reacted.event'; import { CommentReactedEvent } from '../events/comment-reacted.event';
@@ -29,6 +35,32 @@ export interface CommentProps {
deleted: boolean; deleted: boolean;
createdAt: string; createdAt: string;
editedAt: string | null; editedAt: string | null;
/** A quick poll's options (2–6); absent without one. */
poll?: string[] | null;
}
export const MIN_POLL_OPTIONS = 2;
export const MAX_POLL_OPTIONS = 6;
const MAX_POLL_OPTION = 80;
/** A poll's options, trimmed: 2 to 6, different, short. */
export function validPoll(options: string[]): string[] {
const clean = options
.map((o) => o.replace(/\s+/g, ' ').trim())
.filter(Boolean);
assertThat(
clean.length >= MIN_POLL_OPTIONS && clean.length <= MAX_POLL_OPTIONS,
`A poll takes ${MIN_POLL_OPTIONS} to ${MAX_POLL_OPTIONS} options`,
);
assertThat(
clean.every((o) => o.length <= MAX_POLL_OPTION),
`A poll option takes ${MAX_POLL_OPTION} characters at most`,
);
assertThat(
new Set(clean.map((o) => o.toLowerCase())).size === clean.length,
'Poll options must be different',
);
return clean;
} }
const MAX_BODY = 20_000; const MAX_BODY = 20_000;
@@ -44,6 +76,15 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
public reactions: Record<string, string[]> = {}; public reactions: Record<string, string[]> = {};
/** What was decided on the ticket: shown above it and searchable. */ /** What was decided on the ticket: shown above it and searchable. */
public decision = false; public decision = false;
/** A top-level comment whose conversation was settled. */
public resolved = false;
/** A quick poll: its options, who voted what, and whether it closed. */
public poll: string[] | null = null;
public pollVotes: Record<string, number> = {};
public pollClosed = false;
public pollWinner: number | null = null;
/** Tickets made from it, oldest first. */
public spawned: { id: string; key: string }[] = [];
public deleted = false; public deleted = false;
public createdAt!: string; public createdAt!: string;
public editedAt: string | null = null; public editedAt: string | null = null;
@@ -114,6 +155,85 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
); );
} }
/**
* Settles the conversation this comment opens (`on`) or reopens it;
* repeating it changes nothing. Only a top-level comment opens one.
*/
resolveThread(actorId: string, on: boolean): void {
this.assertAlive();
if (this.replyTo)
throw new BusinessRuleError(
'Only a conversation is resolved, not a reply',
);
if (on === this.resolved) return;
this.apply(
new CommentThreadResolvedEvent(
actorId,
this.id,
this.projectId,
this.issueId,
on,
),
);
}
/** Votes `option` in its poll (null takes the vote back); repeating it changes nothing. */
votePoll(actorId: string, option: number | null): void {
this.assertAlive();
if (!this.poll) throw new BusinessRuleError('This comment has no poll');
if (this.pollClosed) throw new BusinessRuleError('The poll is closed');
assertThat(
option === null ||
(Number.isInteger(option) && option >= 0 && option < this.poll.length),
'No such option',
);
if ((this.pollVotes[actorId] ?? null) === option) return;
const votes = { ...this.pollVotes };
if (option === null) delete votes[actorId];
else votes[actorId] = option;
this.apply(
new CommentPollVotedEvent(
actorId,
this.id,
this.projectId,
this.issueId,
votes,
),
);
}
/**
* Closes the poll: its author, or (`canModerate`) whoever manages the
* project. The winner is the most voted option; none on a tie.
*/
closePoll(actorId: string, canModerate: boolean): number | null {
this.assertAlive();
if (!this.poll) throw new BusinessRuleError('This comment has no poll');
if (this.pollClosed) throw new BusinessRuleError('The poll is closed');
if (actorId !== this.authorId && !canModerate)
throw new ForbiddenError(
'Only whoever started the poll or a project admin can close it',
);
const counts = this.poll.map(
(_, i) => Object.values(this.pollVotes).filter((v) => v === i).length,
);
const most = Math.max(...counts);
const winner =
most > 0 && counts.filter((c) => c === most).length === 1
? counts.indexOf(most)
: null;
this.apply(
new CommentPollClosedEvent(
actorId,
this.id,
this.projectId,
this.issueId,
winner,
),
);
return winner;
}
/** @param canModerate whether the actor manages the project (may delete others' comments). */ /** @param canModerate whether the actor manages the project (may delete others' comments). */
delete(actorId: string, canModerate: boolean): void { delete(actorId: string, canModerate: boolean): void {
this.assertAlive(); this.assertAlive();
@@ -131,6 +251,16 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
onCommentAddedEvent({ comment }: CommentAddedEvent): void { onCommentAddedEvent({ comment }: CommentAddedEvent): void {
Object.assign(this, comment); Object.assign(this, comment);
this.poll = comment.poll ?? null;
}
onCommentPollVotedEvent(event: CommentPollVotedEvent): void {
this.pollVotes = event.votes;
}
onCommentPollClosedEvent(event: CommentPollClosedEvent): void {
this.pollClosed = true;
this.pollWinner = event.winner;
} }
onCommentEditedEvent(event: CommentEditedEvent): void { onCommentEditedEvent(event: CommentEditedEvent): void {
@@ -146,10 +276,36 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
this.decision = event.decision; this.decision = event.decision;
} }
onCommentThreadResolvedEvent(event: CommentThreadResolvedEvent): void {
this.resolved = event.resolved;
}
onCommentTurnedIntoIssueEvent(event: CommentTurnedIntoIssueEvent): void {
this.spawned = event.spawned;
}
onCommentDeletedEvent(): void { onCommentDeletedEvent(): void {
this.deleted = true; this.deleted = true;
} }
/**
* Notes a ticket made from it (its text became the ticket's description).
* Who may create it is the caller's check.
*/
turnedInto(actorId: string, issue: { id: string; key: string }): void {
this.assertAlive();
if (this.spawned.some((s) => s.id === issue.id)) return;
this.apply(
new CommentTurnedIntoIssueEvent(
actorId,
this.id,
this.projectId,
this.issueId,
[...this.spawned, issue],
),
);
}
private assertAlive(): void { private assertAlive(): void {
if (this.deleted) throw new BusinessRuleError('Comment was deleted'); if (this.deleted) throw new BusinessRuleError('Comment was deleted');
} }

View File

@@ -0,0 +1,31 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** Someone voted in a comment's poll, changed or took back their vote. */
@AutowiredEvent
export class CommentPollVotedEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
/** Every vote after the change: user id → option index. */
readonly votes: Record<string, number>,
) {
super(actorId, commentId, projectId);
}
}
/** A comment's poll was closed; `winner` is null on a tie or no votes. */
@AutowiredEvent
export class CommentPollClosedEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
readonly winner: number | null,
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A conversation (a top-level comment and its replies) was settled, or reopened. */
@AutowiredEvent
export class CommentThreadResolvedEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
/** False: reopened. */
readonly resolved: boolean,
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A ticket of its own was made from a comment («→ WEB-45» on it). */
@AutowiredEvent
export class CommentTurnedIntoIssueEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
/** Every ticket made from it so far, this one last. */
readonly spawned: { id: string; key: string }[],
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -16,6 +16,10 @@ export * from './comment-added.event';
export * from './comment-edited.event'; export * from './comment-edited.event';
export * from './comment-reacted.event'; export * from './comment-reacted.event';
export * from './comment-decision-marked.event'; export * from './comment-decision-marked.event';
export * from './comment-thread-resolved.event';
export * from './comment-turned-into-issue.event';
export * from './issue-review.events';
export * from './comment-poll.events';
export * from './comment-deleted.event'; export * from './comment-deleted.event';
export * from './release-created.event'; export * from './release-created.event';
export * from './release-updated.event'; export * from './release-updated.event';

View File

@@ -0,0 +1,46 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { ReviewVerdict } from '../issue/review';
import { TrackerEvent } from './tracker.event';
/** Someone asked someone else to review a ticket. */
@AutowiredEvent
export class IssueReviewRequestedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly reviewId: string,
readonly reviewerId: string,
readonly note: string | null,
) {
super(actorId, issueId, projectId);
}
}
/** The reviewer answered: all good, or changes wanted. */
@AutowiredEvent
export class IssueReviewAnsweredEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly reviewId: string,
readonly verdict: ReviewVerdict,
readonly answer: string | null,
) {
super(actorId, issueId, projectId);
}
}
/** A review request was withdrawn before it was answered. */
@AutowiredEvent
export class IssueReviewCancelledEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly reviewId: string,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -1,6 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { newId } from '@task-stream/api/shared/domain'; import { newId } from '@task-stream/api/shared/domain';
import { Comment, CommentProps, validCommentBody } from '../comment/comment'; import {
Comment,
CommentProps,
validCommentBody,
validPoll,
} from '../comment/comment';
import { CommentAddedEvent } from '../events/comment-added.event'; import { CommentAddedEvent } from '../events/comment-added.event';
@Injectable() @Injectable()
@@ -12,6 +17,8 @@ export class CommentFactory {
body: string; body: string;
/** Top-level comment it answers. */ /** Top-level comment it answers. */
replyTo?: string | null; replyTo?: string | null;
/** A quick poll's options. */
poll?: string[] | null;
}): Comment { }): Comment {
const data: CommentProps = { const data: CommentProps = {
id: newId(), id: newId(),
@@ -23,6 +30,7 @@ export class CommentFactory {
deleted: false, deleted: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
editedAt: null, editedAt: null,
...(props.poll?.length ? { poll: validPoll(props.poll) } : {}),
}; };
const comment = new Comment(data.id); const comment = new Comment(data.id);
Object.assign(comment, { ...data }); Object.assign(comment, { ...data });

View File

@@ -1,6 +1,7 @@
import { import {
BusinessRuleError, BusinessRuleError,
ForbiddenError, ForbiddenError,
NotFoundError,
VersionedAggregateRoot, VersionedAggregateRoot,
assertThat, assertThat,
} from '@task-stream/api/shared/domain'; } from '@task-stream/api/shared/domain';
@@ -41,6 +42,17 @@ import { ParentCandidate, assertCanNest } from './issue-hierarchy';
import { DevLink, MAX_DEV_LINKS, normalizeDevLink } from './dev-link'; import { DevLink, MAX_DEV_LINKS, normalizeDevLink } from './dev-link';
import { implicitRank, rankBetween } from './issue-rank'; import { implicitRank, rankBetween } from './issue-rank';
import { IssueLinkType, IssuePriority } from './issue.enums'; import { IssueLinkType, IssuePriority } from './issue.enums';
import {
IssueReview,
MAX_OPEN_REVIEWS,
MAX_REVIEW_NOTE,
ReviewVerdict,
} from './review';
import {
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueReviewRequestedEvent,
} from '../events/issue-review.events';
export interface IssueLink { export interface IssueLink {
type: IssueLinkType; type: IssueLinkType;
@@ -136,6 +148,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public attachments: IssueAttachment[] = []; public attachments: IssueAttachment[] = [];
/** People who asked to be told about this ticket. */ /** People who asked to be told about this ticket. */
public watchers: string[] = []; public watchers: string[] = [];
/** Asked reviews, open and answered, oldest first. */
public reviews: IssueReview[] = [];
/** People who want it done (a public roadmap's way to prioritise). */ /** People who want it done (a public roadmap's way to prioritise). */
public voterIds: string[] = []; public voterIds: string[] = [];
/** The epic or ticket this one breaks down. */ /** The epic or ticket this one breaks down. */
@@ -404,6 +418,96 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
); );
} }
/**
* Asks `reviewerId` to review it. Not oneself, and not twice to the same
* person while the first is unanswered. Who may be asked is the caller's
* check (a member of the team).
*/
requestReview(
actorId: string,
reviewId: string,
reviewerId: string,
note: string | null,
): void {
if (this.trashedAt)
throw new BusinessRuleError(
`${this.key} is in the trash: restore it to change it`,
);
assertThat(reviewerId !== actorId, 'Ask someone else to review it');
const text = note?.trim() || null;
assertThat(
!text || text.length <= MAX_REVIEW_NOTE,
`A review request takes ${MAX_REVIEW_NOTE} characters at most`,
);
const open = this.reviews.filter((r) => !r.verdict);
if (open.some((r) => r.reviewerId === reviewerId))
throw new BusinessRuleError(
'That person already has an unanswered review request on this ticket',
);
assertThat(
open.length < MAX_OPEN_REVIEWS,
`A ticket takes ${MAX_OPEN_REVIEWS} open review requests at most`,
);
this.apply(
new IssueReviewRequestedEvent(
actorId,
this.id,
this.projectId,
reviewId,
reviewerId,
text,
),
);
}
/** The reviewer answers an open request of theirs. */
answerReview(
actorId: string,
reviewId: string,
verdict: ReviewVerdict,
answer: string | null,
): void {
const review = this.openReview(reviewId);
if (review.reviewerId !== actorId)
throw new ForbiddenError('Only the person asked can answer the review');
const text = answer?.trim() || null;
assertThat(
!text || text.length <= MAX_REVIEW_NOTE,
`An answer takes ${MAX_REVIEW_NOTE} characters at most`,
);
this.apply(
new IssueReviewAnsweredEvent(
actorId,
this.id,
this.projectId,
reviewId,
verdict,
text,
),
);
}
/**
* Withdraws an open request: whoever asked, or (`canModerate`) whoever
* manages the project.
*/
cancelReview(actorId: string, reviewId: string, canModerate: boolean): void {
const review = this.openReview(reviewId);
if (review.requesterId !== actorId && !canModerate)
throw new ForbiddenError('Only whoever asked can withdraw the request');
this.apply(
new IssueReviewCancelledEvent(actorId, this.id, this.projectId, reviewId),
);
}
private openReview(reviewId: string): IssueReview {
const review = this.reviews.find((r) => r.id === reviewId);
if (!review) throw new NotFoundError('Review', reviewId);
if (review.verdict)
throw new BusinessRuleError('That review was already answered');
return review;
}
/** `userId` starts following the ticket. Idempotent. */ /** `userId` starts following the ticket. Idempotent. */
watch(actorId: string, userId: string): void { watch(actorId: string, userId: string): void {
if (this.watchers.includes(userId)) return; if (this.watchers.includes(userId)) return;
@@ -625,6 +729,39 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.voterIds = event.voterIds; this.voterIds = event.voterIds;
} }
onIssueReviewRequestedEvent(event: IssueReviewRequestedEvent): void {
this.reviews = [
...(this.reviews ?? []),
{
id: event.reviewId,
requesterId: event.actorId,
reviewerId: event.reviewerId,
note: event.note,
requestedAt: event.occurredAt,
verdict: null,
answer: null,
answeredAt: null,
},
];
}
onIssueReviewAnsweredEvent(event: IssueReviewAnsweredEvent): void {
this.reviews = this.reviews.map((r) =>
r.id === event.reviewId
? {
...r,
verdict: event.verdict,
answer: event.answer,
answeredAt: event.occurredAt,
}
: r,
);
}
onIssueReviewCancelledEvent(event: IssueReviewCancelledEvent): void {
this.reviews = this.reviews.filter((r) => r.id !== event.reviewId);
}
onIssueWatchedEvent(event: IssueWatchedEvent): void { onIssueWatchedEvent(event: IssueWatchedEvent): void {
this.watchers = [...this.watchers, event.userId]; this.watchers = [...this.watchers, event.userId];
} }

View File

@@ -0,0 +1,23 @@
/** What a reviewer said. */
export enum ReviewVerdict {
APPROVED = 'APPROVED',
CHANGES = 'CHANGES',
}
/** «Ana, can you review this?» on a ticket. */
export interface IssueReview {
id: string;
requesterId: string;
reviewerId: string;
/** What to look at, in a line or two. */
note: string | null;
requestedAt: string;
/** Null while waiting for an answer. */
verdict: ReviewVerdict | null;
answer: string | null;
answeredAt: string | null;
}
export const MAX_REVIEW_NOTE = 1000;
/** Open requests a ticket takes at once. */
export const MAX_OPEN_REVIEWS = 10;

View File

@@ -84,6 +84,25 @@ describe('Project settings', () => {
); );
}); });
it('takes a palette colour or any #rrggbb, and nothing else', () => {
const project = newProject();
const settings = defaultProjectSettings();
settings.statuses[0].color = '#7c3aed';
settings.issueTypes[0].color = '#0EA5E9';
project.updateSettings('owner', settings);
expect(project.workflow.status(settings.statuses[0].id)?.color).toBe(
'#7c3aed',
);
for (const color of ['purple', '#abc', 'url(x)', '#7c3aedff']) {
const bad = defaultProjectSettings();
bad.statuses[0].color = color as never;
expect(() => project.updateSettings('owner', bad)).toThrow(
/Color no válido/,
);
}
});
it('archives values instead of deleting them', () => { it('archives values instead of deleting them', () => {
const project = newProject(); const project = newProject();
const settings = defaultProjectSettings(); const settings = defaultProjectSettings();

View File

@@ -32,7 +32,17 @@ export const SETTING_COLORS = [
'red', 'red',
'pink', 'pink',
] as const; ] as const;
export type SettingColor = (typeof SETTING_COLORS)[number]; /** A colour: one of the palette's (they follow the theme) or any `#rrggbb`. */
export type SettingColor = (typeof SETTING_COLORS)[number] | `#${string}`;
/** A palette name or a hex colour, as the API takes it. */
export const SETTING_COLOR_PATTERN = new RegExp(
`^(${SETTING_COLORS.join('|')}|#[0-9a-fA-F]{6})$`,
);
export function isSettingColor(color: string): color is SettingColor {
return SETTING_COLOR_PATTERN.test(color);
}
/** Icons a ticket type can use (registered in the web app). */ /** Icons a ticket type can use (registered in the web app). */
export const TYPE_ICONS = [ export const TYPE_ICONS = [
@@ -513,7 +523,7 @@ export function validateProjectSettings(
`Categoría no válida en el estado ${s.name}`, `Categoría no válida en el estado ${s.name}`,
); );
assertThat( assertThat(
SETTING_COLORS.includes(s.color), isSettingColor(s.color),
`Color no válido en el estado ${s.name}`, `Color no válido en el estado ${s.name}`,
); );
assertThat( assertThat(
@@ -527,10 +537,7 @@ export function validateProjectSettings(
TYPE_ICONS.includes(t.icon), TYPE_ICONS.includes(t.icon),
`Icono no válido en el tipo ${t.name}`, `Icono no válido en el tipo ${t.name}`,
); );
assertThat( assertThat(isSettingColor(t.color), `Color no válido en el tipo ${t.name}`);
SETTING_COLORS.includes(t.color),
`Color no válido en el tipo ${t.name}`,
);
} }
const active = statuses.filter((s) => !s.archived); const active = statuses.filter((s) => !s.archived);
assertThat( assertThat(

View File

@@ -6,6 +6,14 @@ export class CommentReactionReadModel {
users!: UserRefReadModel[]; users!: UserRefReadModel[];
} }
/** A quick poll: each option with who voted it. */
export class CommentPollReadModel {
options!: { text: string; voters: UserRefReadModel[] }[];
closed!: boolean;
/** Index of the winning option once closed (null on a tie). */
winner!: number | null;
}
export class CommentReadModel { export class CommentReadModel {
id!: string; id!: string;
issueId!: string; issueId!: string;
@@ -26,6 +34,15 @@ export class CommentReadModel {
/** Who marked it and when (null when it is not a decision). */ /** Who marked it and when (null when it is not a decision). */
decidedBy!: UserRefReadModel | null; decidedBy!: UserRefReadModel | null;
decidedAt!: string | null; decidedAt!: string | null;
/** A quick poll in the comment, if any. */
poll!: CommentPollReadModel | null;
/** A top-level comment whose conversation was settled. */
resolved!: boolean;
/** Who settled it and when (null while open). */
resolvedBy!: UserRefReadModel | null;
resolvedAt!: string | null;
/** Tickets made from it («Convertir en ticket»), oldest first. */
spawned!: { id: string; key: string }[];
createdAt!: string; createdAt!: string;
editedAt!: string | null; editedAt!: string | null;
version!: number; version!: number;

View File

@@ -1,3 +1,4 @@
import { ReviewVerdict } from '../issue/review';
import { DevLink } from '../issue/dev-link'; import { DevLink } from '../issue/dev-link';
import { IssueLinkType, IssuePriority } from '../issue/issue.enums'; import { IssueLinkType, IssuePriority } from '../issue/issue.enums';
import { StatusCategory } from '../project/project-settings'; import { StatusCategory } from '../project/project-settings';
@@ -85,6 +86,8 @@ export class IssueReadModel {
attachments!: AttachmentReadModel[]; attachments!: AttachmentReadModel[];
/** People following the ticket. */ /** People following the ticket. */
watchers!: UserRefReadModel[]; watchers!: UserRefReadModel[];
/** Asked reviews, open and answered, oldest first. */
reviews!: IssueReviewReadModel[];
/** How many want it done. */ /** How many want it done. */
votes!: number; votes!: number;
/** Who voted: to tell someone whether they did; never shown as a list. */ /** Who voted: to tell someone whether they did; never shown as a list. */
@@ -103,3 +106,16 @@ export class IssueReadModel {
/** Position of the last projected event. */ /** Position of the last projected event. */
version!: number; version!: number;
} }
/** A review asked on a ticket, as the read side shows it. */
export class IssueReviewReadModel {
id!: string;
requester!: UserRefReadModel;
reviewer!: UserRefReadModel;
note!: string | null;
requestedAt!: string;
/** Null while waiting for an answer. */
verdict!: ReviewVerdict | null;
answer!: string | null;
answeredAt!: string | null;
}

View File

@@ -22,6 +22,10 @@ export enum NotificationKind {
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
/** A ticket of yours is due soon or has been still for too long. */ /** A ticket of yours is due soon or has been still for too long. */
REMINDER = 'REMINDER', REMINDER = 'REMINDER',
/** Someone asked you to review a ticket. */
REVIEW_REQUESTED = 'REVIEW_REQUESTED',
/** The review you asked for was answered. */
REVIEW_ANSWERED = 'REVIEW_ANSWERED',
} }
/** /**

View File

@@ -30,21 +30,21 @@ export class MongoAutomationsRepository extends AutomationsRepository {
.find({ projectId }) .find({ projectId })
.sort({ createdAt: 1 }) .sort({ createdAt: 1 })
.lean<AutomationDoc[]>(); .lean<AutomationDoc[]>();
return docs.map((doc) => fromDoc(doc) as Automation); return docs.map(toAutomation);
} }
async findActive(projectId: string): Promise<Automation[]> { async findActive(projectId: string): Promise<Automation[]> {
const docs = await this.automations const docs = await this.automations
.find({ projectId, active: true }) .find({ projectId, active: true })
.lean<AutomationDoc[]>(); .lean<AutomationDoc[]>();
return docs.map((doc) => fromDoc(doc) as Automation); return docs.map(toAutomation);
} }
async findById(id: string): Promise<Automation | null> { async findById(id: string): Promise<Automation | null> {
const doc = await this.automations const doc = await this.automations
.findOne({ id: id }) .findOne({ id: id })
.lean<AutomationDoc>(); .lean<AutomationDoc>();
return doc ? (fromDoc(doc) as Automation) : null; return doc ? toAutomation(doc) : null;
} }
async insert({ id, ...rest }: Automation): Promise<void> { async insert({ id, ...rest }: Automation): Promise<void> {
@@ -83,3 +83,13 @@ export class MongoAutomationsRepository extends AutomationsRepository {
); );
} }
} }
/**
* Mongo leaves empty objects out (`minimize`): a rule saved with no
* conditions comes back without them, and both GraphQL and the rules
* engine need the object.
*/
function toAutomation(doc: AutomationDoc): Automation {
const automation = fromDoc(doc) as Automation;
return { ...automation, conditions: automation.conditions ?? {} };
}

View File

@@ -54,6 +54,11 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
decision: doc.decision ?? false, decision: doc.decision ?? false,
decidedBy: doc.decidedBy ?? null, decidedBy: doc.decidedBy ?? null,
decidedAt: doc.decidedAt ?? null, decidedAt: doc.decidedAt ?? null,
poll: doc.poll ?? null,
resolved: doc.resolved ?? false,
resolvedBy: doc.resolvedBy ?? null,
resolvedAt: doc.resolvedAt ?? null,
spawned: doc.spawned ?? [],
}); });
const escapeRegex = (text: string) => const escapeRegex = (text: string) =>
@@ -173,6 +178,10 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
}; };
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.watcherId) query['watchers.id'] = filter.watcherId; if (filter.watcherId) query['watchers.id'] = filter.watcherId;
if (filter.reviewerId)
query['reviews'] = {
$elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null },
};
if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
if (filter.categories?.length) if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories }; query['statusCategory'] = { $in: filter.categories };
@@ -494,6 +503,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
if (filter.unassigned) query['assignee'] = null; if (filter.unassigned) query['assignee'] = null;
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.watcherId) query['watchers.id'] = filter.watcherId; if (filter.watcherId) query['watchers.id'] = filter.watcherId;
if (filter.reviewerId)
query['reviews'] = {
$elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null },
};
if (filter.participantId) if (filter.participantId)
query.$and = [ query.$and = [
...(query.$and ?? []), ...(query.$and ?? []),

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
IssueReminder,
IssueRemindersRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
type Stored = IssueReminder & { _id?: unknown };
const strip = ({ _id: _ignored, ...reminder }: Stored): IssueReminder =>
reminder;
@Injectable()
export class MongoIssueRemindersRepository extends IssueRemindersRepository {
constructor(
@InjectModel(ReadModels.IssueReminder)
private readonly reminders: Model<Stored>,
) {
super();
}
async save(reminder: IssueReminder): Promise<void> {
await this.remove(reminder.userId, reminder.issueId);
await this.reminders.create(reminder);
}
async waitingFor(
userId: string,
issueId: string,
): Promise<IssueReminder | null> {
const doc = await this.reminders
.findOne({ userId, issueId, sentAt: null })
.lean<Stored>();
return doc ? strip(doc) : null;
}
async waitingOf(userId: string): Promise<IssueReminder[]> {
const docs = await this.reminders
.find({ userId, sentAt: null })
.sort({ at: 1 })
.lean<Stored[]>();
return docs.map(strip);
}
async remove(userId: string, issueId: string): Promise<void> {
await this.reminders.deleteMany({ userId, issueId, sentAt: null });
}
async due(now: string, limit: number): Promise<IssueReminder[]> {
const docs = await this.reminders
.find({ sentAt: null, at: { $lte: now } })
.sort({ at: 1 })
.limit(limit)
.lean<Stored[]>();
return docs.map(strip);
}
async markSent(id: string, at: string): Promise<void> {
await this.reminders.updateOne({ id }, { $set: { sentAt: at } });
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
IssueVisit,
IssueVisitsRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
@Injectable()
export class MongoIssueVisitsRepository extends IssueVisitsRepository {
constructor(
@InjectModel(ReadModels.IssueVisit)
private readonly visits: Model<IssueVisit>,
) {
super();
}
async visit(visit: IssueVisit): Promise<string | null> {
// One round trip: the document as it was before this visit.
const before = await this.visits
.findOneAndUpdate(
{ userId: visit.userId, issueId: visit.issueId },
{ $set: { seenAt: visit.seenAt } },
{ upsert: true, new: false },
)
.lean<IssueVisit>();
return before?.seenAt ?? null;
}
}

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
MyDayRecord,
MyDaysRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
type Stored = MyDayRecord & { _id?: unknown };
const strip = ({ _id: _ignored, ...record }: Stored): MyDayRecord => ({
...record,
// Mongo leaves an emptied list out.
issueIds: record.issueIds ?? [],
});
@Injectable()
export class MongoMyDaysRepository extends MyDaysRepository {
constructor(
@InjectModel(ReadModels.MyDay) private readonly days: Model<Stored>,
) {
super();
}
async find(userId: string, day: string): Promise<MyDayRecord | null> {
const doc = await this.days.findOne({ userId, day }).lean<Stored>();
return doc ? strip(doc) : null;
}
async latestBefore(userId: string, day: string): Promise<MyDayRecord | null> {
const doc = await this.days
.findOne({ userId, day: { $lt: day } })
.sort({ day: -1 })
.lean<Stored>();
return doc ? strip(doc) : null;
}
async save(record: MyDayRecord): Promise<void> {
await this.days.updateOne(
{ userId: record.userId, day: record.day },
{ $set: record },
{ upsert: true },
);
}
}

View File

@@ -109,6 +109,11 @@ export const ReadModels = {
PinnedIssue: 'PinnedIssue', PinnedIssue: 'PinnedIssue',
AlertIntegration: 'AlertIntegration', AlertIntegration: 'AlertIntegration',
AlertTrace: 'AlertTrace', AlertTrace: 'AlertTrace',
TreeFollow: 'TreeFollow',
IssueVisit: 'IssueVisit',
IssueReminder: 'IssueReminder',
MyDay: 'MyDay',
ReleaseNotesShare: 'ReleaseNotesShare',
Sprint: 'SprintReadModel', Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration', GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook', OutgoingWebhook: 'OutgoingWebhook',
@@ -360,6 +365,57 @@ export const AlertTraceSchema =
SchemaFactory.createForClass(AlertTraceDocument); SchemaFactory.createForClass(AlertTraceDocument);
AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true }); AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'tree_follows' })
class TreeFollowDocument {
@Prop({ type: String }) userId!: string;
@Prop({ type: String, index: true }) issueId!: string;
}
export const TreeFollowSchema =
SchemaFactory.createForClass(TreeFollowDocument);
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'issue_visits' })
class IssueVisitDocument {
@Prop({ type: String }) userId!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) seenAt!: string;
}
export const IssueVisitSchema =
SchemaFactory.createForClass(IssueVisitDocument);
IssueVisitSchema.index({ userId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'issue_reminders' })
class IssueReminderDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) userId!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) at!: string;
@Prop({ type: String, default: null }) sentAt!: string | null;
}
export const IssueReminderSchema = SchemaFactory.createForClass(
IssueReminderDocument,
);
IssueReminderSchema.index({ sentAt: 1, at: 1 });
IssueReminderSchema.index({ userId: 1, issueId: 1 });
@Schema({ ...LOOSE, collection: 'my_days' })
class MyDayDocument {
@Prop({ type: String }) userId!: string;
@Prop({ type: String }) day!: string;
@Prop({ type: [String] }) issueIds!: string[];
}
export const MyDaySchema = SchemaFactory.createForClass(MyDayDocument);
MyDaySchema.index({ userId: 1, day: -1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'release_note_shares' })
class ReleaseNotesShareDocument {
@Prop({ type: String, required: true, unique: true }) releaseId!: string;
@Prop({ type: String, required: true, unique: true }) token!: string;
}
export const ReleaseNotesShareSchema = SchemaFactory.createForClass(
ReleaseNotesShareDocument,
);
@Schema({ ...LOOSE, collection: 'git_integrations' }) @Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument { class GitIntegrationDocument {
/** The project's id. */ /** The project's id. */
@@ -465,6 +521,11 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema }, { name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema }, { name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema }, { name: ReadModels.AlertTrace, schema: AlertTraceSchema },
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
{ name: ReadModels.IssueVisit, schema: IssueVisitSchema },
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
{ name: ReadModels.MyDay, schema: MyDaySchema },
{ name: ReadModels.ReleaseNotesShare, schema: ReleaseNotesShareSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema }, { name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
@@ -486,6 +547,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
// Tickets projected before attachments existed have none. // Tickets projected before attachments existed have none.
attachments: rest.attachments ?? [], attachments: rest.attachments ?? [],
watchers: rest.watchers ?? [], watchers: rest.watchers ?? [],
reviews: rest.reviews ?? [],
votes: rest.votes ?? 0, votes: rest.votes ?? 0,
voterIds: rest.voterIds ?? [], voterIds: rest.voterIds ?? [],
parent: rest.parent?.id ? rest.parent : null, parent: rest.parent?.id ? rest.parent : null,

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
ReleaseNoteSharesRepository,
ReleaseNotesShare,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
type Stored = ReleaseNotesShare & { _id?: unknown };
const strip = ({ _id: _ignored, ...share }: Stored): ReleaseNotesShare => share;
@Injectable()
export class MongoReleaseNoteSharesRepository extends ReleaseNoteSharesRepository {
constructor(
@InjectModel(ReadModels.ReleaseNotesShare)
private readonly shares: Model<Stored>,
) {
super();
}
async findByRelease(releaseId: string): Promise<ReleaseNotesShare | null> {
const doc = await this.shares.findOne({ releaseId }).lean<Stored>();
return doc ? strip(doc) : null;
}
async findByToken(token: string): Promise<ReleaseNotesShare | null> {
const doc = await this.shares.findOne({ token }).lean<Stored>();
return doc ? strip(doc) : null;
}
async save(share: ReleaseNotesShare): Promise<void> {
await this.shares.updateOne(
{ releaseId: share.releaseId },
{ $set: share },
{ upsert: true },
);
}
async remove(releaseId: string): Promise<void> {
await this.shares.deleteOne({ releaseId });
}
}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
TreeFollow,
TreeFollowsRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
@Injectable()
export class MongoTreeFollowsRepository extends TreeFollowsRepository {
constructor(
@InjectModel(ReadModels.TreeFollow)
private readonly follows: Model<TreeFollow>,
) {
super();
}
async follow(follow: TreeFollow): Promise<void> {
await this.follows.updateOne(
{ userId: follow.userId, issueId: follow.issueId },
{ $setOnInsert: follow },
{ upsert: true },
);
}
async unfollow(userId: string, issueId: string): Promise<void> {
await this.follows.deleteOne({ userId, issueId });
}
async isFollowing(userId: string, issueId: string): Promise<boolean> {
return !!(await this.follows.exists({ userId, issueId }));
}
async followersOf(issueIds: string[]): Promise<string[]> {
if (!issueIds.length) return [];
return this.follows.distinct('userId', { issueId: { $in: issueIds } });
}
}

View File

@@ -7,6 +7,7 @@ import {
ProjectChange, ProjectChange,
ReleaseChange, ReleaseChange,
SprintChange, SprintChange,
Typing,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
import { import {
ActivityReadModel, ActivityReadModel,
@@ -18,6 +19,7 @@ import { RedisPubSub } from 'graphql-redis-subscriptions';
const Channels = { const Channels = {
issue: (projectId: string) => `tracker.issue.${projectId}`, issue: (projectId: string) => `tracker.issue.${projectId}`,
comment: (issueId: string) => `tracker.comment.${issueId}`, comment: (issueId: string) => `tracker.comment.${issueId}`,
typing: (issueId: string) => `tracker.typing.${issueId}`,
activity: (projectId: string) => `tracker.activity.${projectId}`, activity: (projectId: string) => `tracker.activity.${projectId}`,
release: (projectId: string) => `tracker.release.${projectId}`, release: (projectId: string) => `tracker.release.${projectId}`,
sprint: (projectId: string) => `tracker.sprint.${projectId}`, sprint: (projectId: string) => `tracker.sprint.${projectId}`,
@@ -86,6 +88,14 @@ export class RedisChangeFeed extends ChangeFeed {
return this.pubSub.asyncIterableIterator(Channels.comment(issueId)); return this.pubSub.asyncIterableIterator(Channels.comment(issueId));
} }
publishTyping(typing: Typing): Promise<void> {
return this.pubSub.publish(Channels.typing(typing.issueId), typing);
}
typing(issueId: string): AsyncIterableIterator<Typing> {
return this.pubSub.asyncIterableIterator(Channels.typing(issueId));
}
activity(projectId: string): AsyncIterableIterator<ActivityReadModel> { activity(projectId: string): AsyncIterableIterator<ActivityReadModel> {
return this.pubSub.asyncIterableIterator(Channels.activity(projectId)); return this.pubSub.asyncIterableIterator(Channels.activity(projectId));
} }

View File

@@ -15,6 +15,11 @@ import {
MyViewsRepository, MyViewsRepository,
PinsRepository, PinsRepository,
AlertIntegrationsRepository, AlertIntegrationsRepository,
TreeFollowsRepository,
IssueVisitsRepository,
IssueRemindersRepository,
MyDaysRepository,
ReleaseNoteSharesRepository,
GitIntegrationsRepository, GitIntegrationsRepository,
OutgoingWebhooksRepository, OutgoingWebhooksRepository,
AutomationsRepository, AutomationsRepository,
@@ -82,6 +87,11 @@ import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoMyViewsRepository } from './mongo/my-views.repository'; import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoPinsRepository } from './mongo/pins.repository'; import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository'; import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
import { MongoIssueVisitsRepository } from './mongo/issue-visits.repository';
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
import { MongoMyDaysRepository } from './mongo/my-days.repository';
import { MongoReleaseNoteSharesRepository } from './mongo/release-note-shares.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender'; import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -163,6 +173,17 @@ const PORTS = [
provide: AlertIntegrationsRepository, provide: AlertIntegrationsRepository,
useClass: MongoAlertIntegrationsRepository, useClass: MongoAlertIntegrationsRepository,
}, },
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
{ provide: IssueVisitsRepository, useClass: MongoIssueVisitsRepository },
{
provide: IssueRemindersRepository,
useClass: MongoIssueRemindersRepository,
},
{ provide: MyDaysRepository, useClass: MongoMyDaysRepository },
{
provide: ReleaseNoteSharesRepository,
useClass: MongoReleaseNoteSharesRepository,
},
{ {
provide: GitIntegrationsRepository, provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository, useClass: MongoGitIntegrationsRepository,

View File

@@ -22,8 +22,44 @@ export class CommentResolver {
@Args('body') body: string, @Args('body') body: string,
@Args('replyTo', { type: () => ID, nullable: true }) @Args('replyTo', { type: () => ID, nullable: true })
replyTo: string | null, replyTo: string | null,
@Args('poll', {
type: () => [String],
nullable: true,
description: 'A quick poll: 2 to 6 options',
})
poll: string[] | null,
) { ) {
return this.comments.add(me.id, issueId, body, replyTo ?? null); return this.comments.add(
me.id,
issueId,
body,
replyTo ?? null,
poll ?? null,
);
}
@Mutation(() => CommandResultGql, {
description:
"Vote in a comment's poll (option index), or take the vote back (null). Members, viewers too",
})
votePoll(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('option', { type: () => Int, nullable: true }) option: number | null,
) {
return this.comments.votePoll(me.id, commentId, option ?? null);
}
@Mutation(() => CommandResultGql, {
description:
'Close a poll (whoever started it, or an admin); with decide, the winner stays as a decision of the ticket',
})
closePoll(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('decide', { defaultValue: true }) decide: boolean,
) {
return this.comments.closePoll(me.id, commentId, decide);
} }
@Mutation(() => CommandResultGql) @Mutation(() => CommandResultGql)
@@ -59,6 +95,45 @@ export class CommentResolver {
return this.comments.markDecision(me.id, commentId, on); return this.comments.markDecision(me.id, commentId, on);
} }
@Mutation(() => CommandResultGql, {
description:
'Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket',
})
resolveThread(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('resolved', { defaultValue: true }) resolved: boolean,
@Args('conclusion', { nullable: true }) conclusion: string | null,
) {
return this.comments.resolveThread(
me.id,
commentId,
resolved,
conclusion ?? null,
);
}
@Mutation(() => CommandResultGql, {
description:
'Make a ticket of a comment: its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO). Answers the new ticket. Whoever can change tickets',
})
turnCommentIntoIssue(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('title') title: string,
@Args('type', { description: "One of the project's ticket type ids" })
type: string,
@Args('asSubtask', { defaultValue: false }) asSubtask: boolean,
) {
return this.comments.turnIntoIssue(
me.id,
commentId,
title,
type,
asSubtask,
);
}
@OptionalAuth() @OptionalAuth()
@Query(() => [DecisionGql], { @Query(() => [DecisionGql], {
description: description:

View File

@@ -15,6 +15,7 @@ import {
StatusCategory, StatusCategory,
DevLinkKind, DevLinkKind,
DevLinkState, DevLinkState,
ReviewVerdict,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
export enum LinkDirection { export enum LinkDirection {
@@ -23,6 +24,10 @@ export enum LinkDirection {
} }
registerEnumType(IssuePriority, { name: 'IssuePriority' }); registerEnumType(IssuePriority, { name: 'IssuePriority' });
registerEnumType(ReviewVerdict, {
name: 'ReviewVerdict',
description: 'APPROVED: all good. CHANGES: something to change',
});
registerEnumType(CustomFieldType, { registerEnumType(CustomFieldType, {
name: 'CustomFieldType', name: 'CustomFieldType',
description: 'What a custom field holds (DATE: YYYY-MM-DD)', description: 'What a custom field holds (DATE: YYYY-MM-DD)',

View File

@@ -661,7 +661,9 @@ export class WorkflowStatusInput {
@Field(() => StatusCategory) @Field(() => StatusCategory)
@IsEnum(StatusCategory) @IsEnum(StatusCategory)
category!: StatusCategory; category!: StatusCategory;
@Field() @IsString() color!: string; @Field({ description: 'A palette colour (follows the theme) or #rrggbb' })
@IsString()
color!: string;
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean; @Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
@Field(() => Int, { @Field(() => Int, {
@@ -705,7 +707,9 @@ export class IssueTypeOptionInput {
@Field() @Matches(SETTING_ID) id!: string; @Field() @Matches(SETTING_ID) id!: string;
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
@Field() @IsString() icon!: string; @Field() @IsString() icon!: string;
@Field() @IsString() color!: string; @Field({ description: 'A palette colour (follows the theme) or #rrggbb' })
@IsString()
color!: string;
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
@Field(() => IssueTemplateInput, { nullable: true }) @Field(() => IssueTemplateInput, { nullable: true })
@IsOptional() @IsOptional()

View File

@@ -18,12 +18,17 @@ import {
CommentsService, CommentsService,
IssuesService, IssuesService,
PinsService, PinsService,
TreeFollowsService,
IssueRemindersService,
IssueVisitsService,
MyDayService,
PresenceService, PresenceService,
WorkflowsService, WorkflowsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
import { import {
IssuePriority, IssuePriority,
IssueReadModel, IssueReadModel,
ReviewVerdict,
StatusCategory, StatusCategory,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { import {
@@ -51,6 +56,9 @@ import {
IssueGql, IssueGql,
IssuePageGql, IssuePageGql,
IssueParentGql, IssueParentGql,
IssueReminderGql,
MyDayGql,
ReminderOnIssueGql,
PresenceGql, PresenceGql,
} from './types'; } from './types';
@@ -63,6 +71,10 @@ export class IssueResolver {
private readonly blockers: BlockersService, private readonly blockers: BlockersService,
private readonly presence: PresenceService, private readonly presence: PresenceService,
private readonly pins: PinsService, private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
private readonly myDayService: MyDayService,
private readonly visits: IssueVisitsService,
) {} ) {}
@Mutation(() => [PresenceGql], { @Mutation(() => [PresenceGql], {
@@ -77,6 +89,28 @@ export class IssueResolver {
return this.presence.touch({ id: me.id, name: me.name }, issueId, editing); return this.presence.touch({ id: me.id, name: me.name }, issueId, editing);
} }
@Mutation(() => Boolean, {
description:
'I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing`',
})
async typing(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('threadId', {
type: () => ID,
nullable: true,
description: 'The conversation being answered; null: a new comment',
})
threadId: string | null,
) {
await this.presence.typing(
{ id: me.id, name: me.name },
issueId,
threadId ?? null,
);
return true;
}
@Mutation(() => Boolean, { description: "I left this ticket's page" }) @Mutation(() => Boolean, { description: "I left this ticket's page" })
async leavePresence( async leavePresence(
@CurrentUser() me: User, @CurrentUser() me: User,
@@ -137,6 +171,11 @@ export class IssueResolver {
description: 'Only the tickets I follow', description: 'Only the tickets I follow',
}) })
watching: boolean, watching: boolean,
@Args('reviewing', {
defaultValue: false,
description: 'Only the tickets I was asked to review (unanswered)',
})
reviewing: boolean,
@Args('statuses', { type: () => [String], nullable: true }) @Args('statuses', { type: () => [String], nullable: true })
statuses: string[] | undefined, statuses: string[] | undefined,
@Args('categories', { type: () => [StatusCategory], nullable: true }) @Args('categories', { type: () => [StatusCategory], nullable: true })
@@ -173,6 +212,7 @@ export class IssueResolver {
return this.issuesService.mine(me.id, { return this.issuesService.mine(me.id, {
assignedToMe, assignedToMe,
watching, watching,
reviewing,
statuses: statuses ?? undefined, statuses: statuses ?? undefined,
categories: categories ?? undefined, categories: categories ?? undefined,
search: search ?? undefined, search: search ?? undefined,
@@ -258,6 +298,28 @@ export class IssueResolver {
return this.commentsService.decisions(viewerOf(me), issue.id); return this.commentsService.decisions(viewerOf(me), issue.id);
} }
@ResolveField(() => IssueReminderGql, {
nullable: true,
description: 'My waiting reminder on it (null without one or a token)',
})
myReminder(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
) {
return me ? this.reminders.of(me.id, issue.id) : null;
}
@ResolveField(() => Boolean, {
description:
'Whether I follow it with everything under it (false without a token)',
})
followingTree(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
): Promise<boolean> | boolean {
return !!me && this.treeFollows.isFollowing(me.id, issue.id);
}
@ResolveField(() => Boolean, { @ResolveField(() => Boolean, {
description: 'Whether I pinned it (false without a token)', description: 'Whether I pinned it (false without a token)',
}) })
@@ -369,6 +431,152 @@ export class IssueResolver {
return true; return true;
} }
@Mutation(() => Boolean, {
description:
'Follow a ticket with everything under it (an epic): its changes and those of every ticket hanging from it, also future ones',
})
async followTree(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.treeFollows.follow(me.id, issueId, true);
return true;
}
@Mutation(() => Boolean, {
description: 'Stop following a ticket with everything under it',
})
async unfollowTree(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.treeFollows.follow(me.id, issueId, false);
return true;
}
@Mutation(() => String, {
nullable: true,
description:
'I opened this ticket now: answers when I had opened it before (null: never), to show what is new since',
})
visitIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<string | null> {
return this.visits.visit(me.id, issueId);
}
@Query(() => MyDayGql, {
description:
'«Mi día»: the tickets I picked for today, and what was left on my last day',
})
myDay(
@CurrentUser() me: User,
@Args('day', { description: 'Today, YYYY-MM-DD in my time zone' })
day: string,
) {
return this.myDayService.get(me.id, day);
}
@Mutation(() => Boolean, {
description: 'Add tickets to «Mi día» (10 at most)',
})
async addToMyDay(
@CurrentUser() me: User,
@Args('day') day: string,
@Args('issueIds', { type: () => [ID] }) issueIds: string[],
): Promise<boolean> {
await this.myDayService.add(me.id, day, issueIds);
return true;
}
@Mutation(() => Boolean, { description: 'Take a ticket out of «Mi día»' })
async removeFromMyDay(
@CurrentUser() me: User,
@Args('day') day: string,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.myDayService.remove(me.id, day, issueId);
return true;
}
@Query(() => [ReminderOnIssueGql], {
description: 'My waiting reminders on tickets, soonest first',
})
myReminders(@CurrentUser() me: User) {
return this.reminders.mine(me.id);
}
@Mutation(() => IssueReminderGql, {
description:
'Remind me of a ticket at a date and time (a notification), with an optional note. One per ticket: it replaces the waiting one',
})
setReminder(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('at', { description: 'ISO date-time, in the future' }) at: string,
@Args('note', { nullable: true }) note: string | null,
) {
return this.reminders.set(me.id, issueId, at, note ?? null);
}
@Mutation(() => Boolean, { description: 'Drop my reminder on a ticket' })
async clearReminder(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.reminders.clear(me.id, issueId);
return true;
}
@Mutation(() => CommandResultGql, {
description:
'Ask a member to review a ticket (with an optional note): they are told and see it in their work',
})
requestReview(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('reviewerId', { type: () => ID }) reviewerId: string,
@Args('note', { nullable: true }) note: string | null,
) {
return this.issuesService.requestReview(
me.id,
issueId,
reviewerId,
note ?? null,
);
}
@Mutation(() => CommandResultGql, {
description: 'Answer a review I was asked for: all good, or changes',
})
answerReview(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('reviewId', { type: () => ID }) reviewId: string,
@Args('verdict', { type: () => ReviewVerdict }) verdict: ReviewVerdict,
@Args('answer', { nullable: true }) answer: string | null,
) {
return this.issuesService.answerReview(
me.id,
issueId,
reviewId,
verdict,
answer ?? null,
);
}
@Mutation(() => CommandResultGql, {
description: 'Withdraw a review request I made (or, as admin, any)',
})
cancelReview(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('reviewId', { type: () => ID }) reviewId: string,
) {
return this.issuesService.cancelReview(me.id, issueId, reviewId);
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue( unwatchIssue(
@CurrentUser() me: User, @CurrentUser() me: User,

View File

@@ -6,7 +6,12 @@ import {
} from '@task-stream/api/identity/presenters'; } from '@task-stream/api/identity/presenters';
import { SharingService } from '@task-stream/api/tracker/application'; import { SharingService } from '@task-stream/api/tracker/application';
import { ShareDashboardInput } from './inputs'; import { ShareDashboardInput } from './inputs';
import { DashboardShareGql, SharedDashboardGql } from './types'; import {
DashboardShareGql,
PublicReleaseNotesGql,
ReleaseNotesShareGql,
SharedDashboardGql,
} from './types';
/** The project's dashboard for clients, behind a secret link. */ /** The project's dashboard for clients, behind a secret link. */
@Resolver(() => DashboardShareGql) @Resolver(() => DashboardShareGql)
@@ -49,6 +54,51 @@ export class SharingResolver {
return true; return true;
} }
@Query(() => ReleaseNotesShareGql, {
nullable: true,
description: "A release's public notes link, if any (owners and admins)",
})
releaseNotesShare(
@CurrentUser() me: User,
@Args('releaseId', { type: () => ID }) releaseId: string,
) {
return this.sharing.releaseNotesShare(me.id, releaseId);
}
@Mutation(() => ReleaseNotesShareGql, {
description:
"Turn a release's public notes page on; renew makes a new link (the old one stops working)",
})
shareReleaseNotes(
@CurrentUser() me: User,
@Args('releaseId', { type: () => ID }) releaseId: string,
@Args('renew', { defaultValue: false }) renew: boolean,
) {
return this.sharing.shareReleaseNotes(me.id, releaseId, renew);
}
@Mutation(() => Boolean, {
description: "A release's public notes link stops working",
})
async unshareReleaseNotes(
@CurrentUser() me: User,
@Args('releaseId', { type: () => ID }) releaseId: string,
) {
await this.sharing.unshareReleaseNotes(me.id, releaseId);
return true;
}
@OptionalAuth()
@Query(() => PublicReleaseNotesGql, {
nullable: true,
description: "A release's notes, for anyone with its link",
})
publicReleaseNotes(@Args('token') token: string) {
return /^[\w-]{20,100}$/.test(token)
? this.sharing.publicReleaseNotes(token)
: null;
}
@OptionalAuth() @OptionalAuth()
@Query(() => SharedDashboardGql, { @Query(() => SharedDashboardGql, {
nullable: true, nullable: true,

View File

@@ -13,6 +13,7 @@ import {
ProjectChangeGql, ProjectChangeGql,
ReleaseChangeGql, ReleaseChangeGql,
SprintChangeGql, SprintChangeGql,
TypingGql,
} from './types'; } from './types';
const asIs = { resolve: <T>(payload: T) => payload }; const asIs = { resolve: <T>(payload: T) => payload };
@@ -48,6 +49,18 @@ export class SubscriptionResolver {
return this.changes.watchComments(me.id, issueId); return this.changes.watchComments(me.id, issueId);
} }
@Subscription(() => TypingGql, {
...asIs,
description:
"Who is writing on a ticket, as they type (one's own come too: leave them out)",
})
typing(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.changes.watchTyping(me.id, issueId);
}
@Subscription(() => ActivityGql, { @Subscription(() => ActivityGql, {
...asIs, ...asIs,
description: 'Live activity feed', description: 'Live activity feed',

View File

@@ -10,6 +10,7 @@ import {
StatusCategory, StatusCategory,
DevLinkKind, DevLinkKind,
DevLinkState, DevLinkState,
ReviewVerdict,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { import {
AutomationActionKind, AutomationActionKind,
@@ -35,7 +36,11 @@ export class WorkflowStatusGql {
id!: string; id!: string;
@Field() name!: string; @Field() name!: string;
@Field(() => StatusCategory) category!: StatusCategory; @Field(() => StatusCategory) category!: StatusCategory;
@Field({ description: 'Colour token: slate, violet, green…' }) color!: string; @Field({
description:
'A palette colour (slate, violet, green…, follows the theme) or #rrggbb',
})
color!: string;
@Field({ description: 'Entering it needs a reason' }) @Field({ description: 'Entering it needs a reason' })
requiresReason!: boolean; requiresReason!: boolean;
@Field({ description: 'No new tickets or transitions into it' }) @Field({ description: 'No new tickets or transitions into it' })
@@ -367,6 +372,24 @@ export class IssueParentGql {
@Field() title!: string; @Field() title!: string;
} }
@ObjectType('IssueReview', {
description: 'A review someone asked on the ticket',
})
export class IssueReviewGql {
@Field(() => ID) id!: string;
@Field(() => UserRefGql) requester!: UserRefGql;
@Field(() => UserRefGql) reviewer!: UserRefGql;
@Field(() => String, { nullable: true }) note!: string | null;
@Field() requestedAt!: string;
@Field(() => ReviewVerdict, {
nullable: true,
description: 'Null while waiting for an answer',
})
verdict!: ReviewVerdict | null;
@Field(() => String, { nullable: true }) answer!: string | null;
@Field(() => String, { nullable: true }) answeredAt!: string | null;
}
@ObjectType('Issue') @ObjectType('Issue')
export class IssueGql { export class IssueGql {
@Field(() => ID) id!: string; @Field(() => ID) id!: string;
@@ -404,6 +427,10 @@ export class IssueGql {
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[]; @Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
@Field(() => [UserRefGql], { description: 'People following the ticket' }) @Field(() => [UserRefGql], { description: 'People following the ticket' })
watchers!: UserRefGql[]; watchers!: UserRefGql[];
@Field(() => [IssueReviewGql], {
description: 'Reviews asked, open and answered, oldest first',
})
reviews!: IssueReviewGql[];
@Field(() => IssueParentGql, { @Field(() => IssueParentGql, {
nullable: true, nullable: true,
description: 'The epic or ticket this one breaks down', description: 'The epic or ticket this one breaks down',
@@ -528,6 +555,29 @@ export class CommentReactionGql {
users!: UserRefGql[]; users!: UserRefGql[];
} }
@ObjectType('PollOption')
export class PollOptionGql {
@Field() text!: string;
@Field(() => [UserRefGql]) voters!: UserRefGql[];
}
@ObjectType('CommentPoll', { description: 'A quick poll in a comment' })
export class CommentPollGql {
@Field(() => [PollOptionGql]) options!: PollOptionGql[];
@Field() closed!: boolean;
@Field(() => Int, {
nullable: true,
description: 'Index of the winning option once closed (null on a tie)',
})
winner!: number | null;
}
@ObjectType('CommentSpawn', { description: 'A ticket made from a comment' })
export class CommentSpawnGql {
@Field(() => ID) id!: string;
@Field() key!: string;
}
@ObjectType('Comment') @ObjectType('Comment')
export class CommentGql { export class CommentGql {
@Field(() => ID) id!: string; @Field(() => ID) id!: string;
@@ -558,6 +608,23 @@ export class CommentGql {
}) })
decidedBy!: UserRefGql | null; decidedBy!: UserRefGql | null;
@Field(() => String, { nullable: true }) decidedAt!: string | null; @Field(() => String, { nullable: true }) decidedAt!: string | null;
@Field(() => CommentPollGql, { nullable: true })
poll!: CommentPollGql | null;
@Field({
description:
'A conversation (top-level comment and its replies) that was settled',
})
resolved!: boolean;
@Field(() => UserRefGql, {
nullable: true,
description: 'Who settled it',
})
resolvedBy!: UserRefGql | null;
@Field(() => String, { nullable: true }) resolvedAt!: string | null;
@Field(() => [CommentSpawnGql], {
description: 'Tickets made from it («Convertir en ticket»), oldest first',
})
spawned!: CommentSpawnGql[];
@Field() createdAt!: string; @Field() createdAt!: string;
@Field(() => String, { nullable: true }) editedAt!: string | null; @Field(() => String, { nullable: true }) editedAt!: string | null;
@Field(() => Int) version!: number; @Field(() => Int) version!: number;
@@ -1153,6 +1220,20 @@ export class ProjectInsightsGql {
proposals!: ProposalsFunnelGql | null; proposals!: ProposalsFunnelGql | null;
} }
@ObjectType('Typing', {
description: 'Someone writing on a ticket right now (not stored)',
})
export class TypingGql {
@Field(() => ID) issueId!: string;
@Field(() => UserRefGql) user!: UserRefGql;
@Field(() => ID, {
nullable: true,
description: 'The conversation being answered; null: a new comment',
})
threadId!: string | null;
@Field() at!: string;
}
@ObjectType('Presence', { @ObjectType('Presence', {
description: 'Someone on a ticket right now (heartbeat in the last 30 s)', description: 'Someone on a ticket right now (heartbeat in the last 30 s)',
}) })
@@ -1348,3 +1429,66 @@ export class AlertIntegrationGql {
typeId!: string | null; typeId!: string | null;
@Field(() => IssuePriority) priority!: IssuePriority; @Field(() => IssuePriority) priority!: IssuePriority;
} }
@ObjectType('IssueReminder', {
description: 'My own reminder on a ticket («remind me on Thursday»)',
})
export class IssueReminderGql {
@Field({ description: 'When it tells me (ISO, UTC)' }) at!: string;
@Field(() => String, { nullable: true }) note!: string | null;
}
@ObjectType('ReminderOnIssue', {
description: 'One of my waiting reminders, with its ticket',
})
export class ReminderOnIssueGql {
@Field(() => IssueReminderGql) reminder!: IssueReminderGql;
@Field(() => IssueGql) issue!: IssueGql;
}
@ObjectType('MyDayLeftover', {
description: 'What was left unfinished on the latest earlier day',
})
export class MyDayLeftoverGql {
@Field() day!: string;
@Field(() => [IssueGql]) issues!: IssueGql[];
}
@ObjectType('MyDay', {
description: '«Mi día»: the tickets I picked for today',
})
export class MyDayGql {
@Field({ description: 'YYYY-MM-DD, in my time zone' }) day!: string;
@Field(() => [IssueGql], { description: 'In order, done ones included' })
issues!: IssueGql[];
@Field(() => MyDayLeftoverGql, { nullable: true })
leftover!: MyDayLeftoverGql | null;
}
@ObjectType('ReleaseNotesShare', {
description: "A release's public notes link (owners and admins)",
})
export class ReleaseNotesShareGql {
@Field(() => ID) releaseId!: string;
@Field({ description: 'The secret of the link: /notes/{token}' })
token!: string;
@Field() createdAt!: string;
}
@ObjectType('PublicReleaseNotes', {
description: "A release's notes, for anyone with its link",
})
export class PublicReleaseNotesGql {
@Field() projectName!: string;
@Field() projectKey!: string;
@Field() releaseName!: string;
@Field(() => String, { nullable: true }) description!: string | null;
@Field(() => ReleaseStatus) status!: ReleaseStatus;
@Field(() => String, { nullable: true }) targetDate!: string | null;
@Field(() => String, { nullable: true }) releasedAt!: string | null;
@Field({ description: 'Markdown' }) notes!: string;
@Field({
description: 'False: drawn from its finished tickets (none written yet)',
})
written!: boolean;
}

View File

@@ -57,7 +57,7 @@ interface UploadedPart {
* always downloaded: a file someone uploaded must never run in this origin. * always downloaded: a file someone uploaded must never run in this origin.
*/ */
const SHOWN_INLINE = const SHOWN_INLINE =
/^(image\/(png|jpe?g|gif|webp|avif)|video\/(mp4|webm)|audio\/(mpeg|ogg|wav)|application\/pdf|text\/plain)$/; /^(image\/(png|jpe?g|gif|webp|avif)|video\/(mp4|webm)|audio\/(mpeg|ogg|wav|webm|mp4)|application\/pdf|text\/plain)$/;
/** REST adapter for a ticket's files: uploaded here, listed on the ticket. */ /** REST adapter for a ticket's files: uploaded here, listed on the ticket. */
@ApiTags('Issues') @ApiTags('Issues')

View File

@@ -17,7 +17,14 @@ import {
import { ApiCommandErrors } from '@task-stream/api/shared/presenters'; import { ApiCommandErrors } from '@task-stream/api/shared/presenters';
import { CommentsService } from '@task-stream/api/tracker/application'; import { CommentsService } from '@task-stream/api/tracker/application';
import { REACTIONS } from '@task-stream/api/tracker/domain'; import { REACTIONS } from '@task-stream/api/tracker/domain';
import { CommentBodyDto, ReactToCommentDto } from './dto/requests.dto'; import {
CommentBodyDto,
ClosePollDto,
ReactToCommentDto,
ResolveThreadDto,
TurnCommentIntoIssueDto,
VotePollDto,
} from './dto/requests.dto';
import { CommandResultDto } from './dto/responses.dto'; import { CommandResultDto } from './dto/responses.dto';
/** REST adapter of the comment use cases (listing and adding live under /issues). */ /** REST adapter of the comment use cases (listing and adding live under /issues). */
@@ -78,6 +85,90 @@ export class CommentsController {
return this.comments.markDecision(me.id, commentId, false); return this.comments.markDecision(me.id, commentId, false);
} }
@Post(':commentId/poll/vote')
@HttpCode(200)
@ApiOperation({
summary: "Vote in a comment's poll (members, viewers too)",
description: 'option: its index; null takes the vote back.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
votePoll(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
@Body() body: VotePollDto,
) {
return this.comments.votePoll(me.id, commentId, body.option);
}
@Post(':commentId/poll/close')
@HttpCode(200)
@ApiOperation({
summary: 'Close a poll (whoever started it, or an admin)',
description:
'With decide (default), the winner stays as a decision of the ticket.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
closePoll(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
@Body() body: ClosePollDto,
) {
return this.comments.closePoll(me.id, commentId, body.decide ?? true);
}
@Post(':commentId/issue')
@ApiOperation({
summary: 'Make a ticket of a comment',
description:
'Its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO); the comment shows where it went. Answers the new ticket. Whoever can change tickets.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
turnIntoIssue(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
@Body() body: TurnCommentIntoIssueDto,
) {
return this.comments.turnIntoIssue(
me.id,
commentId,
body.title,
body.type,
body.asSubtask ?? false,
);
}
@Put(':commentId/resolution')
@ApiOperation({
summary: 'Settle a conversation (a top-level comment and its replies)',
description:
'Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
resolveThread(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
@Body() body: ResolveThreadDto,
) {
return this.comments.resolveThread(
me.id,
commentId,
true,
body.conclusion ?? null,
);
}
@Delete(':commentId/resolution')
@ApiOperation({ summary: 'Reopen a conversation' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
reopenThread(@CurrentUser() me: User, @Param('commentId') commentId: string) {
return this.comments.resolveThread(me.id, commentId, false);
}
@Delete(':commentId') @Delete(':commentId')
@ApiOperation({ summary: 'Delete a comment (author or project admin)' }) @ApiOperation({ summary: 'Delete a comment (author or project admin)' })
@ApiOkResponse({ type: CommandResultDto }) @ApiOkResponse({ type: CommandResultDto })

View File

@@ -10,7 +10,8 @@ import {
IssuePriority, IssuePriority,
ProjectRole, ProjectRole,
REACTIONS, REACTIONS,
SETTING_COLORS, ReviewVerdict,
SETTING_COLOR_PATTERN,
StatusCategory, StatusCategory,
TYPE_ICONS, TYPE_ICONS,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -153,8 +154,15 @@ export class WorkflowStatusBodyDto {
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
@IsEnum(StatusCategory) @IsEnum(StatusCategory)
category!: StatusCategory; category!: StatusCategory;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @ApiProperty({
@IsIn(SETTING_COLORS) description:
'A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb',
example: '#7c3aed',
pattern: SETTING_COLOR_PATTERN.source,
})
@Matches(SETTING_COLOR_PATTERN, {
message: 'color must be a palette colour or #rrggbb',
})
color!: string; color!: string;
@ApiPropertyOptional({ type: Boolean, default: false }) @ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional() @IsOptional()
@@ -212,8 +220,15 @@ export class IssueTypeOptionBodyDto {
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) @ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' })
@IsIn(TYPE_ICONS) @IsIn(TYPE_ICONS)
icon!: string; icon!: string;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @ApiProperty({
@IsIn(SETTING_COLORS) description:
'A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb',
example: '#7c3aed',
pattern: SETTING_COLOR_PATTERN.source,
})
@Matches(SETTING_COLOR_PATTERN, {
message: 'color must be a palette colour or #rrggbb',
})
color!: string; color!: string;
@ApiPropertyOptional({ type: Boolean, default: false }) @ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional() @IsOptional()
@@ -541,6 +556,14 @@ export class MyIssuesQueryDto extends PageQueryDto {
@IsBoolean() @IsBoolean()
watching?: boolean; watching?: boolean;
@ApiPropertyOptional({
description: 'Only the tickets I was asked to review and have not answered',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
reviewing?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
type: [String], type: [String],
description: 'Status ids. Repeat the parameter or separate with commas', description: 'Status ids. Repeat the parameter or separate with commas',
@@ -1293,6 +1316,40 @@ export class AddCommentDto extends CommentBodyDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
replyTo?: string; replyTo?: string;
@ApiPropertyOptional({
type: [String],
description: 'A quick poll: 2 to 6 options',
example: ['Stripe', 'Redsys'],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@IsString({ each: true })
@MaxLength(80, { each: true })
poll?: string[];
}
export class VotePollDto {
@ApiProperty({
type: Number,
nullable: true,
description: 'Option index; null takes the vote back',
})
@ValidateIf((dto: VotePollDto) => dto.option !== null)
@IsInt()
@Min(0)
option!: number | null;
}
export class ClosePollDto {
@ApiPropertyOptional({
default: true,
description: 'Keep the winner as a decision of the ticket',
})
@IsOptional()
@IsBoolean()
decide?: boolean;
} }
export class ReactToCommentDto { export class ReactToCommentDto {
@@ -1538,6 +1595,84 @@ export class PushEndpointDto {
endpoint!: string; endpoint!: string;
} }
export class RequestReviewDto {
@ApiProperty({ description: "Reviewer's user id (a member of the project)" })
@IsString()
reviewer!: string;
@ApiPropertyOptional({ maxLength: 1000, description: 'What to look at' })
@IsOptional()
@IsString()
@MaxLength(1000)
note?: string;
}
export class AnswerReviewDto {
@ApiProperty({ enum: ReviewVerdict, enumName: 'ReviewVerdict' })
@IsEnum(ReviewVerdict)
verdict!: ReviewVerdict;
@ApiPropertyOptional({ maxLength: 1000 })
@IsOptional()
@IsString()
@MaxLength(1000)
answer?: string;
}
export class MyDayQueryDto {
@ApiProperty({ example: '2026-10-20', description: 'Today, in my time zone' })
@Matches(/^\d{4}-\d{2}-\d{2}$/)
day!: string;
}
export class SetReminderDto {
@ApiProperty({
format: 'date-time',
description: 'When to tell me; in the future, at most a year ahead',
})
@IsISO8601()
at!: string;
@ApiPropertyOptional({ maxLength: 280, description: 'What to remember' })
@IsOptional()
@IsString()
@MaxLength(280)
note?: string;
}
export class TurnCommentIntoIssueDto {
@ApiProperty({ example: 'También falla en Android', maxLength: 200 })
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty({ description: "One of the project's ticket type ids" })
@IsString()
type!: string;
@ApiPropertyOptional({
default: false,
description:
'Hang it from the ticket of the comment; otherwise it is linked to it (RELATES_TO)',
})
@IsOptional()
@IsBoolean()
asSubtask?: boolean;
}
export class ResolveThreadDto {
@ApiPropertyOptional({
description:
'What was settled: added as a reply marked as a decision before it closes',
maxLength: 20000,
})
@IsOptional()
@IsString()
@MaxLength(20000)
conclusion?: string;
}
// ---- Alerts → bugs ---------------------------------------------------------- // ---- Alerts → bugs ----------------------------------------------------------
export class AlertSettingsDto { export class AlertSettingsDto {

View File

@@ -3,11 +3,11 @@ import {
CustomFieldType, CustomFieldType,
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
ReviewVerdict,
NotificationKind, NotificationKind,
ProjectRole, ProjectRole,
ReleaseStatus, ReleaseStatus,
SprintStatus, SprintStatus,
SETTING_COLORS,
StatusCategory, StatusCategory,
TYPE_ICONS, TYPE_ICONS,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -34,7 +34,10 @@ export class WorkflowStatusDto {
@ApiProperty({ example: 'En revisión' }) name!: string; @ApiProperty({ example: 'En revisión' }) name!: string;
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
category!: StatusCategory; category!: StatusCategory;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @ApiProperty({
description: 'A palette colour (follows the theme) or #rrggbb',
example: 'violet',
})
color!: string; color!: string;
@ApiProperty({ description: 'Entering it needs a reason' }) @ApiProperty({ description: 'Entering it needs a reason' })
requiresReason!: boolean; requiresReason!: boolean;
@@ -63,7 +66,10 @@ export class IssueTypeOptionDto {
@ApiProperty({ example: 'BUG' }) id!: string; @ApiProperty({ example: 'BUG' }) id!: string;
@ApiProperty({ example: 'Bug' }) name!: string; @ApiProperty({ example: 'Bug' }) name!: string;
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) icon!: string; @ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) icon!: string;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @ApiProperty({
description: 'A palette colour (follows the theme) or #rrggbb',
example: 'violet',
})
color!: string; color!: string;
@ApiProperty() archived!: boolean; @ApiProperty() archived!: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -476,6 +482,11 @@ export class IssueDto {
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[]; @ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
@ApiProperty({ type: [UserRefDto], description: 'People following it' }) @ApiProperty({ type: [UserRefDto], description: 'People following it' })
watchers!: UserRefDto[]; watchers!: UserRefDto[];
@ApiProperty({
type: () => [IssueReviewDto],
description: 'Reviews asked, open and answered, oldest first',
})
reviews!: IssueReviewDto[];
@ApiProperty({ @ApiProperty({
type: IssueParentDto, type: IssueParentDto,
nullable: true, nullable: true,
@@ -601,6 +612,26 @@ export class CommentReactionDto {
@ApiProperty({ type: [UserRefDto] }) users!: UserRefDto[]; @ApiProperty({ type: [UserRefDto] }) users!: UserRefDto[];
} }
export class PollOptionDto {
@ApiProperty() text!: string;
@ApiProperty({ type: [UserRefDto] }) voters!: UserRefDto[];
}
export class CommentPollDto {
@ApiProperty({ type: [PollOptionDto] }) options!: PollOptionDto[];
@ApiProperty() closed!: boolean;
@ApiProperty({
type: Number,
nullable: true,
description: 'Index of the winning option once closed (null on a tie)',
})
winner!: number | null;
}
export class CommentSpawnDto {
@ApiProperty() id!: string;
@ApiProperty({ example: 'WEB-45' }) key!: string;
}
export class CommentDto { export class CommentDto {
@ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) issueId!: string; @ApiProperty({ format: 'uuid' }) issueId!: string;
@@ -637,6 +668,26 @@ export class CommentDto {
decidedBy!: UserRefDto | null; decidedBy!: UserRefDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
decidedAt!: string | null; decidedAt!: string | null;
@ApiProperty({ type: CommentPollDto, nullable: true })
poll!: CommentPollDto | null;
@ApiProperty({
description:
'A conversation (top-level comment and its replies) that was settled',
})
resolved!: boolean;
@ApiProperty({
type: UserRefDto,
nullable: true,
description: 'Who settled it',
})
resolvedBy!: UserRefDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
resolvedAt!: string | null;
@ApiProperty({
type: () => [CommentSpawnDto],
description: 'Tickets made from it («Convertir en ticket»), oldest first',
})
spawned!: CommentSpawnDto[];
@ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
editedAt!: string | null; editedAt!: string | null;
@@ -1399,3 +1450,77 @@ export class AlertOutcomeDto {
@ApiProperty({ type: String, nullable: true, example: 'WEB-42' }) @ApiProperty({ type: String, nullable: true, example: 'WEB-42' })
issueKey!: string | null; issueKey!: string | null;
} }
export class IssueReminderDto {
@ApiProperty({ format: 'date-time' }) at!: string;
@ApiProperty({ type: String, nullable: true }) note!: string | null;
}
export class IssueVisitDto {
@ApiProperty({
type: String,
format: 'date-time',
nullable: true,
description: 'My visit before this one (null: first time)',
})
previousVisit!: string | null;
}
export class ReminderOnIssueDto {
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;
}
export class MyDayLeftoverDto {
@ApiProperty() day!: string;
@ApiProperty({ type: () => [IssueDto] }) issues!: IssueDto[];
}
export class MyDayDto {
@ApiProperty({ description: 'YYYY-MM-DD, in my time zone' }) day!: string;
@ApiProperty({ type: () => [IssueDto] }) issues!: IssueDto[];
@ApiProperty({ type: MyDayLeftoverDto, nullable: true })
leftover!: MyDayLeftoverDto | null;
}
export class IssueReviewDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ type: UserRefDto }) requester!: UserRefDto;
@ApiProperty({ type: UserRefDto }) reviewer!: UserRefDto;
@ApiProperty({ type: String, nullable: true }) note!: string | null;
@ApiProperty({ format: 'date-time' }) requestedAt!: string;
@ApiProperty({
enum: ReviewVerdict,
enumName: 'ReviewVerdict',
nullable: true,
description: 'Null while waiting for an answer',
})
verdict!: ReviewVerdict | null;
@ApiProperty({ type: String, nullable: true }) answer!: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
answeredAt!: string | null;
}
export class ReleaseNotesShareDto {
@ApiProperty({ format: 'uuid' }) releaseId!: string;
@ApiProperty({ description: 'The secret of the link: /notes/{token}' })
token!: string;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
}
export class PublicReleaseNotesDto {
@ApiProperty() projectName!: string;
@ApiProperty() projectKey!: string;
@ApiProperty() releaseName!: string;
@ApiProperty({ type: String, nullable: true }) description!: string | null;
@ApiProperty({ enum: ReleaseStatus, enumName: 'ReleaseStatus' })
status!: ReleaseStatus;
@ApiProperty({ type: String, nullable: true }) targetDate!: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
releasedAt!: string | null;
@ApiProperty({ description: 'Markdown' }) notes!: string;
@ApiProperty({
description: 'False: drawn from its finished tickets (none written yet)',
})
written!: boolean;
}

View File

@@ -33,6 +33,10 @@ import {
CommentsService, CommentsService,
IssuesService, IssuesService,
PinsService, PinsService,
TreeFollowsService,
IssueRemindersService,
IssueVisitsService,
MyDayService,
PresenceService, PresenceService,
ProjectsService, ProjectsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
@@ -46,6 +50,10 @@ import {
AddCommentDto, AddCommentDto,
CreateIssueDto, CreateIssueDto,
DecisionsQueryDto, DecisionsQueryDto,
SetReminderDto,
RequestReviewDto,
AnswerReviewDto,
MyDayQueryDto,
IssueSearchQueryDto, IssueSearchQueryDto,
PresenceBodyDto, PresenceBodyDto,
LinkIssueDto, LinkIssueDto,
@@ -65,6 +73,10 @@ import {
CommentDto, CommentDto,
CommentPageDto, CommentPageDto,
DecisionDto, DecisionDto,
IssueReminderDto,
IssueVisitDto,
MyDayDto,
ReminderOnIssueDto,
EpicDto, EpicDto,
IssueDto, IssueDto,
IssuePageDto, IssuePageDto,
@@ -99,6 +111,10 @@ export class IssuesController {
private readonly presence: PresenceService, private readonly presence: PresenceService,
private readonly projects: ProjectsService, private readonly projects: ProjectsService,
private readonly pins: PinsService, private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
private readonly myDayService: MyDayService,
private readonly visits: IssueVisitsService,
) {} ) {}
@Get('me/home') @Get('me/home')
@@ -203,6 +219,225 @@ export class IssuesController {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false); await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
} }
@Get('me/day')
@ApiOperation({
summary: '«Mi día»: the tickets I picked for today',
description: 'And what was left unfinished on my latest earlier day.',
})
@ApiOkResponse({ type: MyDayDto })
@ApiReadErrors()
async myDay(@CurrentUser() me: User, @Query() query: MyDayQueryDto) {
const myDay = await this.myDayService.get(me.id, query.day);
return {
day: myDay.day,
issues: await this.refs.issueDtos(myDay.issues, viewerOf(me)),
leftover: myDay.leftover && {
day: myDay.leftover.day,
issues: await this.refs.issueDtos(myDay.leftover.issues, viewerOf(me)),
},
};
}
@Put('me/day/:issue')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Add a ticket to «Mi día» (10 at most)' })
@ApiNoContentResponse()
@ApiCommandErrors()
async addToMyDay(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Query() query: MyDayQueryDto,
) {
await this.myDayService.add(me.id, query.day, [
await this.refs.issueId(me.id, issue),
]);
}
@Delete('me/day/:issue')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Take a ticket out of «Mi día»' })
@ApiNoContentResponse()
@ApiCommandErrors()
async removeFromMyDay(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Query() query: MyDayQueryDto,
) {
await this.myDayService.remove(
me.id,
query.day,
await this.refs.issueId(me.id, issue),
);
}
@Get('me/reminders')
@ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' })
@ApiOkResponse({ type: [ReminderOnIssueDto] })
@ApiReadErrors()
async myReminders(@CurrentUser() me: User) {
const mine = await this.reminders.mine(me.id);
const issues = await this.refs.issueDtos(
mine.map((m) => m.issue),
viewerOf(me),
);
return mine.map((m, i) => ({
reminder: { at: m.reminder.at, note: m.reminder.note },
issue: issues[i],
}));
}
@Put('issues/:issue/reminder')
@ISSUE
@ApiOperation({
summary: 'Remind me of a ticket at a date and time',
description:
'A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me.',
})
@ApiOkResponse({ type: IssueReminderDto })
@ApiCommandErrors()
async setReminder(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: SetReminderDto,
) {
const reminder = await this.reminders.set(
me.id,
await this.refs.issueId(me.id, issue),
body.at,
body.note ?? null,
);
return { at: reminder.at, note: reminder.note };
}
@Delete('issues/:issue/reminder')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Drop my reminder on a ticket' })
@ApiNoContentResponse()
@ApiCommandErrors()
async clearReminder(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.reminders.clear(me.id, await this.refs.issueId(me.id, issue));
}
@Post('issues/:issue/reviews')
@ISSUE
@ApiOperation({
summary: 'Ask a member to review the ticket',
description:
'Whoever can change the ticket asks; the reviewer is told and sees it in GET /api/me/issues?reviewing=true until they answer.',
})
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async requestReview(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: RequestReviewDto,
) {
return this.issues.requestReview(
me.id,
await this.refs.issueId(me.id, issue),
body.reviewer,
body.note ?? null,
);
}
@Post('issues/:issue/reviews/:review/answer')
@HttpCode(200)
@ISSUE
@ApiParam({ name: 'review', format: 'uuid' })
@ApiOperation({
summary: 'Answer a review I was asked for (all good, or changes)',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async answerReview(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Param('review') review: string,
@Body() body: AnswerReviewDto,
) {
return this.issues.answerReview(
me.id,
await this.refs.issueId(me.id, issue),
review,
body.verdict,
body.answer ?? null,
);
}
@Delete('issues/:issue/reviews/:review')
@ISSUE
@ApiParam({ name: 'review', format: 'uuid' })
@ApiOperation({ summary: 'Withdraw a review request I made' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async cancelReview(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Param('review') review: string,
) {
return this.issues.cancelReview(
me.id,
await this.refs.issueId(me.id, issue),
review,
);
}
@Put('issues/:issue/visit')
@ISSUE
@ApiOperation({
summary: 'I opened this ticket now',
description:
'Answers when I had opened it before (null: never), to show what others did since. Private to me.',
})
@ApiOkResponse({ type: IssueVisitDto })
@ApiCommandErrors()
async visit(
@CurrentUser() me: User,
@Param('issue') issue: string,
): Promise<IssueVisitDto> {
return {
previousVisit: await this.visits.visit(
me.id,
await this.refs.issueId(me.id, issue),
),
};
}
@Put('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Follow a ticket with everything under it (an epic)',
description:
'Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async followTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
true,
);
}
@Delete('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Stop following a ticket with everything under it' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unfollowTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@Put('issues/:issue/watch') @Put('issues/:issue/watch')
@ISSUE @ISSUE
@ApiOperation({ @ApiOperation({
@@ -245,6 +480,7 @@ export class IssuesController {
const page = await this.issues.mine(me.id, { const page = await this.issues.mine(me.id, {
assignedToMe: query.assignedToMe, assignedToMe: query.assignedToMe,
watching: query.watching, watching: query.watching,
reviewing: query.reviewing,
statuses: query.status, statuses: query.status,
categories: query.category, categories: query.category,
search: query.search, search: query.search,
@@ -778,6 +1014,7 @@ export class IssuesController {
await this.refs.issueId(me.id, issue), await this.refs.issueId(me.id, issue),
body.body, body.body,
body.replyTo ?? null, body.replyTo ?? null,
body.poll ?? null,
); );
} }

View File

@@ -7,6 +7,7 @@ import {
NotFoundException, NotFoundException,
Param, Param,
Put, Put,
Query,
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
@@ -29,7 +30,12 @@ import {
import { SharingService } from '@task-stream/api/tracker/application'; import { SharingService } from '@task-stream/api/tracker/application';
import type { Response } from 'express'; import type { Response } from 'express';
import { ShareDashboardDto } from './dto/requests.dto'; import { ShareDashboardDto } from './dto/requests.dto';
import { DashboardShareDto, SharedDashboardDto } from './dto/responses.dto'; import {
DashboardShareDto,
PublicReleaseNotesDto,
ReleaseNotesShareDto,
SharedDashboardDto,
} from './dto/responses.dto';
import { Refs } from './refs'; import { Refs } from './refs';
const PROJECT = ApiParam({ const PROJECT = ApiParam({
@@ -102,6 +108,68 @@ export class SharingController {
await this.sharing.remove(me.id, await this.refs.projectId(me.id, project)); await this.sharing.remove(me.id, await this.refs.projectId(me.id, project));
} }
@Get('releases/:release/public-notes')
@ApiPireonAuth()
@ApiParam({ name: 'release', format: 'uuid' })
@ApiOperation({
summary: "A release's public notes link, if any (owners and admins)",
})
@ApiOkResponse({ type: ReleaseNotesShareDto })
@ApiReadErrors()
async releaseNotesShare(
@CurrentUser() me: User,
@Param('release') release: string,
@Res({ passthrough: true }) res: Response,
) {
const share = await this.sharing.releaseNotesShare(me.id, release);
if (!share) res.status(204);
return share ?? undefined;
}
@Put('releases/:release/public-notes')
@ApiPireonAuth()
@ApiParam({ name: 'release', format: 'uuid' })
@ApiOperation({
summary: "Turn a release's public notes page on (owners and admins)",
description:
'The page shows the notes written, or the ones drawn from its finished tickets. ?renew=true makes a new link.',
})
@ApiOkResponse({ type: ReleaseNotesShareDto })
@ApiCommandErrors()
shareReleaseNotes(
@CurrentUser() me: User,
@Param('release') release: string,
@Query('renew') renew?: string,
) {
return this.sharing.shareReleaseNotes(me.id, release, renew === 'true');
}
@Delete('releases/:release/public-notes')
@HttpCode(204)
@ApiPireonAuth()
@ApiParam({ name: 'release', format: 'uuid' })
@ApiOperation({ summary: "A release's public notes link stops working" })
@ApiNoContentResponse()
@ApiCommandErrors()
async unshareReleaseNotes(
@CurrentUser() me: User,
@Param('release') release: string,
) {
await this.sharing.unshareReleaseNotes(me.id, release);
}
@Get('shared/release-notes/:token')
@Public()
@ApiOperation({ summary: "A release's notes, for anyone with its link" })
@ApiOkResponse({ type: PublicReleaseNotesDto })
async publicReleaseNotes(@Param('token') token: string) {
const notes = /^[\w-]{20,100}$/.test(token)
? await this.sharing.publicReleaseNotes(token)
: null;
if (!notes) throw new NotFoundException('Unknown link');
return notes;
}
@Get('shared/dashboards/:token') @Get('shared/dashboards/:token')
@Public() @Public()
@ApiOperation({ @ApiOperation({

View File

@@ -0,0 +1,55 @@
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IssueRemindersService } from '@task-stream/api/tracker/application';
const MINUTE = 60_000;
/**
* Tells people's own ticket reminders («remind me on Thursday») every
* minute. Each is told once (its notification id), so several instances
* running this at once only repeat lookups. REMINDERS_ENABLED=false turns it
* off, with the due-date ones.
*/
@Injectable()
export class IssueReminderScheduler
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger(IssueReminderScheduler.name);
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(
private readonly reminders: IssueRemindersService,
private readonly config: ConfigService,
) {}
onApplicationBootstrap(): void {
if (String(this.config.get('REMINDERS_ENABLED') ?? 'true') === 'false')
return;
this.timer = setInterval(() => void this.run(), MINUTE);
this.timer.unref?.();
}
onApplicationShutdown(): void {
if (this.timer) clearInterval(this.timer);
}
private async run(): Promise<void> {
// A slow round must not pile up with the next.
if (this.running) return;
this.running = true;
try {
const sent = await this.reminders.sendDue();
if (sent) this.logger.log(`Ticket reminders sent (${sent})`);
} catch (error) {
this.logger.warn(`Ticket reminders failed: ${(error as Error).message}`);
} finally {
this.running = false;
}
}
}

View File

@@ -17,6 +17,7 @@ import { CalendarController } from './http/calendar.controller';
import { CalendarResolver } from './graphql/calendar.resolver'; import { CalendarResolver } from './graphql/calendar.resolver';
import { ReminderScheduler } from './schedulers/reminder.scheduler'; import { ReminderScheduler } from './schedulers/reminder.scheduler';
import { TrashScheduler } from './schedulers/trash.scheduler'; import { TrashScheduler } from './schedulers/trash.scheduler';
import { IssueReminderScheduler } from './schedulers/issue-reminder.scheduler';
import { ProjectsController } from './http/projects.controller'; import { ProjectsController } from './http/projects.controller';
import { Refs } from './http/refs'; import { Refs } from './http/refs';
import { ReleasesController } from './http/releases.controller'; import { ReleasesController } from './http/releases.controller';
@@ -80,6 +81,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
// Timers: entry points like the HTTP and GraphQL ones. // Timers: entry points like the HTTP and GraphQL ones.
ReminderScheduler, ReminderScheduler,
TrashScheduler, TrashScheduler,
IssueReminderScheduler,
ProjectResolver, ProjectResolver,
IssueResolver, IssueResolver,
CommentResolver, CommentResolver,

View File

@@ -169,6 +169,11 @@ export const APP_ICONS: Record<string, string> = {
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>',
'user-profile-company': 'user-profile-company':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V8l6-3v16M9 21h12V11l-6-2"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V8l6-3v16M9 21h12V11l-6-2"/></svg>',
poll: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20V12M12 20V5M19 20v-9M3 20h18"/></svg>',
video:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10.5 5-3v9l-5-3"/></svg>',
mic: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6"/></svg>',
stop: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
whatsapp: whatsapp:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-5.2A8 8 0 1 1 21 12Z"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-5.2A8 8 0 1 1 21 12Z"/></svg>',
}; };

View File

@@ -17,6 +17,7 @@ export const homeApiEvents = eventGroup({
mine: IssuePage; mine: IssuePage;
awaitingQa: IssuePage; awaitingQa: IssuePage;
following: IssuePage; following: IssuePage;
reviewing: IssuePage;
releases: Release[]; releases: Release[];
}>(), }>(),
loadFailed: type<string>(), loadFailed: type<string>(),

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