Compare commits
9 Commits
179d6a5e94
...
c27aa4b1d7
| Author | SHA1 | Date | |
|---|---|---|---|
| c27aa4b1d7 | |||
| 79b8094011 | |||
| 8ed16f133e | |||
| 3a46a236e4 | |||
| 93d028e870 | |||
| 172c53d21d | |||
| dfb5f973f4 | |||
| b88296c22b | |||
| d79ef418d3 |
File diff suppressed because it is too large
Load Diff
@@ -304,6 +304,7 @@ type Comment {
|
||||
editedAt: String
|
||||
id: ID!
|
||||
issueId: ID!
|
||||
poll: CommentPoll
|
||||
|
||||
"""In the order they are offered; only the ones somebody chose"""
|
||||
reactions: [CommentReaction!]!
|
||||
@@ -336,6 +337,15 @@ type CommentPage {
|
||||
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"""
|
||||
type CommentReaction {
|
||||
emoji: String!
|
||||
@@ -691,6 +701,9 @@ type Issue {
|
||||
dueDate: String
|
||||
figmaUrl: String
|
||||
|
||||
"""Whether I follow it with everything under it (false without a token)"""
|
||||
followingTree: Boolean!
|
||||
|
||||
"""History of this ticket"""
|
||||
history(limit: Int! = 50): [Activity!]!
|
||||
id: ID!
|
||||
@@ -702,6 +715,9 @@ type Issue {
|
||||
"""Linked tickets I can see"""
|
||||
linkedIssues: [Issue!]!
|
||||
links: [IssueLink!]!
|
||||
|
||||
"""My waiting reminder on it (null without one or a token)"""
|
||||
myReminder: IssueReminder
|
||||
number: Int!
|
||||
|
||||
"""The epic or ticket this one breaks down"""
|
||||
@@ -719,6 +735,9 @@ type Issue {
|
||||
rank: String!
|
||||
release: ReleaseRef
|
||||
reporter: UserRef!
|
||||
|
||||
"""Reviews asked, open and answered, oldest first"""
|
||||
reviews: [IssueReview!]!
|
||||
sprint: SprintRef
|
||||
|
||||
"""When work should start (roadmap)"""
|
||||
@@ -842,6 +861,27 @@ enum IssuePriority {
|
||||
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."""
|
||||
enum IssueSort {
|
||||
RANK
|
||||
@@ -934,7 +974,14 @@ type Mutation {
|
||||
"""
|
||||
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)
|
||||
@@ -944,6 +991,12 @@ type Mutation {
|
||||
"""Add a Pireon user to the project"""
|
||||
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
|
||||
"""
|
||||
@@ -964,10 +1017,21 @@ type Mutation {
|
||||
"""
|
||||
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)"""
|
||||
changeIssueStatus(input: ChangeIssueStatusInput!): 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.
|
||||
"""
|
||||
@@ -1016,6 +1080,11 @@ type Mutation {
|
||||
duplicateIssue(issueId: 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"""
|
||||
leavePresence(issueId: ID!): Boolean!
|
||||
|
||||
@@ -1052,10 +1121,18 @@ type Mutation {
|
||||
removeAttachment(attachmentId: ID!, issueId: 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"""
|
||||
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
|
||||
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
|
||||
"""
|
||||
@@ -1095,9 +1172,24 @@ type Mutation {
|
||||
"""Put a ticket in a sprint (null: back to the backlog)"""
|
||||
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"""
|
||||
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)"""
|
||||
startSprint(sprintId: ID!): CommandResult!
|
||||
|
||||
@@ -1129,6 +1221,9 @@ type Mutation {
|
||||
|
||||
"""Bring an archived project back (owner only)"""
|
||||
unarchiveProject(projectId: ID!): CommandResult!
|
||||
|
||||
"""Stop following a ticket with everything under it"""
|
||||
unfollowTree(issueId: ID!): Boolean!
|
||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
|
||||
"""Unpin a ticket"""
|
||||
@@ -1137,6 +1232,9 @@ type Mutation {
|
||||
"""The link stops working"""
|
||||
unshareDashboard(projectId: ID!): Boolean!
|
||||
|
||||
"""A release's public notes link stops working"""
|
||||
unshareReleaseNotes(releaseId: ID!): Boolean!
|
||||
|
||||
"""Push off on this device"""
|
||||
unsubscribePush(endpoint: String!): Boolean!
|
||||
|
||||
@@ -1165,6 +1263,11 @@ type Mutation {
|
||||
"""
|
||||
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
|
||||
"""
|
||||
@@ -1176,6 +1279,22 @@ type Mutation {
|
||||
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 {
|
||||
items: [Notification!]!
|
||||
|
||||
@@ -1255,6 +1374,8 @@ enum NotificationKind {
|
||||
QA_FAILED
|
||||
REMINDER
|
||||
REPLIED
|
||||
REVIEW_ANSWERED
|
||||
REVIEW_REQUESTED
|
||||
STATUS_CHANGED
|
||||
WATCHED
|
||||
}
|
||||
@@ -1287,6 +1408,11 @@ type OptionCount {
|
||||
value: String!
|
||||
}
|
||||
|
||||
type PollOption {
|
||||
text: String!
|
||||
voters: [UserRef!]!
|
||||
}
|
||||
|
||||
"""
|
||||
One of my active projects at a glance: open work, risks, sprint, releases and epics
|
||||
"""
|
||||
@@ -1519,6 +1645,23 @@ type ProposalsFunnel {
|
||||
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())"""
|
||||
input PushSubscriptionInput {
|
||||
auth: String!
|
||||
@@ -1591,6 +1734,14 @@ type Query {
|
||||
"""My calendar URL, if I made one"""
|
||||
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
|
||||
"""
|
||||
@@ -1615,6 +1766,9 @@ type Query {
|
||||
"""Only these of my projects"""
|
||||
projectIds: [ID!]
|
||||
|
||||
"""Only the tickets I was asked to review (unanswered)"""
|
||||
reviewing: Boolean! = false
|
||||
|
||||
"""Matches key, title or description (global search)"""
|
||||
search: String
|
||||
statuses: [String!]
|
||||
@@ -1634,6 +1788,9 @@ type Query {
|
||||
"""Projects where I am a member"""
|
||||
myProjects: [Project!]!
|
||||
|
||||
"""My waiting reminders on tickets, soonest first"""
|
||||
myReminders: [ReminderOnIssue!]!
|
||||
|
||||
"""My saved views of my work, across my projects"""
|
||||
myViews: [MyView!]!
|
||||
notificationSettings: NotificationSettings!
|
||||
@@ -1651,8 +1808,14 @@ type Query {
|
||||
|
||||
"""Projects anyone can read, also without signing in"""
|
||||
publicProjects: [Project!]!
|
||||
|
||||
"""A release's notes, for anyone with its link"""
|
||||
publicReleaseNotes(token: String!): PublicReleaseNotes
|
||||
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"""
|
||||
savedFilters(projectId: ID!): [SavedFilter!]!
|
||||
|
||||
@@ -1750,6 +1913,15 @@ type ReleaseForecast {
|
||||
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 {
|
||||
byStatus: [CountByStatus!]!
|
||||
finished: Int!
|
||||
@@ -1768,6 +1940,18 @@ enum ReleaseStatus {
|
||||
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)"""
|
||||
type Risks {
|
||||
blocked: [InsightIssue!]!
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,12 @@ export class IssuesResolver {
|
||||
.describe(
|
||||
'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
|
||||
.array(CATEGORY)
|
||||
.optional()
|
||||
@@ -110,6 +116,7 @@ export class IssuesResolver {
|
||||
params: {
|
||||
assignedToMe: boolean;
|
||||
watching: boolean;
|
||||
reviewing: boolean;
|
||||
categories?: z.infer<typeof CATEGORY>[];
|
||||
priorities?: z.infer<typeof PRIORITY>[];
|
||||
bugsOnly?: boolean;
|
||||
@@ -125,6 +132,7 @@ export class IssuesResolver {
|
||||
query: {
|
||||
assignedToMe: params.assignedToMe,
|
||||
watching: params.watching,
|
||||
reviewing: params.reviewing,
|
||||
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
|
||||
priority: params.priorities,
|
||||
bugsOnly: params.bugsOnly,
|
||||
@@ -291,6 +299,17 @@ export class IssuesResolver {
|
||||
})),
|
||||
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;
|
||||
const decisions = (
|
||||
@@ -332,6 +351,14 @@ export class IssuesResolver {
|
||||
url: d.url,
|
||||
})),
|
||||
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,
|
||||
trashedAt: issue.trashedAt,
|
||||
createdAt: issue.createdAt,
|
||||
@@ -496,18 +523,77 @@ export class IssuesResolver {
|
||||
.string()
|
||||
.optional()
|
||||
.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,
|
||||
})
|
||||
comment(
|
||||
{ issue, body, replyTo }: { issue: string; body: string; replyTo?: string },
|
||||
{
|
||||
issue,
|
||||
body,
|
||||
replyTo,
|
||||
poll,
|
||||
}: { issue: string; body: string; replyTo?: string; poll?: string[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
||||
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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -565,19 +651,29 @@ export class IssuesResolver {
|
||||
@Tool({
|
||||
name: 'watch_issue',
|
||||
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({
|
||||
issue: issueRef,
|
||||
follow: z.boolean().default(true),
|
||||
tree: z.boolean().default(false),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
watch(
|
||||
{ issue, follow }: { issue: string; follow: boolean },
|
||||
{ issue, follow, tree }: { issue: string; follow: boolean; tree: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const client = this.api.as(ctx);
|
||||
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(() =>
|
||||
this.api.data(
|
||||
follow
|
||||
@@ -773,6 +869,141 @@ 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:
|
||||
|
||||
@@ -2,49 +2,51 @@
|
||||
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con 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, «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 |
|
||||
| 📋 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 |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
|
||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, 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 |
|
||||
| 🗺️ 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 |
|
||||
| 🗳️ 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 |
|
||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
|
||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||
| 🚨 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 |
|
||||
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
|
||||
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
||||
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar; 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 |
|
||||
| 💬 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, 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 |
|
||||
| 📋 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 |
|
||||
| ☀ 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 |
|
||||
| 🚦 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 |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
|
||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, 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 |
|
||||
| 🗺️ 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 |
|
||||
| 🗳️ 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 |
|
||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
|
||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||
| 🚨 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 |
|
||||
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
|
||||
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
||||
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||
|
||||
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
||||
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
||||
|
||||
@@ -239,6 +239,12 @@ stateDiagram-v2
|
||||
- **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
|
||||
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 }`.
|
||||
- **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
|
||||
@@ -311,6 +317,12 @@ stateDiagram-v2
|
||||
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 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 }`).
|
||||
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`.
|
||||
@@ -425,7 +437,26 @@ 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,
|
||||
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 })`,
|
||||
`?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. **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`,
|
||||
`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
|
||||
@@ -540,17 +571,17 @@ que funcionan también sin token.
|
||||
|
||||
Todas devuelven `CommandResult { id, key, version }`.
|
||||
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||
| 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») |
|
||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||
| Comentarios | `addComment(replyTo, poll)`, `votePoll`, `closePoll`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||
| 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») |
|
||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
||||
|
||||
### Subscriptions
|
||||
|
||||
@@ -748,25 +779,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`).
|
||||
|
||||
| Recurso | Rutas |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `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` |
|
||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `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` |
|
||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||
| 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`) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||
| Recurso | Rutas |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `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`, `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`, `PUT/DELETE …/resolution`, `POST …/poll/vote`, `POST …/poll/close`; `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` |
|
||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||
| 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`) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||
|
||||
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
||||
|
||||
@@ -300,25 +300,26 @@ visualización de la plataforma:
|
||||
|
||||
## 4.6 Rutas
|
||||
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/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» |
|
||||
| `/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 |
|
||||
| `/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/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». 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) |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/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/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/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/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» |
|
||||
| `/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 |
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/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), «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. Cada proyecto lleva su semáforo de salud (`portfolioHealth`), con las causas al pasar por encima |
|
||||
| `/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. 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». 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 («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión |
|
||||
| `/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/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` | 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/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/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» |
|
||||
| `/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
|
||||
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
|
||||
|
||||
@@ -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_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`) |
|
||||
| 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` |
|
||||
| | `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) |
|
||||
@@ -62,12 +62,18 @@ flowchart LR
|
||||
| | `set_issue_release` | Meter en una release o sacar |
|
||||
| | `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 |
|
||||
| | `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 |
|
||||
| | `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 |
|
||||
| | `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 |
|
||||
| | `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 |
|
||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||
|
||||
@@ -40,6 +40,19 @@ export * from './lib/alerts/alert-payloads';
|
||||
export * from './lib/alerts/alerts';
|
||||
export * from './lib/ports/alert-integrations.repository';
|
||||
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/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/sharing/release-notes-shares';
|
||||
export * from './lib/ports/release-note-shares.repository';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
@@ -38,6 +38,7 @@ export class AddCommentCommandHandler
|
||||
replyTo: command.replyTo
|
||||
? await this.threadOf(command.replyTo, issue.id)
|
||||
: null,
|
||||
poll: command.poll,
|
||||
});
|
||||
|
||||
this.eventPublisher.mergeObjectContext(comment);
|
||||
|
||||
@@ -8,6 +8,8 @@ export class AddCommentCommand extends Command<CommandResult> {
|
||||
public readonly body: string,
|
||||
/** Comment it answers; a reply to a reply joins the same thread. */
|
||||
public readonly replyTo: string | null = null,
|
||||
/** A quick poll's options (2–6), if any. */
|
||||
public readonly poll: string[] | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ReactToCommentCommandHandler } from './react-to-comment.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 { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
@@ -50,6 +52,8 @@ export const COMMAND_HANDLERS = [
|
||||
ReactToCommentCommandHandler,
|
||||
MarkDecisionCommandHandler,
|
||||
ResolveThreadCommandHandler,
|
||||
...REVIEW_HANDLERS,
|
||||
...POLL_HANDLERS,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Project,
|
||||
IssuePriority,
|
||||
IssueType,
|
||||
ReviewVerdict,
|
||||
ProjectRole,
|
||||
TRACKER_FACTORIES,
|
||||
defaultProjectSettings,
|
||||
@@ -79,6 +80,12 @@ import { AddCommentCommand } from './add-comment.command';
|
||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './polls';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
RequestReviewCommand,
|
||||
} from './reviews';
|
||||
import { UpdateProjectCommand } from './update-project.command';
|
||||
import { VoteIssueCommand } from './vote-issue.command';
|
||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||
@@ -944,6 +951,125 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
108
libs/api/tracker/application/src/lib/commands/polls.ts
Normal file
108
libs/api/tracker/application/src/lib/commands/polls.ts
Normal 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];
|
||||
132
libs/api/tracker/application/src/lib/commands/reviews.ts
Normal file
132
libs/api/tracker/application/src/lib/commands/reviews.ts
Normal 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,
|
||||
];
|
||||
@@ -12,6 +12,7 @@ import { EditCommentCommand } from './commands/edit-comment.command';
|
||||
import { ReactToCommentCommand } from './commands/react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
||||
import { ResolveThreadCommand } from './commands/resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './commands/polls';
|
||||
import { Page } from './ports/find-issues.repository';
|
||||
import { GetCommentsQuery } from './queries/get-comments.query';
|
||||
import {
|
||||
@@ -31,9 +32,32 @@ export class CommentsService {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): Promise<CommandResult> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
ReviewVerdict,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -191,6 +195,14 @@ export function describeEvent(
|
||||
: 'abierta';
|
||||
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) {
|
||||
return event.parentKey
|
||||
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -75,6 +78,9 @@ import { versionOf } from './read-model-notifier';
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -127,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
event.actorId,
|
||||
(event as { userId?: string }).userId,
|
||||
(event as { assigneeId?: string }).assigneeId,
|
||||
(event as { reviewerId?: string }).reviewerId,
|
||||
];
|
||||
const users = await this.users.refs(mentioned);
|
||||
const actorOf = (id: string) =>
|
||||
|
||||
@@ -33,6 +33,13 @@ export class CommentAddedEventHandler
|
||||
decision: false,
|
||||
decidedBy: 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { CommentEditedEventHandler } from './comment-edited.event-handler';
|
||||
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
||||
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
||||
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
|
||||
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
|
||||
import { CommentPollEventHandler } from './comment-poll.event-handler';
|
||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||
@@ -54,6 +56,8 @@ export const EVENT_HANDLERS = [
|
||||
CommentReactedEventHandler,
|
||||
CommentDecisionMarkedEventHandler,
|
||||
CommentThreadResolvedEventHandler,
|
||||
IssueReviewsEventHandler,
|
||||
CommentPollEventHandler,
|
||||
IssueVotedEventHandler,
|
||||
IssueAssignedEventHandler,
|
||||
IssueCreatedEventHandler,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
82
libs/api/tracker/application/src/lib/follows/tree-follows.ts
Normal file
82
libs/api/tracker/application/src/lib/follows/tree-follows.ts
Normal 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,
|
||||
];
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
ReviewVerdict,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
|
||||
@@ -21,6 +22,11 @@ import {
|
||||
} from './commands/attach-file.command';
|
||||
import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
RequestReviewCommand,
|
||||
} from './commands/reviews';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-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. */
|
||||
download(
|
||||
viewerId: string | null,
|
||||
|
||||
32
libs/api/tracker/application/src/lib/my-day.service.ts
Normal file
32
libs/api/tracker/application/src/lib/my-day.service.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
libs/api/tracker/application/src/lib/my-day/my-day.spec.ts
Normal file
145
libs/api/tracker/application/src/lib/my-day/my-day.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
194
libs/api/tracker/application/src/lib/my-day/my-day.ts
Normal file
194
libs/api/tracker/application/src/lib/my-day/my-day.ts
Normal 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,
|
||||
];
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
CommentAddedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssuePriority,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
ReviewVerdict,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
@@ -24,6 +28,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { NotificationDraft, Notifier } from './notifier';
|
||||
|
||||
@@ -61,10 +66,14 @@ const excerpt = (text: string, max = 120) => {
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
CommentAddedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
)
|
||||
@@ -76,6 +85,7 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
private readonly users: UserDirectory,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly treeFollows: TreeFollowsRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent): Promise<void> {
|
||||
@@ -118,9 +128,23 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
const assigneeId =
|
||||
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
|
||||
const ticket = `${issue.key}: ${issue.title}`;
|
||||
const watcherIds =
|
||||
'watchers' in issue ? issue.watchers.map((w) => w.id) : [];
|
||||
const memberIds = new Set(project.members.map((m) => m.user.id));
|
||||
// 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[] = [];
|
||||
// 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) {
|
||||
drafts.push({
|
||||
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
|
||||
* sprint; null for the other edits (title, labels…), too small to tell.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IssuePriority,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
NotificationKind,
|
||||
NotificationReadModel,
|
||||
@@ -23,6 +24,7 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { NotificationMailer } from '../ports/notification-mailer';
|
||||
import { NotificationsRepository } from '../ports/notifications.repository';
|
||||
import { NotificationsEventHandler } from './notifications.event-handler';
|
||||
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
|
||||
import { Notifier } from './notifier';
|
||||
import {
|
||||
PushPayload,
|
||||
@@ -62,7 +64,12 @@ const at = <T extends TrackerEvent>(event: T, version = 2): T =>
|
||||
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 published: NotificationReadModel[] = [];
|
||||
const mailed: string[] = [];
|
||||
@@ -74,7 +81,9 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
||||
.add('ana')
|
||||
.add('bob')
|
||||
.add('qa')
|
||||
.add('pm');
|
||||
.add('pm')
|
||||
.add('lia')
|
||||
.add('gus');
|
||||
const issue = {
|
||||
id: 'i-1',
|
||||
key: 'WEB-1',
|
||||
@@ -85,7 +94,10 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
||||
{ id: 'pm', name: 'PM' },
|
||||
{ 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(
|
||||
repo,
|
||||
{
|
||||
@@ -118,15 +130,23 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
||||
);
|
||||
const handler = new NotificationsEventHandler(
|
||||
notifier,
|
||||
{ findById: async () => issue } as unknown as FindIssuesRepository,
|
||||
{
|
||||
findById: async (id: string) => (id === 'e-1' ? epic : issue),
|
||||
} as unknown as FindIssuesRepository,
|
||||
{
|
||||
findById: async () => ({
|
||||
id: 'p-1',
|
||||
key: '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 },
|
||||
role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER,
|
||||
role:
|
||||
id === 'ana'
|
||||
? ProjectRole.OWNER
|
||||
: id === 'gus'
|
||||
? ProjectRole.GUEST
|
||||
: ProjectRole.MEMBER,
|
||||
})),
|
||||
}),
|
||||
} as unknown as FindProjectsRepository,
|
||||
@@ -142,11 +162,49 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
||||
findById: async (id: string) =>
|
||||
id === 's-14' ? { id, name: 'Sprint 14' } : null,
|
||||
} 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 };
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const { handler, repo, published } = setup();
|
||||
await handler.handle(
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface IssueFilter {
|
||||
watching?: boolean;
|
||||
/** Only the ones this person follows. */
|
||||
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). */
|
||||
participantId?: string;
|
||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||
@@ -62,6 +64,8 @@ export interface CrossProjectIssueFilter {
|
||||
assigneeId?: string;
|
||||
/** Only the ones this person follows. */
|
||||
watcherId?: string;
|
||||
/** Only the ones this person was asked to review and has not answered. */
|
||||
reviewerId?: string;
|
||||
statuses?: string[];
|
||||
categories?: StatusCategory[];
|
||||
priorities?: IssuePriority[];
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export class SearchMyIssuesQueryHandler
|
||||
: undefined,
|
||||
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
||||
watcherId: query.filter.watching ? query.viewerId : undefined,
|
||||
reviewerId: query.filter.reviewing ? query.viewerId : undefined,
|
||||
statuses: query.filter.statuses,
|
||||
categories: filter.overdue
|
||||
? (filter.categories?.length ? filter.categories : open).filter((c) =>
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface MyIssuesFilter {
|
||||
assignedToMe: boolean;
|
||||
/** Only the tickets the viewer follows. */
|
||||
watching?: boolean;
|
||||
/** Only the tickets the viewer was asked to review (unanswered). */
|
||||
reviewing?: boolean;
|
||||
statuses?: string[];
|
||||
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
|
||||
categories?: StatusCategory[];
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
@@ -11,8 +11,16 @@ import {
|
||||
ShareDashboardCommand,
|
||||
UnshareDashboardCommand,
|
||||
} 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()
|
||||
export class SharingService {
|
||||
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. */
|
||||
shared(token: string): Promise<SharedDashboardReadModel | null> {
|
||||
return this.queryBus.execute(new GetSharedDashboardQuery(token));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
@@ -37,6 +37,13 @@ import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { PINS } from './saved-filters/pins';
|
||||
import { ALERT_HANDLERS } from './alerts/alerts';
|
||||
import { TREE_FOLLOWS } from './follows/tree-follows';
|
||||
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 { PinsService } from './pins.service';
|
||||
import { SendDueRemindersCommandHandler } from './notifications/reminders';
|
||||
@@ -84,6 +91,9 @@ const SERVICES = [
|
||||
SavedFiltersService,
|
||||
PinsService,
|
||||
AlertsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
MyDayService,
|
||||
SprintsService,
|
||||
GitService,
|
||||
WebhooksService,
|
||||
@@ -116,6 +126,10 @@ const SERVICES = [
|
||||
...MY_VIEWS,
|
||||
...PINS,
|
||||
...ALERT_HANDLERS,
|
||||
...TREE_FOLLOWS,
|
||||
...ISSUE_REMINDERS,
|
||||
...MY_DAY,
|
||||
...RELEASE_NOTES_SHARES,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
|
||||
24
libs/api/tracker/application/src/lib/tree-follows.service.ts
Normal file
24
libs/api/tracker/application/src/lib/tree-follows.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export * from './lib/issue/issue-hierarchy';
|
||||
export * from './lib/issue/issue-checklist';
|
||||
export * from './lib/issue/issue-rank';
|
||||
export * from './lib/issue/dev-link';
|
||||
export * from './lib/issue/review';
|
||||
export * from './lib/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
||||
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event';
|
||||
import {
|
||||
CommentPollClosedEvent,
|
||||
CommentPollVotedEvent,
|
||||
} from '../events/comment-poll.events';
|
||||
import { CommentDeletedEvent } from '../events/comment-deleted.event';
|
||||
import { CommentEditedEvent } from '../events/comment-edited.event';
|
||||
import { CommentReactedEvent } from '../events/comment-reacted.event';
|
||||
@@ -30,6 +34,32 @@ export interface CommentProps {
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
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;
|
||||
@@ -47,6 +77,11 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
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;
|
||||
public deleted = false;
|
||||
public createdAt!: string;
|
||||
public editedAt: string | null = null;
|
||||
@@ -139,6 +174,63 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
delete(actorId: string, canModerate: boolean): void {
|
||||
this.assertAlive();
|
||||
@@ -156,6 +248,16 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
|
||||
onCommentAddedEvent({ comment }: CommentAddedEvent): void {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export * from './comment-edited.event';
|
||||
export * from './comment-reacted.event';
|
||||
export * from './comment-decision-marked.event';
|
||||
export * from './comment-thread-resolved.event';
|
||||
export * from './issue-review.events';
|
||||
export * from './comment-poll.events';
|
||||
export * from './comment-deleted.event';
|
||||
export * from './release-created.event';
|
||||
export * from './release-updated.event';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,6 +17,8 @@ export class CommentFactory {
|
||||
body: string;
|
||||
/** Top-level comment it answers. */
|
||||
replyTo?: string | null;
|
||||
/** A quick poll's options. */
|
||||
poll?: string[] | null;
|
||||
}): Comment {
|
||||
const data: CommentProps = {
|
||||
id: newId(),
|
||||
@@ -23,6 +30,7 @@ export class CommentFactory {
|
||||
deleted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
editedAt: null,
|
||||
...(props.poll?.length ? { poll: validPoll(props.poll) } : {}),
|
||||
};
|
||||
const comment = new Comment(data.id);
|
||||
Object.assign(comment, { ...data });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
VersionedAggregateRoot,
|
||||
assertThat,
|
||||
} 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 { implicitRank, rankBetween } from './issue-rank';
|
||||
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 {
|
||||
type: IssueLinkType;
|
||||
@@ -136,6 +148,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public attachments: IssueAttachment[] = [];
|
||||
/** People who asked to be told about this ticket. */
|
||||
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). */
|
||||
public voterIds: string[] = [];
|
||||
/** 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. */
|
||||
watch(actorId: string, userId: string): void {
|
||||
if (this.watchers.includes(userId)) return;
|
||||
@@ -625,6 +729,39 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
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 {
|
||||
this.watchers = [...this.watchers, event.userId];
|
||||
}
|
||||
|
||||
23
libs/api/tracker/domain/src/lib/issue/review.ts
Normal file
23
libs/api/tracker/domain/src/lib/issue/review.ts
Normal 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;
|
||||
@@ -6,6 +6,14 @@ export class CommentReactionReadModel {
|
||||
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 {
|
||||
id!: string;
|
||||
issueId!: string;
|
||||
@@ -26,6 +34,8 @@ export class CommentReadModel {
|
||||
/** Who marked it and when (null when it is not a decision). */
|
||||
decidedBy!: UserRefReadModel | 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). */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReviewVerdict } from '../issue/review';
|
||||
import { DevLink } from '../issue/dev-link';
|
||||
import { IssueLinkType, IssuePriority } from '../issue/issue.enums';
|
||||
import { StatusCategory } from '../project/project-settings';
|
||||
@@ -85,6 +86,8 @@ export class IssueReadModel {
|
||||
attachments!: AttachmentReadModel[];
|
||||
/** People following the ticket. */
|
||||
watchers!: UserRefReadModel[];
|
||||
/** Asked reviews, open and answered, oldest first. */
|
||||
reviews!: IssueReviewReadModel[];
|
||||
/** How many want it done. */
|
||||
votes!: number;
|
||||
/** 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. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export enum NotificationKind {
|
||||
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
|
||||
/** A ticket of yours is due soon or has been still for too long. */
|
||||
REMINDER = 'REMINDER',
|
||||
/** Someone asked you to review a ticket. */
|
||||
REVIEW_REQUESTED = 'REVIEW_REQUESTED',
|
||||
/** The review you asked for was answered. */
|
||||
REVIEW_ANSWERED = 'REVIEW_ANSWERED',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,7 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
||||
decision: doc.decision ?? false,
|
||||
decidedBy: doc.decidedBy ?? null,
|
||||
decidedAt: doc.decidedAt ?? null,
|
||||
poll: doc.poll ?? null,
|
||||
resolved: doc.resolved ?? false,
|
||||
resolvedBy: doc.resolvedBy ?? null,
|
||||
resolvedAt: doc.resolvedAt ?? null,
|
||||
@@ -176,6 +177,10 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
};
|
||||
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||
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.categories?.length)
|
||||
query['statusCategory'] = { $in: filter.categories };
|
||||
@@ -497,6 +502,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
if (filter.unassigned) query['assignee'] = null;
|
||||
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
|
||||
if (filter.reviewerId)
|
||||
query['reviews'] = {
|
||||
$elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null },
|
||||
};
|
||||
if (filter.participantId)
|
||||
query.$and = [
|
||||
...(query.$and ?? []),
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,10 @@ export const ReadModels = {
|
||||
PinnedIssue: 'PinnedIssue',
|
||||
AlertIntegration: 'AlertIntegration',
|
||||
AlertTrace: 'AlertTrace',
|
||||
TreeFollow: 'TreeFollow',
|
||||
IssueReminder: 'IssueReminder',
|
||||
MyDay: 'MyDay',
|
||||
ReleaseNotesShare: 'ReleaseNotesShare',
|
||||
Sprint: 'SprintReadModel',
|
||||
GitIntegration: 'GitIntegration',
|
||||
OutgoingWebhook: 'OutgoingWebhook',
|
||||
@@ -360,6 +364,47 @@ export const AlertTraceSchema =
|
||||
SchemaFactory.createForClass(AlertTraceDocument);
|
||||
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_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' })
|
||||
class GitIntegrationDocument {
|
||||
/** The project's id. */
|
||||
@@ -465,6 +510,10 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
|
||||
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
|
||||
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
||||
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
||||
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
|
||||
{ name: ReadModels.MyDay, schema: MyDaySchema },
|
||||
{ name: ReadModels.ReleaseNotesShare, schema: ReleaseNotesShareSchema },
|
||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||
@@ -486,6 +535,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
// Tickets projected before attachments existed have none.
|
||||
attachments: rest.attachments ?? [],
|
||||
watchers: rest.watchers ?? [],
|
||||
reviews: rest.reviews ?? [],
|
||||
votes: rest.votes ?? 0,
|
||||
voterIds: rest.voterIds ?? [],
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
MyViewsRepository,
|
||||
PinsRepository,
|
||||
AlertIntegrationsRepository,
|
||||
TreeFollowsRepository,
|
||||
IssueRemindersRepository,
|
||||
MyDaysRepository,
|
||||
ReleaseNoteSharesRepository,
|
||||
GitIntegrationsRepository,
|
||||
OutgoingWebhooksRepository,
|
||||
AutomationsRepository,
|
||||
@@ -82,6 +86,10 @@ import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
||||
import { MongoPinsRepository } from './mongo/pins.repository';
|
||||
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
||||
import { MongoTreeFollowsRepository } from './mongo/tree-follows.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 { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||
@@ -163,6 +171,16 @@ const PORTS = [
|
||||
provide: AlertIntegrationsRepository,
|
||||
useClass: MongoAlertIntegrationsRepository,
|
||||
},
|
||||
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
|
||||
{
|
||||
provide: IssueRemindersRepository,
|
||||
useClass: MongoIssueRemindersRepository,
|
||||
},
|
||||
{ provide: MyDaysRepository, useClass: MongoMyDaysRepository },
|
||||
{
|
||||
provide: ReleaseNoteSharesRepository,
|
||||
useClass: MongoReleaseNoteSharesRepository,
|
||||
},
|
||||
{
|
||||
provide: GitIntegrationsRepository,
|
||||
useClass: MongoGitIntegrationsRepository,
|
||||
|
||||
@@ -22,8 +22,44 @@ export class CommentResolver {
|
||||
@Args('body') body: string,
|
||||
@Args('replyTo', { type: () => ID, nullable: true })
|
||||
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)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
StatusCategory,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export enum LinkDirection {
|
||||
@@ -23,6 +24,10 @@ export enum LinkDirection {
|
||||
}
|
||||
|
||||
registerEnumType(IssuePriority, { name: 'IssuePriority' });
|
||||
registerEnumType(ReviewVerdict, {
|
||||
name: 'ReviewVerdict',
|
||||
description: 'APPROVED: all good. CHANGES: something to change',
|
||||
});
|
||||
registerEnumType(CustomFieldType, {
|
||||
name: 'CustomFieldType',
|
||||
description: 'What a custom field holds (DATE: YYYY-MM-DD)',
|
||||
|
||||
@@ -18,12 +18,16 @@ import {
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
PinsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
MyDayService,
|
||||
PresenceService,
|
||||
WorkflowsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
ReviewVerdict,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
@@ -51,6 +55,9 @@ import {
|
||||
IssueGql,
|
||||
IssuePageGql,
|
||||
IssueParentGql,
|
||||
IssueReminderGql,
|
||||
MyDayGql,
|
||||
ReminderOnIssueGql,
|
||||
PresenceGql,
|
||||
} from './types';
|
||||
|
||||
@@ -63,6 +70,9 @@ export class IssueResolver {
|
||||
private readonly blockers: BlockersService,
|
||||
private readonly presence: PresenceService,
|
||||
private readonly pins: PinsService,
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
private readonly myDayService: MyDayService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => [PresenceGql], {
|
||||
@@ -159,6 +169,11 @@ export class IssueResolver {
|
||||
description: 'Only the tickets I follow',
|
||||
})
|
||||
watching: boolean,
|
||||
@Args('reviewing', {
|
||||
defaultValue: false,
|
||||
description: 'Only the tickets I was asked to review (unanswered)',
|
||||
})
|
||||
reviewing: boolean,
|
||||
@Args('statuses', { type: () => [String], nullable: true })
|
||||
statuses: string[] | undefined,
|
||||
@Args('categories', { type: () => [StatusCategory], nullable: true })
|
||||
@@ -195,6 +210,7 @@ export class IssueResolver {
|
||||
return this.issuesService.mine(me.id, {
|
||||
assignedToMe,
|
||||
watching,
|
||||
reviewing,
|
||||
statuses: statuses ?? undefined,
|
||||
categories: categories ?? undefined,
|
||||
search: search ?? undefined,
|
||||
@@ -280,6 +296,28 @@ export class IssueResolver {
|
||||
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, {
|
||||
description: 'Whether I pinned it (false without a token)',
|
||||
})
|
||||
@@ -391,6 +429,140 @@ export class IssueResolver {
|
||||
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;
|
||||
}
|
||||
|
||||
@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' })
|
||||
unwatchIssue(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import { SharingService } from '@task-stream/api/tracker/application';
|
||||
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. */
|
||||
@Resolver(() => DashboardShareGql)
|
||||
@@ -49,6 +54,51 @@ export class SharingResolver {
|
||||
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()
|
||||
@Query(() => SharedDashboardGql, {
|
||||
nullable: true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
StatusCategory,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AutomationActionKind,
|
||||
@@ -371,6 +372,24 @@ export class IssueParentGql {
|
||||
@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')
|
||||
export class IssueGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -408,6 +427,10 @@ export class IssueGql {
|
||||
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
||||
@Field(() => [UserRefGql], { description: 'People following the ticket' })
|
||||
watchers!: UserRefGql[];
|
||||
@Field(() => [IssueReviewGql], {
|
||||
description: 'Reviews asked, open and answered, oldest first',
|
||||
})
|
||||
reviews!: IssueReviewGql[];
|
||||
@Field(() => IssueParentGql, {
|
||||
nullable: true,
|
||||
description: 'The epic or ticket this one breaks down',
|
||||
@@ -532,6 +555,23 @@ export class CommentReactionGql {
|
||||
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('Comment')
|
||||
export class CommentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -562,6 +602,8 @@ export class CommentGql {
|
||||
})
|
||||
decidedBy!: UserRefGql | 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',
|
||||
@@ -1377,3 +1419,66 @@ export class AlertIntegrationGql {
|
||||
typeId!: string | null;
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import { CommentsService } from '@task-stream/api/tracker/application';
|
||||
import { REACTIONS } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
CommentBodyDto,
|
||||
ClosePollDto,
|
||||
ReactToCommentDto,
|
||||
ResolveThreadDto,
|
||||
VotePollDto,
|
||||
} from './dto/requests.dto';
|
||||
import { CommandResultDto } from './dto/responses.dto';
|
||||
|
||||
@@ -82,6 +84,39 @@ export class CommentsController {
|
||||
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);
|
||||
}
|
||||
|
||||
@Put(':commentId/resolution')
|
||||
@ApiOperation({
|
||||
summary: 'Settle a conversation (a top-level comment and its replies)',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
REACTIONS,
|
||||
ReviewVerdict,
|
||||
SETTING_COLOR_PATTERN,
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
@@ -555,6 +556,14 @@ export class MyIssuesQueryDto extends PageQueryDto {
|
||||
@IsBoolean()
|
||||
watching?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Only the tickets I was asked to review and have not answered',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
reviewing?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'Status ids. Repeat the parameter or separate with commas',
|
||||
@@ -1307,6 +1316,40 @@ export class AddCommentDto extends CommentBodyDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
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 {
|
||||
@@ -1552,6 +1595,51 @@ export class PushEndpointDto {
|
||||
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 ResolveThreadDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CustomFieldType,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ReviewVerdict,
|
||||
NotificationKind,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
@@ -481,6 +482,11 @@ export class IssueDto {
|
||||
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
||||
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
||||
watchers!: UserRefDto[];
|
||||
@ApiProperty({
|
||||
type: () => [IssueReviewDto],
|
||||
description: 'Reviews asked, open and answered, oldest first',
|
||||
})
|
||||
reviews!: IssueReviewDto[];
|
||||
@ApiProperty({
|
||||
type: IssueParentDto,
|
||||
nullable: true,
|
||||
@@ -606,6 +612,22 @@ export class CommentReactionDto {
|
||||
@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 CommentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||
@@ -642,6 +664,8 @@ export class CommentDto {
|
||||
decidedBy!: UserRefDto | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
decidedAt!: string | null;
|
||||
@ApiProperty({ type: CommentPollDto, nullable: true })
|
||||
poll!: CommentPollDto | null;
|
||||
@ApiProperty({
|
||||
description:
|
||||
'A conversation (top-level comment and its replies) that was settled',
|
||||
@@ -1417,3 +1441,67 @@ export class AlertOutcomeDto {
|
||||
@ApiProperty({ type: String, nullable: true, example: 'WEB-42' })
|
||||
issueKey!: string | null;
|
||||
}
|
||||
|
||||
export class IssueReminderDto {
|
||||
@ApiProperty({ format: 'date-time' }) at!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) note!: 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;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ import {
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
PinsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
MyDayService,
|
||||
PresenceService,
|
||||
ProjectsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
@@ -46,6 +49,10 @@ import {
|
||||
AddCommentDto,
|
||||
CreateIssueDto,
|
||||
DecisionsQueryDto,
|
||||
SetReminderDto,
|
||||
RequestReviewDto,
|
||||
AnswerReviewDto,
|
||||
MyDayQueryDto,
|
||||
IssueSearchQueryDto,
|
||||
PresenceBodyDto,
|
||||
LinkIssueDto,
|
||||
@@ -65,6 +72,9 @@ import {
|
||||
CommentDto,
|
||||
CommentPageDto,
|
||||
DecisionDto,
|
||||
IssueReminderDto,
|
||||
MyDayDto,
|
||||
ReminderOnIssueDto,
|
||||
EpicDto,
|
||||
IssueDto,
|
||||
IssuePageDto,
|
||||
@@ -99,6 +109,9 @@ export class IssuesController {
|
||||
private readonly presence: PresenceService,
|
||||
private readonly projects: ProjectsService,
|
||||
private readonly pins: PinsService,
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
private readonly myDayService: MyDayService,
|
||||
) {}
|
||||
|
||||
@Get('me/home')
|
||||
@@ -203,6 +216,204 @@ export class IssuesController {
|
||||
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/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')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
@@ -245,6 +456,7 @@ export class IssuesController {
|
||||
const page = await this.issues.mine(me.id, {
|
||||
assignedToMe: query.assignedToMe,
|
||||
watching: query.watching,
|
||||
reviewing: query.reviewing,
|
||||
statuses: query.status,
|
||||
categories: query.category,
|
||||
search: query.search,
|
||||
@@ -778,6 +990,7 @@ export class IssuesController {
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.body,
|
||||
body.replyTo ?? null,
|
||||
body.poll ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NotFoundException,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
@@ -29,7 +30,12 @@ import {
|
||||
import { SharingService } from '@task-stream/api/tracker/application';
|
||||
import type { Response } from 'express';
|
||||
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';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
@@ -102,6 +108,68 @@ export class SharingController {
|
||||
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')
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { CalendarController } from './http/calendar.controller';
|
||||
import { CalendarResolver } from './graphql/calendar.resolver';
|
||||
import { ReminderScheduler } from './schedulers/reminder.scheduler';
|
||||
import { TrashScheduler } from './schedulers/trash.scheduler';
|
||||
import { IssueReminderScheduler } from './schedulers/issue-reminder.scheduler';
|
||||
import { ProjectsController } from './http/projects.controller';
|
||||
import { Refs } from './http/refs';
|
||||
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.
|
||||
ReminderScheduler,
|
||||
TrashScheduler,
|
||||
IssueReminderScheduler,
|
||||
ProjectResolver,
|
||||
IssueResolver,
|
||||
CommentResolver,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const homeApiEvents = eventGroup({
|
||||
mine: IssuePage;
|
||||
awaitingQa: IssuePage;
|
||||
following: IssuePage;
|
||||
reviewing: IssuePage;
|
||||
releases: Release[];
|
||||
}>(),
|
||||
loadFailed: type<string>(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IssueSummary,
|
||||
LinkIssuesInput,
|
||||
UpdateIssueInput,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const issuePageEvents = eventGroup({
|
||||
@@ -46,9 +47,32 @@ export const issuePageEvents = eventGroup({
|
||||
files?: File[];
|
||||
/** Comment it answers (one level of replies). */
|
||||
replyTo?: string | null;
|
||||
/** A quick poll's options. */
|
||||
poll?: string[] | null;
|
||||
}>(),
|
||||
pollVoteRequested: type<{ commentId: string; option: number | null }>(),
|
||||
pollCloseRequested: type<{ commentId: string; decide: boolean }>(),
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
||||
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
||||
reviewRequested: type<{
|
||||
issueId: string;
|
||||
reviewerId: string;
|
||||
note: string | null;
|
||||
}>(),
|
||||
reviewAnswerRequested: type<{
|
||||
issueId: string;
|
||||
reviewId: string;
|
||||
verdict: ReviewVerdict;
|
||||
answer: string | null;
|
||||
}>(),
|
||||
reviewCancelRequested: type<{ issueId: string; reviewId: string }>(),
|
||||
reminderRequested: type<{
|
||||
issueId: string;
|
||||
/** null: drop it. */
|
||||
at: string | null;
|
||||
note: string | null;
|
||||
}>(),
|
||||
trashRequested: type<{
|
||||
issueId: string;
|
||||
trashed: boolean;
|
||||
|
||||
@@ -21,6 +21,8 @@ export class HomeFacade {
|
||||
/** Open tickets I follow, whoever works on them. */
|
||||
readonly following = this.store.following;
|
||||
readonly followingTotal = this.store.followingTotal;
|
||||
/** Tickets I was asked to review and have not answered. */
|
||||
readonly reviewing = this.store.reviewing;
|
||||
/** Planned releases of my projects, soonest first. */
|
||||
readonly releases = this.store.releases;
|
||||
/** False until the first load: show skeletons, not empty states. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IssueLinkType,
|
||||
IssueSummary,
|
||||
UpdateIssueInput,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { issuePageEvents } from '../events/issue.events';
|
||||
@@ -100,12 +101,29 @@ export class IssueFacade {
|
||||
body: string,
|
||||
files: File[] = [],
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.commentAddRequested({ issueId, body, files, replyTo }),
|
||||
this.dispatch.commentAddRequested({
|
||||
issueId,
|
||||
body,
|
||||
files,
|
||||
replyTo,
|
||||
poll,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Votes in a comment's poll (null takes the vote back). */
|
||||
votePoll(commentId: string, option: number | null): void {
|
||||
this.dispatch.pollVoteRequested({ commentId, option });
|
||||
}
|
||||
|
||||
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
||||
closePoll(commentId: string, decide = true): void {
|
||||
this.dispatch.pollCloseRequested({ commentId, decide });
|
||||
}
|
||||
|
||||
/** To the trash (true) or back from it (false). */
|
||||
setTrashed(trashed: boolean): void {
|
||||
this.withIssue((issueId) =>
|
||||
@@ -120,6 +138,57 @@ export class IssueFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/** Asks a member to review the open ticket. */
|
||||
requestReview(reviewerId: string, note: string | null = null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.reviewRequested({
|
||||
issueId,
|
||||
reviewerId,
|
||||
note: note?.trim() || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Answers a review I was asked for. */
|
||||
answerReview(
|
||||
reviewId: string,
|
||||
verdict: ReviewVerdict,
|
||||
answer: string | null = null,
|
||||
): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.reviewAnswerRequested({
|
||||
issueId,
|
||||
reviewId,
|
||||
verdict,
|
||||
answer: answer?.trim() || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cancelReview(reviewId: string): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.reviewCancelRequested({ issueId, reviewId }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Remind me of the ticket then (null: drop it); one per ticket. */
|
||||
setReminder(at: string | null, note: string | null = null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.reminderRequested({
|
||||
issueId,
|
||||
at,
|
||||
note: note?.trim() || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Follow the ticket with everything under it (an epic), or stop. */
|
||||
setFollowingTree(on: boolean): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.followTreeRequested({ issueId, on }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pin the ticket for me (or unpin it): at hand on my home page and in search. */
|
||||
setPinned(pinned: boolean): void {
|
||||
this.withIssue((issueId) =>
|
||||
|
||||
@@ -34,6 +34,8 @@ interface HomeState {
|
||||
awaitingQaTotal: number;
|
||||
following: IssueSummary[];
|
||||
followingTotal: number;
|
||||
/** Tickets I was asked to review and have not answered. */
|
||||
reviewing: IssueSummary[];
|
||||
releases: Release[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
@@ -54,6 +56,7 @@ export const HomeStore = signalStore(
|
||||
awaitingQaTotal: 0,
|
||||
following: [],
|
||||
followingTotal: 0,
|
||||
reviewing: [],
|
||||
releases: [],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
@@ -88,6 +91,7 @@ export const HomeStore = signalStore(
|
||||
awaitingQaTotal: payload.awaitingQa.total,
|
||||
following: payload.following.items,
|
||||
followingTotal: payload.following.total,
|
||||
reviewing: payload.reviewing.items,
|
||||
releases: payload.releases,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
@@ -123,6 +127,12 @@ export const HomeStore = signalStore(
|
||||
categories: [...OPEN_CATEGORIES],
|
||||
limit: 20,
|
||||
}),
|
||||
reviewing: issues.mine({
|
||||
assignedToMe: false,
|
||||
reviewing: true,
|
||||
categories: [...OPEN_CATEGORIES, StatusCategory.DONE],
|
||||
limit: 20,
|
||||
}),
|
||||
releases: releases.upcoming(5),
|
||||
}).pipe(
|
||||
mapResponse({
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
SprintsGateway,
|
||||
ReviewVerdict,
|
||||
TrackerRealtimeGateway,
|
||||
whenLabel,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
concatMap,
|
||||
@@ -379,6 +381,113 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.pollVoteRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments.votePoll(payload.commentId, payload.option).pipe(
|
||||
// A vote is not worth a toast, like a reaction.
|
||||
mapResponse({
|
||||
next: () => issueApiEvents.checklistSaved(),
|
||||
error: (error) =>
|
||||
issueApiEvents.commandFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.pollCloseRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.closePoll(payload.commentId, payload.decide),
|
||||
payload.decide
|
||||
? 'Encuesta cerrada: el resultado queda como decisión'
|
||||
: 'Encuesta cerrada',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reviewRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.requestReview(
|
||||
payload.issueId,
|
||||
payload.reviewerId,
|
||||
payload.note,
|
||||
),
|
||||
'Revisión pedida',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reviewAnswerRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.answerReview(
|
||||
payload.issueId,
|
||||
payload.reviewId,
|
||||
payload.verdict,
|
||||
payload.answer,
|
||||
),
|
||||
payload.verdict === ReviewVerdict.APPROVED
|
||||
? 'Revisado: todo bien'
|
||||
: 'Revisado: pides cambios',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reviewCancelRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.cancelReview(payload.issueId, payload.reviewId),
|
||||
'Petición de revisión retirada',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reminderRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
payload.at
|
||||
? issues.setReminder(
|
||||
payload.issueId,
|
||||
payload.at,
|
||||
payload.note,
|
||||
)
|
||||
: issues.clearReminder(payload.issueId),
|
||||
payload.at
|
||||
? `Te lo recordaré ${whenLabel(payload.at)}`
|
||||
: 'Recordatorio quitado',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.followTreeRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.followTree(payload.issueId, payload.on),
|
||||
payload.on
|
||||
? 'Ahora sigues este ticket con todo lo que cuelga de él'
|
||||
: 'Ya no sigues lo que cuelga de este ticket',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.pinRequested)
|
||||
.pipe(
|
||||
@@ -615,7 +724,12 @@ export const IssueStore = signalStore(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments
|
||||
.add(payload.issueId, payload.body, payload.replyTo ?? null)
|
||||
.add(
|
||||
payload.issueId,
|
||||
payload.body,
|
||||
payload.replyTo ?? null,
|
||||
payload.poll ?? null,
|
||||
)
|
||||
.pipe(
|
||||
concatMap((comment) =>
|
||||
payload.files?.length
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export * from './lib/models/enums';
|
||||
export * from './lib/models/models';
|
||||
export * from './lib/models/bulk';
|
||||
export * from './lib/models/reminders';
|
||||
export * from './lib/models/my-day';
|
||||
export * from './lib/models/health';
|
||||
export * from './lib/models/inputs';
|
||||
export * from './lib/models/workflow';
|
||||
export * from './lib/ports/gateways';
|
||||
|
||||
@@ -70,7 +70,15 @@ export enum NotificationKind {
|
||||
WATCHED = 'WATCHED',
|
||||
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
|
||||
REMINDER = 'REMINDER',
|
||||
REVIEW_REQUESTED = 'REVIEW_REQUESTED',
|
||||
REVIEW_ANSWERED = 'REVIEW_ANSWERED',
|
||||
}
|
||||
|
||||
/** The reactions a comment takes, in the order they are offered (GitHub's). */
|
||||
export const REACTIONS = ['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀'];
|
||||
|
||||
/** What a reviewer said. */
|
||||
export enum ReviewVerdict {
|
||||
APPROVED = 'APPROVED',
|
||||
CHANGES = 'CHANGES',
|
||||
}
|
||||
|
||||
126
libs/web/tracker/domain/src/lib/models/health.spec.ts
Normal file
126
libs/web/tracker/domain/src/lib/models/health.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { IssuePriority } from './enums';
|
||||
import { portfolioHealth, projectHealth } from './health';
|
||||
import { ProjectInsights } from './insights';
|
||||
|
||||
const issue = (key: string) => ({ key }) as never;
|
||||
|
||||
const insights = (over: Partial<ProjectInsights> = {}): ProjectInsights =>
|
||||
({
|
||||
risks: {
|
||||
overdue: [],
|
||||
overdueCount: 0,
|
||||
dueSoon: [],
|
||||
dueSoonCount: 0,
|
||||
blocked: [],
|
||||
blockedCount: 0,
|
||||
stuck: [],
|
||||
stuckCount: 0,
|
||||
stuckDays: 5,
|
||||
},
|
||||
quality: {
|
||||
openBugsByPriority: [],
|
||||
bugWeeks: [],
|
||||
qaByArea: [],
|
||||
escaped: [],
|
||||
},
|
||||
releases: [],
|
||||
bottlenecks: [],
|
||||
...over,
|
||||
}) as ProjectInsights;
|
||||
|
||||
describe('project health', () => {
|
||||
it('is good with nothing to say', () => {
|
||||
expect(projectHealth(insights())).toEqual({ level: 'GOOD', signals: [] });
|
||||
});
|
||||
|
||||
it('turns to attention, then risk, with the cause and the tickets behind it', () => {
|
||||
const base = insights();
|
||||
const stuck = projectHealth({
|
||||
...base,
|
||||
risks: { ...base.risks, stuck: [issue('WEB-1')], stuckCount: 1 },
|
||||
});
|
||||
expect(stuck.level).toBe('ATTENTION');
|
||||
expect(stuck.signals[0]).toMatchObject({
|
||||
key: 'stuck',
|
||||
title: '1 ticket lleva 5 días o más sin moverse',
|
||||
keys: ['WEB-1'],
|
||||
});
|
||||
|
||||
const bad = projectHealth({
|
||||
...base,
|
||||
risks: { ...base.risks, overdueCount: 4, overdue: [] },
|
||||
quality: {
|
||||
...base.quality,
|
||||
openBugsByPriority: [{ priority: IssuePriority.CRITICAL, count: 1 }],
|
||||
qaByArea: [{ area: null, returned: 3, passed: 3 }],
|
||||
},
|
||||
releases: [
|
||||
{
|
||||
releaseId: 'r',
|
||||
name: 'v2.0',
|
||||
targetDate: '2026-11-15',
|
||||
total: 10,
|
||||
finished: 2,
|
||||
likelyDate: '2026-12-01',
|
||||
safeDate: null,
|
||||
verdict: 'LATE',
|
||||
},
|
||||
],
|
||||
bottlenecks: [
|
||||
{
|
||||
status: 'IN_PROGRESS',
|
||||
averageDays: 1,
|
||||
samples: 3,
|
||||
open: 9,
|
||||
wipLimit: 5,
|
||||
overLimit: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(bad.level).toBe('RISK');
|
||||
expect(bad.signals.map((s) => [s.key, s.level])).toEqual([
|
||||
['overdue', 'RISK'],
|
||||
['bugs', 'RISK'],
|
||||
['qa', 'RISK'],
|
||||
['releases', 'RISK'],
|
||||
['wip', 'ATTENTION'],
|
||||
]);
|
||||
expect(bad.signals[3].title).toContain('v2.0 no llega a su fecha');
|
||||
});
|
||||
|
||||
it('does not read a trend into a handful of QA verdicts', () => {
|
||||
const base = insights();
|
||||
expect(
|
||||
projectHealth({
|
||||
...base,
|
||||
quality: {
|
||||
...base.quality,
|
||||
qaByArea: [{ area: null, returned: 2, passed: 1 }],
|
||||
},
|
||||
}).level,
|
||||
).toBe('GOOD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('portfolio health', () => {
|
||||
it('reads the light from the counts and the forecasts', () => {
|
||||
const project = {
|
||||
stuck: 0,
|
||||
overdue: 1,
|
||||
blocked: 0,
|
||||
releases: [{ name: 'v2.0', verdict: 'LATE' }],
|
||||
} as never;
|
||||
expect(portfolioHealth(project)).toEqual({
|
||||
level: 'RISK',
|
||||
causes: ['1 vencido', 'v2.0 llega tarde'],
|
||||
});
|
||||
expect(
|
||||
portfolioHealth({
|
||||
stuck: 0,
|
||||
overdue: 0,
|
||||
blocked: 0,
|
||||
releases: [],
|
||||
} as never),
|
||||
).toEqual({ level: 'GOOD', causes: [] });
|
||||
});
|
||||
});
|
||||
173
libs/web/tracker/domain/src/lib/models/health.ts
Normal file
173
libs/web/tracker/domain/src/lib/models/health.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { IssuePriority } from './enums';
|
||||
import { PortfolioProject, ProjectInsights } from './insights';
|
||||
|
||||
/** How a project looks, as a traffic light. */
|
||||
export type HealthLevel = 'GOOD' | 'ATTENTION' | 'RISK';
|
||||
|
||||
/** One thing that colours the light, with its cause. */
|
||||
export interface HealthSignal {
|
||||
key: 'stuck' | 'overdue' | 'blocked' | 'bugs' | 'qa' | 'releases' | 'wip';
|
||||
level: Exclude<HealthLevel, 'GOOD'>;
|
||||
/** What is wrong, in a line. */
|
||||
title: string;
|
||||
/** Tickets behind it (keys), when there are. */
|
||||
keys: string[];
|
||||
/** Where to look: a dashboard section id. */
|
||||
section: string;
|
||||
}
|
||||
|
||||
export interface ProjectHealth {
|
||||
level: HealthLevel;
|
||||
/** Worst first. */
|
||||
signals: HealthSignal[];
|
||||
}
|
||||
|
||||
const RANK: Record<HealthLevel, number> = { GOOD: 0, ATTENTION: 1, RISK: 2 };
|
||||
|
||||
const plural = (n: number, one: string, many: string) =>
|
||||
`${n} ${n === 1 ? one : many}`;
|
||||
|
||||
const shortDate = (day: string) =>
|
||||
new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
/**
|
||||
* The project's health from its insights: stuck work, overdue and blocked
|
||||
* tickets, urgent bugs, QA sending too much back, releases that will not
|
||||
* make it and columns over their WIP limit. The light is the worst signal.
|
||||
*/
|
||||
export function projectHealth(insights: ProjectInsights): ProjectHealth {
|
||||
const signals: HealthSignal[] = [];
|
||||
const { risks, quality, releases, bottlenecks } = insights;
|
||||
const keys = (issues: { key: string }[]) => issues.map((i) => i.key);
|
||||
|
||||
if (risks.stuckCount)
|
||||
signals.push({
|
||||
key: 'stuck',
|
||||
level: risks.stuckCount >= 3 ? 'RISK' : 'ATTENTION',
|
||||
title: `${plural(risks.stuckCount, 'ticket lleva', 'tickets llevan')} ${risks.stuckDays} días o más sin moverse`,
|
||||
keys: keys(risks.stuck),
|
||||
section: 'risks',
|
||||
});
|
||||
if (risks.overdueCount)
|
||||
signals.push({
|
||||
key: 'overdue',
|
||||
level: risks.overdueCount >= 3 ? 'RISK' : 'ATTENTION',
|
||||
title: `${plural(risks.overdueCount, 'ticket ha pasado', 'tickets han pasado')} su fecha límite`,
|
||||
keys: keys(risks.overdue),
|
||||
section: 'risks',
|
||||
});
|
||||
if (risks.blockedCount)
|
||||
signals.push({
|
||||
key: 'blocked',
|
||||
level: risks.blockedCount >= 5 ? 'RISK' : 'ATTENTION',
|
||||
title: `${plural(risks.blockedCount, 'ticket espera', 'tickets esperan')} a otro que lo bloquea`,
|
||||
keys: keys(risks.blocked),
|
||||
section: 'risks',
|
||||
});
|
||||
|
||||
const bugs = (priority: IssuePriority) =>
|
||||
quality.openBugsByPriority.find((b) => b.priority === priority)?.count ?? 0;
|
||||
const critical = bugs(IssuePriority.CRITICAL);
|
||||
const high = bugs(IssuePriority.HIGH);
|
||||
if (critical || high >= 3)
|
||||
signals.push({
|
||||
key: 'bugs',
|
||||
level: critical ? 'RISK' : 'ATTENTION',
|
||||
title: critical
|
||||
? `${plural(critical, 'bug crítico abierto', 'bugs críticos abiertos')}${high ? ` y ${plural(high, 'de prioridad alta', 'de prioridad alta')}` : ''}`
|
||||
: `${high} bugs de prioridad alta abiertos`,
|
||||
keys: [],
|
||||
section: 'quality',
|
||||
});
|
||||
|
||||
const returned = quality.qaByArea.reduce((n, a) => n + a.returned, 0);
|
||||
const passed = quality.qaByArea.reduce((n, a) => n + a.passed, 0);
|
||||
const reviewed = returned + passed;
|
||||
// Too few to tell a trend from bad luck.
|
||||
if (reviewed >= 5 && returned / reviewed >= 0.3) {
|
||||
const share = Math.round((returned / reviewed) * 100);
|
||||
signals.push({
|
||||
key: 'qa',
|
||||
level: share >= 50 ? 'RISK' : 'ATTENTION',
|
||||
title: `QA devuelve el ${share} % de lo que revisa (90 días)`,
|
||||
keys: [],
|
||||
section: 'quality',
|
||||
});
|
||||
}
|
||||
|
||||
for (const release of releases) {
|
||||
if (release.verdict !== 'LATE' && release.verdict !== 'AT_RISK') continue;
|
||||
const target = release.targetDate ? shortDate(release.targetDate) : '';
|
||||
const likely = release.likelyDate ? shortDate(release.likelyDate) : '';
|
||||
signals.push({
|
||||
key: 'releases',
|
||||
level: release.verdict === 'LATE' ? 'RISK' : 'ATTENTION',
|
||||
title:
|
||||
release.verdict === 'LATE'
|
||||
? `${release.name} no llega a su fecha (${target}): al ritmo real, hacia el ${likely}`
|
||||
: `${release.name} va justa para el ${target}: la mitad de las veces no llega`,
|
||||
keys: [],
|
||||
section: 'releases',
|
||||
});
|
||||
}
|
||||
|
||||
const over = bottlenecks.filter((b) => b.overLimit);
|
||||
if (over.length)
|
||||
signals.push({
|
||||
key: 'wip',
|
||||
level: 'ATTENTION',
|
||||
title: `${plural(over.length, 'columna pasa', 'columnas pasan')} de su límite WIP`,
|
||||
keys: [],
|
||||
section: 'bottlenecks',
|
||||
});
|
||||
|
||||
signals.sort((a, b) => RANK[b.level] - RANK[a.level]);
|
||||
return {
|
||||
level: signals.reduce<HealthLevel>(
|
||||
(worst, s) => (RANK[s.level] > RANK[worst] ? s.level : worst),
|
||||
'GOOD',
|
||||
),
|
||||
signals,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same light from what the portfolio knows of a project (overdue,
|
||||
* stuck and blocked tickets, release forecasts), with its causes in a line.
|
||||
*/
|
||||
export function portfolioHealth(project: PortfolioProject): {
|
||||
level: HealthLevel;
|
||||
causes: string[];
|
||||
} {
|
||||
const causes: string[] = [];
|
||||
let level: HealthLevel = 'GOOD';
|
||||
const raise = (to: HealthLevel) => {
|
||||
if (RANK[to] > RANK[level]) level = to;
|
||||
};
|
||||
if (project.stuck) {
|
||||
raise(project.stuck >= 3 ? 'RISK' : 'ATTENTION');
|
||||
causes.push(plural(project.stuck, 'atascado', 'atascados'));
|
||||
}
|
||||
if (project.overdue) {
|
||||
raise(project.overdue >= 3 ? 'RISK' : 'ATTENTION');
|
||||
causes.push(plural(project.overdue, 'vencido', 'vencidos'));
|
||||
}
|
||||
if (project.blocked >= 5) raise('RISK');
|
||||
else if (project.blocked) raise('ATTENTION');
|
||||
if (project.blocked)
|
||||
causes.push(plural(project.blocked, 'bloqueado', 'bloqueados'));
|
||||
for (const release of project.releases) {
|
||||
if (release.verdict === 'LATE') {
|
||||
raise('RISK');
|
||||
causes.push(`${release.name} llega tarde`);
|
||||
} else if (release.verdict === 'AT_RISK') {
|
||||
raise('ATTENTION');
|
||||
causes.push(`${release.name} va justa`);
|
||||
}
|
||||
}
|
||||
return { level, causes };
|
||||
}
|
||||
@@ -121,6 +121,8 @@ export interface MyIssuesFilter {
|
||||
assignedToMe: boolean;
|
||||
/** Only the tickets I follow. */
|
||||
watching?: boolean;
|
||||
/** Only the tickets I was asked to review (unanswered). */
|
||||
reviewing?: boolean;
|
||||
categories?: StatusCategory[];
|
||||
/** Matches key, title or description. */
|
||||
search?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
NotificationKind,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ReviewVerdict,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
@@ -75,6 +76,8 @@ export interface Epic {
|
||||
progress: ChildProgress;
|
||||
/** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */
|
||||
links: IssueLink[];
|
||||
/** Whether I follow it with everything under it. */
|
||||
followingTree: boolean;
|
||||
}
|
||||
|
||||
/** What a saved view narrows the board to; empty: everything. */
|
||||
@@ -190,8 +193,14 @@ export interface Issue extends IssueSummary {
|
||||
attachments: Attachment[];
|
||||
/** People following the ticket. */
|
||||
watchers: UserRef[];
|
||||
/** Reviews asked, open and answered, oldest first. */
|
||||
reviews: IssueReview[];
|
||||
/** Whether I keep it pinned (home page and search). */
|
||||
pinned: boolean;
|
||||
/** Whether I follow it with everything under it (an epic). */
|
||||
followingTree: boolean;
|
||||
/** My waiting reminder on it («remind me on Thursday»). */
|
||||
myReminder: IssueReminder | null;
|
||||
/** Its comments marked as decisions, oldest first. */
|
||||
decisions: Comment[];
|
||||
checklist: ChecklistItem[];
|
||||
@@ -202,6 +211,26 @@ export interface Issue extends IssueSummary {
|
||||
}
|
||||
|
||||
/** Someone else on a ticket's page right now. */
|
||||
/** A review someone asked on a ticket. */
|
||||
export interface IssueReview {
|
||||
id: string;
|
||||
requester: UserRef;
|
||||
reviewer: UserRef;
|
||||
note: string | null;
|
||||
requestedAt: string;
|
||||
/** Null while waiting for an answer. */
|
||||
verdict: ReviewVerdict | null;
|
||||
answer: string | null;
|
||||
answeredAt: string | null;
|
||||
}
|
||||
|
||||
/** My own reminder on a ticket. */
|
||||
export interface IssueReminder {
|
||||
/** ISO date-time. */
|
||||
at: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** Someone writing on a ticket right now. */
|
||||
export interface Typing {
|
||||
user: UserRef;
|
||||
@@ -350,6 +379,8 @@ export interface Comment {
|
||||
decision: boolean;
|
||||
/** Who marked it as a decision (null when it is not one). */
|
||||
decidedBy: UserRef | null;
|
||||
/** A quick poll in it, if any. */
|
||||
poll: CommentPoll | null;
|
||||
/** A conversation (top-level comment and its replies) that was settled. */
|
||||
resolved: boolean;
|
||||
/** Who settled it (null while open). */
|
||||
@@ -358,6 +389,14 @@ export interface Comment {
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
/** A quick poll in a comment: each option with who voted it. */
|
||||
export interface CommentPoll {
|
||||
options: { text: string; voters: UserRef[] }[];
|
||||
closed: boolean;
|
||||
/** Index of the winning option once closed (null on a tie). */
|
||||
winner: number | null;
|
||||
}
|
||||
|
||||
/** A decision found by its text, with its ticket. */
|
||||
export interface Decision {
|
||||
comment: Comment;
|
||||
|
||||
11
libs/web/tracker/domain/src/lib/models/my-day.ts
Normal file
11
libs/web/tracker/domain/src/lib/models/my-day.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IssueSummary } from './models';
|
||||
|
||||
/** «Mi día»: the tickets I picked for today. */
|
||||
export interface MyDay {
|
||||
/** `YYYY-MM-DD` in my time zone. */
|
||||
day: string;
|
||||
/** In order, done ones included. */
|
||||
issues: IssueSummary[];
|
||||
/** What was left unfinished on my latest earlier day. */
|
||||
leftover: { day: string; issues: IssueSummary[] } | null;
|
||||
}
|
||||
40
libs/web/tracker/domain/src/lib/models/reminders.spec.ts
Normal file
40
libs/web/tracker/domain/src/lib/models/reminders.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { reminderChoices } from './reminders';
|
||||
|
||||
const local = (y: number, m: number, d: number, h: number, min = 0) =>
|
||||
new Date(y, m - 1, d, h, min);
|
||||
|
||||
describe('reminder choices', () => {
|
||||
it('offers this evening only while there is time', () => {
|
||||
// Tuesday 20 October 2026.
|
||||
const morning = reminderChoices(local(2026, 10, 20, 10)).map(
|
||||
(c) => c.label,
|
||||
);
|
||||
expect(morning).toEqual([
|
||||
'Dentro de 1 hora',
|
||||
'Esta tarde, a las 18:00',
|
||||
'Mañana a las 9:00',
|
||||
'El lunes a las 9:00',
|
||||
'Dentro de una semana',
|
||||
]);
|
||||
const late = reminderChoices(local(2026, 10, 20, 19)).map((c) => c.label);
|
||||
expect(late).not.toContain('Esta tarde, a las 18:00');
|
||||
});
|
||||
|
||||
it('lands on the right days and hours, local time', () => {
|
||||
const [, evening, tomorrow, monday, week] = reminderChoices(
|
||||
local(2026, 10, 20, 10, 30),
|
||||
).map((c) => new Date(c.at));
|
||||
expect([evening.getDate(), evening.getHours()]).toEqual([20, 18]);
|
||||
expect([tomorrow.getDate(), tomorrow.getHours()]).toEqual([21, 9]);
|
||||
expect([monday.getDay(), monday.getDate(), monday.getHours()]).toEqual([
|
||||
1, 26, 9,
|
||||
]);
|
||||
expect([week.getDate(), week.getHours()]).toEqual([27, 9]);
|
||||
});
|
||||
|
||||
it('does not offer Monday twice on a Sunday (it is tomorrow)', () => {
|
||||
const sunday = reminderChoices(local(2026, 10, 25, 10)).map((c) => c.label);
|
||||
expect(sunday.filter((l) => l.includes('lunes'))).toEqual([]);
|
||||
expect(sunday).toContain('Mañana a las 9:00');
|
||||
});
|
||||
});
|
||||
48
libs/web/tracker/domain/src/lib/models/reminders.ts
Normal file
48
libs/web/tracker/domain/src/lib/models/reminders.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** A ready-made moment for «remind me…». */
|
||||
export interface ReminderChoice {
|
||||
label: string;
|
||||
/** ISO date-time. */
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** «el jue 23 a las 9:00», in the browser's time zone. */
|
||||
export function whenLabel(at: string): string {
|
||||
const date = new Date(at);
|
||||
const day = date.toLocaleDateString('es', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
const time = date.toLocaleTimeString('es', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `el ${day} a las ${time}`;
|
||||
}
|
||||
|
||||
/** `now` moved `days` days on, at `hour`:00 (local time). */
|
||||
function at(now: Date, days: number, hour: number): Date {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() + days);
|
||||
date.setHours(hour, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The usual moments, from `now` in local time: in an hour, this evening
|
||||
* (while there is time), tomorrow and next Monday at 9, and in a week.
|
||||
*/
|
||||
export function reminderChoices(now: Date = new Date()): ReminderChoice[] {
|
||||
const choices: { label: string; at: Date }[] = [
|
||||
{ label: 'Dentro de 1 hora', at: new Date(now.getTime() + 3_600_000) },
|
||||
];
|
||||
if (now.getHours() < 17)
|
||||
choices.push({ label: 'Esta tarde, a las 18:00', at: at(now, 0, 18) });
|
||||
choices.push({ label: 'Mañana a las 9:00', at: at(now, 1, 9) });
|
||||
// Monday is 1; from Sunday it is tomorrow, from Monday next week.
|
||||
const toMonday = (8 - now.getDay()) % 7 || 7;
|
||||
if (toMonday > 1)
|
||||
choices.push({ label: 'El lunes a las 9:00', at: at(now, toMonday, 9) });
|
||||
choices.push({ label: 'Dentro de una semana', at: at(now, 7, 9) });
|
||||
return choices.map((c) => ({ label: c.label, at: c.at.toISOString() }));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IssueLinkType } from './enums';
|
||||
import {
|
||||
dependencyClash,
|
||||
externalDependencies,
|
||||
roadmapBar,
|
||||
roadmapDependencies,
|
||||
roadmapMonths,
|
||||
@@ -74,3 +75,43 @@ describe('roadmap', () => {
|
||||
expect(dependencyClash('2026-11-10', '2026-11-03')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dependencies on other projects', () => {
|
||||
const link = (
|
||||
type: IssueLinkType,
|
||||
direction: 'OUTWARD' | 'INWARD',
|
||||
issueId: string,
|
||||
) => ({ type, direction, issueId, issueKey: issueId.toUpperCase() });
|
||||
|
||||
it('reads both halves of a relation with a ticket outside, once each', () => {
|
||||
expect(
|
||||
externalDependencies([
|
||||
{
|
||||
issue: { id: 'web' },
|
||||
links: [
|
||||
// API-1 blocks us (recorded on its side): our inward half.
|
||||
link(IssueLinkType.BLOCKS, 'INWARD', 'api-1'),
|
||||
// We depend on API-2: it goes first.
|
||||
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', 'api-2'),
|
||||
// We block MOB-1.
|
||||
link(IssueLinkType.BLOCKS, 'OUTWARD', 'mob-1'),
|
||||
// MOB-2 depends on us (recorded on its side).
|
||||
link(IssueLinkType.DEPENDS_ON, 'INWARD', 'mob-2'),
|
||||
link(IssueLinkType.RELATES_TO, 'OUTWARD', 'doc-1'),
|
||||
link(IssueLinkType.BLOCKS, 'OUTWARD', 'mob-1'),
|
||||
],
|
||||
},
|
||||
// Among the epics shown: not external.
|
||||
{
|
||||
issue: { id: 'web-2' },
|
||||
links: [link(IssueLinkType.BLOCKS, 'OUTWARD', 'web')],
|
||||
},
|
||||
]).map((d) => `${d.before}>${d.after} (${d.externalKey})`),
|
||||
).toEqual([
|
||||
'api-1>web (API-1)',
|
||||
'api-2>web (API-2)',
|
||||
'web>mob-1 (MOB-1)',
|
||||
'web>mob-2 (MOB-2)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,51 @@ export function roadmapDependencies(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A dependency on a ticket outside the epics shown (another project). */
|
||||
export interface ExternalDependency extends RoadmapDependency {
|
||||
/** Id and key of the ticket outside. */
|
||||
externalId: string;
|
||||
externalKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* «Bloquea» / «depende de» between these epics and tickets that are not
|
||||
* among them (another project's): both directions, since the relation was
|
||||
* maybe recorded from the other side and only its inward half is here.
|
||||
*/
|
||||
export function externalDependencies(
|
||||
epics: { issue: { id: string }; links: IssueLink[] }[],
|
||||
): ExternalDependency[] {
|
||||
const ids = new Set(epics.map((e) => e.issue.id));
|
||||
const seen = new Set<string>();
|
||||
const out: ExternalDependency[] = [];
|
||||
for (const epic of epics)
|
||||
for (const link of epic.links) {
|
||||
if (ids.has(link.issueId)) continue;
|
||||
const outward = link.direction === 'OUTWARD';
|
||||
// Who goes first: the one that blocks, or the one depended on.
|
||||
const otherFirst =
|
||||
link.type === IssueLinkType.BLOCKS
|
||||
? !outward
|
||||
: link.type === IssueLinkType.DEPENDS_ON
|
||||
? outward
|
||||
: null;
|
||||
if (otherFirst === null) continue;
|
||||
const pair = otherFirst
|
||||
? { before: link.issueId, after: epic.issue.id }
|
||||
: { before: epic.issue.id, after: link.issueId };
|
||||
const key = `${pair.before}>${pair.after}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
...pair,
|
||||
externalId: link.issueId,
|
||||
externalKey: link.issueKey,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The epic that waits starts on or before the day the other one ends. */
|
||||
export function dependencyClash(
|
||||
beforeEnds: string,
|
||||
|
||||
@@ -39,3 +39,26 @@ export interface SharedDashboard {
|
||||
| null;
|
||||
activity: { day: string; count: number }[] | null;
|
||||
}
|
||||
|
||||
/** A release's public notes link (owners and admins). */
|
||||
export interface ReleaseNotesShare {
|
||||
releaseId: string;
|
||||
/** The secret of the link: /notes/{token}. */
|
||||
token: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A release's notes, for anyone with its link. */
|
||||
export interface PublicReleaseNotes {
|
||||
projectName: string;
|
||||
projectKey: string;
|
||||
releaseName: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
targetDate: string | null;
|
||||
releasedAt: string | null;
|
||||
/** Markdown. */
|
||||
notes: string;
|
||||
/** False: drawn from its finished tickets (none written yet). */
|
||||
written: boolean;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,22 @@ export const SHORTCUTS: { keys: string; what: string; where: string }[] = [
|
||||
{ keys: 'g s', what: 'Ir a los sprints', where: 'Dentro de un proyecto' },
|
||||
{ keys: 'g r', what: 'Ir a la hoja de ruta', where: 'Dentro de un proyecto' },
|
||||
{ keys: 'c', what: 'Crear un ticket', where: 'Tablero' },
|
||||
{
|
||||
keys: '← ↑ → ↓',
|
||||
what: 'Moverse entre tarjetas (Tab entra en el tablero)',
|
||||
where: 'Tablero',
|
||||
},
|
||||
{
|
||||
keys: 'Shift ← →',
|
||||
what: 'Pasar la tarjeta a la columna de al lado',
|
||||
where: 'Tablero',
|
||||
},
|
||||
{
|
||||
keys: 'p · e · a',
|
||||
what: 'Prioridad, estimación o persona de la tarjeta',
|
||||
where: 'Tablero',
|
||||
},
|
||||
{ keys: 'Enter', what: 'Abrir la tarjeta', where: 'Tablero' },
|
||||
{ keys: 'e', what: 'Editar el ticket', where: 'Ficha de un ticket' },
|
||||
{ keys: 'Esc', what: 'Cerrar', where: 'Búsqueda y ayuda' },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { IssuePriority } from '../models/enums';
|
||||
import { IssuePriority, ReviewVerdict } from '../models/enums';
|
||||
import { MyDay } from '../models/my-day';
|
||||
import { Home } from '../models/home';
|
||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||
import {
|
||||
DashboardShare,
|
||||
PublicReleaseNotes,
|
||||
ReleaseNotesShare,
|
||||
SharedBlock,
|
||||
SharedDashboard,
|
||||
} from '../models/sharing';
|
||||
@@ -142,12 +145,42 @@ export abstract class IssuesGateway {
|
||||
issueId: string,
|
||||
releaseId: string | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Asks a member to review the ticket. */
|
||||
abstract requestReview(
|
||||
issueId: string,
|
||||
reviewerId: string,
|
||||
note: string | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Answers a review I was asked for. */
|
||||
abstract answerReview(
|
||||
issueId: string,
|
||||
reviewId: string,
|
||||
verdict: ReviewVerdict,
|
||||
answer: string | null,
|
||||
): Observable<CommandResult>;
|
||||
abstract cancelReview(
|
||||
issueId: string,
|
||||
reviewId: string,
|
||||
): Observable<CommandResult>;
|
||||
/** The signed-in person follows (or stops following) the ticket. */
|
||||
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
||||
/** My pinned tickets, most recently pinned first. */
|
||||
abstract pins(): Observable<IssueSummary[]>;
|
||||
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||
/** Follows the ticket with everything under it (or stops). */
|
||||
abstract followTree(issueId: string, on: boolean): Observable<void>;
|
||||
/** Reminds me of the ticket then (replaces the waiting one). */
|
||||
abstract setReminder(
|
||||
issueId: string,
|
||||
at: string,
|
||||
note: string | null,
|
||||
): Observable<void>;
|
||||
abstract clearReminder(issueId: string): Observable<void>;
|
||||
/** «Mi día» on `day` (today, `YYYY-MM-DD` in my time zone). */
|
||||
abstract myDay(day: string): Observable<MyDay>;
|
||||
abstract addToMyDay(day: string, issueIds: string[]): Observable<void>;
|
||||
abstract removeFromMyDay(day: string, issueId: string): Observable<void>;
|
||||
/**
|
||||
* Decisions (comments marked as such) of all my projects by their text,
|
||||
* most recent first: here and not in CommentsGateway, for the palette.
|
||||
@@ -305,6 +338,18 @@ export abstract class CommentsGateway {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo?: string | null,
|
||||
/** A quick poll's options (2–6). */
|
||||
poll?: string[] | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Votes in a comment's poll (null takes the vote back). */
|
||||
abstract votePoll(
|
||||
commentId: string,
|
||||
option: number | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
||||
abstract closePoll(
|
||||
commentId: string,
|
||||
decide: boolean,
|
||||
): Observable<CommandResult>;
|
||||
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
||||
/** Adds (`on`) or takes back one's reaction. */
|
||||
@@ -431,6 +476,20 @@ export abstract class SharingGateway {
|
||||
abstract remove(projectId: string): Observable<void>;
|
||||
/** What a link shows, for anyone (null: no such link). */
|
||||
abstract shared(token: string): Observable<SharedDashboard | null>;
|
||||
/** A release's public notes link, if any (owners and admins). */
|
||||
abstract releaseNotesShare(
|
||||
releaseId: string,
|
||||
): Observable<ReleaseNotesShare | null>;
|
||||
/** Turns it on; `renew` makes a new link. */
|
||||
abstract shareReleaseNotes(
|
||||
releaseId: string,
|
||||
renew: boolean,
|
||||
): Observable<ReleaseNotesShare>;
|
||||
abstract unshareReleaseNotes(releaseId: string): Observable<void>;
|
||||
/** A release's notes for anyone with the link (null: no such link). */
|
||||
abstract publicReleaseNotes(
|
||||
token: string,
|
||||
): Observable<PublicReleaseNotes | null>;
|
||||
}
|
||||
|
||||
export abstract class NotificationsGateway {
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ReactToCommentDocument,
|
||||
MarkDecisionDocument,
|
||||
ResolveThreadDocument,
|
||||
VotePollDocument,
|
||||
ClosePollDocument,
|
||||
PublishReleaseDocument,
|
||||
ReleaseBurnupDocument,
|
||||
ReleaseNotesDraftDocument,
|
||||
@@ -50,12 +52,33 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AddCommentDocument, { issueId, body, replyTo })
|
||||
.mutate(AddCommentDocument, {
|
||||
issueId,
|
||||
body,
|
||||
replyTo,
|
||||
poll: poll?.length ? poll : null,
|
||||
})
|
||||
.pipe(map((d) => toCommandResult(d.addComment)));
|
||||
}
|
||||
|
||||
votePoll(
|
||||
commentId: string,
|
||||
option: number | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(VotePollDocument, { commentId, option })
|
||||
.pipe(map((d) => toCommandResult(d.votePoll)));
|
||||
}
|
||||
|
||||
closePoll(commentId: string, decide: boolean): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(ClosePollDocument, { commentId, decide })
|
||||
.pipe(map((d) => toCommandResult(d.closePoll)));
|
||||
}
|
||||
|
||||
react(
|
||||
commentId: string,
|
||||
emoji: string,
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
IssueListFilter,
|
||||
IssueSummary,
|
||||
LinkIssuesInput,
|
||||
MyDay,
|
||||
MyIssuesFilter,
|
||||
ReviewVerdict,
|
||||
mergeBulkResults,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -50,7 +52,17 @@ import {
|
||||
UnwatchIssueDocument,
|
||||
VoteIssueDocument,
|
||||
WatchIssueDocument,
|
||||
RequestReviewDocument,
|
||||
AnswerReviewDocument,
|
||||
CancelReviewDocument,
|
||||
MyPinsDocument,
|
||||
FollowTreeDocument,
|
||||
SetReminderDocument,
|
||||
MyDayDocument,
|
||||
AddToMyDayDocument,
|
||||
RemoveFromMyDayDocument,
|
||||
ClearReminderDocument,
|
||||
UnfollowTreeDocument,
|
||||
SearchDecisionsDocument,
|
||||
PinIssueDocument,
|
||||
UpdateChecklistItemDocument,
|
||||
@@ -107,6 +119,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.query(MyIssuesDocument, {
|
||||
assignedToMe: filter.assignedToMe,
|
||||
watching: filter.watching ?? false,
|
||||
reviewing: filter.reviewing ?? false,
|
||||
categories: filter.categories as never,
|
||||
limit: filter.limit,
|
||||
search: filter.search?.trim() || null,
|
||||
@@ -218,6 +231,54 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
),
|
||||
);
|
||||
}
|
||||
setReminder(
|
||||
issueId: string,
|
||||
at: string,
|
||||
note: string | null,
|
||||
): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(SetReminderDocument, { issueId, at, note })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
myDay(day: string): Observable<MyDay> {
|
||||
return this.gql.query(MyDayDocument, { day }).pipe(
|
||||
map(({ myDay }) => ({
|
||||
day: myDay.day,
|
||||
issues: myDay.issues.map(toIssueSummary),
|
||||
leftover: myDay.leftover && {
|
||||
day: myDay.leftover.day,
|
||||
issues: myDay.leftover.issues.map(toIssueSummary),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
addToMyDay(day: string, issueIds: string[]): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(AddToMyDayDocument, { day, issueIds })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
removeFromMyDay(day: string, issueId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(RemoveFromMyDayDocument, { day, issueId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
clearReminder(issueId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(ClearReminderDocument, { issueId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
followTree(issueId: string, on: boolean): Observable<void> {
|
||||
const done$: Observable<unknown> = on
|
||||
? this.gql.mutate(FollowTreeDocument, { issueId })
|
||||
: this.gql.mutate(UnfollowTreeDocument, { issueId });
|
||||
return done$.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
pin(issueId: string, pinned: boolean): Observable<void> {
|
||||
const done$: Observable<unknown> = pinned
|
||||
? this.gql.mutate(PinIssueDocument, { issueId })
|
||||
@@ -225,6 +286,38 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
return done$.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
requestReview(
|
||||
issueId: string,
|
||||
reviewerId: string,
|
||||
note: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(RequestReviewDocument, { issueId, reviewerId, note })
|
||||
.pipe(map((d) => toCommandResult(d.requestReview)));
|
||||
}
|
||||
|
||||
answerReview(
|
||||
issueId: string,
|
||||
reviewId: string,
|
||||
verdict: ReviewVerdict,
|
||||
answer: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AnswerReviewDocument, {
|
||||
issueId,
|
||||
reviewId,
|
||||
verdict: verdict as never,
|
||||
answer,
|
||||
})
|
||||
.pipe(map((d) => toCommandResult(d.answerReview)));
|
||||
}
|
||||
|
||||
cancelReview(issueId: string, reviewId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(CancelReviewDocument, { issueId, reviewId })
|
||||
.pipe(map((d) => toCommandResult(d.cancelReview)));
|
||||
}
|
||||
|
||||
watch(issueId: string, watching: boolean): Observable<CommandResult> {
|
||||
return watching
|
||||
? this.gql
|
||||
@@ -259,6 +352,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
issue: toIssueSummary(epic.issue),
|
||||
progress: { ...epic.progress },
|
||||
links: epic.issue.links.map((l) => toIssueLink(l)),
|
||||
followingTree: epic.issue.followingTree,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -85,6 +85,8 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
||||
issue: toIssueSummary(e.issue),
|
||||
progress: { ...e.progress },
|
||||
links: [],
|
||||
// The portfolio does not offer following.
|
||||
followingTree: false,
|
||||
})),
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
DashboardShare,
|
||||
PublicReleaseNotes,
|
||||
ReleaseNotesShare,
|
||||
SharedBlock,
|
||||
SharedDashboard,
|
||||
SharingGateway,
|
||||
@@ -8,7 +10,11 @@ import {
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
DashboardShareDocument,
|
||||
PublicReleaseNotesDocument,
|
||||
ReleaseNotesShareDocument,
|
||||
ShareDashboardDocument,
|
||||
ShareReleaseNotesDocument,
|
||||
UnshareReleaseNotesDocument,
|
||||
SharedDashboardDocument,
|
||||
UnshareDashboardDocument,
|
||||
} from '../graphql/generated';
|
||||
@@ -45,4 +51,33 @@ export class GraphqlSharingGateway extends SharingGateway {
|
||||
.query(SharedDashboardDocument, { token })
|
||||
.pipe(map((d) => (d.sharedDashboard as SharedDashboard | null) ?? null));
|
||||
}
|
||||
|
||||
releaseNotesShare(releaseId: string): Observable<ReleaseNotesShare | null> {
|
||||
return this.gql
|
||||
.query(ReleaseNotesShareDocument, { releaseId })
|
||||
.pipe(map((d) => d.releaseNotesShare ?? null));
|
||||
}
|
||||
|
||||
shareReleaseNotes(
|
||||
releaseId: string,
|
||||
renew: boolean,
|
||||
): Observable<ReleaseNotesShare> {
|
||||
return this.gql
|
||||
.mutate(ShareReleaseNotesDocument, { releaseId, renew })
|
||||
.pipe(map((d) => d.shareReleaseNotes));
|
||||
}
|
||||
|
||||
unshareReleaseNotes(releaseId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(UnshareReleaseNotesDocument, { releaseId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
publicReleaseNotes(token: string): Observable<PublicReleaseNotes | null> {
|
||||
return this.gql
|
||||
.query(PublicReleaseNotesDocument, { token })
|
||||
.pipe(
|
||||
map((d) => (d.publicReleaseNotes as PublicReleaseNotes | null) ?? null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,25 @@ fragment IssueSummaryFields on Issue {
|
||||
fragment IssueFields on Issue {
|
||||
...IssueSummaryFields
|
||||
pinned
|
||||
followingTree
|
||||
myReminder {
|
||||
at
|
||||
note
|
||||
}
|
||||
reviews {
|
||||
id
|
||||
requester {
|
||||
...UserRefFields
|
||||
}
|
||||
reviewer {
|
||||
...UserRefFields
|
||||
}
|
||||
note
|
||||
requestedAt
|
||||
verdict
|
||||
answer
|
||||
answeredAt
|
||||
}
|
||||
decisions {
|
||||
...CommentFields
|
||||
}
|
||||
@@ -185,6 +204,16 @@ fragment CommentFields on Comment {
|
||||
decidedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
poll {
|
||||
options {
|
||||
text
|
||||
voters {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
closed
|
||||
winner
|
||||
}
|
||||
resolved
|
||||
resolvedBy {
|
||||
...UserRefFields
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user