feat: formatted text, a list view, ticket details, public proposals and votes

Formatted text
- Descriptions, comments, replies and release notes render Markdown,
  sanitised with DOMPurify (tested against scripts, handlers and
  javascript: links). A pasted image is uploaded and written as
  ![name](attachment:<id>); the web fetches it with the reader's session,
  so it works on private projects. Every text box has a preview. The
  attachment upload now answers its attachmentId.

List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
  text, open/closed, type, priority and assignee; status, priority and
  assignee change in place; bulk edit (the backlog's bar, now shared).

Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
  when reading with one query per board or page; shown on cards, list
  rows and the ticket.

Public proposals
- A public project can take bug reports and ideas from anyone
  (acceptsProposals): POST /api/explore/projects/{project}/proposals and
  the form at /p/:key/proponer. They become tickets labelled «propuesta»,
  opened by TaskStream; a honeypot field and 5 per address every 15
  minutes keep spam out. No e-mail is asked: it would be public.

Votes
- Anyone signed in votes on a public project's tickets, members on a
  private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
  voted on each ticket, buttons on the ticket and the roadmap, a column
  in the list. The showcase carries votes and the open proposals.

MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:36:11 +02:00
parent 298766ad1a
commit f2001aca88
116 changed files with 17997 additions and 650 deletions

View File

@@ -3324,6 +3324,309 @@
] ]
} }
}, },
"/api/issues/{issue}/vote": {
"put": {
"description": "Anyone signed in, on a public project (that is the point: the people it is for say what they want); members on a private one. Voting twice changes nothing.",
"operationId": "IssuesController_vote",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Vote for the ticket",
"tags": [
"Issues"
]
},
"delete": {
"operationId": "IssuesController_unvote",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Take back my vote for the ticket",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/duplicate": {
"post": {
"description": "Copies what it says (type, title, description, area, priority, labels, Figma, estimate, dates, parent and checklist steps, unticked), not where it stands: the copy starts in the first status, unassigned, out of sprints and releases, without comments or files. The result carries its key.",
"operationId": "IssuesController_duplicate",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Duplicate the ticket",
"tags": [
"Issues"
]
}
},
"/api/projects/{project}/trash": { "/api/projects/{project}/trash": {
"get": { "get": {
"operationId": "IssuesController_trashed", "operationId": "IssuesController_trashed",
@@ -4653,7 +4956,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CommandResultDto" "$ref": "#/components/schemas/AttachmentResultDto"
} }
} }
} }
@@ -5129,6 +5432,116 @@
] ]
} }
}, },
"/api/comments/{commentId}/reactions": {
"post": {
"description": "One of 👍 👎 😄 🎉 😕 ❤️ 🚀 👀; repeating it changes nothing.",
"operationId": "CommentsController_react",
"parameters": [
{
"name": "commentId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReactToCommentDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "React to a comment, or take the reaction back (members)",
"tags": [
"Comments"
]
}
},
"/api/me/releases": { "/api/me/releases": {
"get": { "get": {
"operationId": "ReleasesController_upcoming", "operationId": "ReleasesController_upcoming",
@@ -6321,6 +6734,60 @@
"security": [] "security": []
} }
}, },
"/api/explore/projects/{project}/proposals": {
"post": {
"description": "For public projects that take proposals. It becomes a ticket labelled `propuesta`, opened by TaskStream in the first status (Bug or Feature), signed with `author` if given. Five per address every 15 minutes.",
"operationId": "ExploreController_propose",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProposalDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProposalResultDto"
}
}
}
},
"403": {
"description": "It does not take proposals"
},
"404": {
"description": "Not a public project"
},
"429": {
"description": "Too many from this address"
}
},
"summary": "Report a bug or propose an idea (no token needed)",
"tags": [
"Explore"
],
"security": []
}
},
"/api/me/notifications": { "/api/me/notifications": {
"get": { "get": {
"operationId": "NotificationsController_mine", "operationId": "NotificationsController_mine",
@@ -9794,6 +10261,10 @@
"type": "boolean", "type": "boolean",
"description": "Readable by anyone, also without a token" "description": "Readable by anyone, also without a token"
}, },
"acceptsProposals": {
"type": "boolean",
"description": "Takes bug reports and ideas from anyone (POST /api/explore/projects/{project}/proposals) while public"
},
"owner": { "owner": {
"$ref": "#/components/schemas/UserRefDto" "$ref": "#/components/schemas/UserRefDto"
}, },
@@ -9825,6 +10296,7 @@
"name", "name",
"description", "description",
"isPublic", "isPublic",
"acceptsProposals",
"owner", "owner",
"members", "members",
"settings", "settings",
@@ -9901,6 +10373,10 @@
"type": "boolean", "type": "boolean",
"default": false, "default": false,
"description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)" "description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)"
},
"acceptsProposals": {
"type": "boolean",
"description": "Take bug reports and ideas from anyone through the public form (while public)"
} }
} }
}, },
@@ -10510,6 +10986,17 @@
} }
] ]
}, },
"blockedBy": {
"description": "Open tickets it waits for: the ones that block it and the ones it depends on",
"type": "array",
"items": {
"$ref": "#/components/schemas/IssueParentDto"
}
},
"votes": {
"type": "number",
"description": "How many want it done"
},
"rank": { "rank": {
"type": "string", "type": "string",
"description": "Place in the backlog and its board column (ascending, as text)" "description": "Place in the backlog and its board column (ascending, as text)"
@@ -10575,6 +11062,8 @@
"attachments", "attachments",
"watchers", "watchers",
"parent", "parent",
"blockedBy",
"votes",
"rank", "rank",
"checklist", "checklist",
"trashedAt", "trashedAt",
@@ -11426,6 +11915,25 @@
"targetIssue" "targetIssue"
] ]
}, },
"CommentReactionDto": {
"type": "object",
"properties": {
"emoji": {
"type": "string",
"example": "👍"
},
"users": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserRefDto"
}
}
},
"required": [
"emoji",
"users"
]
},
"CommentDto": { "CommentDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11454,6 +11962,13 @@
"type": "boolean", "type": "boolean",
"description": "Deleted, but kept (without its text) while it has replies" "description": "Deleted, but kept (without its text) while it has replies"
}, },
"reactions": {
"description": "In the order they are offered; only the ones somebody chose",
"type": "array",
"items": {
"$ref": "#/components/schemas/CommentReactionDto"
}
},
"createdAt": { "createdAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
@@ -11474,6 +11989,7 @@
"body", "body",
"replyTo", "replyTo",
"deleted", "deleted",
"reactions",
"createdAt", "createdAt",
"editedAt", "editedAt",
"version" "version"
@@ -11533,6 +12049,34 @@
"file" "file"
] ]
}, },
"AttachmentResultDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Id of the aggregate written"
},
"key": {
"type": "string",
"description": "Human key when one was assigned (project or issue)",
"example": "WEB-13"
},
"version": {
"type": "number",
"description": "Version after the write. Writes are acknowledged at once and the read model catches up asynchronously: a GET may briefly return the previous version (or 404 right after a create)."
},
"attachmentId": {
"type": "string",
"format": "uuid",
"description": "The new file; `![name](attachment:<id>)` shows an image inside a description or comment"
}
},
"required": [
"id",
"version",
"attachmentId"
]
},
"CommentBodyDto": { "CommentBodyDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11546,6 +12090,33 @@
"body" "body"
] ]
}, },
"ReactToCommentDto": {
"type": "object",
"properties": {
"emoji": {
"type": "string",
"enum": [
"👍",
"👎",
"😄",
"🎉",
"😕",
"❤️",
"🚀",
"👀"
],
"example": "👍"
},
"on": {
"type": "boolean",
"description": "true adds the reaction, false takes it back"
}
},
"required": [
"emoji",
"on"
]
},
"ReleaseStatus": { "ReleaseStatus": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -11847,6 +12418,10 @@
"done": { "done": {
"type": "boolean" "type": "boolean"
}, },
"votes": {
"type": "number",
"description": "How many want it"
},
"path": { "path": {
"type": "string", "type": "string",
"example": "/p/WEB/issues/WEB-3", "example": "/p/WEB/issues/WEB-3",
@@ -11860,6 +12435,7 @@
"dueDate", "dueDate",
"percent", "percent",
"done", "done",
"votes",
"path" "path"
] ]
}, },
@@ -11927,6 +12503,63 @@
"path" "path"
] ]
}, },
"ShowcaseProposalDto": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"title": {
"type": "string"
},
"type": {
"type": "string",
"description": "A ticket type id"
},
"path": {
"type": "string",
"example": "/p/WEB/issues/WEB-9"
},
"votes": {
"type": "number",
"description": "How many want it"
}
},
"required": [
"key",
"title",
"type",
"path",
"votes"
]
},
"ShowcaseProposalsDto": {
"type": "object",
"properties": {
"open": {
"type": "boolean",
"description": "Whether it takes bug reports and ideas now"
},
"path": {
"type": "string",
"nullable": true,
"example": "/p/WEB/proponer",
"description": "The form in the web app, while it takes them"
},
"top": {
"description": "Open proposals, most voted first",
"type": "array",
"items": {
"$ref": "#/components/schemas/ShowcaseProposalDto"
}
}
},
"required": [
"open",
"path",
"top"
]
},
"ShowcaseDto": { "ShowcaseDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11960,6 +12593,9 @@
"items": { "items": {
"$ref": "#/components/schemas/ShowcaseIssueDto" "$ref": "#/components/schemas/ShowcaseIssueDto"
} }
},
"proposals": {
"$ref": "#/components/schemas/ShowcaseProposalsDto"
} }
}, },
"required": [ "required": [
@@ -11967,7 +12603,65 @@
"activeSprint", "activeSprint",
"roadmap", "roadmap",
"releases", "releases",
"goodFirst" "goodFirst",
"proposals"
]
},
"ProposalDto": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"BUG",
"IDEA"
],
"description": "A bug report or an idea"
},
"title": {
"type": "string",
"minLength": 3,
"maxLength": 150
},
"description": {
"type": "string",
"maxLength": 5000,
"description": "What happens, or what they would like (Markdown)"
},
"author": {
"type": "string",
"maxLength": 80,
"description": "How they sign it; shown in the ticket (no e-mail: it would be public)"
},
"website": {
"type": "string",
"description": "Leave empty: a field only bots fill in"
}
},
"required": [
"kind",
"title"
]
},
"ProposalResultDto": {
"type": "object",
"properties": {
"key": {
"type": "string",
"nullable": true,
"example": "WEB-42",
"description": "The ticket it became"
},
"path": {
"type": "string",
"nullable": true,
"example": "/p/WEB/issues/WEB-42",
"description": "Where to see it in the web app"
}
},
"required": [
"key",
"path"
] ]
}, },
"NotificationKind": { "NotificationKind": {

View File

@@ -207,6 +207,9 @@ type Comment {
id: ID! id: ID!
issueId: ID! issueId: ID!
"""In the order they are offered; only the ones somebody chose"""
reactions: [CommentReaction!]!
"""The top-level comment it answers; null for a top-level one""" """The top-level comment it answers; null for a top-level one"""
replyTo: ID replyTo: ID
version: Int! version: Int!
@@ -228,6 +231,14 @@ type CommentPage {
total: Int! total: Int!
} }
"""One emoji on a comment"""
type CommentReaction {
emoji: String!
"""Who chose it"""
users: [UserRef!]!
}
type CountByAssignee { type CountByAssignee {
count: Int! count: Int!
@@ -414,6 +425,11 @@ type Issue {
area: String area: String
assignee: UserRef assignee: UserRef
attachments: [Attachment!]! attachments: [Attachment!]!
"""
Open tickets it waits for: the ones that block it and the ones it depends on
"""
blockedBy: [IssueParent!]!
checklist: [ChecklistItem!]! checklist: [ChecklistItem!]!
commentCount: Int! commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage! comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
@@ -469,6 +485,12 @@ type Issue {
updatedAt: String! updatedAt: String!
version: Int! version: Int!
"""Whether I voted for it"""
voted: Boolean!
"""How many want it done"""
votes: Int!
"""People following the ticket""" """People following the ticket"""
watchers: [UserRef!]! watchers: [UserRef!]!
} }
@@ -672,6 +694,11 @@ type Mutation {
"""Delete a sprint that never started; its tickets go back to the backlog""" """Delete a sprint that never started; its tickets go back to the backlog"""
deleteSprint(sprintId: ID!): CommandResult! deleteSprint(sprintId: ID!): CommandResult!
deleteWebhook(webhookId: ID!): Boolean! deleteWebhook(webhookId: ID!): Boolean!
"""
A new ticket like this one: what it says, not where it stands (the key comes in the result)
"""
duplicateIssue(issueId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult! editComment(body: String!, commentId: ID!): CommandResult!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
@@ -688,6 +715,9 @@ type Mutation {
"""Move a ticket in the backlog, between two others of the same project""" """Move a ticket in the backlog, between two others of the same project"""
rankIssue(input: RankIssueInput!): CommandResult! rankIssue(input: RankIssueInput!): CommandResult!
"""React (👍 👎 😄 🎉 😕 ❤️ 🚀 👀) or take it back (on: false)"""
reactToComment(commentId: ID!, emoji: String!, on: Boolean!): CommandResult!
""" """
Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API. Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.
""" """
@@ -750,6 +780,11 @@ type Mutation {
updateSprint(input: UpdateSprintInput!): CommandResult! updateSprint(input: UpdateSprintInput!): CommandResult!
updateWebhook(input: UpdateWebhookInput!): Webhook! updateWebhook(input: UpdateWebhookInput!): Webhook!
"""
Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one
"""
voteIssue(issueId: ID!, on: Boolean!): CommandResult!
""" """
Follow a ticket: be told when it moves, is reassigned or gets a comment Follow a ticket: be told when it moves, is reassigned or gets a comment
""" """
@@ -805,6 +840,9 @@ type NotificationSettings {
} }
type Project { type Project {
"""Takes bug reports and ideas from anyone (while public)"""
acceptsProposals: Boolean!
""" """
Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA
""" """
@@ -1263,6 +1301,10 @@ input UpdateIssueInput {
} }
input UpdateProjectInput { input UpdateProjectInput {
"""
Take bug reports and ideas from anyone through the public form (while public)
"""
acceptsProposals: Boolean
description: String description: String
isPublic: Boolean isPublic: Boolean
name: String name: String

View File

@@ -476,6 +476,47 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/issues/{issue}/vote": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Vote for the ticket
* @description Anyone signed in, on a public project (that is the point: the people it is for say what they want); members on a private one. Voting twice changes nothing.
*/
put: operations["IssuesController_vote"];
post?: never;
/** Take back my vote for the ticket */
delete: operations["IssuesController_unvote"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/duplicate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Duplicate the ticket
* @description Copies what it says (type, title, description, area, priority, labels, Figma, estimate, dates, parent and checklist steps, unticked), not where it stands: the copy starts in the first status, unassigned, out of sprints and releases, without comments or files. The result carries its key.
*/
post: operations["IssuesController_duplicate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/trash": { "/api/projects/{project}/trash": {
parameters: { parameters: {
query?: never; query?: never;
@@ -716,6 +757,26 @@ export interface paths {
patch: operations["CommentsController_edit"]; patch: operations["CommentsController_edit"];
trace?: never; trace?: never;
}; };
"/api/comments/{commentId}/reactions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* React to a comment, or take the reaction back (members)
* @description One of 👍 👎 😄 🎉 😕 ❤️ 🚀 👀; repeating it changes nothing.
*/
post: operations["CommentsController_react"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/releases": { "/api/me/releases": {
parameters: { parameters: {
query?: never; query?: never;
@@ -937,6 +998,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/explore/projects/{project}/proposals": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Report a bug or propose an idea (no token needed)
* @description For public projects that take proposals. It becomes a ticket labelled `propuesta`, opened by TaskStream in the first status (Bug or Feature), signed with `author` if given. Five per address every 15 minutes.
*/
post: operations["ExploreController_propose"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/notifications": { "/api/me/notifications": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1540,6 +1621,8 @@ export interface components {
description: string | null; description: string | null;
/** @description Readable by anyone, also without a token */ /** @description Readable by anyone, also without a token */
isPublic: boolean; isPublic: boolean;
/** @description Takes bug reports and ideas from anyone (POST /api/explore/projects/{project}/proposals) while public */
acceptsProposals: boolean;
owner: components["schemas"]["UserRefDto"]; owner: components["schemas"]["UserRefDto"];
members: components["schemas"]["ProjectMemberDto"][]; members: components["schemas"]["ProjectMemberDto"][];
settings: components["schemas"]["ProjectSettingsDto"]; settings: components["schemas"]["ProjectSettingsDto"];
@@ -1585,6 +1668,8 @@ export interface components {
* @default false * @default false
*/ */
isPublic: boolean; isPublic: boolean;
/** @description Take bug reports and ideas from anyone through the public form (while public) */
acceptsProposals?: boolean;
}; };
WorkflowStatusBodyDto: { WorkflowStatusBodyDto: {
/** /**
@@ -1786,6 +1871,10 @@ export interface components {
watchers: components["schemas"]["UserRefDto"][]; watchers: components["schemas"]["UserRefDto"][];
/** @description The epic or ticket this one breaks down */ /** @description The epic or ticket this one breaks down */
parent: components["schemas"]["IssueParentDto"] | null; parent: components["schemas"]["IssueParentDto"] | null;
/** @description Open tickets it waits for: the ones that block it and the ones it depends on */
blockedBy: components["schemas"]["IssueParentDto"][];
/** @description How many want it done */
votes: number;
/** @description Place in the backlog and its board column (ascending, as text) */ /** @description Place in the backlog and its board column (ascending, as text) */
rank: string; rank: string;
checklist: components["schemas"]["ChecklistItemDto"][]; checklist: components["schemas"]["ChecklistItemDto"][];
@@ -2081,6 +2170,11 @@ export interface components {
*/ */
targetIssue: string; targetIssue: string;
}; };
CommentReactionDto: {
/** @example 👍 */
emoji: string;
users: components["schemas"]["UserRefDto"][];
};
CommentDto: { CommentDto: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -2096,6 +2190,8 @@ export interface components {
replyTo: string | null; replyTo: string | null;
/** @description Deleted, but kept (without its text) while it has replies */ /** @description Deleted, but kept (without its text) while it has replies */
deleted: boolean; deleted: boolean;
/** @description In the order they are offered; only the ones somebody chose */
reactions: components["schemas"]["CommentReactionDto"][];
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
/** Format: date-time */ /** Format: date-time */
@@ -2127,10 +2223,35 @@ export interface components {
*/ */
commentId?: string; commentId?: string;
}; };
AttachmentResultDto: {
/** @description Id of the aggregate written */
id: string;
/**
* @description Human key when one was assigned (project or issue)
* @example WEB-13
*/
key?: string;
/** @description Version after the write. Writes are acknowledged at once and the read model catches up asynchronously: a GET may briefly return the previous version (or 404 right after a create). */
version: number;
/**
* Format: uuid
* @description The new file; `![name](attachment:<id>)` shows an image inside a description or comment
*/
attachmentId: string;
};
CommentBodyDto: { CommentBodyDto: {
/** @example Reproducido en Safari 17. */ /** @example Reproducido en Safari 17. */
body: string; body: string;
}; };
ReactToCommentDto: {
/**
* @example 👍
* @enum {string}
*/
emoji: "👍" | "👎" | "😄" | "🎉" | "😕" | "❤️" | "🚀" | "👀";
/** @description true adds the reaction, false takes it back */
on: boolean;
};
/** @enum {string} */ /** @enum {string} */
ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED"; ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED";
ReleaseDto: { ReleaseDto: {
@@ -2235,6 +2356,8 @@ export interface components {
dueDate: string | null; dueDate: string | null;
percent: number; percent: number;
done: boolean; done: boolean;
/** @description How many want it */
votes: number;
/** /**
* @description In the web app * @description In the web app
* @example /p/WEB/issues/WEB-3 * @example /p/WEB/issues/WEB-3
@@ -2258,6 +2381,27 @@ export interface components {
/** @example /p/WEB/issues/WEB-9 */ /** @example /p/WEB/issues/WEB-9 */
path: string; path: string;
}; };
ShowcaseProposalDto: {
key: string;
title: string;
/** @description A ticket type id */
type: string;
/** @example /p/WEB/issues/WEB-9 */
path: string;
/** @description How many want it */
votes: number;
};
ShowcaseProposalsDto: {
/** @description Whether it takes bug reports and ideas now */
open: boolean;
/**
* @description The form in the web app, while it takes them
* @example /p/WEB/proponer
*/
path: string | null;
/** @description Open proposals, most voted first */
top: components["schemas"]["ShowcaseProposalDto"][];
};
ShowcaseDto: { ShowcaseDto: {
project: components["schemas"]["ShowcaseProjectDto"]; project: components["schemas"]["ShowcaseProjectDto"];
activeSprint: components["schemas"]["ShowcaseSprintDto"] | null; activeSprint: components["schemas"]["ShowcaseSprintDto"] | null;
@@ -2265,6 +2409,33 @@ export interface components {
releases: components["schemas"]["ShowcaseReleaseDto"][]; releases: components["schemas"]["ShowcaseReleaseDto"][];
/** @description Open tickets labelled good-first-issue */ /** @description Open tickets labelled good-first-issue */
goodFirst: components["schemas"]["ShowcaseIssueDto"][]; goodFirst: components["schemas"]["ShowcaseIssueDto"][];
proposals: components["schemas"]["ShowcaseProposalsDto"];
};
ProposalDto: {
/**
* @description A bug report or an idea
* @enum {string}
*/
kind: "BUG" | "IDEA";
title: string;
/** @description What happens, or what they would like (Markdown) */
description?: string;
/** @description How they sign it; shown in the ticket (no e-mail: it would be public) */
author?: string;
/** @description Leave empty: a field only bots fill in */
website?: string;
};
ProposalResultDto: {
/**
* @description The ticket it became
* @example WEB-42
*/
key: string | null;
/**
* @description Where to see it in the web app
* @example /p/WEB/issues/WEB-42
*/
path: string | null;
}; };
/** @enum {string} */ /** @enum {string} */
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
@@ -4753,6 +4924,234 @@ export interface operations {
}; };
}; };
}; };
IssuesController_vote: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_unvote: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_duplicate: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_trashed: { IssuesController_trashed: {
parameters: { parameters: {
query?: never; query?: never;
@@ -5668,7 +6067,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["CommandResultDto"]; "application/json": components["schemas"]["AttachmentResultDto"];
}; };
}; };
/** @description Invalid input */ /** @description Invalid input */
@@ -6016,6 +6415,85 @@ export interface operations {
}; };
}; };
}; };
CommentsController_react: {
parameters: {
query?: never;
header?: never;
path: {
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ReactToCommentDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
ReleasesController_upcoming: { ReleasesController_upcoming: {
parameters: { parameters: {
query?: { query?: {
@@ -6859,6 +7337,53 @@ export interface operations {
}; };
}; };
}; };
ExploreController_propose: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProposalDto"];
};
};
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProposalResultDto"];
};
};
/** @description It does not take proposals */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not a public project */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Too many from this address */
429: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
NotificationsController_mine: { NotificationsController_mine: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -19,6 +19,7 @@ const REMOVE = {
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']); const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']); const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']);
const REACTION = z.enum(['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀']);
const LINK_TYPE = z.enum([ const LINK_TYPE = z.enum([
'BLOCKS', 'BLOCKS',
'DEPENDS_ON', 'DEPENDS_ON',
@@ -242,6 +243,10 @@ export class IssuesResolver {
author: c.deleted ? null : c.author.name, author: c.deleted ? null : c.author.name,
at: c.createdAt, at: c.createdAt,
body: c.deleted ? '(deleted; kept for its replies)' : c.body, body: c.deleted ? '(deleted; kept for its replies)' : c.body,
reactions: c.reactions.map((r) => ({
emoji: r.emoji,
by: r.users.map((u) => u.name),
})),
})) }))
: undefined; : undefined;
const history = params.withHistory const history = params.withHistory
@@ -639,4 +644,70 @@ export class IssuesResolver {
), ),
); );
} }
@Tool({
name: 'duplicate_issue',
description:
"A new ticket like this one: what it says (type, title, description, area, priority, labels, Figma, estimate, dates, parent, checklist steps unticked), not where it stands (first status, unassigned, no sprint, release, comments or files). Answers the copy's key.",
paramsSchema: z.object({ issue: issueRef }),
annotations: WRITE,
})
duplicate({ issue }: { issue: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/duplicate', {
params: { path: { issue } },
}),
),
);
}
@Tool({
name: 'react_to_comment',
description:
'React to a comment (👍 👎 😄 🎉 😕 ❤️ 🚀 👀) or take the reaction back (on: false). Comment ids come with get_issue.',
paramsSchema: z.object({
commentId: z.string().min(1),
emoji: REACTION,
on: z.boolean().default(true),
}),
annotations: { ...WRITE, idempotentHint: true },
})
react(
params: {
commentId: string;
emoji: z.infer<typeof REACTION>;
on: boolean;
},
ctx: McpContext,
) {
const { commentId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/reactions', {
params: { path: { commentId } },
body,
}),
),
);
}
@Tool({
name: 'vote_issue',
description:
'Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one. Votes say what people want done.',
paramsSchema: z.object({ issue: issueRef, on: z.boolean().default(true) }),
annotations: { ...WRITE, idempotentHint: true },
})
vote({ issue, on }: { issue: string; on: boolean }, ctx: McpContext) {
const client = this.api.as(ctx);
const options = { params: { path: { issue } } };
return toolResult(() =>
this.api.data(
on
? client.PUT('/api/issues/{issue}/vote', options)
: client.DELETE('/api/issues/{issue}/vote', options),
),
);
}
} }

View File

@@ -22,6 +22,9 @@ export const issueLine = (issue: Issue) => ({
release: issue.release?.name ?? null, release: issue.release?.name ?? null,
sprint: issue.sprint?.name ?? null, sprint: issue.sprint?.name ?? null,
parent: issue.parent?.key ?? null, parent: issue.parent?.key ?? null,
/** Open tickets it waits for. */
blockedBy: issue.blockedBy.map((b) => b.key),
votes: issue.votes,
storyPoints: issue.storyPoints, storyPoints: issue.storyPoints,
dueDate: issue.dueDate, dueDate: issue.dueDate,
labels: issue.labels, labels: issue.labels,

View File

@@ -27,6 +27,8 @@ describe('appRoutes', () => {
expect(project?.children?.map((route) => route.path)).toEqual([ expect(project?.children?.map((route) => route.path)).toEqual([
'', '',
'board', 'board',
'proponer',
'list',
'issues/:issueKey', 'issues/:issueKey',
'backlog', 'backlog',
'sprints', 'sprints',

View File

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

View File

@@ -56,6 +56,18 @@ historial, releases (progreso y burnup), actividad, seguimiento y estadísticas.
miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro
lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos. lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos.
**Propuestas del público**: un proyecto público puede aceptar errores e ideas de cualquiera, sin cuenta
(`acceptsProposals`, en `updateProject` / `PATCH`). `POST /api/explore/projects/{project}/proposals` (`kind`: `BUG` o
`IDEA`, `title`, `description`, `author` opcional) crea un ticket con la etiqueta `propuesta`, a nombre de TaskStream,
en el primer estado, de tipo Bug o Feature (o el primer tipo activo). No se pide correo, porque quedaría a la vista. Contra
el spam, el campo `website` es una trampa (si llega relleno se responde como si nada y no se guarda) y cada dirección
envía como mucho 5 cada 15 minutos (429); el límite vive en memoria de cada instancia, así que con varias es
aproximado. La web tiene el formulario en `/p/{CLAVE}/proponer`, enlazado en la cabecera del proyecto.
**Votos**: quien tenga sesión vota los tickets de un proyecto público, y los miembros los de uno privado
(`voteIssue(issueId, on)`, `PUT/DELETE /api/issues/{issue}/vote`). Cada ticket da `votes` y, en GraphQL, `voted` (si he
votado yo); la lista de quién votó no se publica.
`publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es `publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es
miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede
leer esos proyectos. leer esos proyectos.
@@ -150,6 +162,20 @@ stateDiagram-v2
que ser del mismo ticket. Quien escribió el comentario recibe un aviso (`REPLIED`). Un comentario borrado que tiene que ser del mismo ticket. Quien escribió el comentario recibe un aviso (`REPLIED`). Un comentario borrado que tiene
respuestas se queda, sin texto (`deleted: true`), para que el hilo se siga entendiendo, y desaparece con su última respuestas se queda, sin texto (`deleted: true`), para que el hilo se siga entendiendo, y desaparece con su última
respuesta; no cuenta en `commentCount`. respuesta; no cuenta en `commentCount`.
- **Formato**: descripciones, comentarios y notas de versión se escriben en Markdown; la API los guarda tal cual y la web
los muestra limpiando el HTML (DOMPurify). Una imagen pegada se sube como adjunto y queda en el texto como
`![nombre](attachment:<id>)`: la subida responde `attachmentId`, y la web la descarga con la sesión de quien lee, así
que sirve también en proyectos privados.
- **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.
- **Bloqueos**: un ticket espera a otro si ese otro lo bloquea (`BLOCKS`) o si él depende de ese otro (`DEPENDS_ON`),
mientras el otro no esté hecho ni cancelado. `blockedBy` lo calcula al leer (los enlaces no guardan el estado del
otro ticket), con una sola consulta por tablero o página.
- **Duplicar**: `duplicateIssue` / `POST /api/issues/{issue}/duplicate` copia lo que dice el ticket (tipo, título con
«(copia)», descripción, área, prioridad, etiquetas, Figma, estimación, fechas, épica y pasos de la checklist sin
marcar), no dónde está: la copia empieza en el primer estado, sin asignar, sin sprint, release, comentarios ni
adjuntos.
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`. - **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por - **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin. ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
@@ -329,17 +355,17 @@ que funcionan también sin token.
Todas devuelven `CommandResult { id, key, version }`. Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations | | Ámbito | Mutations |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment`, `editComment`, `deleteComment` | | Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
### Subscriptions ### Subscriptions
@@ -536,23 +562,23 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | | Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | | Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | | Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | | Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | | Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | | Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | | Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | | Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` | | Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto

View File

@@ -5,18 +5,18 @@ API repartida en `libs/web/*`.
## 4.1 Librerías ## 4.1 Librerías
| Librería | Contenido | | Librería | Contenido |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` | | `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` | | `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` |
| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` | | `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` |
| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` | | `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` |
| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` | | `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` |
| `web/identity/presenters` | Página de login, callback de Pireon, `authGuard` / `guestGuard`, menú de usuario | | `web/identity/presenters` | Página de login, callback de Pireon, `authGuard` / `guestGuard`, menú de usuario |
| `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) | | `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) |
| `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos | | `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos |
| `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) | | `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) |
| `web/tracker/presenters` | Páginas: proyectos, tablero, ticket, releases, seguimiento, actividad y miembros | | `web/tracker/presenters` | Páginas: proyectos, tablero, lista, ticket, propuestas, releases, seguimiento, actividad y miembros; `ts-markdown` (texto con formato, saneado con DOMPurify), `ts-bulk-bar`, `ts-vote` |
## 4.2 Estado: NgRx Signals con eventos ## 4.2 Estado: NgRx Signals con eventos
@@ -109,15 +109,16 @@ Qué gana la página:
- **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres… - **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres…
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`). - **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
| Facade | Store | Ámbito | Responsabilidad | | Facade | Store | Ámbito | Responsabilidad |
| ---------------- | --------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | --------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar | | `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) | | `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña | | `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión | | `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) | | `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo | | `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones | | `ListFacade` | `ListStore` | ruta `list` | Todos los tickets del proyecto en filas: filtros (enviados a la API), cambios de estado, prioridad y persona en la fila, edición en bloque; se recarga tras cada cambio propio y con los de otros |
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los
`providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas `providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas
@@ -168,7 +169,7 @@ La librería de componentes de Pireon se instala desde su Nexus (scope `@pi`, co
| Drawers y modales se abren con `MatDialog` | `DrawerService.open(título, template)` con `getDrawerConfigs` de la librería | | Drawers y modales se abren con `MatDialog` | `DrawerService.open(título, template)` con `getDrawerConfigs` de la librería |
| `pi-search-field` es un buscador plegable (un icono) | Para un campo de búsqueda normal se usa `pi-text-field` | | `pi-search-field` es un buscador plegable (un icono) | Para un campo de búsqueda normal se usa `pi-text-field` |
| `button` globales: `min-width`, padding y radio con selectores muy específicos | Los botones propios de la app usan `.ts-icon-button`; `pi-action-button size="w100"` necesita `box-sizing` (en `styles.scss`) | | `button` globales: `min-width`, padding y radio con selectores muy específicos | Los botones propios de la app usan `.ts-icon-button`, o anulan esas reglas con `!important` (votos, reacciones); `pi-action-button size="w100"` necesita `box-sizing` (en `styles.scss`) |
| Los campos pintan cualquier error que reciben, aunque no se hayan tocado | En el login, los errores se pasan solo tras salir del campo o intentar entrar (`(touch)`) | | Los campos pintan cualquier error que reciben, aunque no se hayan tocado | En el login, los errores se pasan solo tras salir del campo o intentar entrar (`(touch)`) |
Componentes en uso: `pi-action-button`, `pi-icon-button`, `pi-icon`, `pi-note`, `pi-text-field`, `pi-email-field`, Componentes en uso: `pi-action-button`, `pi-icon-button`, `pi-icon`, `pi-note`, `pi-text-field`, `pi-email-field`,

View File

@@ -32,7 +32,7 @@ flowchart LR
## 9.2 Herramientas ## 9.2 Herramientas
54 herramientas, agrupadas por ámbito: 57 herramientas, agrupadas por ámbito:
| Ámbito | Herramienta | Qué hace | | Ámbito | Herramienta | Qué hace |
| ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
@@ -60,6 +60,9 @@ flowchart LR
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | | | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | | | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
| | `watch_issue` | Seguir un ticket o dejar de seguirlo | | | `watch_issue` | Seguir un ticket o dejar de seguirlo |
| | `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 |
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | | | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | | Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | | | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |

View File

@@ -17,6 +17,12 @@ tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo re
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido. Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.
**Ideas para más adelante**
- **Avisos a Discord, Slack o Telegram**: los webhooks salientes mandan JSON en bruto, que esos chats no muestran bien.
Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana movió WEB-12
a En revisión», con enlace.
**Empezar rápido** **Empezar rápido**
```sh ```sh

Binary file not shown.

Binary file not shown.

View File

@@ -7,6 +7,7 @@ export * from './lib/comments.service';
export * from './lib/releases.service'; export * from './lib/releases.service';
export * from './lib/changes.service'; export * from './lib/changes.service';
export * from './lib/workflows.service'; export * from './lib/workflows.service';
export * from './lib/blockers.service';
export * from './lib/notifications.service'; export * from './lib/notifications.service';
export * from './lib/notifications'; export * from './lib/notifications';
export * from './lib/saved-filters.service'; export * from './lib/saved-filters.service';

View File

@@ -0,0 +1,78 @@
import {
IssueLinkType,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { BlockersService } from './blockers.service';
import { FindIssuesRepository } from './ports/find-issues.repository';
const issue = (
id: string,
statusCategory = StatusCategory.TODO,
links: IssueReadModel['links'] = [],
) =>
({
id,
key: `WEB-${id}`,
title: `Ticket ${id}`,
statusCategory,
trashedAt: null,
links,
}) as unknown as IssueReadModel;
describe('BlockersService', () => {
const store = new Map(
[
issue('2'),
issue('3', StatusCategory.DONE),
issue('4', StatusCategory.IN_PROGRESS),
issue('5'),
].map((i) => [i.id, i]),
);
const queries: string[][] = [];
const service = new BlockersService({
findByIds: async (ids: string[]) => {
queries.push(ids);
return ids.map((id) => store.get(id)).filter(Boolean);
},
} as unknown as FindIssuesRepository);
const link = (
type: IssueLinkType,
direction: 'INWARD' | 'OUTWARD',
id: string,
) => ({ type, direction, issueId: id, issueKey: `WEB-${id}` });
beforeEach(() => (queries.length = 0));
it('is waiting on what blocks it and what it depends on, while open', async () => {
const one = issue('1', StatusCategory.TODO, [
link(IssueLinkType.BLOCKS, 'INWARD', '2'), // 2 blocks 1
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', '3'), // 1 depends on 3 (done)
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', '4'), // 1 depends on 4
link(IssueLinkType.BLOCKS, 'OUTWARD', '5'), // 1 blocks 5: not a blocker
link(IssueLinkType.RELATES_TO, 'OUTWARD', '2'),
]);
expect((await service.blockedBy(one)).map((b) => b.key)).toEqual([
'WEB-2',
'WEB-4',
]);
});
it('asks once for the tickets of one moment (a board)', async () => {
const card = (id: string, blocker: string) =>
issue(id, StatusCategory.TODO, [
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', blocker),
]);
const results = await Promise.all([
service.blockedBy(card('10', '2')),
service.blockedBy(card('11', '4')),
service.blockedBy(issue('12')),
]);
expect(results.map((r) => r.map((b) => b.key))).toEqual([
['WEB-2'],
['WEB-4'],
[],
]);
expect(queries).toEqual([['2', '4']]);
});
});

View File

@@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import {
IssueLinkType,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from './ports/find-issues.repository';
/** A ticket another one waits for. */
export interface Blocker {
id: string;
key: string;
title: string;
}
const FINISHED = new Set<StatusCategory>([
StatusCategory.DONE,
StatusCategory.CANCELLED,
]);
/**
* What a ticket waits for: the tickets that block it and the ones it
* depends on, while they are open. Links keep no status, so it is worked
* out when reading; the lookups of one moment (a board, a page of a list)
* go to the read model as one query, not one per ticket.
*/
@Injectable()
export class BlockersService {
private batch: {
ids: Set<string>;
result: Promise<Map<string, IssueReadModel>>;
} | null = null;
constructor(private readonly findIssues: FindIssuesRepository) {}
async blockedBy(issue: IssueReadModel): Promise<Blocker[]> {
const ids = blockerIdsOf(issue);
if (!ids.length) return [];
const found = await this.load(ids);
return ids
.map((id) => found.get(id))
.filter(
(blocker): blocker is IssueReadModel =>
!!blocker &&
!blocker.trashedAt &&
!FINISHED.has(blocker.statusCategory),
)
.map(({ id, key, title }) => ({ id, key, title }));
}
private load(ids: string[]): Promise<Map<string, IssueReadModel>> {
if (!this.batch) {
const pending = new Set<string>();
const result = new Promise<Map<string, IssueReadModel>>(
(resolve, reject) =>
setImmediate(() => {
this.batch = null;
this.findIssues
.findByIds([...pending])
.then((issues) => resolve(new Map(issues.map((i) => [i.id, i]))))
.catch(reject);
}),
);
this.batch = { ids: pending, result };
}
for (const id of ids) this.batch.ids.add(id);
return this.batch.result;
}
}
/** "X blocks this" and "this depends on X": X is what it waits for. */
export function blockerIdsOf(issue: IssueReadModel): string[] {
return [
...new Set(
(issue.links ?? [])
.filter(
(link) =>
(link.type === IssueLinkType.BLOCKS &&
link.direction === 'INWARD') ||
(link.type === IssueLinkType.DEPENDS_ON &&
link.direction === 'OUTWARD'),
)
.map((link) => link.issueId),
),
];
}

View File

@@ -2,8 +2,8 @@ import { randomUUID } from 'node:crypto';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain'; import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage'; import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage';
import { AttachFileCommand } from './attach-file.command'; import { AttachFileCommand, AttachmentResult } from './attach-file.command';
import { CommandResult, ack } from './command-result'; import { ack } from './command-result';
import { WriteAccess } from './write-access'; import { WriteAccess } from './write-access';
/** /**
@@ -19,7 +19,7 @@ export class AttachFileCommandHandler
private readonly storage: AttachmentStorage, private readonly storage: AttachmentStorage,
) {} ) {}
async execute(command: AttachFileCommand): Promise<CommandResult> { async execute(command: AttachFileCommand): Promise<AttachmentResult> {
const { issue } = await this.access.issueForContributor( const { issue } = await this.access.issueForContributor(
command.issueId, command.issueId,
command.actorId, command.actorId,
@@ -52,6 +52,7 @@ export class AttachFileCommandHandler
await this.storage.remove(key); await this.storage.remove(key);
throw error; throw error;
} }
return ack(issue); // The id lets a client point at the file, e.g. an image inside a comment.
return { ...ack(issue), attachmentId: id };
} }
} }

View File

@@ -7,7 +7,12 @@ export interface IncomingFile {
content: Buffer; content: Buffer;
} }
export class AttachFileCommand extends Command<CommandResult> { /** The ticket's acknowledgement, and the id of the file now on it. */
export interface AttachmentResult extends CommandResult {
attachmentId: string;
}
export class AttachFileCommand extends Command<AttachmentResult> {
constructor( constructor(
public readonly actorId: string, public readonly actorId: string,
public readonly issueId: string, public readonly issueId: string,

View File

@@ -1,3 +1,7 @@
import { DuplicateIssueCommandHandler } from './duplicate-issue.command-handler';
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { SubmitProposalCommandHandler } from './submit-proposal.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler'; import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler'; import { UpdateProjectCommandHandler } from './update-project.command-handler';
import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler'; import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler';
@@ -42,6 +46,10 @@ import {
} from './change-checklist.command-handler'; } from './change-checklist.command-handler';
export const COMMAND_HANDLERS = [ export const COMMAND_HANDLERS = [
SubmitProposalCommandHandler,
VoteIssueCommandHandler,
ReactToCommentCommandHandler,
DuplicateIssueCommandHandler,
CreateProjectCommandHandler, CreateProjectCommandHandler,
UpdateProjectCommandHandler, UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler, UpdateProjectSettingsCommandHandler,

View File

@@ -16,6 +16,7 @@ import {
Issue, Issue,
IssueStatus, IssueStatus,
Project, Project,
IssuePriority,
IssueType, IssueType,
ProjectRole, ProjectRole,
TRACKER_FACTORIES, TRACKER_FACTORIES,
@@ -74,6 +75,11 @@ import { ReleaseNamesRepository } from '../ports/release-names.repository';
import { UserDirectory } from '../ports/user-directory'; import { UserDirectory } from '../ports/user-directory';
import { AttachmentStorage } from '../ports/attachment-storage'; import { AttachmentStorage } from '../ports/attachment-storage';
import { AddCommentCommand } from './add-comment.command'; import { AddCommentCommand } from './add-comment.command';
import { DuplicateIssueCommand } from './duplicate-issue.command';
import { ReactToCommentCommand } from './react-to-comment.command';
import { UpdateProjectCommand } from './update-project.command';
import { SubmitProposalCommand } from './submit-proposal.command';
import { VoteIssueCommand } from './vote-issue.command';
import { AddProjectMemberCommand } from './add-project-member.command'; import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command'; import { AssignIssueCommand } from './assign-issue.command';
import { AttachFileCommand } from './attach-file.command'; import { AttachFileCommand } from './attach-file.command';
@@ -97,6 +103,10 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
async function setup() { async function setup() {
const store = new InMemoryEventStore(); const store = new InMemoryEventStore();
const projectViews = new Map<
string,
{ id: string; key: string; isPublic: boolean; acceptsProposals: boolean }
>();
// The project's Git repositories, as Mongo would keep them. // The project's Git repositories, as Mongo would keep them.
const gitRepositories = new Map<string, GitRepositoryConfig[]>(); const gitRepositories = new Map<string, GitRepositoryConfig[]>();
const git: GitIntegrationsRepository = { const git: GitIntegrationsRepository = {
@@ -129,7 +139,13 @@ async function setup() {
ReadAccess, ReadAccess,
{ {
provide: FindProjectsRepository, provide: FindProjectsRepository,
useValue: { findRole: async () => null, findById: async () => null }, // Read-model lookups: projects are added by the tests that need them.
useValue: {
findRole: async () => null,
findById: async (id: string) => projectViews.get(id) ?? null,
findByKey: async (key: string) =>
[...projectViews.values()].find((p) => p.key === key) ?? null,
},
}, },
{ provide: GitIntegrationsRepository, useValue: git }, { provide: GitIntegrationsRepository, useValue: git },
AggregateRehydrator, AggregateRehydrator,
@@ -177,6 +193,7 @@ async function setup() {
sprints, sprints,
keys, keys,
git, git,
projectViews,
run, run,
rehydrator: moduleRef.get(AggregateRehydrator), rehydrator: moduleRef.get(AggregateRehydrator),
}; };
@@ -832,6 +849,133 @@ describe('tracker commands (event sourced)', () => {
expect(rebuilt.deleted).toBe(true); expect(rebuilt.deleted).toBe(true);
}); });
it('duplicates what a ticket says, not where it stands', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const original = await run(
new CreateIssueCommand(
'dev',
projectId,
IssueType.BUG,
'Pago roto',
{
description: 'Falla en Safari',
priority: IssuePriority.HIGH,
labels: ['pagos'],
storyPoints: 3,
},
'qa',
),
);
await run(new AddChecklistItemCommand('dev', original.id, 'Reproducir'));
const copy = await run(new DuplicateIssueCommand('qa', original.id));
expect(copy.key).not.toBe(original.key);
const issue = await rehydrator.rehydrate(copy.id, Issue);
expect(issue).toMatchObject({
title: 'Pago roto (copia)',
description: 'Falla en Safari',
priority: IssuePriority.HIGH,
labels: ['pagos'],
storyPoints: 3,
assigneeId: null,
reporterId: 'qa',
});
expect(issue.checklist.map((s) => [s.text, s.done])).toEqual([
['Reproducir', false],
]);
await expect(
run(new DuplicateIssueCommand('pm', original.id)),
).rejects.toThrow(ForbiddenError);
});
it('takes reactions from members, once each, viewers included', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Spike', {}),
);
const comment = await run(new AddCommentCommand('dev', issue.id, 'Redis'));
await run(new ReactToCommentCommand('qa', comment.id, '👍', true));
await run(new ReactToCommentCommand('pm', comment.id, '👍', true));
await run(new ReactToCommentCommand('pm', comment.id, '👍', true));
await run(new ReactToCommentCommand('dev', comment.id, '🎉', true));
await run(new ReactToCommentCommand('dev', comment.id, '🎉', false));
expect((await rehydrator.rehydrate(comment.id, Comment)).reactions).toEqual(
{ '👍': ['qa', 'pm'] },
);
await expect(
run(new ReactToCommentCommand('qa', comment.id, '💩', true)),
).rejects.toThrow(ValidationError);
await expect(
run(new ReactToCommentCommand('stranger', comment.id, '👍', true)),
).rejects.toThrow(ForbiddenError);
});
it('takes votes from members, and from anyone once it is public', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand(
'dev',
projectId,
IssueType.FEATURE,
'Modo oscuro',
{},
),
);
await run(new VoteIssueCommand('pm', issue.id, true));
await run(new VoteIssueCommand('pm', issue.id, true));
await expect(
run(new VoteIssueCommand('stranger', issue.id, true)),
).rejects.toThrow(ForbiddenError);
await run(new UpdateProjectCommand('owner', projectId, { isPublic: true }));
await run(new VoteIssueCommand('stranger', issue.id, true));
await run(new VoteIssueCommand('pm', issue.id, false));
expect((await rehydrator.rehydrate(issue.id, Issue)).voterIds).toEqual([
'stranger',
]);
});
it('turns a proposal into a ticket, only where the project takes them', async () => {
const { run, rehydrator, projectViews } = await setup();
const projectId = await projectWithTeam(run);
const view = {
id: projectId,
key: 'TS',
isPublic: true,
acceptsProposals: false,
};
projectViews.set(projectId, view);
const propose = () =>
run(
new SubmitProposalCommand(
'ts',
'BUG',
'No carga en Firefox',
'Pantalla en blanco',
'Lucía',
),
);
await expect(propose()).rejects.toThrow(ForbiddenError);
view.acceptsProposals = true;
const result = await propose();
const issue = await rehydrator.rehydrate(result.id, Issue);
expect(issue).toMatchObject({
title: 'No carga en Firefox',
type: IssueType.BUG,
labels: ['propuesta'],
reporterId: 'system',
});
expect(issue.description).toContain('Pantalla en blanco');
expect(issue.description).toContain('por Lucía');
view.isPublic = false;
await expect(propose()).rejects.toThrow(NotFoundError);
});
it('answers comments one level deep, on the same ticket', async () => { it('answers comments one level deep, on the same ticket', async () => {
const { run, rehydrator } = await setup(); const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run); const projectId = await projectWithTeam(run);

View File

@@ -1,6 +1,10 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain'; import {
IssueFactory,
SYSTEM_ACTOR_ID,
assertCanNest,
} from '@task-stream/api/tracker/domain';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository'; import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { CommandResult, ack } from './command-result'; import { CommandResult, ack } from './command-result';
import { CreateIssueCommand } from './create-issue.command'; import { CreateIssueCommand } from './create-issue.command';
@@ -21,7 +25,10 @@ export class CreateIssueCommandHandler
async execute(command: CreateIssueCommand): Promise<CommandResult> { async execute(command: CreateIssueCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId); const project = await this.access.project(command.projectId);
project.assertCanContribute(command.actorId); // TaskStream itself (proposals from the public form) is no member; only
// internal commands act as it, never a request from outside.
if (command.actorId !== SYSTEM_ACTOR_ID)
project.assertCanContribute(command.actorId);
// Before taking a number: an invalid type or area must not burn one. // Before taking a number: an invalid type or area must not burn one.
const workflow = project.workflow; const workflow = project.workflow;
workflow.assertActiveType(command.type); workflow.assertActiveType(command.type);

View File

@@ -0,0 +1,61 @@
import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { AddChecklistItemCommand } from './change-checklist.command';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { DuplicateIssueCommand } from './duplicate-issue.command';
import { WriteAccess } from './write-access';
/**
* Copies what a ticket says (type, title, description, area, priority,
* labels, Figma, estimate, dates, parent and checklist steps, unticked),
* not where it stands: the copy starts in the first status, unassigned,
* out of sprints and releases, without comments or files. Created through
* the usual command, so the same rules and events apply.
*/
@CommandHandler(DuplicateIssueCommand)
export class DuplicateIssueCommandHandler
implements ICommandHandler<DuplicateIssueCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
) {}
async execute(command: DuplicateIssueCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
const copy: CommandResult = await this.commandBus.execute(
new CreateIssueCommand(
command.actorId,
issue.projectId,
issue.type,
`${issue.title} (copia)`.slice(0, 255),
{
description: issue.description,
area: issue.area,
priority: issue.priority,
labels: issue.labels,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
startDate: issue.startDate,
dueDate: issue.dueDate,
},
null,
null,
issue.parentId,
null,
// The original's text is the content, not the type's template.
false,
),
);
let result = copy;
for (const step of issue.checklist) {
result = await this.commandBus.execute(
new AddChecklistItemCommand(command.actorId, copy.id, step.text),
);
}
return { ...result, key: copy.key };
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** A new ticket like this one, to start from: the copy's key in the result. */
export class DuplicateIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
) {
super();
}
}

View File

@@ -6,6 +6,11 @@ export * from './assign-issue.command';
export * from './change-issue-status.command'; export * from './change-issue-status.command';
export * from './change-project-member-role.command'; export * from './change-project-member-role.command';
export * from './create-issue.command'; export * from './create-issue.command';
export * from './duplicate-issue.command';
export * from './react-to-comment.command';
export * from './vote-issue.command';
export * from './submit-proposal.command';
export { PROPOSAL_LABEL } from './submit-proposal.command-handler';
export * from './create-project.command'; export * from './create-project.command';
export * from './create-release.command'; export * from './create-release.command';
export * from './delete-comment.command'; export * from './delete-comment.command';

View File

@@ -0,0 +1,25 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { CommandResult, ack } from './command-result';
import { ReactToCommentCommand } from './react-to-comment.command';
import { WriteAccess } from './write-access';
/** Reacting is for members, viewers included: reading is enough to agree. */
@CommandHandler(ReactToCommentCommand)
export class ReactToCommentCommandHandler
implements ICommandHandler<ReactToCommentCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: ReactToCommentCommand): 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}`,
);
comment.react(command.actorId, command.emoji, command.on);
await comment.commitAndWait();
return ack(comment);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** Adds (`on`) or takes back one's reaction (👍…) on a comment. */
export class ReactToCommentCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
public readonly emoji: string,
public readonly on: boolean,
) {
super();
}
}

View File

@@ -0,0 +1,82 @@
import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
ForbiddenError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
IssuePriority,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { ProposalKind, SubmitProposalCommand } from './submit-proposal.command';
import { WriteAccess } from './write-access';
/** The label every proposal carries, for the team to find and sort them. */
export const PROPOSAL_LABEL = 'propuesta';
/** Preferred ticket type of each kind, when the project has it active. */
const TYPE_OF: Record<ProposalKind, string> = { BUG: 'BUG', IDEA: 'FEATURE' };
/**
* Only a public project that takes proposals accepts one. It is opened by
* TaskStream (nobody signed in), in the first status, as a Bug or a Feature
* (or the first active type if the project has neither), and signed with
* the name they gave, if any.
*/
@CommandHandler(SubmitProposalCommand)
export class SubmitProposalCommandHandler
implements ICommandHandler<SubmitProposalCommand>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
) {}
async execute(command: SubmitProposalCommand): Promise<CommandResult> {
const found =
(await this.findProjects.findById(command.project)) ??
(await this.findProjects.findByKey(command.project.toUpperCase()));
if (!found?.isPublic) throw new NotFoundError('Project', command.project);
if (!found.acceptsProposals)
throw new ForbiddenError(`${found.key} does not take proposals`);
const title = command.title.trim();
assertThat(
title.length >= 3 && title.length <= 150,
'A proposal needs a title of 3 to 150 characters',
);
const text = command.description.trim();
assertThat(text.length <= 5000, 'A proposal is 5000 characters at most');
const project = await this.access.project(found.id);
const active = project.settings.issueTypes.filter((t) => !t.archived);
const type =
active.find((t) => t.id === TYPE_OF[command.kind])?.id ?? active[0]?.id;
const author = command.author?.trim().slice(0, 80);
const signature = `_Propuesta enviada desde el formulario público${author ? ` por ${author}` : ''}._`;
return this.commandBus.execute(
new CreateIssueCommand(
SYSTEM_ACTOR_ID,
project.id,
type,
title,
{
description: text ? `${text}\n\n${signature}` : signature,
labels: [PROPOSAL_LABEL],
priority: IssuePriority.MEDIUM,
},
null,
null,
null,
null,
// What they wrote is the content, not the type's template.
false,
),
);
}
}

View File

@@ -0,0 +1,22 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export type ProposalKind = 'BUG' | 'IDEA';
/**
* A bug report or an idea from anyone, through a public project's form:
* it becomes a ticket labelled `propuesta`, for the team to look at.
*/
export class SubmitProposalCommand extends Command<CommandResult> {
constructor(
/** Project id or key. */
public readonly project: string,
public readonly kind: ProposalKind,
public readonly title: string,
public readonly description: string,
/** How they sign it, if they want to: shown in the ticket. */
public readonly author: string | null,
) {
super();
}
}

View File

@@ -9,6 +9,7 @@ export class UpdateProjectCommand extends Command<CommandResult> {
name?: string; name?: string;
description?: string | null; description?: string | null;
isPublic?: boolean; isPublic?: boolean;
acceptsProposals?: boolean;
}, },
) { ) {
super(); super();

View File

@@ -0,0 +1,33 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
BusinessRuleError,
ForbiddenError,
} from '@task-stream/api/shared/domain';
import { CommandResult, ack } from './command-result';
import { VoteIssueCommand } from './vote-issue.command';
import { WriteAccess } from './write-access';
/**
* Anyone signed in votes on a public project's tickets (that is the point:
* the people it is for say what they want); on a private one, its members.
*/
@CommandHandler(VoteIssueCommand)
export class VoteIssueCommandHandler
implements ICommandHandler<VoteIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: VoteIssueCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
if (!project.isPublic && !project.isMember(command.actorId))
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
if (issue.trashedAt)
throw new BusinessRuleError(`${issue.key} is in the trash`);
issue.vote(command.actorId, command.on);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** The actor votes for a ticket (`on`) or takes the vote back. */
export class VoteIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly on: boolean,
) {
super();
}
}

View File

@@ -5,6 +5,7 @@ import { AddCommentCommand } from './commands/add-comment.command';
import { CommandResult } from './commands/command-result'; import { CommandResult } from './commands/command-result';
import { DeleteCommentCommand } from './commands/delete-comment.command'; import { DeleteCommentCommand } from './commands/delete-comment.command';
import { EditCommentCommand } from './commands/edit-comment.command'; import { EditCommentCommand } from './commands/edit-comment.command';
import { ReactToCommentCommand } from './commands/react-to-comment.command';
import { Page } from './ports/find-issues.repository'; import { Page } from './ports/find-issues.repository';
import { GetCommentsQuery } from './queries/get-comments.query'; import { GetCommentsQuery } from './queries/get-comments.query';
@@ -36,6 +37,18 @@ export class CommentsService {
); );
} }
/** Adds (`on`) or takes back one's reaction (👍…). */
react(
actorId: string,
commentId: string,
emoji: string,
on: boolean,
): Promise<CommandResult> {
return this.commandBus.execute(
new ReactToCommentCommand(actorId, commentId, emoji, on),
);
}
delete(actorId: string, commentId: string): Promise<CommandResult> { delete(actorId: string, commentId: string): Promise<CommandResult> {
return this.commandBus.execute( return this.commandBus.execute(
new DeleteCommentCommand(actorId, commentId), new DeleteCommentCommand(actorId, commentId),

View File

@@ -29,6 +29,7 @@ export class CommentAddedEventHandler
body: comment.body, body: comment.body,
replyTo: comment.replyTo ?? null, replyTo: comment.replyTo ?? null,
deleted: false, deleted: false,
reactions: [],
createdAt: comment.createdAt, createdAt: comment.createdAt,
editedAt: null, editedAt: null,
version: versionOf(event), version: versionOf(event),

View File

@@ -0,0 +1,34 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
CommentReactedEvent,
REACTIONS,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(CommentReactedEvent)
export class CommentReactedEventHandler
implements IEventHandler<CommentReactedEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentReactedEvent) {
const refs = await this.users.refs(Object.values(event.reactions).flat());
await this.upsertMaterializedCommentRepository.upsert({
id: event.aggregateId,
reactions: REACTIONS.filter(
(emoji) => event.reactions[emoji]?.length,
).map((emoji) => ({
emoji,
users: event.reactions[emoji].map((id) => refs.of(id)),
})),
version: versionOf(event),
});
await this.notifier.comment(event.aggregateId, event.issueId, event);
}
}

View File

@@ -2,6 +2,8 @@ import { ActivityEventHandler } from './activity.event-handler';
import { CommentAddedEventHandler } from './comment-added.event-handler'; import { CommentAddedEventHandler } from './comment-added.event-handler';
import { CommentDeletedEventHandler } from './comment-deleted.event-handler'; import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
import { CommentEditedEventHandler } from './comment-edited.event-handler'; import { CommentEditedEventHandler } from './comment-edited.event-handler';
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
import { IssueVotedEventHandler } from './issue-voted.event-handler';
import { IssueAssignedEventHandler } from './issue-assigned.event-handler'; import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
import { IssueCreatedEventHandler } from './issue-created.event-handler'; import { IssueCreatedEventHandler } from './issue-created.event-handler';
import { IssueLinkedEventHandler } from './issue-linked.event-handler'; import { IssueLinkedEventHandler } from './issue-linked.event-handler';
@@ -46,6 +48,8 @@ export const EVENT_HANDLERS = [
CommentAddedEventHandler, CommentAddedEventHandler,
CommentDeletedEventHandler, CommentDeletedEventHandler,
CommentEditedEventHandler, CommentEditedEventHandler,
CommentReactedEventHandler,
IssueVotedEventHandler,
IssueAssignedEventHandler, IssueAssignedEventHandler,
IssueCreatedEventHandler, IssueCreatedEventHandler,
IssueLinkedEventHandler, IssueLinkedEventHandler,

View File

@@ -0,0 +1,23 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueVotedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueVotedEvent)
export class IssueVotedEventHandler implements IEventHandler<IssueVotedEvent> {
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueVotedEvent) {
// A vote is not a change to the ticket: `updatedAt` stays.
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
votes: event.voterIds.length,
voterIds: event.voterIds,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -26,6 +26,7 @@ export class ProjectCreatedEventHandler
name: project.name, name: project.name,
description: project.description, description: project.description,
isPublic: project.isPublic ?? false, isPublic: project.isPublic ?? false,
acceptsProposals: project.acceptsProposals ?? false,
owner: users.of(project.ownerId), owner: users.of(project.ownerId),
members: project.members.map((m) => ({ members: project.members.map((m) => ({
user: users.of(m.userId), user: users.of(m.userId),

View File

@@ -8,8 +8,11 @@ import {
IssueReadModel, IssueReadModel,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './commands/assign-issue.command'; import { AssignIssueCommand } from './commands/assign-issue.command';
import { DuplicateIssueCommand } from './commands/duplicate-issue.command';
import { VoteIssueCommand } from './commands/vote-issue.command';
import { import {
AttachFileCommand, AttachFileCommand,
AttachmentResult,
IncomingFile, IncomingFile,
} from './commands/attach-file.command'; } from './commands/attach-file.command';
import { DetachFileCommand } from './commands/detach-file.command'; import { DetachFileCommand } from './commands/detach-file.command';
@@ -169,7 +172,7 @@ export class IssuesService {
issueId: string, issueId: string,
file: IncomingFile, file: IncomingFile,
commentId: string | null = null, commentId: string | null = null,
): Promise<CommandResult> { ): Promise<AttachmentResult> {
return this.commandBus.execute( return this.commandBus.execute(
new AttachFileCommand(actorId, issueId, file, commentId), new AttachFileCommand(actorId, issueId, file, commentId),
); );
@@ -258,6 +261,16 @@ export class IssuesService {
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId)); return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
} }
/** Votes for the ticket (`on`) or takes the vote back. */
vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> {
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on));
}
/** A new ticket like this one (what it says, not where it stands). */
duplicate(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new DuplicateIssueCommand(actorId, issueId));
}
trashed( trashed(
viewerId: string | null, viewerId: string | null,
projectId: string, projectId: string,

View File

@@ -1,3 +1,7 @@
import {
ProposalKind,
SubmitProposalCommand,
} from './commands/submit-proposal.command';
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query'; import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { CommandBus, QueryBus } from '@nestjs/cqrs';
@@ -34,6 +38,19 @@ export class ProjectsService {
) {} ) {}
/** A public project's roadmap, releases, sprint and good first tickets (no token). */ /** A public project's roadmap, releases, sprint and good first tickets (no token). */
/** A bug report or an idea from anyone, for a public project that takes them. */
propose(
project: string,
kind: ProposalKind,
title: string,
description: string,
author: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new SubmitProposalCommand(project, kind, title, description, author),
);
}
showcase(project: string): Promise<Showcase> { showcase(project: string): Promise<Showcase> {
return this.queryBus.execute(new GetShowcaseQuery(project)); return this.queryBus.execute(new GetShowcaseQuery(project));
} }
@@ -61,7 +78,12 @@ export class ProjectsService {
update( update(
actorId: string, actorId: string,
projectId: string, projectId: string,
changes: { name?: string; description?: string | null; isPublic?: boolean }, changes: {
name?: string;
description?: string | null;
isPublic?: boolean;
acceptsProposals?: boolean;
},
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commandBus.execute(
new UpdateProjectCommand(actorId, projectId, changes), new UpdateProjectCommand(actorId, projectId, changes),

View File

@@ -6,6 +6,8 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository'; import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
/** Tickets with this label are offered to newcomers on public pages. */ /** Tickets with this label are offered to newcomers on public pages. */
export const GOOD_FIRST_LABEL = 'good-first-issue'; export const GOOD_FIRST_LABEL = 'good-first-issue';
@@ -35,6 +37,8 @@ export interface Showcase {
dueDate: string | null; dueDate: string | null;
percent: number; percent: number;
done: boolean; done: boolean;
/** How many want it. */
votes: number;
path: string; path: string;
}[]; }[];
releases: { releases: {
@@ -46,6 +50,20 @@ export interface Showcase {
notes: string | null; notes: string | null;
}[]; }[];
goodFirst: { key: string; title: string; type: string; path: string }[]; goodFirst: { key: string; title: string; type: string; path: string }[];
/** Bug reports and ideas from the public: whether it takes them, where, and the open ones. */
proposals: {
open: boolean;
/** The form in the web app, while it takes them. */
path: string | null;
/** Open proposals, most voted first. */
top: {
key: string;
title: string;
type: string;
votes: number;
path: string;
}[];
};
} }
export class GetShowcaseQuery extends Query<Showcase> { export class GetShowcaseQuery extends Query<Showcase> {
@@ -55,6 +73,11 @@ export class GetShowcaseQuery extends Query<Showcase> {
} }
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED]; const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
const OPEN = [
StatusCategory.TODO,
StatusCategory.IN_PROGRESS,
StatusCategory.QA,
];
@QueryHandler(GetShowcaseQuery) @QueryHandler(GetShowcaseQuery)
export class GetShowcaseQueryHandler export class GetShowcaseQueryHandler
@@ -74,7 +97,7 @@ export class GetShowcaseQueryHandler
(await this.findProjects.findByKey(query.project.toUpperCase())); (await this.findProjects.findByKey(query.project.toUpperCase()));
if (!project?.isPublic) throw new NotFoundError('Project', query.project); if (!project?.isPublic) throw new NotFoundError('Project', query.project);
const path = (key: string) => `/p/${project.key}/issues/${key}`; const path = (key: string) => `/p/${project.key}/issues/${key}`;
const [epics, releases, sprint, goodFirst] = await Promise.all([ const [epics, releases, sprint, goodFirst, proposals] = await Promise.all([
this.findIssues.epics(project.id), this.findIssues.epics(project.id),
this.findReleases.findByProject(project.id), this.findReleases.findByProject(project.id),
this.findSprints.findActive(project.id), this.findSprints.findActive(project.id),
@@ -85,6 +108,13 @@ export class GetShowcaseQueryHandler
limit: 20, limit: 20,
offset: 0, offset: 0,
}), }),
this.findIssues.search({
projectId: project.id,
labels: [PROPOSAL_LABEL],
categories: OPEN,
limit: 100,
offset: 0,
}),
]); ]);
const visible = releases const visible = releases
.filter((r) => r.status !== ReleaseStatus.ARCHIVED) .filter((r) => r.status !== ReleaseStatus.ARCHIVED)
@@ -120,6 +150,7 @@ export class GetShowcaseQueryHandler
dueDate: issue.dueDate, dueDate: issue.dueDate,
percent: p.percent, percent: p.percent,
done: FINISHED.includes(issue.statusCategory), done: FINISHED.includes(issue.statusCategory),
votes: issue.votes,
path: path(issue.key), path: path(issue.key),
})), })),
releases: visible.map((r, i) => ({ releases: visible.map((r, i) => ({
@@ -136,6 +167,20 @@ export class GetShowcaseQueryHandler
type: i.type, type: i.type,
path: path(i.key), path: path(i.key),
})), })),
proposals: {
open: project.acceptsProposals,
path: project.acceptsProposals ? `/p/${project.key}/proponer` : null,
top: [...proposals.items]
.sort((a, b) => b.votes - a.votes)
.slice(0, 10)
.map((i) => ({
key: i.key,
title: i.title,
type: i.type,
votes: i.votes,
path: path(i.key),
})),
},
}; };
} }
} }

View File

@@ -55,6 +55,7 @@ import {
StartSprintCommand, StartSprintCommand,
} from '../commands/sprint.commands'; } from '../commands/sprint.commands';
import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { SprintCarryOverSaga } from './sprint-carry-over.saga'; import { SprintCarryOverSaga } from './sprint-carry-over.saga';
import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository'; import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
@@ -87,6 +88,8 @@ async function setup() {
IssueTree, IssueTree,
AggregateRehydrator, AggregateRehydrator,
{ provide: EventStore, useValue: store }, { provide: EventStore, useValue: store },
// Proposals look projects up in the read model; no saga sends one.
{ provide: FindProjectsRepository, useValue: {} },
{ provide: AttachmentStorage, useValue: files }, { provide: AttachmentStorage, useValue: files },
{ provide: UserDirectory, useValue: users }, { provide: UserDirectory, useValue: users },
{ provide: ProjectKeysRepository, useClass: InMemoryProjectKeys }, { provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },

View File

@@ -17,6 +17,7 @@ import { ReadAccess } from './queries/read-access';
import { ReleasesService } from './releases.service'; import { ReleasesService } from './releases.service';
import { SAGAS } from './sagas/sagas'; import { SAGAS } from './sagas/sagas';
import { WorkflowsService } from './workflows.service'; import { WorkflowsService } from './workflows.service';
import { BlockersService } from './blockers.service';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { Notifier } from './notifications/notifier'; import { Notifier } from './notifications/notifier';
import { NotificationsEventHandler } from './notifications/notifications.event-handler'; import { NotificationsEventHandler } from './notifications/notifications.event-handler';
@@ -60,6 +61,7 @@ const SERVICES = [
ReleasesService, ReleasesService,
ChangesService, ChangesService,
WorkflowsService, WorkflowsService,
BlockersService,
NotificationsService, NotificationsService,
SavedFiltersService, SavedFiltersService,
SprintsService, SprintsService,

View File

@@ -7,6 +7,10 @@ import {
import { CommentAddedEvent } from '../events/comment-added.event'; import { CommentAddedEvent } from '../events/comment-added.event';
import { CommentDeletedEvent } from '../events/comment-deleted.event'; import { CommentDeletedEvent } from '../events/comment-deleted.event';
import { CommentEditedEvent } from '../events/comment-edited.event'; import { CommentEditedEvent } from '../events/comment-edited.event';
import { CommentReactedEvent } from '../events/comment-reacted.event';
/** The reactions a comment takes, in the order they are shown (GitHub's). */
export const REACTIONS = ['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀'];
/** Data of a comment (what CommentAddedEvent carries). */ /** Data of a comment (what CommentAddedEvent carries). */
export interface CommentProps { export interface CommentProps {
@@ -35,6 +39,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
public authorId!: string; public authorId!: string;
public body!: string; public body!: string;
public replyTo: string | null = null; public replyTo: string | null = null;
/** Who chose each emoji (only the ones somebody chose). */
public reactions: Record<string, string[]> = {};
public deleted = false; public deleted = false;
public createdAt!: string; public createdAt!: string;
public editedAt: string | null = null; public editedAt: string | null = null;
@@ -62,6 +68,31 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
); );
} }
/** Adds (`on`) or takes back the actor's reaction; repeating it changes nothing. */
react(actorId: string, emoji: string, on: boolean): void {
this.assertAlive();
assertThat(
REACTIONS.includes(emoji),
`Reactions are one of ${REACTIONS.join(' ')}`,
);
const users = this.reactions[emoji] ?? [];
if (on === users.includes(actorId)) return;
const next = {
...this.reactions,
[emoji]: on ? [...users, actorId] : users.filter((u) => u !== actorId),
};
if (!next[emoji].length) delete next[emoji];
this.apply(
new CommentReactedEvent(
actorId,
this.id,
this.projectId,
this.issueId,
next,
),
);
}
/** @param canModerate whether the actor manages the project (may delete others' comments). */ /** @param canModerate whether the actor manages the project (may delete others' comments). */
delete(actorId: string, canModerate: boolean): void { delete(actorId: string, canModerate: boolean): void {
this.assertAlive(); this.assertAlive();
@@ -86,6 +117,10 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
this.editedAt = event.occurredAt; this.editedAt = event.occurredAt;
} }
onCommentReactedEvent(event: CommentReactedEvent): void {
this.reactions = event.reactions;
}
onCommentDeletedEvent(): void { onCommentDeletedEvent(): void {
this.deleted = true; this.deleted = true;
} }

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** Someone added or took back a reaction (👍…) on a comment. */
@AutowiredEvent
export class CommentReactedEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
/** Every reaction after the change: who chose each emoji. */
readonly reactions: Record<string, string[]>,
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -14,6 +14,7 @@ export * from './issue-unlinked.event';
export * from './issue-release-changed.event'; export * from './issue-release-changed.event';
export * from './comment-added.event'; export * from './comment-added.event';
export * from './comment-edited.event'; export * from './comment-edited.event';
export * from './comment-reacted.event';
export * from './comment-deleted.event'; export * from './comment-deleted.event';
export * from './release-created.event'; export * from './release-created.event';
export * from './release-updated.event'; export * from './release-updated.event';
@@ -25,6 +26,7 @@ export * from './release-deleted.event';
export * from './issue-attachment-added.event'; export * from './issue-attachment-added.event';
export * from './issue-attachment-removed.event'; export * from './issue-attachment-removed.event';
export * from './issue-watched.event'; export * from './issue-watched.event';
export * from './issue-voted.event';
export * from './issue-unwatched.event'; export * from './issue-unwatched.event';
export * from './issue-parent-changed.event'; export * from './issue-parent-changed.event';
export * from './issue-checklist-item-added.event'; export * from './issue-checklist-item-added.event';

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** Someone voted for a ticket, or took the vote back. */
@AutowiredEvent
export class IssueVotedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
/** Everyone who wants it, after the change. */
readonly voterIds: string[],
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -2,14 +2,17 @@ import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { ProjectProps } from '../project/project'; import type { ProjectProps } from '../project/project';
import { TrackerEvent } from './tracker.event'; import { TrackerEvent } from './tracker.event';
/** Name, description or visibility changed. */ /** Name, description, visibility or whether it takes public proposals changed. */
@AutowiredEvent @AutowiredEvent
export class ProjectUpdatedEvent extends TrackerEvent { export class ProjectUpdatedEvent extends TrackerEvent {
constructor( constructor(
actorId: string, actorId: string,
projectId: string, projectId: string,
readonly changes: Partial< readonly changes: Partial<
Pick<ProjectProps, 'name' | 'description' | 'isPublic'> Pick<
ProjectProps,
'name' | 'description' | 'isPublic' | 'acceptsProposals'
>
>, >,
) { ) {
super(actorId, projectId, projectId); super(actorId, projectId, projectId);

View File

@@ -25,6 +25,7 @@ import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event'; import { IssueUpdatedEvent } from '../events/issue-updated.event';
import { IssueUnwatchedEvent } from '../events/issue-unwatched.event'; import { IssueUnwatchedEvent } from '../events/issue-unwatched.event';
import { IssueWatchedEvent } from '../events/issue-watched.event'; import { IssueWatchedEvent } from '../events/issue-watched.event';
import { IssueVotedEvent } from '../events/issue-voted.event';
import { ProjectWorkflow } from '../project/project-settings'; import { ProjectWorkflow } from '../project/project-settings';
import { import {
IssueAttachment, IssueAttachment,
@@ -116,6 +117,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public attachments: IssueAttachment[] = []; public attachments: IssueAttachment[] = [];
/** People who asked to be told about this ticket. */ /** People who asked to be told about this ticket. */
public watchers: string[] = []; public watchers: string[] = [];
/** People who want it done (a public roadmap's way to prioritise). */
public voterIds: string[] = [];
/** The epic or ticket this one breaks down. */ /** The epic or ticket this one breaks down. */
public parentId: string | null = null; public parentId: string | null = null;
public checklist: ChecklistItem[] = []; public checklist: ChecklistItem[] = [];
@@ -337,6 +340,21 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
); );
} }
/** The actor votes for it (`on`) or takes the vote back. Idempotent. */
vote(actorId: string, on: boolean): void {
if (on === this.voterIds.includes(actorId)) return;
this.apply(
new IssueVotedEvent(
actorId,
this.id,
this.projectId,
on
? [...this.voterIds, actorId]
: this.voterIds.filter((id) => id !== actorId),
),
);
}
/** `userId` starts following the ticket. Idempotent. */ /** `userId` starts following the ticket. Idempotent. */
watch(actorId: string, userId: string): void { watch(actorId: string, userId: string): void {
if (this.watchers.includes(userId)) return; if (this.watchers.includes(userId)) return;
@@ -553,6 +571,10 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.updatedAt = event.occurredAt; this.updatedAt = event.occurredAt;
} }
onIssueVotedEvent(event: IssueVotedEvent): void {
this.voterIds = event.voterIds;
}
onIssueWatchedEvent(event: IssueWatchedEvent): void { onIssueWatchedEvent(event: IssueWatchedEvent): void {
this.watchers = [...this.watchers, event.userId]; this.watchers = [...this.watchers, event.userId];
} }

View File

@@ -32,6 +32,11 @@ export interface ProjectProps {
description: string | null; description: string | null;
/** Anyone (even without signing in) can read it. Absent in old projects: private. */ /** Anyone (even without signing in) can read it. Absent in old projects: private. */
isPublic?: boolean; isPublic?: boolean;
/**
* Takes bug reports and ideas from anyone, through a public form (only
* while it is public). Absent in old projects: no.
*/
acceptsProposals?: boolean;
ownerId: string; ownerId: string;
members: ProjectMember[]; members: ProjectMember[];
/** Absent in projects created before settings existed: defaults apply. */ /** Absent in projects created before settings existed: defaults apply. */
@@ -49,6 +54,7 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
public name!: string; public name!: string;
public description: string | null = null; public description: string | null = null;
public isPublic = false; public isPublic = false;
public acceptsProposals = false;
public ownerId!: string; public ownerId!: string;
public members: ProjectMember[] = []; public members: ProjectMember[] = [];
public settings: ProjectSettings = defaultProjectSettings(); public settings: ProjectSettings = defaultProjectSettings();
@@ -104,13 +110,19 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
updateDetails( updateDetails(
actorId: string, actorId: string,
changes: { name?: string; description?: string | null; isPublic?: boolean }, changes: {
name?: string;
description?: string | null;
isPublic?: boolean;
acceptsProposals?: boolean;
},
): void { ): void {
this.assertCanManage(actorId); this.assertCanManage(actorId);
const patch: { const patch: {
name?: string; name?: string;
description?: string | null; description?: string | null;
isPublic?: boolean; isPublic?: boolean;
acceptsProposals?: boolean;
} = {}; } = {};
if (changes.name !== undefined) { if (changes.name !== undefined) {
const name = changes.name.trim(); const name = changes.name.trim();
@@ -123,6 +135,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
} }
if (changes.isPublic !== undefined && changes.isPublic !== this.isPublic) if (changes.isPublic !== undefined && changes.isPublic !== this.isPublic)
patch.isPublic = changes.isPublic; patch.isPublic = changes.isPublic;
if (
changes.acceptsProposals !== undefined &&
changes.acceptsProposals !== this.acceptsProposals
)
patch.acceptsProposals = changes.acceptsProposals;
if (Object.keys(patch).length === 0) return; if (Object.keys(patch).length === 0) return;
this.apply(new ProjectUpdatedEvent(actorId, this.id, patch)); this.apply(new ProjectUpdatedEvent(actorId, this.id, patch));
} }
@@ -213,6 +230,7 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
this.name = project.name; this.name = project.name;
this.description = project.description; this.description = project.description;
this.isPublic = project.isPublic ?? false; this.isPublic = project.isPublic ?? false;
this.acceptsProposals = project.acceptsProposals ?? false;
this.ownerId = project.ownerId; this.ownerId = project.ownerId;
this.members = project.members.map((m) => ({ ...m })); this.members = project.members.map((m) => ({ ...m }));
this.settings = structuredClone( this.settings = structuredClone(

View File

@@ -1,5 +1,11 @@
import { UserRefReadModel } from './user-ref.read-model'; import { UserRefReadModel } from './user-ref.read-model';
/** One emoji on a comment and who chose it. */
export class CommentReactionReadModel {
emoji!: string;
users!: UserRefReadModel[];
}
export class CommentReadModel { export class CommentReadModel {
id!: string; id!: string;
issueId!: string; issueId!: string;
@@ -13,6 +19,8 @@ export class CommentReadModel {
* conversation under it still makes sense. Gone with its last reply. * conversation under it still makes sense. Gone with its last reply.
*/ */
deleted!: boolean; deleted!: boolean;
/** In the order they are offered; only the ones somebody chose. */
reactions!: CommentReactionReadModel[];
createdAt!: string; createdAt!: string;
editedAt!: string | null; editedAt!: string | null;
version!: number; version!: number;

View File

@@ -81,6 +81,10 @@ export class IssueReadModel {
attachments!: AttachmentReadModel[]; attachments!: AttachmentReadModel[];
/** People following the ticket. */ /** People following the ticket. */
watchers!: UserRefReadModel[]; watchers!: UserRefReadModel[];
/** How many want it done. */
votes!: number;
/** Who voted: to tell someone whether they did; never shown as a list. */
voterIds!: string[];
parent!: IssueParentReadModel | null; parent!: IssueParentReadModel | null;
/** Place in the backlog (and in each board column): ascending. */ /** Place in the backlog (and in each board column): ascending. */
rank!: string; rank!: string;

View File

@@ -14,6 +14,8 @@ export class ProjectReadModel {
description!: string | null; description!: string | null;
/** Readable by anyone, also without signing in. */ /** Readable by anyone, also without signing in. */
isPublic!: boolean; isPublic!: boolean;
/** Takes bug reports and ideas from anyone (only while public). */
acceptsProposals!: boolean;
owner!: UserRefReadModel; owner!: UserRefReadModel;
members!: ProjectMemberReadModel[]; members!: ProjectMemberReadModel[];
/** Statuses, transitions, ticket types and areas. */ /** Statuses, transitions, ticket types and areas. */

View File

@@ -50,6 +50,7 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
...fromDoc(doc), ...fromDoc(doc),
replyTo: doc.replyTo ?? null, replyTo: doc.replyTo ?? null,
deleted: doc.deleted ?? false, deleted: doc.deleted ?? false,
reactions: doc.reactions ?? [],
}); });
const escapeRegex = (text: string) => const escapeRegex = (text: string) =>
@@ -63,6 +64,7 @@ function toProject(doc: ProjectDoc): ProjectReadModel {
return { return {
...fromDoc(rest), ...fromDoc(rest),
isPublic: rest.isPublic ?? false, isPublic: rest.isPublic ?? false,
acceptsProposals: rest.acceptsProposals ?? false,
settings: rest.settings ?? defaultProjectSettings(), settings: rest.settings ?? defaultProjectSettings(),
}; };
} }

View File

@@ -293,6 +293,8 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
// Tickets projected before attachments existed have none. // Tickets projected before attachments existed have none.
attachments: rest.attachments ?? [], attachments: rest.attachments ?? [],
watchers: rest.watchers ?? [], watchers: rest.watchers ?? [],
votes: rest.votes ?? 0,
voterIds: rest.voterIds ?? [],
parent: rest.parent?.id ? rest.parent : null, parent: rest.parent?.id ? rest.parent : null,
rank: rest.rank ?? implicitRank(rest.number), rank: rest.rank ?? implicitRank(rest.number),
startDate: rest.startDate ?? null, startDate: rest.startDate ?? null,

View File

@@ -31,6 +31,18 @@ export class CommentResolver {
return this.comments.edit(me.id, commentId, body); return this.comments.edit(me.id, commentId, body);
} }
@Mutation(() => CommandResultGql, {
description: 'React (👍 👎 😄 🎉 😕 ❤️ 🚀 👀) or take it back (on: false)',
})
reactToComment(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('emoji') emoji: string,
@Args('on') on: boolean,
) {
return this.comments.react(me.id, commentId, emoji, on);
}
@Mutation(() => CommandResultGql, { description: 'Author or project admin' }) @Mutation(() => CommandResultGql, { description: 'Author or project admin' })
deleteComment( deleteComment(
@CurrentUser() me: User, @CurrentUser() me: User,

View File

@@ -74,6 +74,14 @@ export class UpdateProjectInput {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isPublic?: boolean; isPublic?: boolean;
@Field({
nullable: true,
description:
'Take bug reports and ideas from anyone through the public form (while public)',
})
@IsOptional()
@IsBoolean()
acceptsProposals?: boolean;
} }
@InputType() @InputType()

View File

@@ -14,6 +14,7 @@ import {
OptionalAuth, OptionalAuth,
} from '@task-stream/api/identity/presenters'; } from '@task-stream/api/identity/presenters';
import { import {
BlockersService,
CommentsService, CommentsService,
IssuesService, IssuesService,
WorkflowsService, WorkflowsService,
@@ -44,6 +45,7 @@ import {
EpicGql, EpicGql,
IssueGql, IssueGql,
IssuePageGql, IssuePageGql,
IssueParentGql,
} from './types'; } from './types';
@Resolver(() => IssueGql) @Resolver(() => IssueGql)
@@ -52,6 +54,7 @@ export class IssueResolver {
private readonly issuesService: IssuesService, private readonly issuesService: IssuesService,
private readonly commentsService: CommentsService, private readonly commentsService: CommentsService,
private readonly workflows: WorkflowsService, private readonly workflows: WorkflowsService,
private readonly blockers: BlockersService,
) {} ) {}
@OptionalAuth() @OptionalAuth()
@@ -154,6 +157,22 @@ export class IssueResolver {
return this.workflows.allowedTransitions(issue); return this.workflows.allowedTransitions(issue);
} }
@ResolveField(() => [IssueParentGql], {
description:
'Open tickets it waits for: the ones that block it and the ones it depends on',
})
blockedBy(@Parent() issue: IssueReadModel) {
return this.blockers.blockedBy(issue);
}
@ResolveField(() => Boolean, { description: 'Whether I voted for it' })
voted(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
): boolean {
return !!me && issue.voterIds.includes(me.id);
}
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' }) @ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
linkedIssues( linkedIssues(
@CurrentUser() me: User | undefined, @CurrentUser() me: User | undefined,
@@ -307,6 +326,29 @@ export class IssueResolver {
return this.issuesService.trash(me.id, issueId); return this.issuesService.trash(me.id, issueId);
} }
@Mutation(() => CommandResultGql, {
description:
'A new ticket like this one: what it says, not where it stands (the key comes in the result)',
})
duplicateIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.issuesService.duplicate(me.id, issueId);
}
@Mutation(() => CommandResultGql, {
description:
'Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one',
})
voteIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('on') on: boolean,
) {
return this.issuesService.vote(me.id, issueId, on);
}
@Mutation(() => CommandResultGql, { description: 'Back from the trash' }) @Mutation(() => CommandResultGql, { description: 'Back from the trash' })
restoreIssue( restoreIssue(
@CurrentUser() me: User, @CurrentUser() me: User,

View File

@@ -102,6 +102,10 @@ export class ProjectGql {
@Field(() => String, { nullable: true }) description!: string | null; @Field(() => String, { nullable: true }) description!: string | null;
@Field({ description: 'Anyone can read it, also without signing in' }) @Field({ description: 'Anyone can read it, also without signing in' })
isPublic!: boolean; isPublic!: boolean;
@Field({
description: 'Takes bug reports and ideas from anyone (while public)',
})
acceptsProposals!: boolean;
@Field(() => UserRefGql) owner!: UserRefGql; @Field(() => UserRefGql) owner!: UserRefGql;
@Field(() => [ProjectMemberGql]) members!: ProjectMemberGql[]; @Field(() => [ProjectMemberGql]) members!: ProjectMemberGql[];
@Field() createdAt!: string; @Field() createdAt!: string;
@@ -374,6 +378,8 @@ export class IssueGql {
}) })
devLinks!: DevLinkGql[]; devLinks!: DevLinkGql[];
@Field(() => Int) commentCount!: number; @Field(() => Int) commentCount!: number;
@Field(() => Int, { description: 'How many want it done' })
votes!: number;
@Field() createdAt!: string; @Field() createdAt!: string;
@Field() updatedAt!: string; @Field() updatedAt!: string;
@Field(() => Int) version!: number; @Field(() => Int) version!: number;
@@ -440,6 +446,13 @@ export class BoardColumnGql {
@Field(() => Int) total!: number; @Field(() => Int) total!: number;
} }
@ObjectType('CommentReaction', { description: 'One emoji on a comment' })
export class CommentReactionGql {
@Field() emoji!: string;
@Field(() => [UserRefGql], { description: 'Who chose it' })
users!: UserRefGql[];
}
@ObjectType('Comment') @ObjectType('Comment')
export class CommentGql { export class CommentGql {
@Field(() => ID) id!: string; @Field(() => ID) id!: string;
@@ -456,6 +469,10 @@ export class CommentGql {
description: 'Deleted, but kept (without its text) while it has replies', description: 'Deleted, but kept (without its text) while it has replies',
}) })
deleted!: boolean; deleted!: boolean;
@Field(() => [CommentReactionGql], {
description: 'In the order they are offered; only the ones somebody chose',
})
reactions!: CommentReactionGql[];
@Field() createdAt!: string; @Field() createdAt!: string;
@Field(() => String, { nullable: true }) editedAt!: string | null; @Field(() => String, { nullable: true }) editedAt!: string | null;
@Field(() => Int) version!: number; @Field(() => Int) version!: number;

View File

@@ -36,7 +36,7 @@ import { IssuesService } from '@task-stream/api/tracker/application';
import type { Response } from 'express'; import type { Response } from 'express';
import { viewerOf } from '../common/viewer'; import { viewerOf } from '../common/viewer';
import { AttachFileDto } from './dto/requests.dto'; import { AttachFileDto } from './dto/requests.dto';
import { CommandResultDto } from './dto/responses.dto'; import { AttachmentResultDto, CommandResultDto } from './dto/responses.dto';
import { Refs } from './refs'; import { Refs } from './refs';
const ISSUE = ApiParam({ const ISSUE = ApiParam({
@@ -80,7 +80,7 @@ export class AttachmentsController {
'Multipart with the file as `file` and, optionally, `commentId` to place it under a comment. ' + 'Multipart with the file as `file` and, optionally, `commentId` to place it under a comment. ' +
'Up to ATTACHMENTS_MAX_MB megabytes (25 by default) and 50 files per ticket.', 'Up to ATTACHMENTS_MAX_MB megabytes (25 by default) and 50 files per ticket.',
}) })
@ApiCreatedResponse({ type: CommandResultDto }) @ApiCreatedResponse({ type: AttachmentResultDto })
@ApiCommandErrors() @ApiCommandErrors()
async attach( async attach(
@CurrentUser() me: User, @CurrentUser() me: User,

View File

@@ -1,4 +1,12 @@
import { Body, Controller, Delete, Param, Patch } from '@nestjs/common'; import {
Body,
Controller,
Delete,
HttpCode,
Param,
Patch,
Post,
} from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain'; import { User } from '@task-stream/api/identity/domain';
import { import {
@@ -7,7 +15,8 @@ import {
} from '@task-stream/api/identity/presenters'; } from '@task-stream/api/identity/presenters';
import { ApiCommandErrors } from '@task-stream/api/shared/presenters'; import { ApiCommandErrors } from '@task-stream/api/shared/presenters';
import { CommentsService } from '@task-stream/api/tracker/application'; import { CommentsService } from '@task-stream/api/tracker/application';
import { CommentBodyDto } from './dto/requests.dto'; import { REACTIONS } from '@task-stream/api/tracker/domain';
import { CommentBodyDto, ReactToCommentDto } from './dto/requests.dto';
import { CommandResultDto } from './dto/responses.dto'; import { CommandResultDto } from './dto/responses.dto';
/** REST adapter of the comment use cases (listing and adding live under /issues). */ /** REST adapter of the comment use cases (listing and adding live under /issues). */
@@ -29,6 +38,22 @@ export class CommentsController {
return this.comments.edit(me.id, commentId, body.body); return this.comments.edit(me.id, commentId, body.body);
} }
@Post(':commentId/reactions')
@HttpCode(200)
@ApiOperation({
summary: 'React to a comment, or take the reaction back (members)',
description: `One of ${REACTIONS.join(' ')}; repeating it changes nothing.`,
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
react(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
@Body() body: ReactToCommentDto,
) {
return this.comments.react(me.id, commentId, body.emoji, body.on);
}
@Delete(':commentId') @Delete(':commentId')
@ApiOperation({ summary: 'Delete a comment (author or project admin)' }) @ApiOperation({ summary: 'Delete a comment (author or project admin)' })
@ApiOkResponse({ type: CommandResultDto }) @ApiOkResponse({ type: CommandResultDto })

View File

@@ -8,6 +8,7 @@ import {
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
ProjectRole, ProjectRole,
REACTIONS,
SETTING_COLORS, SETTING_COLORS,
StatusCategory, StatusCategory,
TYPE_ICONS, TYPE_ICONS,
@@ -88,7 +89,15 @@ export class CreateProjectDto {
export class UpdateProjectDto extends PartialType( export class UpdateProjectDto extends PartialType(
OmitType(CreateProjectDto, ['key'] as const), OmitType(CreateProjectDto, ['key'] as const),
) {} ) {
@ApiPropertyOptional({
description:
'Take bug reports and ideas from anyone through the public form (while public)',
})
@IsOptional()
@IsBoolean()
acceptsProposals?: boolean;
}
export class DeleteProjectQueryDto { export class DeleteProjectQueryDto {
@ApiProperty({ @ApiProperty({
@@ -985,6 +994,59 @@ export class AddCommentDto extends CommentBodyDto {
replyTo?: string; replyTo?: string;
} }
export class ReactToCommentDto {
@ApiProperty({ enum: REACTIONS, example: '👍' })
@IsIn(REACTIONS)
emoji!: string;
@ApiProperty({ description: 'true adds the reaction, false takes it back' })
@IsBoolean()
on!: boolean;
}
// ---- Public proposals -------------------------------------------------------
export class ProposalDto {
@ApiProperty({
enum: ['BUG', 'IDEA'],
description: 'A bug report or an idea',
})
@IsIn(['BUG', 'IDEA'])
kind!: 'BUG' | 'IDEA';
@ApiProperty({ minLength: 3, maxLength: 150 })
@IsString()
@MinLength(3)
@MaxLength(150)
title!: string;
@ApiPropertyOptional({
maxLength: 5000,
description: 'What happens, or what they would like (Markdown)',
})
@IsOptional()
@IsString()
@MaxLength(5000)
description?: string;
@ApiPropertyOptional({
maxLength: 80,
description:
'How they sign it; shown in the ticket (no e-mail: it would be public)',
})
@IsOptional()
@IsString()
@MaxLength(80)
author?: string;
@ApiPropertyOptional({
description: 'Leave empty: a field only bots fill in',
})
@IsOptional()
@IsString()
website?: string;
}
// ---- Releases --------------------------------------------------------------- // ---- Releases ---------------------------------------------------------------
export class CreateReleaseDto { export class CreateReleaseDto {

View File

@@ -96,6 +96,11 @@ export class ProjectDto {
@ApiProperty({ type: String, nullable: true }) description!: string | null; @ApiProperty({ type: String, nullable: true }) description!: string | null;
@ApiProperty({ description: 'Readable by anyone, also without a token' }) @ApiProperty({ description: 'Readable by anyone, also without a token' })
isPublic!: boolean; isPublic!: boolean;
@ApiProperty({
description:
'Takes bug reports and ideas from anyone (POST /api/explore/projects/{project}/proposals) while public',
})
acceptsProposals!: boolean;
@ApiProperty({ type: UserRefDto }) owner!: UserRefDto; @ApiProperty({ type: UserRefDto }) owner!: UserRefDto;
@ApiProperty({ type: [ProjectMemberDto] }) members!: ProjectMemberDto[]; @ApiProperty({ type: [ProjectMemberDto] }) members!: ProjectMemberDto[];
@ApiProperty({ type: ProjectSettingsDto }) @ApiProperty({ type: ProjectSettingsDto })
@@ -279,6 +284,7 @@ class ShowcaseEpicDto {
dueDate!: string | null; dueDate!: string | null;
@ApiProperty() percent!: number; @ApiProperty() percent!: number;
@ApiProperty() done!: boolean; @ApiProperty() done!: boolean;
@ApiProperty({ description: 'How many want it' }) votes!: number;
@ApiProperty({ @ApiProperty({
example: '/p/WEB/issues/WEB-3', example: '/p/WEB/issues/WEB-3',
description: 'In the web app', description: 'In the web app',
@@ -308,6 +314,27 @@ class ShowcaseIssueDto {
@ApiProperty({ example: '/p/WEB/issues/WEB-9' }) path!: string; @ApiProperty({ example: '/p/WEB/issues/WEB-9' }) path!: string;
} }
class ShowcaseProposalDto extends ShowcaseIssueDto {
@ApiProperty({ description: 'How many want it' }) votes!: number;
}
class ShowcaseProposalsDto {
@ApiProperty({ description: 'Whether it takes bug reports and ideas now' })
open!: boolean;
@ApiProperty({
type: String,
nullable: true,
example: '/p/WEB/proponer',
description: 'The form in the web app, while it takes them',
})
path!: string | null;
@ApiProperty({
type: [ShowcaseProposalDto],
description: 'Open proposals, most voted first',
})
top!: ShowcaseProposalDto[];
}
export class ShowcaseDto { export class ShowcaseDto {
@ApiProperty({ type: ShowcaseProjectDto }) project!: ShowcaseProjectDto; @ApiProperty({ type: ShowcaseProjectDto }) project!: ShowcaseProjectDto;
@ApiProperty({ type: ShowcaseSprintDto, nullable: true }) @ApiProperty({ type: ShowcaseSprintDto, nullable: true })
@@ -319,6 +346,25 @@ export class ShowcaseDto {
description: 'Open tickets labelled good-first-issue', description: 'Open tickets labelled good-first-issue',
}) })
goodFirst!: ShowcaseIssueDto[]; goodFirst!: ShowcaseIssueDto[];
@ApiProperty({ type: ShowcaseProposalsDto })
proposals!: ShowcaseProposalsDto;
}
export class ProposalResultDto {
@ApiProperty({
type: String,
nullable: true,
example: 'WEB-42',
description: 'The ticket it became',
})
key!: string | null;
@ApiProperty({
type: String,
nullable: true,
example: '/p/WEB/issues/WEB-42',
description: 'Where to see it in the web app',
})
path!: string | null;
} }
export class GitDeliveryResultDto { export class GitDeliveryResultDto {
@@ -390,6 +436,13 @@ export class IssueDto {
description: 'The epic or ticket this one breaks down', description: 'The epic or ticket this one breaks down',
}) })
parent!: IssueParentDto | null; parent!: IssueParentDto | null;
@ApiProperty({
type: [IssueParentDto],
description:
'Open tickets it waits for: the ones that block it and the ones it depends on',
})
blockedBy!: IssueParentDto[];
@ApiProperty({ description: 'How many want it done' }) votes!: number;
@ApiProperty({ @ApiProperty({
description: description:
'Place in the backlog and its board column (ascending, as text)', 'Place in the backlog and its board column (ascending, as text)',
@@ -462,6 +515,11 @@ export class BoardColumnDto {
total!: number; total!: number;
} }
export class CommentReactionDto {
@ApiProperty({ example: '👍' }) emoji!: string;
@ApiProperty({ type: [UserRefDto] }) users!: UserRefDto[];
}
export class CommentDto { export class CommentDto {
@ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) issueId!: string; @ApiProperty({ format: 'uuid' }) issueId!: string;
@@ -481,6 +539,11 @@ export class CommentDto {
description: 'Deleted, but kept (without its text) while it has replies', description: 'Deleted, but kept (without its text) while it has replies',
}) })
deleted!: boolean; deleted!: boolean;
@ApiProperty({
type: [CommentReactionDto],
description: 'In the order they are offered; only the ones somebody chose',
})
reactions!: CommentReactionDto[];
@ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
editedAt!: string | null; editedAt!: string | null;
@@ -649,6 +712,15 @@ export class CommandResultDto {
version!: number; version!: number;
} }
export class AttachmentResultDto extends CommandResultDto {
@ApiProperty({
format: 'uuid',
description:
'The new file; `![name](attachment:<id>)` shows an image inside a description or comment',
})
attachmentId!: string;
}
// ---- Statistics --------------------------------------------------------------- // ---- Statistics ---------------------------------------------------------------
export class CreatedResolvedDto { export class CreatedResolvedDto {

View File

@@ -1,6 +1,17 @@
import { Controller, Get, Header, Param } from '@nestjs/common';
import { import {
Body,
Controller,
Get,
Header,
Param,
Post,
Req,
} from '@nestjs/common';
import {
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNotFoundResponse, ApiNotFoundResponse,
ApiTooManyRequestsResponse,
ApiOkResponse, ApiOkResponse,
ApiOperation, ApiOperation,
ApiParam, ApiParam,
@@ -8,14 +19,24 @@ import {
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Public } from '@task-stream/api/identity/presenters'; import { Public } from '@task-stream/api/identity/presenters';
import { ProjectsService } from '@task-stream/api/tracker/application'; import { ProjectsService } from '@task-stream/api/tracker/application';
import { ProjectDto, ShowcaseDto } from './dto/responses.dto'; import { Request } from 'express';
import { ProposalDto } from './dto/requests.dto';
import {
ProjectDto,
ProposalResultDto,
ShowcaseDto,
} from './dto/responses.dto';
import { ProposalLimiter } from './proposal-limiter';
import { toProjectDto } from './refs'; import { toProjectDto } from './refs';
/** What anyone can read without signing in. */ /** What anyone can read without signing in. */
@ApiTags('Explore') @ApiTags('Explore')
@Controller('explore') @Controller('explore')
export class ExploreController { export class ExploreController {
constructor(private readonly projects: ProjectsService) {} constructor(
private readonly projects: ProjectsService,
private readonly limiter: ProposalLimiter,
) {}
@Get('projects') @Get('projects')
@Public() @Public()
@@ -47,4 +68,51 @@ export class ExploreController {
showcase(@Param('project') project: string) { showcase(@Param('project') project: string) {
return this.projects.showcase(project); return this.projects.showcase(project);
} }
@Post('projects/:project/proposals')
@Public()
@ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
})
@ApiOperation({
summary: 'Report a bug or propose an idea (no token needed)',
description:
'For public projects that take proposals. It becomes a ticket labelled `propuesta`, opened by TaskStream in the first status (Bug or Feature), signed with `author` if given. Five per address every 15 minutes.',
})
@ApiCreatedResponse({ type: ProposalResultDto })
@ApiNotFoundResponse({ description: 'Not a public project' })
@ApiForbiddenResponse({ description: 'It does not take proposals' })
@ApiTooManyRequestsResponse({ description: 'Too many from this address' })
async propose(
@Param('project') project: string,
@Body() body: ProposalDto,
@Req() req: Request,
): Promise<ProposalResultDto> {
// Bots fill in every field: pretend it worked, and keep nothing.
if (body.website?.trim()) return { key: null, path: null };
this.limiter.take(addressOf(req));
const result = await this.projects.propose(
project,
body.kind,
body.title,
body.description ?? '',
body.author ?? null,
);
const key = result.key ?? null;
return {
key,
path: key ? `/p/${key.split('-')[0]}/issues/${key}` : null,
};
}
}
/** Behind a proxy (Traefik, nginx), who asked comes in X-Forwarded-For. */
function addressOf(req: Request): string {
const forwarded = req.headers['x-forwarded-for'];
const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)
?.split(',')[0]
?.trim();
return first || req.ip || 'unknown';
} }

View File

@@ -338,6 +338,45 @@ export class IssuesController {
return this.issues.restore(me.id, await this.refs.issueId(me.id, issue)); return this.issues.restore(me.id, await this.refs.issueId(me.id, issue));
} }
@Put('issues/:issue/vote')
@ISSUE
@ApiOperation({
summary: 'Vote for the ticket',
description:
'Anyone signed in, on a public project (that is the point: the people it is for say what they want); members on a private one. Voting twice changes nothing.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async vote(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.vote(me.id, await this.refs.issueId(me.id, issue), true);
}
@Delete('issues/:issue/vote')
@ISSUE
@ApiOperation({ summary: 'Take back my vote for the ticket' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async unvote(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.vote(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@Post('issues/:issue/duplicate')
@ISSUE
@ApiOperation({
summary: 'Duplicate the ticket',
description:
'Copies what it says (type, title, description, area, priority, labels, Figma, estimate, dates, parent and checklist steps, unticked), not where it stands: the copy starts in the first status, unassigned, out of sprints and releases, without comments or files. The result carries its key.',
})
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async duplicate(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.duplicate(me.id, await this.refs.issueId(me.id, issue));
}
@Get('projects/:project/trash') @Get('projects/:project/trash')
@ApiOptionalPireonAuth() @ApiOptionalPireonAuth()
@PROJECT @PROJECT

View File

@@ -0,0 +1,33 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
/** Proposals from one address within the window. */
const LIMIT = 5;
const WINDOW_MS = 15 * 60_000;
/**
* Keeps one address from flooding a project with proposals. In memory:
* enough for one instance, and an approximation with several (each counts
* its own), which is fine for what it guards against.
*/
@Injectable()
export class ProposalLimiter {
private readonly sent = new Map<string, number[]>();
take(address: string): void {
const now = Date.now();
const recent = (this.sent.get(address) ?? []).filter(
(at) => now - at < WINDOW_MS,
);
if (recent.length >= LIMIT)
throw new HttpException(
'Too many proposals from here; try again in a few minutes',
HttpStatus.TOO_MANY_REQUESTS,
);
recent.push(now);
this.sent.set(address, recent);
// Forget quiet addresses now and then, so the map does not grow forever.
if (this.sent.size > 10_000)
for (const [key, times] of this.sent)
if (times.every((at) => now - at >= WINDOW_MS)) this.sent.delete(key);
}
}

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
BlockersService,
IssuesService, IssuesService,
ProjectsService, ProjectsService,
WorkflowsService, WorkflowsService,
@@ -23,6 +24,7 @@ export class Refs {
private readonly projects: ProjectsService, private readonly projects: ProjectsService,
private readonly issues: IssuesService, private readonly issues: IssuesService,
private readonly workflows: WorkflowsService, private readonly workflows: WorkflowsService,
private readonly blockers: BlockersService,
) {} ) {}
async projectId(viewerId: string | null, ref: string): Promise<string> { async projectId(viewerId: string | null, ref: string): Promise<string> {
@@ -35,12 +37,15 @@ export class Refs {
return (await this.issues.findOne(viewerId, ref)).id; return (await this.issues.findOne(viewerId, ref)).id;
} }
/** The ticket as the REST API returns it: read model + allowed transitions. */ /** The ticket as the REST API returns it: read model, allowed transitions and what blocks it. */
async issueDto(issue: IssueReadModel) { async issueDto(issue: IssueReadModel) {
return { const [allowedTransitions, blockedBy] = await Promise.all([
...issue, this.workflows.allowedTransitions(issue),
allowedTransitions: await this.workflows.allowedTransitions(issue), this.blockers.blockedBy(issue),
}; ]);
// Who voted is only for telling someone whether they did.
const { voterIds: _voters, ...shown } = issue;
return { ...shown, allowedTransitions, blockedBy };
} }
issueDtos(issues: IssueReadModel[]) { issueDtos(issues: IssueReadModel[]) {

View File

@@ -12,6 +12,7 @@ import { AttachmentsController } from './http/attachments.controller';
import { CommentsController } from './http/comments.controller'; import { CommentsController } from './http/comments.controller';
import { IssuesController } from './http/issues.controller'; import { IssuesController } from './http/issues.controller';
import { ExploreController } from './http/explore.controller'; import { ExploreController } from './http/explore.controller';
import { ProposalLimiter } from './http/proposal-limiter';
import { ProjectsController } from './http/projects.controller'; import { ProjectsController } from './http/projects.controller';
import { Refs } from './http/refs'; import { Refs } from './http/refs';
import { ReleasesController } from './http/releases.controller'; import { ReleasesController } from './http/releases.controller';
@@ -63,6 +64,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
], ],
providers: [ providers: [
Refs, Refs,
ProposalLimiter,
ProjectResolver, ProjectResolver,
IssueResolver, IssueResolver,
CommentResolver, CommentResolver,

View File

@@ -43,6 +43,7 @@ export const APP_ICONS: Record<string, string> = {
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6 6 18"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6 6 18"/></svg>',
coins: coins:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6.5" rx="7" ry="3"/><path d="M5 6.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5M5 11.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6.5" rx="7" ry="3"/><path d="M5 6.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5M5 11.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>',
copy: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>',
document: document:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
email: email:

View File

@@ -8,3 +8,6 @@ export * from './lib/facades/explore.facade';
export * from './lib/facades/inbox.facade'; export * from './lib/facades/inbox.facade';
export * from './lib/facades/epics.facade'; export * from './lib/facades/epics.facade';
export * from './lib/facades/backlog.facade'; export * from './lib/facades/backlog.facade';
export * from './lib/facades/list.facade';
export * from './lib/facades/propose.facade';
export type { ListCriteria } from './lib/events/list.events';

View File

@@ -6,6 +6,7 @@ export const epicsPageEvents = eventGroup({
source: 'Epics Page', source: 'Epics Page',
events: { events: {
opened: type<{ projectId: string }>(), opened: type<{ projectId: string }>(),
voteRequested: type<{ issueId: string; on: boolean }>(),
}, },
}); });
@@ -14,5 +15,7 @@ export const epicsApiEvents = eventGroup({
events: { events: {
loaded: type<Epic[]>(), loaded: type<Epic[]>(),
loadFailed: type<string>(), loadFailed: type<string>(),
voted: type<void>(),
voteFailed: type<string>(),
}, },
}); });

View File

@@ -69,6 +69,10 @@ export const issuePageEvents = eventGroup({
attachmentId: string; attachmentId: string;
}>(), }>(),
commentEditRequested: type<{ commentId: string; body: string }>(), commentEditRequested: type<{ commentId: string; body: string }>(),
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
voteRequested: type<{ issueId: string; on: boolean }>(),
/** An image pasted into a text was uploaded (it joins the ticket's files). */
imagePasted: type<{ issueId: string }>(),
commentDeleteRequested: type<{ commentId: string }>(), commentDeleteRequested: type<{ commentId: string }>(),
}, },
}); });

View File

@@ -0,0 +1,38 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import {
BulkChange,
BulkResult,
IssueListFilter,
IssuePage,
IssuePriority,
} from '@task-stream/web/tracker/domain';
/** What the list shows, beyond the project. */
export type ListCriteria = Omit<IssueListFilter, 'projectId' | 'limit'>;
export const listPageEvents = eventGroup({
source: 'List Page',
events: {
opened: type<{ projectId: string }>(),
filtered: type<ListCriteria>(),
statusChangeRequested: type<{ issueId: string; status: string }>(),
priorityChangeRequested: type<{
issueId: string;
priority: IssuePriority;
}>(),
assignRequested: type<{ issueId: string; assigneeId: string | null }>(),
bulkRequested: type<{ issueIds: string[]; change: BulkChange }>(),
},
});
export const listApiEvents = eventGroup({
source: 'List API',
events: {
loaded: type<IssuePage>(),
loadFailed: type<string>(),
changed: type<void>(),
changeFailed: type<string>(),
bulkDone: type<BulkResult>(),
},
});

View File

@@ -48,6 +48,10 @@ export const projectPageEvents = eventGroup({
analyticsRequested: type<{ projectId: string; days: number }>(), analyticsRequested: type<{ projectId: string; days: number }>(),
burnupRequested: type<{ releaseId: string }>(), burnupRequested: type<{ releaseId: string }>(),
visibilityChangeRequested: type<{ projectId: string; isPublic: boolean }>(), visibilityChangeRequested: type<{ projectId: string; isPublic: boolean }>(),
proposalsChangeRequested: type<{
projectId: string;
acceptsProposals: boolean;
}>(),
deleteRequested: type<{ projectId: string; confirmKey: string }>(), deleteRequested: type<{ projectId: string; confirmKey: string }>(),
}, },
}); });

View File

@@ -19,6 +19,11 @@ export class EpicsFacade {
open(projectId: string): void { open(projectId: string): void {
this.dispatch.opened({ projectId }); this.dispatch.opened({ projectId });
} }
/** Votes for an epic (`on`) or takes the vote back. */
vote(issueId: string, on: boolean): void {
this.dispatch.voteRequested({ issueId, on });
}
} }
/** Store + facade of the epics view, for its route component. */ /** Store + facade of the epics view, for its route component. */

View File

@@ -4,10 +4,11 @@ import {
Attachment, Attachment,
AttachmentsGateway, AttachmentsGateway,
IssueLink, IssueLink,
IssuesGateway,
IssueLinkType, IssueLinkType,
UpdateIssueInput, UpdateIssueInput,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { issuePageEvents } from '../events/issue.events'; import { issuePageEvents } from '../events/issue.events';
import { IssueStore } from '../stores/issue.store'; import { IssueStore } from '../stores/issue.store';
@@ -20,6 +21,7 @@ export class IssueFacade {
private readonly store = inject(IssueStore); private readonly store = inject(IssueStore);
private readonly dispatch = injectDispatch(issuePageEvents); private readonly dispatch = injectDispatch(issuePageEvents);
private readonly files = inject(AttachmentsGateway); private readonly files = inject(AttachmentsGateway);
private readonly issues = inject(IssuesGateway);
readonly issue = this.store.issue; readonly issue = this.store.issue;
readonly comments = this.store.comments; readonly comments = this.store.comments;
@@ -178,6 +180,43 @@ export class IssueFacade {
return this.files.download(issueId, attachment); return this.files.download(issueId, attachment);
} }
/** A file's bytes by id alone (an image inside a description or comment). */
fileById(attachmentId: string): Observable<Blob> {
return this.fileOf({ id: attachmentId } as Attachment);
}
/**
* Uploads an image pasted into a text and answers its id, to write
* `![name](attachment:<id>)`. It also shows among the ticket's files.
*/
uploadImage(file: File): Observable<string> {
const issueId = this.store.issue()?.id ?? '';
return this.files.upload(issueId, file).pipe(
map((uploaded) => {
this.dispatch.imagePasted({ issueId });
return uploaded.attachmentId;
}),
);
}
/** Votes for the open ticket (`on`) or takes the vote back. */
vote(on: boolean): void {
this.withIssue((issueId) => this.dispatch.voteRequested({ issueId, on }));
}
/** Adds (`on`) or takes back the signed-in person's reaction. */
react(commentId: string, emoji: string, on: boolean): void {
this.dispatch.reactRequested({ commentId, emoji, on });
}
/** A copy of what the open ticket says; answers its key. */
duplicate(): Observable<string | null> {
const issueId = this.store.issue()?.id ?? '';
return this.issues
.duplicate(issueId)
.pipe(map((result) => result.key ?? null));
}
editComment(commentId: string, body: string): void { editComment(commentId: string, body: string): void {
this.dispatch.commentEditRequested({ commentId, body }); this.dispatch.commentEditRequested({ commentId, body });
} }

View File

@@ -0,0 +1,51 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { BulkChange, IssuePriority } from '@task-stream/web/tracker/domain';
import { ListCriteria, listPageEvents } from '../events/list.events';
import { LIST_LIMIT, ListStore } from '../stores/list.store';
/** The list view of a project, for presenters. */
@Injectable()
export class ListFacade {
private readonly store = inject(ListStore);
private readonly dispatch = injectDispatch(listPageEvents);
/** Most recently updated first (the first LIST_LIMIT). */
readonly issues = this.store.issues;
readonly total = this.store.total;
readonly criteria = this.store.criteria;
readonly limit = LIST_LIMIT;
/** False until the first load: show skeletons, not empty states. */
readonly loaded = this.store.loaded;
readonly error = this.store.error;
open(projectId: string): void {
this.dispatch.opened({ projectId });
}
filter(criteria: ListCriteria): void {
this.dispatch.filtered(criteria);
}
changeStatus(issueId: string, status: string): void {
this.dispatch.statusChangeRequested({ issueId, status });
}
changePriority(issueId: string, priority: IssuePriority): void {
this.dispatch.priorityChangeRequested({ issueId, priority });
}
assign(issueId: string, assigneeId: string | null): void {
this.dispatch.assignRequested({ issueId, assigneeId });
}
/** The same change on several tickets (each may refuse on its own). */
bulkUpdate(issueIds: string[], change: BulkChange): void {
if (issueIds.length) this.dispatch.bulkRequested({ issueIds, change });
}
}
/** Store + facade of the list view, for its route component. */
export function provideListFacade(): Provider[] {
return [ListStore, ListFacade];
}

View File

@@ -161,6 +161,13 @@ export class ProjectFacade {
this.dispatch.visibilityChangeRequested({ projectId, isPublic }); this.dispatch.visibilityChangeRequested({ projectId, isPublic });
} }
/** Take bug reports and ideas from anyone (while public). Owners and admins. */
setAcceptsProposals(acceptsProposals: boolean): void {
const projectId = this.projectId();
if (projectId)
this.dispatch.proposalsChangeRequested({ projectId, acceptsProposals });
}
/** /**
* Owner only, typing the key to confirm. Tickets, comments and releases go * Owner only, typing the key to confirm. Tickets, comments and releases go
* too; on success it navigates home. * too; on success it navigates home.

View File

@@ -0,0 +1,20 @@
import { inject, Injectable } from '@angular/core';
import {
ProposalInput,
ProposalResult,
ProposalsGateway,
} from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
/** The public form of a project: anyone reports a bug or proposes an idea. */
@Injectable({ providedIn: 'root' })
export class ProposeFacade {
private readonly proposals = inject(ProposalsGateway);
submit(
projectKey: string,
proposal: ProposalInput,
): Observable<ProposalResult> {
return this.proposals.submit(projectKey, proposal);
}
}

View File

@@ -12,9 +12,9 @@ import {
IssuesGateway, IssuesGateway,
TrackerRealtimeGateway, TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { debounceTime, startWith, switchMap } from 'rxjs'; import { debounceTime, merge, mergeMap, startWith, switchMap, tap } from 'rxjs';
import { epicsApiEvents, epicsPageEvents } from '../events/epics.events'; import { epicsApiEvents, epicsPageEvents } from '../events/epics.events';
import { describeError } from '../notifications'; import { describeError, Notifications } from '../notifications';
interface EpicsState { interface EpicsState {
epics: Epic[]; epics: Epic[];
@@ -42,11 +42,16 @@ export const EpicsStore = signalStore(
events = inject(Events), events = inject(Events),
issues = inject(IssuesGateway), issues = inject(IssuesGateway),
realtime = inject(TrackerRealtimeGateway), realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
) => ({ ) => ({
// Any ticket of the project may move an epic's progress: reload, calmly. // Any ticket of the project may move an epic's progress: reload, calmly.
load$: events.on(epicsPageEvents.opened).pipe( load$: events.on(epicsPageEvents.opened).pipe(
switchMap(({ payload }) => switchMap(({ payload }) =>
realtime.issueChanges(payload.projectId).pipe( merge(
realtime.issueChanges(payload.projectId),
// One's own vote, without waiting for the live update.
events.on(epicsApiEvents.voted),
).pipe(
debounceTime(500), debounceTime(500),
startWith(null), startWith(null),
switchMap(() => switchMap(() =>
@@ -61,6 +66,19 @@ export const EpicsStore = signalStore(
), ),
), ),
), ),
vote$: events.on(epicsPageEvents.voteRequested).pipe(
mergeMap(({ payload }) =>
issues.vote(payload.issueId, payload.on).pipe(
mapResponse({
next: () => epicsApiEvents.voted(),
error: (error) => epicsApiEvents.voteFailed(describeError(error)),
}),
),
),
),
voteFeedback$: events
.on(epicsApiEvents.voteFailed)
.pipe(tap(({ payload }) => notifications.error(payload))),
}), }),
), ),
); );

View File

@@ -244,7 +244,11 @@ export const IssueStore = signalStore(
* ticket, its comments and its subtasks are asked for twice. * ticket, its comments and its subtasks are asked for twice.
*/ */
catchUp$: events catchUp$: events
.on(issueApiEvents.commandSucceeded, issueApiEvents.checklistSaved) .on(
issueApiEvents.commandSucceeded,
issueApiEvents.checklistSaved,
issuePageEvents.imagePasted,
)
.pipe( .pipe(
map(() => store.issue()), map(() => store.issue()),
filter((issue): issue is Issue => !!issue), filter((issue): issue is Issue => !!issue),
@@ -391,6 +395,30 @@ export const IssueStore = signalStore(
), ),
), ),
), ),
events.on(issuePageEvents.voteRequested).pipe(
mergeMap(({ payload }) =>
issues.vote(payload.issueId, payload.on).pipe(
// The count is the feedback.
mapResponse({
next: () => issueApiEvents.checklistSaved(),
error: (error) =>
issueApiEvents.commandFailed(describeError(error)),
}),
),
),
),
events.on(issuePageEvents.reactRequested).pipe(
mergeMap(({ payload }) =>
comments.react(payload.commentId, payload.emoji, payload.on).pipe(
// A reaction is not worth a toast either.
mapResponse({
next: () => issueApiEvents.checklistSaved(),
error: (error) =>
issueApiEvents.commandFailed(describeError(error)),
}),
),
),
),
events.on(issuePageEvents.checklistUpdateRequested).pipe( events.on(issuePageEvents.checklistUpdateRequested).pipe(
mergeMap(({ payload }) => mergeMap(({ payload }) =>
issues issues

View File

@@ -0,0 +1,183 @@
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
IssuesGateway,
IssueSummary,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
combineLatest,
concatMap,
debounceTime,
map,
merge,
mergeMap,
startWith,
switchMap,
tap,
} from 'rxjs';
import {
ListCriteria,
listApiEvents,
listPageEvents,
} from '../events/list.events';
import { describeError, Notifications } from '../notifications';
/** The list asks for this many at most; past that, the filters narrow it. */
export const LIST_LIMIT = 200;
interface ListState {
projectId: string | null;
criteria: ListCriteria;
issues: IssueSummary[];
total: number;
loaded: boolean;
error: string | null;
}
/** Every ticket of a project that fits the filters, as rows. Live. */
export const ListStore = signalStore(
withState<ListState>({
projectId: null,
criteria: {},
issues: [],
total: 0,
loaded: false,
error: null,
}),
withReducer(
on(listPageEvents.opened, ({ payload }) => ({
projectId: payload.projectId,
loaded: false,
error: null,
})),
on(listPageEvents.filtered, ({ payload }) => ({ criteria: payload })),
on(listApiEvents.loaded, ({ payload }) => ({
issues: payload.items,
total: payload.total,
loaded: true,
error: null,
})),
on(listApiEvents.loadFailed, ({ payload }) => ({
loaded: true,
error: payload,
})),
),
withEventHandlers(
(
store,
events = inject(Events),
issues = inject(IssuesGateway),
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
) => {
const command = (request: ReturnType<IssuesGateway['assign']>) =>
request.pipe(
mapResponse({
next: () => listApiEvents.changed(),
error: (error) => listApiEvents.changeFailed(describeError(error)),
}),
);
return {
/**
* Reloads on opening, on each filter and on anyone's change (calmly);
* also after this page's own changes, not to wait for the live one.
*/
load$: events.on(listPageEvents.opened).pipe(
switchMap(({ payload }) =>
combineLatest([
events.on(listPageEvents.filtered).pipe(
map(({ payload: criteria }) => criteria),
startWith(store.criteria()),
debounceTime(250),
),
merge(
realtime.issueChanges(payload.projectId),
events.on(
listApiEvents.changed,
listApiEvents.changeFailed,
listApiEvents.bulkDone,
),
).pipe(debounceTime(400), startWith(null)),
]).pipe(
switchMap(([criteria]) =>
issues
.list({
...criteria,
projectId: payload.projectId,
limit: LIST_LIMIT,
})
.pipe(
mapResponse({
next: (page) => listApiEvents.loaded(page),
error: (error) =>
listApiEvents.loadFailed(describeError(error)),
}),
),
),
),
),
),
status$: events.on(listPageEvents.statusChangeRequested).pipe(
mergeMap(({ payload }) =>
command(
issues.changeStatus({
issueId: payload.issueId,
status: payload.status,
}),
),
),
),
priority$: events.on(listPageEvents.priorityChangeRequested).pipe(
mergeMap(({ payload }) =>
command(
issues.update({
issueId: payload.issueId,
priority: payload.priority,
}),
),
),
),
assign$: events
.on(listPageEvents.assignRequested)
.pipe(
mergeMap(({ payload }) =>
command(issues.assign(payload.issueId, payload.assigneeId)),
),
),
bulk$: events.on(listPageEvents.bulkRequested).pipe(
concatMap(({ payload }) =>
issues.bulkUpdate(payload.issueIds, payload.change).pipe(
mapResponse({
next: (result) => listApiEvents.bulkDone(result),
error: (error) =>
listApiEvents.changeFailed(describeError(error)),
}),
),
),
),
bulkFeedback$: events.on(listApiEvents.bulkDone).pipe(
tap(({ payload }) => {
const refused = payload.results.filter((r) => !r.ok);
if (!refused.length)
notifications.success(`${payload.updated} tickets actualizados`);
else
notifications.error(
`${payload.updated} actualizados; ${refused.length} no: ${refused[0].error}`,
);
}),
),
feedback$: events
.on(listApiEvents.changeFailed)
.pipe(tap(({ payload }) => notifications.error(payload))),
};
},
),
);

View File

@@ -419,6 +419,20 @@ export const ProjectStore = signalStore(
), ),
), ),
), ),
events
.on(projectPageEvents.proposalsChangeRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
projects.update(payload),
payload.acceptsProposals
? 'Formulario público abierto: cualquiera puede proponer'
: 'Formulario público cerrado',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events events
.on(projectPageEvents.releaseCreateRequested) .on(projectPageEvents.releaseCreateRequested)
.pipe( .pipe(

View File

@@ -58,3 +58,6 @@ export enum NotificationKind {
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
REMINDER = 'REMINDER', REMINDER = 'REMINDER',
} }
/** The reactions a comment takes, in the order they are offered (GitHub's). */
export const REACTIONS = ['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀'];

View File

@@ -17,6 +17,24 @@ export interface UpdateProjectInput {
name?: string; name?: string;
description?: string | null; description?: string | null;
isPublic?: boolean; isPublic?: boolean;
acceptsProposals?: boolean;
}
/** A bug report or an idea from anyone, through a public project's form. */
export interface ProposalInput {
kind: 'BUG' | 'IDEA';
title: string;
description: string;
/** How they sign it (optional; no e-mail: it would be public). */
author: string;
/** Left empty by people; bots fill it in. */
website: string;
}
export interface ProposalResult {
/** The ticket it became (null when it was dropped as spam). */
key: string | null;
path: string | null;
} }
export interface CreateIssueInput { export interface CreateIssueInput {
@@ -68,6 +86,19 @@ export interface CreateReleaseInput {
targetDate?: string | null; targetDate?: string | null;
} }
/** The list view of a project: every ticket that fits, any status. */
export interface IssueListFilter {
projectId: string;
/** Matches key, title or description. */
search?: string;
categories?: StatusCategory[];
types?: string[];
priorities?: IssuePriority[];
assigneeId?: string;
unassigned?: boolean;
limit: number;
}
/** "My work": tickets across all my projects. */ /** "My work": tickets across all my projects. */
export interface MyIssuesFilter { export interface MyIssuesFilter {
/** true: assigned to me. false: any ticket of my projects. */ /** true: assigned to me. false: any ticket of my projects. */

View File

@@ -26,6 +26,8 @@ export interface Project {
description: string | null; description: string | null;
/** Anyone can read it, also without signing in. */ /** Anyone can read it, also without signing in. */
isPublic: boolean; isPublic: boolean;
/** Takes bug reports and ideas from anyone (while public). */
acceptsProposals: boolean;
owner: UserRef; owner: UserRef;
members: ProjectMember[]; members: ProjectMember[];
settings: ProjectSettings; settings: ProjectSettings;
@@ -112,6 +114,11 @@ export interface IssueSummary {
labels: string[]; labels: string[];
/** The epic or ticket this one breaks down. */ /** The epic or ticket this one breaks down. */
parent: IssueParent | null; parent: IssueParent | null;
/** Open tickets it waits for: the ones that block it or it depends on. */
blockedBy: IssueParent[];
/** How many want it done, and whether I am one of them. */
votes: number;
voted: boolean;
/** Place in the backlog and in its board column (ascending, as text). */ /** Place in the backlog and in its board column (ascending, as text). */
rank: string; rank: string;
/** Estimate; null when nobody estimated it. */ /** Estimate; null when nobody estimated it. */
@@ -275,6 +282,8 @@ export interface Comment {
replyTo: string | null; replyTo: string | null;
/** Deleted, but kept (without its text) while it has replies. */ /** Deleted, but kept (without its text) while it has replies. */
deleted: boolean; deleted: boolean;
/** 👍… and who chose each (only the ones somebody chose). */
reactions: { emoji: string; users: UserRef[] }[];
createdAt: string; createdAt: string;
editedAt: string | null; editedAt: string | null;
} }

View File

@@ -38,6 +38,9 @@ import {
CreateReleaseInput, CreateReleaseInput,
LinkIssuesInput, LinkIssuesInput,
MemberInput, MemberInput,
IssueListFilter,
ProposalInput,
ProposalResult,
MyIssuesFilter, MyIssuesFilter,
UpdateIssueInput, UpdateIssueInput,
UpdateProjectInput, UpdateProjectInput,
@@ -88,9 +91,15 @@ export abstract class IssuesGateway {
abstract board(filter: BoardFilter): Observable<BoardColumn[]>; abstract board(filter: BoardFilter): Observable<BoardColumn[]>;
abstract issue(idOrKey: string): Observable<Issue>; abstract issue(idOrKey: string): Observable<Issue>;
abstract search(projectId: string, text: string): Observable<IssueSummary[]>; abstract search(projectId: string, text: string): Observable<IssueSummary[]>;
/** A project's tickets for the list view (most recently updated first). */
abstract list(filter: IssueListFilter): Observable<IssuePage>;
/** Tickets across all my projects (most recently updated first). */ /** Tickets across all my projects (most recently updated first). */
abstract mine(filter: MyIssuesFilter): Observable<IssuePage>; abstract mine(filter: MyIssuesFilter): Observable<IssuePage>;
abstract create(input: CreateIssueInput): Observable<CommandResult>; abstract create(input: CreateIssueInput): Observable<CommandResult>;
/** Votes for it (`on`) or takes the vote back. */
abstract vote(issueId: string, on: boolean): Observable<CommandResult>;
/** A copy of what the ticket says; the result carries its key. */
abstract duplicate(issueId: string): Observable<CommandResult>;
abstract update(input: UpdateIssueInput): Observable<CommandResult>; abstract update(input: UpdateIssueInput): Observable<CommandResult>;
abstract changeStatus(input: ChangeStatusInput): Observable<CommandResult>; abstract changeStatus(input: ChangeStatusInput): Observable<CommandResult>;
abstract assign( abstract assign(
@@ -236,16 +245,33 @@ export abstract class CommentsGateway {
replyTo?: string | null, replyTo?: string | null,
): Observable<CommandResult>; ): Observable<CommandResult>;
abstract edit(commentId: string, body: string): Observable<CommandResult>; abstract edit(commentId: string, body: string): Observable<CommandResult>;
/** Adds (`on`) or takes back one's reaction. */
abstract react(
commentId: string,
emoji: string,
on: boolean,
): Observable<CommandResult>;
abstract remove(commentId: string): Observable<CommandResult>; abstract remove(commentId: string): Observable<CommandResult>;
} }
/** A ticket's files. Uploads and downloads go through the REST API. */ /** A ticket's files. Uploads and downloads go through the REST API. */
/** The ticket's acknowledgement and the new file's id. */
export type AttachmentUploaded = CommandResult & { attachmentId: string };
/** The public form: no session needed. */
export abstract class ProposalsGateway {
abstract submit(
projectKey: string,
proposal: ProposalInput,
): Observable<ProposalResult>;
}
export abstract class AttachmentsGateway { export abstract class AttachmentsGateway {
abstract upload( abstract upload(
issueId: string, issueId: string,
file: File, file: File,
commentId?: string | null, commentId?: string | null,
): Observable<CommandResult>; ): Observable<AttachmentUploaded>;
/** The file's bytes, to show (images) or save. */ /** The file's bytes, to show (images) or save. */
abstract download(issueId: string, attachment: Attachment): Observable<Blob>; abstract download(issueId: string, attachment: Attachment): Observable<Blob>;
abstract remove( abstract remove(

View File

@@ -21,6 +21,7 @@ import {
DeleteCommentDocument, DeleteCommentDocument,
EditCommentDocument, EditCommentDocument,
IssueCommentsDocument, IssueCommentsDocument,
ReactToCommentDocument,
PublishReleaseDocument, PublishReleaseDocument,
ReleaseBurnupDocument, ReleaseBurnupDocument,
ReleaseNotesDraftDocument, ReleaseNotesDraftDocument,
@@ -52,6 +53,16 @@ export class GraphqlCommentsGateway extends CommentsGateway {
.pipe(map((d) => toCommandResult(d.addComment))); .pipe(map((d) => toCommandResult(d.addComment)));
} }
react(
commentId: string,
emoji: string,
on: boolean,
): Observable<CommandResult> {
return this.gql
.mutate(ReactToCommentDocument, { commentId, emoji, on })
.pipe(map((d) => toCommandResult(d.reactToComment)));
}
edit(commentId: string, body: string): Observable<CommandResult> { edit(commentId: string, body: string): Observable<CommandResult> {
return this.gql return this.gql
.mutate(EditCommentDocument, { commentId, body }) .mutate(EditCommentDocument, { commentId, body })

View File

@@ -11,6 +11,7 @@ import {
Issue, Issue,
IssuePage, IssuePage,
IssuesGateway, IssuesGateway,
IssueListFilter,
IssueSummary, IssueSummary,
LinkIssuesInput, LinkIssuesInput,
MyIssuesFilter, MyIssuesFilter,
@@ -25,10 +26,12 @@ import {
BoardDocument, BoardDocument,
ChangeIssueStatusDocument, ChangeIssueStatusDocument,
CreateIssueDocument, CreateIssueDocument,
DuplicateIssueDocument,
DueIssuesDocument, DueIssuesDocument,
EpicsDocument, EpicsDocument,
IssueChildrenDocument, IssueChildrenDocument,
IssueDocument, IssueDocument,
IssueListDocument,
LinkIssuesDocument, LinkIssuesDocument,
MyIssuesDocument, MyIssuesDocument,
RankIssueDocument, RankIssueDocument,
@@ -41,6 +44,7 @@ import {
SetIssueReleaseDocument, SetIssueReleaseDocument,
UnlinkIssuesDocument, UnlinkIssuesDocument,
UnwatchIssueDocument, UnwatchIssueDocument,
VoteIssueDocument,
WatchIssueDocument, WatchIssueDocument,
UpdateChecklistItemDocument, UpdateChecklistItemDocument,
UpdateIssueDocument, UpdateIssueDocument,
@@ -102,6 +106,24 @@ export class GraphqlIssuesGateway extends IssuesGateway {
); );
} }
list(filter: IssueListFilter): Observable<IssuePage> {
const { limit, ...criteria } = filter;
return this.gql
.query(IssueListDocument, {
filter: {
...criteria,
search: criteria.search?.trim() || undefined,
} as never,
limit,
})
.pipe(
map((d) => ({
items: d.issues.items.map(toIssueSummary),
total: d.issues.total,
})),
);
}
search(projectId: string, text: string): Observable<IssueSummary[]> { search(projectId: string, text: string): Observable<IssueSummary[]> {
return this.gql return this.gql
.query(SearchIssuesDocument, { filter: { projectId, search: text } }) .query(SearchIssuesDocument, { filter: { projectId, search: text } })
@@ -114,6 +136,12 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(map((d) => toCommandResult(d.createIssue))); .pipe(map((d) => toCommandResult(d.createIssue)));
} }
duplicate(issueId: string): Observable<CommandResult> {
return this.gql
.mutate(DuplicateIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.duplicateIssue)));
}
update(input: UpdateIssueInput): Observable<CommandResult> { update(input: UpdateIssueInput): Observable<CommandResult> {
return this.gql return this.gql
.mutate(UpdateIssueDocument, { input: input as never }) .mutate(UpdateIssueDocument, { input: input as never })
@@ -147,6 +175,12 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(map((d) => toCommandResult(d.unlinkIssues))); .pipe(map((d) => toCommandResult(d.unlinkIssues)));
} }
vote(issueId: string, on: boolean): Observable<CommandResult> {
return this.gql
.mutate(VoteIssueDocument, { issueId, on })
.pipe(map((d) => toCommandResult(d.voteIssue)));
}
watch(issueId: string, watching: boolean): Observable<CommandResult> { watch(issueId: string, watching: boolean): Observable<CommandResult> {
return watching return watching
? this.gql ? this.gql

View File

@@ -7,6 +7,7 @@ import {
import { import {
Attachment, Attachment,
AttachmentsGateway, AttachmentsGateway,
AttachmentUploaded,
CommandResult, CommandResult,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { from, Observable, switchMap } from 'rxjs'; import { from, Observable, switchMap } from 'rxjs';
@@ -25,12 +26,14 @@ export class HttpAttachmentsGateway extends AttachmentsGateway {
issueId: string, issueId: string,
file: File, file: File,
commentId?: string | null, commentId?: string | null,
): Observable<CommandResult> { ): Observable<AttachmentUploaded> {
const body = new FormData(); const body = new FormData();
body.append('file', file, file.name); body.append('file', file, file.name);
if (commentId) body.append('commentId', commentId); if (commentId) body.append('commentId', commentId);
return this.authorized((headers) => return this.authorized((headers) =>
this.http.post<CommandResult>(this.url(issueId), body, { headers }), this.http.post<AttachmentUploaded>(this.url(issueId), body, {
headers,
}),
); );
} }

View File

@@ -0,0 +1,30 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { API_CONFIG } from '@task-stream/web/shared/infrastructure';
import {
ProposalInput,
ProposalResult,
ProposalsGateway,
} from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
/** The public form posts over REST, without a session (`/api/explore/...`). */
@Injectable()
export class HttpProposalsGateway extends ProposalsGateway {
private readonly http = inject(HttpClient);
private readonly api = inject(API_CONFIG);
submit(
projectKey: string,
proposal: ProposalInput,
): Observable<ProposalResult> {
return this.http.post<ProposalResult>(
`${this.api.restUrl}/explore/projects/${encodeURIComponent(projectKey)}/proposals`,
{
...proposal,
description: proposal.description.trim() || undefined,
author: proposal.author.trim() || undefined,
},
);
}
}

View File

@@ -9,6 +9,7 @@ fragment ProjectFields on Project {
name name
description description
isPublic isPublic
acceptsProposals
updatedAt updatedAt
version version
owner { owner {
@@ -83,6 +84,13 @@ fragment IssueSummaryFields on Issue {
key key
title title
} }
blockedBy {
id
key
title
}
votes
voted
rank rank
storyPoints storyPoints
startDate startDate
@@ -143,6 +151,12 @@ fragment CommentFields on Comment {
body body
replyTo replyTo
deleted deleted
reactions {
emoji
users {
...UserRefFields
}
}
createdAt createdAt
editedAt editedAt
author { author {

File diff suppressed because one or more lines are too long

View File

@@ -249,3 +249,30 @@ query DueIssues($filter: IssueFilterInput!) {
} }
} }
} }
query IssueList($filter: IssueFilterInput!, $limit: Int!) {
issues(filter: $filter, limit: $limit) {
items {
...IssueSummaryFields
}
total
}
}
mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) {
reactToComment(commentId: $commentId, emoji: $emoji, on: $on) {
...CommandResultFields
}
}
mutation DuplicateIssue($issueId: ID!) {
duplicateIssue(issueId: $issueId) {
...CommandResultFields
}
}
mutation VoteIssue($issueId: ID!, $on: Boolean!) {
voteIssue(issueId: $issueId, on: $on) {
...CommandResultFields
}
}

View File

@@ -5,6 +5,7 @@ import {
import { import {
ActivityGateway, ActivityGateway,
AttachmentsGateway, AttachmentsGateway,
ProposalsGateway,
CommentsGateway, CommentsGateway,
IssuesGateway, IssuesGateway,
NotificationsGateway, NotificationsGateway,
@@ -28,6 +29,7 @@ import {
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway'; import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway'; import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway'; import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
import { HttpProposalsGateway } from './adapters/http-proposals.gateway';
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway'; import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway'; import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway'; import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
@@ -46,6 +48,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: ReleasesGateway, useClass: GraphqlReleasesGateway }, { provide: ReleasesGateway, useClass: GraphqlReleasesGateway },
{ provide: ActivityGateway, useClass: GraphqlActivityGateway }, { provide: ActivityGateway, useClass: GraphqlActivityGateway },
{ provide: AttachmentsGateway, useClass: HttpAttachmentsGateway }, { provide: AttachmentsGateway, useClass: HttpAttachmentsGateway },
{ provide: ProposalsGateway, useClass: HttpProposalsGateway },
{ provide: UsersGateway, useClass: GraphqlUsersGateway }, { provide: UsersGateway, useClass: GraphqlUsersGateway },
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway }, { provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },

View File

@@ -6,6 +6,13 @@
"projectType": "library", "projectType": "library",
"tags": ["scope:web-tracker", "type:presenters"], "tags": ["scope:web-tracker", "type:presenters"],
"targets": { "targets": {
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "coverage/libs/web/tracker/presenters"
}
},
"lint": { "lint": {
"executor": "@nx/eslint:lint" "executor": "@nx/eslint:lint"
} }

View File

@@ -10,15 +10,11 @@ import {
} from '@angular/core'; } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { import {
PiActionButtonComponent,
PiEmptyStateComponent, PiEmptyStateComponent,
PiIconButtonComponent, PiIconButtonComponent,
PiIconComponent, PiIconComponent,
PiNoteComponent, PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent, PiSkeletonComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui'; } from '@pi/ui';
import { import {
BacklogFacade, BacklogFacade,
@@ -27,31 +23,26 @@ import {
} from '@task-stream/web/tracker/application'; } from '@task-stream/web/tracker/application';
import { import {
BulkChange, BulkChange,
IssuePriority,
IssueSummary, IssueSummary,
sumPoints, sumPoints,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { initials, options, PRIORITY, statusLook, typeLook } from '../ui/copy'; import { BulkBarComponent } from '../ui/bulk-bar';
import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
/** "Nobody" / "no sprint" in the bulk selects. */
const NOBODY = '__none__';
/** /p/:key/backlog — what is still to start, in the order the team takes it. */ /** /p/:key/backlog — what is still to start, in the order the team takes it. */
@Component({ @Component({
selector: 'ts-backlog-page', selector: 'ts-backlog-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
BulkBarComponent,
RouterLink, RouterLink,
CdkDrag, CdkDrag,
CdkDropList, CdkDropList,
PiActionButtonComponent,
PiEmptyStateComponent, PiEmptyStateComponent,
PiIconButtonComponent, PiIconButtonComponent,
PiIconComponent, PiIconComponent,
PiNoteComponent, PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent, PiSkeletonComponent,
PiTextFieldComponent,
], ],
providers: [provideBacklogFacade()], providers: [provideBacklogFacade()],
template: ` template: `
@@ -85,60 +76,12 @@ const NOBODY = '__none__';
<pi-skeleton shape="text" [lines]="8" /> <pi-skeleton shape="text" [lines]="8" />
} @else if (facade.issues().length) { } @else if (facade.issues().length) {
@if (project.canContribute()) { @if (project.canContribute()) {
<div class="ts-bulk" role="toolbar" aria-label="Edición en bloque"> <ts-bulk-bar
<label class="ts-bulk__all"> [count]="selected().size"
<input [allSelected]="allSelected()"
type="checkbox" (selectAll)="selectAll($event)"
[checked]="allSelected()" (apply)="applyBulk($event)"
[indeterminate]="selected().size > 0 && !allSelected()" />
(change)="selectAll($any($event.target).checked)"
/>
{{
selected().size
? selected().size + ' seleccionados'
: 'Seleccionar'
}}
</label>
@if (selected().size) {
<pi-select-field [options]="bulkFields" [(value)]="bulkField" />
@switch (bulkField()) {
@case ('status') {
<pi-select-field
[options]="statusOptions()"
[(value)]="bulkValue"
/>
}
@case ('assignee') {
<pi-select-field
[options]="assigneeOptions()"
[(value)]="bulkValue"
/>
}
@case ('sprint') {
<pi-select-field
[options]="sprintOptions()"
[(value)]="bulkValue"
/>
}
@case ('priority') {
<pi-select-field
[options]="priorityOptions"
[(value)]="bulkValue"
/>
}
@case ('label') {
<pi-text-field placeholder="etiqueta" [(value)]="bulkLabel" />
}
}
<pi-action-button
size="small"
color="primary"
text="Aplicar"
[disabled]="!canApply()"
(execute)="applyBulk()"
/>
}
</div>
} }
<ol <ol
class="ts-backlog ts-panel" class="ts-backlog ts-panel"
@@ -261,23 +204,6 @@ const NOBODY = '__none__';
.ts-backlog__row.is-selected { .ts-backlog__row.is-selected {
background: color-mix(in srgb, var(--ts-brand) 8%, var(--ts-surface)); background: color-mix(in srgb, var(--ts-brand) 8%, var(--ts-surface));
} }
.ts-bulk {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
pi-select-field,
pi-text-field {
width: 13rem;
}
}
.ts-bulk__all {
display: inline-flex;
gap: 0.375rem;
align-items: center;
font-size: 0.875rem;
}
.ts-backlog__trash { .ts-backlog__trash {
margin: 1rem 0 0; margin: 1rem 0 0;
font-size: 0.875rem; font-size: 0.875rem;
@@ -342,46 +268,6 @@ export class BacklogPage {
this.facade.issues().length > 0 && this.facade.issues().length > 0 &&
this.facade.issues().every((i) => this.selected().has(i.id)), this.facade.issues().every((i) => this.selected().has(i.id)),
); );
protected readonly bulkFields: PiSelectFieldOption<string>[] = [
{ value: 'status', label: 'Mover a…' },
{ value: 'assignee', label: 'Asignar a…' },
{ value: 'sprint', label: 'Sprint…' },
{ value: 'priority', label: 'Prioridad…' },
{ value: 'label', label: 'Añadir etiqueta…' },
];
protected readonly bulkField = signal<string | null>('status');
protected readonly bulkValue = signal<string | null>(null);
protected readonly bulkLabel = signal('');
protected readonly statusOptions = computed<PiSelectFieldOption<string>[]>(
() =>
this.project
.workflow()
.settings.statuses.filter((s) => !s.archived)
.map((s) => ({ value: s.id, label: s.name })),
);
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: NOBODY, label: 'Nadie (sin asignar)' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
],
);
protected readonly sprintOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: NOBODY, label: 'Backlog (sin sprint)' },
...this.project
.openSprints()
.map((s) => ({ value: s.id, label: s.name })),
],
);
protected readonly priorityOptions = options(PRIORITY);
protected readonly canApply = computed(() =>
this.bulkField() === 'label'
? !!this.bulkLabel().trim()
: !!this.bulkValue(),
);
protected toggle(issueId: string, on: boolean): void { protected toggle(issueId: string, on: boolean): void {
this.selected.update((set) => { this.selected.update((set) => {
const next = new Set(set); const next = new Set(set);
@@ -395,29 +281,9 @@ export class BacklogPage {
this.selected.set(new Set(on ? this.facade.issues().map((i) => i.id) : [])); this.selected.set(new Set(on ? this.facade.issues().map((i) => i.id) : []));
} }
protected applyBulk(): void { protected applyBulk(change: BulkChange): void {
const value = this.bulkValue();
const change: BulkChange = {};
switch (this.bulkField()) {
case 'status':
if (value) change.status = value;
break;
case 'assignee':
change.assigneeId = value === NOBODY ? null : value;
break;
case 'sprint':
change.sprintId = value === NOBODY ? null : value;
break;
case 'priority':
if (value) change.priority = value as IssuePriority;
break;
case 'label':
change.addLabels = [this.bulkLabel().trim().toLowerCase()];
break;
}
this.facade.bulkUpdate([...this.selected()], change); this.facade.bulkUpdate([...this.selected()], change);
this.selected.set(new Set()); this.selected.set(new Set());
this.bulkLabel.set('');
} }
protected readonly points = computed(() => sumPoints(this.facade.issues())); protected readonly points = computed(() => sumPoints(this.facade.issues()));
protected readonly unestimated = computed( protected readonly unestimated = computed(

View File

@@ -445,18 +445,6 @@ a.ts-key {
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.ts-mention {
padding: 0 0.2em;
border-radius: 0.25em;
background: color-mix(in oklab, var(--ts-brand) 14%, transparent);
color: inherit;
font-weight: 600;
}
.ts-mention.is-me {
background: color-mix(in oklab, var(--ts-brand) 30%, transparent);
}
.ts-mentions { .ts-mentions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -573,3 +561,107 @@ a.ts-key {
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
} }
// Waiting for other tickets: shown next to the key, with links to them.
.ts-blocked {
display: inline-flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.8125rem;
font-weight: 600;
.ts-key {
color: inherit;
}
}
// 👍 2 · 🎉 1 · (+)
.ts-reactions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
margin-top: 0.5rem;
}
.ts-reaction {
display: inline-flex;
gap: 0.25rem;
align-items: center;
// @pi/ui styles every <button> too specifically to override otherwise.
min-width: 0 !important;
height: auto !important;
padding: 0.125rem 0.5rem !important;
border: 1px solid var(--ts-line) !important;
border-radius: 999px !important;
background: var(--ts-surface);
color: inherit;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
.ts-mono {
font-size: 0.75rem;
}
&.is-mine {
border-color: var(--ts-brand) !important;
background: color-mix(in srgb, var(--ts-brand) 12%, var(--ts-surface));
}
&:disabled {
cursor: default;
}
}
.ts-reaction-picker {
position: relative;
summary {
display: inline-grid;
place-items: center;
width: 1.75rem;
height: 1.625rem;
border: 1px dashed var(--ts-line-strong, var(--ts-line));
border-radius: 999px;
color: var(--ts-muted);
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
}
}
.ts-reaction-picker__menu {
position: absolute;
z-index: 5;
bottom: calc(100% + 0.375rem);
left: 0;
display: flex;
gap: 0.125rem;
padding: 0.25rem;
border: 1px solid var(--ts-line);
border-radius: 0.75rem;
background: var(--ts-surface);
box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
button {
width: 2rem;
min-width: 0 !important;
height: 2rem;
padding: 0 !important;
border: 0 !important;
border-radius: 0.5rem !important;
background: none;
font-size: 1.125rem;
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--ts-brand) 12%, transparent);
}
}
}
.ts-votes {
padding: 0.25rem 0.625rem;
border: 1px solid var(--ts-line);
border-radius: 999px;
color: var(--ts-muted);
font-size: 0.8125rem;
}

View File

@@ -12,7 +12,7 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals'; import { form, FormField, required } from '@angular/forms/signals';
import { RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { import {
PiActionButtonComponent, PiActionButtonComponent,
PiCheckboxComponent, PiCheckboxComponent,
@@ -47,9 +47,9 @@ import {
isTyping, isTyping,
matches, matches,
mentionQuery, mentionQuery,
mentionSegments,
POINT_SCALE, POINT_SCALE,
ReleaseStatus, ReleaseStatus,
REACTIONS,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { import {
clock, clock,
@@ -63,6 +63,13 @@ import {
typeLook, typeLook,
} from '../ui/copy'; } from '../ui/copy';
import { AttachmentItem } from '../ui/attachments'; import { AttachmentItem } from '../ui/attachments';
import {
AttachmentLoader,
ImageUploader,
MarkdownComponent,
MarkdownHintComponent,
PasteImagesDirective,
} from '../ui/markdown';
const NONE = ''; const NONE = '';
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */ /** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
@@ -72,6 +79,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
selector: 'ts-issue-page', selector: 'ts-issue-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
MarkdownComponent,
MarkdownHintComponent,
PasteImagesDirective,
NgTemplateOutlet, NgTemplateOutlet,
RouterLink, RouterLink,
FormField, FormField,
@@ -133,10 +143,46 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<span class="ts-parent__title">{{ parent.title }}</span> <span class="ts-parent__title">{{ parent.title }}</span>
</a> </a>
} }
@if (issue.blockedBy.length) {
<span
class="ts-blocked"
role="status"
[title]="blockedTitle(issue.blockedBy)"
>
<pi-icon icon="lock" size="x-small" aria-hidden="true" />
Bloqueado por
@for (blocker of issue.blockedBy; track blocker.id) {
<a class="ts-key" [routerLink]="['..', blocker.key]">{{
blocker.key
}}</a>
}
</span>
}
</div> </div>
<div class="ts-issue__title-row"> <div class="ts-issue__title-row">
<h1>{{ issue.title }}</h1> <h1>{{ issue.title }}</h1>
<div class="ts-issue__actions"> <div class="ts-issue__actions">
@if (facade.me()) {
<pi-icon-button
icon="arrow-up"
color="grey"
appearance="outline"
[text]="
(issue.voted ? 'Votado · ' : 'Votar · ') + issue.votes
"
[attr.aria-pressed]="issue.voted"
[attr.title]="
issue.voted
? 'Quitar mi voto'
: 'Votar: dice al equipo que lo quieres'
"
(execute)="facade.vote(!issue.voted)"
/>
} @else if (issue.votes) {
<span class="ts-votes ts-mono" title="Votos">
▲ {{ issue.votes }}
</span>
}
@if (project.myRole()) { @if (project.myRole()) {
<pi-icon-button <pi-icon-button
[icon]="facade.watching() ? 'eye-off' : 'eye'" [icon]="facade.watching() ? 'eye-off' : 'eye'"
@@ -165,6 +211,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
title="Editar (tecla E)" title="Editar (tecla E)"
(execute)="openEdit()" (execute)="openEdit()"
/> />
<pi-icon-button
icon="copy"
color="grey"
appearance="outline"
text="Duplicar"
title="Crear otro ticket con lo mismo (sin estado, persona ni comentarios)"
(execute)="duplicate()"
/>
} }
</div> </div>
</div> </div>
@@ -190,7 +244,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<article class="ts-block"> <article class="ts-block">
<h2 class="ts-section">Descripción</h2> <h2 class="ts-section">Descripción</h2>
@if (issue.description) { @if (issue.description) {
<p class="ts-prewrap">{{ issue.description }}</p> <ts-markdown
[text]="issue.description"
[me]="facade.me()"
[loader]="loadImage"
/>
} @else { } @else {
<p class="ts-muted">Sin descripción.</p> <p class="ts-muted">Sin descripción.</p>
} }
@@ -539,7 +597,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
}}{{ comment.editedAt ? ' · editado' : '' }}</span }}{{ comment.editedAt ? ' · editado' : '' }}</span
> >
<span class="ts-spacer"></span> <span class="ts-spacer"></span>
@if (!comment.deleted && comment.author.id === facade.me()) { @if (
!comment.deleted && comment.author.id === facade.me()
) {
<pi-icon-button <pi-icon-button
icon="edit" icon="edit"
color="grey" color="grey"
@@ -564,19 +624,60 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
@if (comment.deleted) { @if (comment.deleted) {
<p class="ts-muted ts-comment__gone">Comentario borrado</p> <p class="ts-muted ts-comment__gone">Comentario borrado</p>
} @else { } @else {
<p class="ts-prewrap"> <ts-markdown
@for (part of segments(comment.body); track $index) { [text]="comment.body"
@if ('mention' in part) { [me]="facade.me()"
<span [loader]="loadImage"
class="ts-mention" />
[class.is-me]="part.userId === facade.me()" }
>@{{ part.mention }}</span @if (
!comment.deleted &&
(comment.reactions.length || project.myRole())
) {
<div class="ts-reactions">
@for (
reaction of comment.reactions;
track reaction.emoji
) {
<button
type="button"
class="ts-reaction"
[class.is-mine]="reacted(reaction)"
[attr.aria-pressed]="reacted(reaction)"
[title]="reactionTitle(reaction)"
[disabled]="!project.myRole()"
(click)="
facade.react(
comment.id,
reaction.emoji,
!reacted(reaction)
)
"
> >
} @else { {{ reaction.emoji }}
{{ part.text }} <span class="ts-mono">{{
reaction.users.length
}}</span>
</button>
} }
} @if (project.myRole()) {
</p> <details class="ts-reaction-picker">
<summary title="Reaccionar" aria-label="Reaccionar">
<pi-icon icon="add" size="x-small" />
</summary>
<div class="ts-reaction-picker__menu">
@for (emoji of reactions; track emoji) {
<button
type="button"
(click)="pickReaction(comment, emoji, $event)"
>
{{ emoji }}
</button>
}
</div>
</details>
}
</div>
} }
@if (!comment.deleted && filesOf(comment).length) { @if (!comment.deleted && filesOf(comment).length) {
<div class="ts-attachments"> <div class="ts-attachments">
@@ -593,12 +694,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</ng-template> </ng-template>
@for (thread of threads(); track thread.comment.id) { @for (thread of threads(); track thread.comment.id) {
<ng-container <ng-container
*ngTemplateOutlet="commentView; context: { $implicit: thread.comment }" *ngTemplateOutlet="
commentView;
context: { $implicit: thread.comment }
"
/> />
<div class="ts-replies"> <div class="ts-replies">
@for (reply of thread.replies; track reply.id) { @for (reply of thread.replies; track reply.id) {
<ng-container <ng-container
*ngTemplateOutlet="commentView; context: { $implicit: reply }" *ngTemplateOutlet="
commentView;
context: { $implicit: reply }
"
/> />
} }
@if (replyingTo() === thread.comment.id) { @if (replyingTo() === thread.comment.id) {
@@ -614,6 +721,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
'… Escribe @ para mencionar a alguien.' '… Escribe @ para mencionar a alguien.'
" "
[(value)]="replyBody" [(value)]="replyBody"
[tsPasteImages]="pasteImage"
/>
<ts-markdown-hint
[text]="replyBody()"
[me]="facade.me()"
[loader]="loadImage"
[images]="true"
/> />
@if (replyMentionOptions().length) { @if (replyMentionOptions().length) {
<div <div
@@ -621,15 +735,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
role="group" role="group"
aria-label="Mencionar a" aria-label="Mencionar a"
> >
@for (member of replyMentionOptions(); track member.id) { @for (
member of replyMentionOptions();
track member.id
) {
<button <button
type="button" type="button"
class="ts-mentions__option" class="ts-mentions__option"
(click)="mentionInReply(member)" (click)="mentionInReply(member)"
> >
<span class="ts-comment__avatar" aria-hidden="true">{{ <span
initialsOf(member.name) class="ts-comment__avatar"
}}</span> aria-hidden="true"
>{{ initialsOf(member.name) }}</span
>
{{ member.name }} {{ member.name }}
</button> </button>
} }
@@ -651,7 +770,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</button> </button>
</div> </div>
</form> </form>
} @else if (project.canContribute() && !thread.comment.deleted) { } @else if (
project.canContribute() && !thread.comment.deleted
) {
<button <button
type="button" type="button"
class="ts-plain-link ts-reply__open" class="ts-plain-link ts-reply__open"
@@ -675,6 +796,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-textarea <pi-textarea
placeholder="Deja una decisión, una explicación, un paso para reproducir… Escribe @ para mencionar a alguien." placeholder="Deja una decisión, una explicación, un paso para reproducir… Escribe @ para mencionar a alguien."
[(value)]="commentBody" [(value)]="commentBody"
[tsPasteImages]="pasteImage"
/>
<ts-markdown-hint
[text]="commentBody()"
[me]="facade.me()"
[loader]="loadImage"
[images]="true"
/> />
@if (mentionOptions().length) { @if (mentionOptions().length) {
<div <div
@@ -881,9 +1009,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-text-field [formField]="edit.title"> <pi-text-field [formField]="edit.title">
<pi-label [isRequired]="true">Título</pi-label> <pi-label [isRequired]="true">Título</pi-label>
</pi-text-field> </pi-text-field>
<pi-textarea [formField]="edit.description"> <pi-textarea
[formField]="edit.description"
[tsPasteImages]="pasteImage"
>
<pi-label>Descripción</pi-label> <pi-label>Descripción</pi-label>
</pi-textarea> </pi-textarea>
<ts-markdown-hint
[text]="editModel().description"
[me]="facade.me()"
[loader]="loadImage"
[images]="true"
/>
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout"> <pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
<pi-label>Etiquetas (separadas por comas)</pi-label> <pi-label>Etiquetas (separadas por comas)</pi-label>
</pi-text-field> </pi-text-field>
@@ -909,7 +1046,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(submit)="$event.preventDefault(); saveComment()" (submit)="$event.preventDefault(); saveComment()"
novalidate novalidate
> >
<pi-textarea [formField]="commentEdit.body" /> <pi-textarea
[formField]="commentEdit.body"
[tsPasteImages]="pasteImage"
/>
<ts-markdown-hint
[text]="commentEditModel().body"
[me]="facade.me()"
[loader]="loadImage"
[images]="true"
/>
<pi-action-button <pi-action-button
size="w100" size="w100"
color="primary" color="primary"
@@ -929,6 +1075,8 @@ export class IssuePage {
protected readonly project = inject(ProjectFacade); protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService); private readonly drawer = inject(DrawerService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly reasonForm = private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm'); viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly editForm = private readonly editForm =
@@ -1046,7 +1194,48 @@ export class IssuePage {
() => this.facade.comments().filter((c) => !c.deleted).length, () => this.facade.comments().filter((c) => !c.deleted).length,
); );
protected readonly commentFiles = signal<File[]>([]); protected readonly commentFiles = signal<File[]>([]);
protected readonly segments = mentionSegments; protected readonly reactions = REACTIONS;
protected reacted(reaction: { users: { id: string }[] }): boolean {
return reaction.users.some((u) => u.id === this.facade.me());
}
protected reactionTitle(reaction: {
emoji: string;
users: { name: string }[];
}): string {
return `${reaction.users.map((u) => u.name).join(', ')} · ${reaction.emoji}`;
}
/** From the picker: adds it, or takes it back if it was already there. */
protected pickReaction(comment: Comment, emoji: string, event: Event): void {
const mine = comment.reactions.find((r) => r.emoji === emoji);
this.facade.react(comment.id, emoji, !(mine && this.reacted(mine)));
(event.target as HTMLElement).closest('details')?.removeAttribute('open');
}
protected blockedTitle(blockers: { key: string; title: string }[]): string {
return blockers.map((b) => `${b.key}: ${b.title}`).join('\n');
}
protected duplicate(): void {
this.facade.duplicate().subscribe({
next: (key) => {
this.toast.success(
key ? `Creado ${key}, copia de este` : 'Copia creada',
);
if (key)
void this.router.navigate(['..', key], { relativeTo: this.route });
},
error: () => this.toast.error('No se pudo duplicar'),
});
}
/** Images inside descriptions and comments: fetched, and uploaded on paste. */
protected readonly loadImage: AttachmentLoader = (id) =>
this.facade.fileById(id);
protected readonly pasteImage: ImageUploader = (file) =>
this.facade.uploadImage(file);
/** Members matching what is typed after an `@` at the end of the comment. */ /** Members matching what is typed after an `@` at the end of the comment. */
protected readonly mentionOptions = computed(() => protected readonly mentionOptions = computed(() =>
this.membersMatching(this.commentBody()), this.membersMatching(this.commentBody()),
@@ -1111,7 +1300,7 @@ export class IssuePage {
private readonly reasonModel = signal({ text: '' }); private readonly reasonModel = signal({ text: '' });
protected readonly reason = form(this.reasonModel, (p) => required(p.text)); protected readonly reason = form(this.reasonModel, (p) => required(p.text));
private readonly editModel = signal({ protected readonly editModel = signal({
title: '', title: '',
description: '', description: '',
labels: '', labels: '',
@@ -1120,7 +1309,7 @@ export class IssuePage {
protected readonly edit = form(this.editModel, (p) => required(p.title)); protected readonly edit = form(this.editModel, (p) => required(p.title));
private editingComment: string | null = null; private editingComment: string | null = null;
private readonly commentEditModel = signal({ body: '' }); protected readonly commentEditModel = signal({ body: '' });
protected readonly commentEdit = form(this.commentEditModel, (p) => protected readonly commentEdit = form(this.commentEditModel, (p) =>
required(p.body), required(p.body),
); );

View File

@@ -0,0 +1,678 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiEmptyStateComponent,
PiIconComponent,
PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import {
ListCriteria,
ListFacade,
ProjectFacade,
provideListFacade,
} from '@task-stream/web/tracker/application';
import {
BulkChange,
IssuePriority,
IssueSummary,
StatusCategory,
} from '@task-stream/web/tracker/domain';
import { BulkBarComponent } from '../ui/bulk-bar';
import { VoteComponent } from '../ui/vote';
import {
options,
PRIORITY,
PRIORITY_LEVEL,
statusLook,
timeAgo,
typeLook,
} from '../ui/copy';
const ALL = '';
const NOBODY = '__nobody__';
const OPEN: StatusCategory[] = [
StatusCategory.TODO,
StatusCategory.IN_PROGRESS,
StatusCategory.QA,
];
const CLOSED: StatusCategory[] = [
StatusCategory.DONE,
StatusCategory.CANCELLED,
];
type SortKey =
| 'key'
| 'title'
| 'type'
| 'status'
| 'priority'
| 'assignee'
| 'sprint'
| 'points'
| 'votes'
| 'due'
| 'updated';
/** Ticket keys in their number's order (WEB-9 before WEB-10). */
const numberOf = (key: string) => Number(key.split('-').pop());
/**
* /p/:key/list — every ticket of the project as rows: sort by any column,
* filter, change status, priority or assignee in place, edit in bulk.
*/
@Component({
selector: 'ts-list-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
BulkBarComponent,
VoteComponent,
RouterLink,
PiEmptyStateComponent,
PiIconComponent,
PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextFieldComponent,
],
providers: [provideListFacade()],
template: `
<div class="ts-list__filters">
<pi-text-field
placeholder="Buscar por clave, título o descripción…"
[(value)]="search"
/>
<pi-select-field [options]="scopeOptions" [(value)]="scope" />
<pi-select-field [options]="typeOptions()" [(value)]="type" />
<pi-select-field [options]="priorityFilterOptions" [(value)]="priority" />
<pi-select-field
[options]="assigneeFilterOptions()"
[(value)]="assignee"
/>
@if (facade.loaded()) {
<span class="ts-muted ts-mono ts-list__count">
{{ facade.total() }} tickets{{
facade.total() > facade.issues().length
? ' · se muestran ' +
facade.issues().length +
', afina los filtros'
: ''
}}
</span>
}
</div>
@if (facade.error(); as error) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo cargar la lista"
[text]="error"
/>
} @else if (!facade.loaded()) {
<pi-skeleton shape="text" [lines]="10" />
} @else if (rows().length) {
@if (project.canContribute()) {
<ts-bulk-bar
[count]="selected().size"
[allSelected]="allSelected()"
(selectAll)="selectAll($event)"
(apply)="applyBulk($event)"
/>
}
<div class="ts-list ts-panel">
<table>
<thead>
<tr>
@if (project.canContribute()) {
<th class="ts-list__pick" aria-label="Seleccionar"></th>
}
@for (column of columns; track column.key) {
<th
[class]="'ts-list__' + column.key"
[attr.aria-sort]="ariaSort(column.key)"
>
<button type="button" (click)="sortBy(column.key)">
{{ column.label }}
@if (sort().key === column.key) {
<span aria-hidden="true">{{
sort().asc ? '↑' : '↓'
}}</span>
}
</button>
</th>
}
</tr>
</thead>
<tbody>
@for (issue of rows(); track issue.id) {
<tr [class.is-selected]="selected().has(issue.id)">
@if (project.canContribute()) {
<td class="ts-list__pick">
<input
type="checkbox"
[attr.aria-label]="'Seleccionar ' + issue.key"
[checked]="selected().has(issue.id)"
(change)="toggle(issue.id, $any($event.target).checked)"
/>
</td>
}
<td class="ts-list__key">
<a
class="ts-key"
[routerLink]="['..', 'issues', issue.key]"
>{{ issue.key }}</a
>
</td>
<td class="ts-list__title">
<a [routerLink]="['..', 'issues', issue.key]">{{
issue.title
}}</a>
@if (issue.blockedBy.length) {
<span
class="ts-list__blocked"
[title]="'Bloqueado por ' + blockersOf(issue)"
>
<pi-icon icon="lock" size="x-small" /> Bloqueado
</span>
}
@if (issue.parent; as parent) {
<span class="ts-muted ts-mono" [title]="parent.title">
↑ {{ parent.key }}</span
>
}
</td>
<td class="ts-list__type">
<span
class="ts-list__type-label"
[style.--tone]="typeTone(issue)"
>
<pi-icon [icon]="typeIcon(issue)" size="x-small" />
{{ typeLabel(issue) }}
</span>
</td>
<td class="ts-list__status">
@if (project.canContribute()) {
<select
class="ts-cell-select"
[style.--tone]="statusTone(issue.status)"
[attr.aria-label]="'Estado de ' + issue.key"
(change)="changeStatus(issue, $any($event.target))"
>
@for (status of statusChoices(issue); track status.id) {
<option
[value]="status.id"
[selected]="status.id === issue.status"
>
{{ status.name }}
</option>
}
</select>
} @else {
<span
class="ts-pill"
[style.--tone]="statusTone(issue.status)"
>{{ statusLabel(issue.status) }}</span
>
}
</td>
<td class="ts-list__priority">
@if (project.canContribute()) {
<select
class="ts-cell-select"
[attr.aria-label]="'Prioridad de ' + issue.key"
(change)="
changePriority(issue, $any($event.target).value)
"
>
@for (option of priorityOptions; track option.value) {
<option
[value]="option.value"
[selected]="option.value === issue.priority"
>
{{ option.label }}
</option>
}
</select>
} @else {
{{ priorityLabel(issue) }}
}
</td>
<td class="ts-list__assignee">
@if (project.canContribute()) {
<select
class="ts-cell-select"
[attr.aria-label]="'Persona asignada a ' + issue.key"
(change)="assign(issue, $any($event.target).value)"
>
<option [value]="NOBODY" [selected]="!issue.assignee">
Sin asignar
</option>
@for (member of project.members(); track member.user.id) {
<option
[value]="member.user.id"
[selected]="member.user.id === issue.assignee?.id"
>
{{ member.user.name }}
</option>
}
</select>
} @else {
{{ issue.assignee?.name ?? '—' }}
}
</td>
<td class="ts-list__sprint ts-muted">
{{ issue.sprint?.name ?? '—' }}
</td>
<td class="ts-list__points ts-mono">
{{ issue.storyPoints ?? '—' }}
</td>
<td class="ts-list__votes">
<ts-vote [votes]="issue.votes" [voted]="issue.voted" />
</td>
<td
class="ts-list__due ts-mono"
[class.is-late]="isLate(issue)"
>
{{ issue.dueDate ? shortDate(issue.dueDate) : '—' }}
</td>
<td class="ts-list__updated ts-muted">
{{ ago(issue.updatedAt) }}
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<pi-empty-state
icon="task"
title="Ningún ticket con estos filtros"
description="Prueba con «Todos» en vez de «Abiertos», o quita algún filtro."
/>
}
`,
styles: `
.ts-list__filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
align-items: center;
margin-bottom: 1rem;
pi-text-field {
flex: 2 1 16rem;
}
pi-select-field {
flex: 1 1 13.5rem;
max-width: 15rem;
}
}
.ts-list__count {
font-size: 0.8125rem;
}
.ts-list {
overflow-x: auto;
padding: 0;
}
table {
width: 100%;
min-width: 60rem;
border-collapse: collapse;
font-size: 0.875rem;
}
th {
position: sticky;
top: 0;
padding: 0;
background: var(--ts-surface);
text-align: left;
button {
width: 100%;
padding: 0.625rem 0.625rem;
border: 0;
background: none;
color: var(--ts-muted);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-align: left;
text-transform: uppercase;
cursor: pointer;
&:hover {
color: var(--ts-ink);
}
}
}
td {
padding: 0.4375rem 0.625rem;
border-top: 1px solid var(--ts-line);
vertical-align: middle;
}
tr.is-selected td {
background: color-mix(in srgb, var(--ts-brand) 8%, var(--ts-surface));
}
.ts-list__pick {
width: 2rem;
padding-left: 0.875rem;
}
.ts-list__key {
white-space: nowrap;
}
.ts-list__title {
min-width: 16rem;
a {
color: inherit;
text-decoration: none;
&:hover {
color: var(--ts-brand);
}
}
.ts-mono {
margin-left: 0.375rem;
font-size: 0.75rem;
}
}
.ts-list__blocked {
display: inline-flex;
gap: 0.25rem;
align-items: center;
margin-left: 0.5rem;
padding: 0 0.375rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.75rem;
font-weight: 600;
}
.ts-list__type-label {
display: inline-flex;
gap: 0.375rem;
align-items: center;
color: var(--tone);
white-space: nowrap;
}
.ts-list__points,
.ts-list__due,
.ts-list__updated {
white-space: nowrap;
}
.ts-list__due.is-late {
color: var(--ts-c-red, var(--ts-ink));
font-weight: 600;
}
.ts-cell-select {
max-width: 11rem;
padding: 0.25rem 0.375rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
&:hover,
&:focus-visible {
border-color: var(--ts-line-strong, var(--ts-line));
}
&[style] {
border-left: 3px solid var(--tone);
}
}
`,
})
export class ListPage {
protected readonly facade = inject(ListFacade);
protected readonly project = inject(ProjectFacade);
protected readonly NOBODY = NOBODY;
protected readonly ago = (iso: string) => timeAgo(iso);
protected readonly columns: { key: SortKey; label: string }[] = [
{ key: 'key', label: 'Clave' },
{ key: 'title', label: 'Título' },
{ key: 'type', label: 'Tipo' },
{ key: 'status', label: 'Estado' },
{ key: 'priority', label: 'Prioridad' },
{ key: 'assignee', label: 'Asignado' },
{ key: 'sprint', label: 'Sprint' },
{ key: 'points', label: 'Pts' },
{ key: 'votes', label: 'Votos' },
{ key: 'due', label: 'Vence' },
{ key: 'updated', label: 'Actualizado' },
];
/* Filters (sent to the API) */
protected readonly search = signal('');
protected readonly scope = signal<string | null>('open');
protected readonly type = signal<string | null>(ALL);
protected readonly priority = signal<string | null>(ALL);
protected readonly assignee = signal<string | null>(ALL);
protected readonly scopeOptions: PiSelectFieldOption<string>[] = [
{ value: 'open', label: 'Abiertos' },
{ value: 'closed', label: 'Terminados o cancelados' },
{ value: 'all', label: 'Todos' },
];
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: ALL, label: 'Todos los tipos' },
...this.project
.workflow()
.settings.issueTypes.filter((t) => !t.archived)
.map((t) => ({ value: t.id, label: t.name })),
],
);
protected readonly priorityOptions = options(PRIORITY);
protected readonly priorityFilterOptions: PiSelectFieldOption<string>[] = [
{ value: ALL, label: 'Todas las prioridades' },
...this.priorityOptions,
];
protected readonly assigneeFilterOptions = computed<
PiSelectFieldOption<string>[]
>(() => [
{ value: ALL, label: 'Todas las personas' },
{ value: NOBODY, label: 'Sin asignar' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
]);
private readonly criteria = computed<ListCriteria>(() => {
const scope = this.scope();
const assignee = this.assignee();
return {
search: this.search() || undefined,
categories:
scope === 'open' ? OPEN : scope === 'closed' ? CLOSED : undefined,
types: this.type() ? [this.type() as string] : undefined,
priorities: this.priority()
? [this.priority() as IssuePriority]
: undefined,
unassigned: assignee === NOBODY || undefined,
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
};
});
/* Sorting (here, on what was loaded) */
protected readonly sort = signal<{ key: SortKey; asc: boolean }>({
key: 'updated',
asc: false,
});
protected readonly rows = computed(() => {
const { key, asc } = this.sort();
const order = this.statusOrder();
const value = (i: IssueSummary): string | number => {
switch (key) {
case 'key':
return numberOf(i.key);
case 'title':
return i.title.toLocaleLowerCase();
case 'type':
return this.typeLabel(i);
case 'status':
return order.get(i.status) ?? 99;
case 'priority':
return PRIORITY_LEVEL[i.priority];
case 'assignee':
return i.assignee?.name.toLocaleLowerCase() ?? '￿';
case 'sprint':
return i.sprint?.name.toLocaleLowerCase() ?? '￿';
case 'points':
return i.storyPoints ?? -1;
case 'votes':
return i.votes;
case 'due':
return i.dueDate ?? '￿';
case 'updated':
return i.updatedAt;
}
};
return [...this.facade.issues()].sort((a, b) => {
const x = value(a);
const y = value(b);
const diff =
typeof x === 'number' && typeof y === 'number'
? x - y
: String(x).localeCompare(String(y));
return asc ? diff : -diff;
});
});
/** Statuses in board order. */
private readonly statusOrder = computed(
() =>
new Map(
this.project
.workflow()
.settings.statuses.map((s, index) => [s.id, index] as const),
),
);
/* Selection */
protected readonly selected = signal(new Set<string>());
protected readonly allSelected = computed(
() =>
this.rows().length > 0 &&
this.rows().every((i) => this.selected().has(i.id)),
);
constructor() {
effect(() => {
const projectId = this.project.project()?.id;
if (projectId) untracked(() => this.facade.open(projectId));
});
effect(() => {
const criteria = this.criteria();
untracked(() => this.facade.filter(criteria));
});
}
protected sortBy(key: SortKey): void {
this.sort.update((current) =>
current.key === key
? { key, asc: !current.asc }
: // Dates and numbers read best from the top; words from A.
{
key,
asc: !['updated', 'priority', 'points', 'votes'].includes(key),
},
);
}
protected ariaSort(key: SortKey): string | null {
const sort = this.sort();
if (sort.key !== key) return null;
return sort.asc ? 'ascending' : 'descending';
}
protected toggle(issueId: string, on: boolean): void {
this.selected.update((set) => {
const next = new Set(set);
if (on) next.add(issueId);
else next.delete(issueId);
return next;
});
}
protected selectAll(on: boolean): void {
this.selected.set(new Set(on ? this.rows().map((i) => i.id) : []));
}
protected applyBulk(change: BulkChange): void {
this.facade.bulkUpdate([...this.selected()], change);
this.selected.set(new Set());
}
/**
* Its status and where the workflow lets it go. Statuses that need a
* reason are left to the ticket page, where there is room to write it.
*/
protected statusChoices(issue: IssueSummary) {
const workflow = this.project.workflow();
const ids = [
issue.status,
...workflow
.allowedFrom(issue.status)
.filter((id) => !workflow.requiresReason(id)),
];
return ids.map((id) => ({ id, name: this.statusLabel(id) }));
}
protected changeStatus(issue: IssueSummary, select: HTMLSelectElement): void {
if (select.value !== issue.status)
this.facade.changeStatus(issue.id, select.value);
}
protected changePriority(issue: IssueSummary, priority: string): void {
if (priority !== issue.priority)
this.facade.changePriority(issue.id, priority as IssuePriority);
}
protected assign(issue: IssueSummary, value: string): void {
const assigneeId = value === NOBODY ? null : value;
if (assigneeId !== (issue.assignee?.id ?? null))
this.facade.assign(issue.id, assigneeId);
}
protected blockersOf(issue: IssueSummary): string {
return issue.blockedBy.map((b) => b.key).join(', ');
}
protected isLate(issue: IssueSummary): boolean {
return (
!!issue.dueDate &&
OPEN.includes(issue.statusCategory) &&
issue.dueDate.slice(0, 10) < new Date().toISOString().slice(0, 10)
);
}
protected shortDate(date: string): string {
return new Date(`${date.slice(0, 10)}T00:00:00`).toLocaleDateString(
'es-ES',
{ day: 'numeric', month: 'short' },
);
}
protected typeIcon = (issue: IssueSummary) =>
typeLook(this.project.workflow(), issue.type).icon;
protected typeTone = (issue: IssueSummary) =>
typeLook(this.project.workflow(), issue.type).tone;
protected typeLabel = (issue: IssueSummary) =>
typeLook(this.project.workflow(), issue.type).label;
protected statusTone = (status: string) =>
statusLook(this.project.workflow(), status).tone;
protected statusLabel = (status: string) =>
statusLook(this.project.workflow(), status).label;
protected priorityLabel = (issue: IssueSummary) =>
PRIORITY[issue.priority].label;
}

View File

@@ -61,6 +61,11 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
{{ project.description }} {{ project.description }}
</p> </p>
} }
@if (project.isPublic && project.acceptsProposals) {
<a class="ts-project-header__propose" routerLink="proponer">
¿Has visto un error o tienes una idea? Cuéntanoslo →
</a>
}
</div> </div>
<div class="ts-project-header__team"> <div class="ts-project-header__team">
<ts-avatars [names]="memberNames()" [max]="6" /> <ts-avatars [names]="memberNames()" [max]="6" />
@@ -85,6 +90,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
} }
<nav class="ts-tabs" aria-label="Secciones del proyecto"> <nav class="ts-tabs" aria-label="Secciones del proyecto">
<a routerLink="board" routerLinkActive="is-active">Tablero</a> <a routerLink="board" routerLinkActive="is-active">Tablero</a>
<a routerLink="list" routerLinkActive="is-active">Lista</a>
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a> <a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a> <a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
<a routerLink="calendar" routerLinkActive="is-active">Calendario</a> <a routerLink="calendar" routerLinkActive="is-active">Calendario</a>
@@ -195,6 +201,16 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
color: var(--ts-muted); color: var(--ts-muted);
} }
.ts-project-header__propose {
display: inline-block;
margin-top: 0.625rem;
color: var(--ts-brand);
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.ts-project-header__team { .ts-project-header__team {
display: flex; display: flex;
align-items: center; align-items: center;

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