Compare commits

..

5 Commits

Author SHA1 Message Date
21bc598590 feat(web): dependencies between epics on the roadmap
Arrows from an epic to the ones that wait for it («bloquea» / «depende
de»), drawn over the bars; dashed red, plus a note in words, when one
starts before the epic blocking it ends. The next batch and the ideas
kept or dropped are written down in docs/10-futuro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:00:14 +02:00
f7e850068b feat: custom fields per project
Text, number, date or option-list fields defined in the project settings
(archived, never removed; their type is fixed once saved). Tickets hold
them, edited in the side panel, listed under the title and filtered by
option in the list view; also over GraphQL, REST (?field=ID:value) and MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:31:12 +02:00
233c46e9fa feat: push notifications on each device (Web Push)
- What reaches the bell also reaches the devices where the person
  turned «Avisos en este dispositivo» on, with TaskStream closed: the
  Notifier sends each new notification encrypted (aes128gcm) and signed
  with VAPID, and forgets subscriptions the push service dropped
  (404/410). A failure never blocks the event stream.
- The API keeps one subscription per device (push_subscriptions) and
  exposes the VAPID public key in the notification settings; GraphQL
  subscribePush/unsubscribePush, REST /me/push-subscriptions. Without
  VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY push is off.
- The web registers /push-sw.js, a service worker that only shows the
  notification and opens its ticket on a tap (no caching, served
  without cache by nginx), and a manifest that makes it installable,
  which iPhones need for push.
- The bell's toggles show their text next to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:38:07 +02:00
5ee627e689 chore(web): @pi/ui 0.0.8, ticked checkboxes follow the theme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:41:24 +02:00
f45069a327 feat: a calendar to subscribe to, and the backlog's corners
- Each person can subscribe their dates in Google Calendar, Outlook or
  Apple Calendar: GET /api/calendar/{token}.ics (no session) brings
  their unfinished tickets with a due date (assigned or followed), the
  planned and running sprints and the planned releases of their
  projects, as all-day events linking to the web. The token is made,
  renewed or dropped under /me/calendar (and in GraphQL); in the web,
  from «Calendario» in «Mi trabajo».
- The backlog panel keeps the selected rows' background inside its
  rounded corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:48:33 +02:00
103 changed files with 5302 additions and 233 deletions

View File

@@ -48,6 +48,13 @@ SMTP_URL=
MAIL_FROM=
WEB_URL=http://localhost:4200
# Optional push notifications (Web Push). Without both keys nothing is sent.
# Make them once with `npx web-push generate-vapid-keys` (changing them drops
# every device's subscription). VAPID_SUBJECT: a mailto: or https: contact.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:no-reply@pireon.dev
# Outgoing webhooks refuse hosts on this machine or its private network unless true.
WEBHOOKS_ALLOW_PRIVATE=false

View File

@@ -1168,4 +1168,115 @@ describe('task-stream (e2e)', () => {
history.body.map((h: { actor: { name: string } }) => h.actor.name),
).toContain('TaskStream');
});
it('adds custom fields to a project and lets tickets hold and filter by them', async () => {
const key = 'C' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const settings = project.body['settings'];
expect(settings.customFields).toEqual([]);
// REST: define them (a client that leaves them out keeps them).
const fields = [
{
id: 'BROWSER',
name: 'Navegador',
type: 'SELECT',
options: ['Chrome', 'Safari'],
},
{ id: 'COST', name: 'Coste', type: 'NUMBER' },
];
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...settings,
customFields: fields,
})
).status,
).toBe(200);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.body['settings'].customFields.length === 2,
);
const { customFields: _left, ...older } = settings;
expect(
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, older))
.status,
).toBe(200);
// REST: a ticket with values; a wrong one is refused.
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Pago roto',
customFields: { BROWSER: 'Safari', COST: '1,5' },
});
expect(created.status).toBe(201);
const ticket = created.body['key'] as string;
const issue = await eventually(
() => rest('token-ana', 'GET', `/issues/${ticket}`),
(r) => r.status === 200,
);
expect(issue.body['customFields']).toEqual({
BROWSER: 'Safari',
COST: '1.5',
});
expect(
(
await rest('token-ana', 'PATCH', `/issues/${ticket}`, {
customFields: { BROWSER: 'Edge' },
})
).status,
).toBe(400);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Otro',
customFields: { BROWSER: 'Chrome' },
});
// GraphQL: change one, clear another; filter by value.
await gql(
'token-ana',
`mutation($i: UpdateIssueInput!) { updateIssue(input: $i) { version } }`,
{
i: {
issueId: issue.body['id'],
customFields: [
{ fieldId: 'BROWSER', value: 'Chrome' },
{ fieldId: 'COST', value: null },
],
},
},
);
const filtered = await eventually(
() =>
gql(
'token-ana',
`query($f: IssueFilterInput!) { issues(filter: $f) { total items { key customFields { fieldId value } } } }`,
{
f: {
projectId: project.body['id'],
customFields: [{ fieldId: 'BROWSER', value: 'Chrome' }],
},
},
),
(d) => d['issues'].total === 2,
);
expect(
filtered['issues'].items.find((i: { key: string }) => i.key === ticket)
.customFields,
).toEqual([{ fieldId: 'BROWSER', value: 'Chrome' }]);
const bySafari = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?field=BROWSER:Safari`,
);
expect(bySafari.body['total']).toBe(0);
const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
expect(
history.body.map((h: { summary: string }) => h.summary).join(' | '),
).toContain('los campos personalizados');
});
});

View File

@@ -380,6 +380,114 @@
"security": []
}
},
"/api/calendar/{file}": {
"get": {
"description": "Unfinished tickets with a due date (assigned or followed), planned and running sprints, and planned releases with a date, as all-day events.",
"operationId": "CalendarController_ics",
"parameters": [
{
"name": "file",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"summary": "A calendar to subscribe to (`{token}.ics`, no session)",
"tags": [
"Users"
],
"security": []
}
},
"/api/me/calendar": {
"get": {
"operationId": "CalendarController_mine",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarFeedDto"
}
}
}
},
"204": {
"description": "I have none"
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "My calendar URL, if I made one",
"tags": [
"Users"
]
},
"post": {
"operationId": "CalendarController_create",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarFeedDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "A new secret calendar URL (the previous one stops working)",
"tags": [
"Users"
]
},
"delete": {
"operationId": "CalendarController_revoke",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "My calendar URL stops working",
"tags": [
"Users"
]
}
},
"/api/projects": {
"get": {
"operationId": "ProjectsController_mine",
@@ -2316,6 +2424,21 @@
"type": "string"
}
},
{
"name": "field",
"required": false,
"in": "query",
"description": "Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several",
"schema": {
"example": [
"BROWSER:Safari"
],
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "sort",
"required": false,
@@ -7152,6 +7275,130 @@
]
}
},
"/api/me/push-subscriptions": {
"post": {
"operationId": "NotificationsController_subscribePush",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PushSubscriptionDto"
}
}
}
},
"responses": {
"204": {
"description": ""
},
"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": "Push on this device (a browser's Web Push subscription)",
"tags": [
"Users"
]
},
"delete": {
"operationId": "NotificationsController_unsubscribePush",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PushEndpointDto"
}
}
}
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Push off on this device",
"tags": [
"Users"
]
}
},
"/api/projects/{project}/filters": {
"get": {
"operationId": "SavedFiltersController_list",
@@ -9967,6 +10214,22 @@
"isActive"
]
},
"CalendarFeedDto": {
"type": "object",
"properties": {
"token": {
"type": "string",
"description": "Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session)"
},
"createdAt": {
"type": "string"
}
},
"required": [
"token",
"createdAt"
]
},
"UserRefDto": {
"type": "object",
"properties": {
@@ -10212,6 +10475,54 @@
"archived"
]
},
"CustomFieldType": {
"type": "string",
"enum": [
"TEXT",
"NUMBER",
"DATE",
"SELECT"
],
"description": "DATE values are YYYY-MM-DD"
},
"CustomFieldDefinitionDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "BROWSER"
},
"name": {
"type": "string",
"example": "Navegador"
},
"type": {
"description": "DATE values are YYYY-MM-DD",
"allOf": [
{
"$ref": "#/components/schemas/CustomFieldType"
}
]
},
"options": {
"description": "The choices of a SELECT field",
"type": "array",
"items": {
"type": "string"
}
},
"archived": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"type",
"options",
"archived"
]
},
"ProjectSettingsDto": {
"type": "object",
"properties": {
@@ -10239,13 +10550,20 @@
"items": {
"$ref": "#/components/schemas/AreaOptionDto"
}
},
"customFields": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomFieldDefinitionDto"
}
}
},
"required": [
"statuses",
"transitions",
"issueTypes",
"areas"
"areas",
"customFields"
]
},
"ProjectDto": {
@@ -10551,6 +10869,50 @@
"name"
]
},
"CustomFieldDefinitionBodyDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "BROWSER"
},
"name": {
"type": "string",
"example": "Navegador",
"maxLength": 40
},
"type": {
"description": "Cannot change once the field exists. DATE: YYYY-MM-DD",
"allOf": [
{
"$ref": "#/components/schemas/CustomFieldType"
}
]
},
"options": {
"maxItems": 50,
"example": [
"Chrome",
"Safari",
"Firefox"
],
"description": "The choices of a SELECT field",
"type": "array",
"items": {
"type": "string"
}
},
"archived": {
"type": "boolean",
"default": false
}
},
"required": [
"id",
"name",
"type"
]
},
"ProjectSettingsBodyDto": {
"type": "object",
"properties": {
@@ -10578,6 +10940,13 @@
"items": {
"$ref": "#/components/schemas/AreaOptionBodyDto"
}
},
"customFields": {
"description": "Absent: unchanged. Fields cannot be removed: archive them.",
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomFieldDefinitionBodyDto"
}
}
},
"required": [
@@ -10968,6 +11337,16 @@
"nullable": true,
"format": "date"
},
"customFields": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"example": {
"BROWSER": "Safari"
},
"description": "Custom field id → value (only those set)"
},
"links": {
"type": "array",
"items": {
@@ -11073,6 +11452,7 @@
"storyPoints",
"startDate",
"dueDate",
"customFields",
"links",
"attachments",
"watchers",
@@ -11575,6 +11955,17 @@
"nullable": true,
"format": "date"
},
"customFields": {
"type": "object",
"additionalProperties": {
"type": "string",
"nullable": true
},
"example": {
"BROWSER": "Safari"
},
"description": "Custom field id → value. Only the fields to change; null or '' clears one"
},
"type": {
"type": "string",
"example": "BUG",
@@ -11702,6 +12093,17 @@
"nullable": true,
"format": "date"
},
"customFields": {
"type": "object",
"additionalProperties": {
"type": "string",
"nullable": true
},
"example": {
"BROWSER": "Safari"
},
"description": "Custom field id → value. Only the fields to change; null or '' clears one"
},
"type": {
"type": "string",
"example": "FEATURE",
@@ -12809,11 +13211,17 @@
"emailAvailable": {
"type": "boolean",
"description": "Whether this installation can send e-mail"
},
"pushPublicKey": {
"type": "string",
"nullable": true,
"description": "VAPID public key to subscribe a device to push with; null: no push here"
}
},
"required": [
"email",
"emailAvailable"
"emailAvailable",
"pushPublicKey"
]
},
"NotificationSettingsDto": {
@@ -12828,6 +13236,40 @@
"email"
]
},
"PushSubscriptionDto": {
"type": "object",
"properties": {
"endpoint": {
"type": "string",
"description": "The push service URL (PushSubscription.endpoint)"
},
"p256dh": {
"type": "string",
"description": "keys.p256dh of the subscription"
},
"auth": {
"type": "string",
"description": "keys.auth of the subscription"
}
},
"required": [
"endpoint",
"p256dh",
"auth"
]
},
"PushEndpointDto": {
"type": "object",
"properties": {
"endpoint": {
"type": "string",
"description": "The endpoint of the device to turn off"
}
},
"required": [
"endpoint"
]
},
"SavedFilterCriteriaResponseDto": {
"type": "object",
"properties": {

View File

@@ -158,6 +158,15 @@ input BulkUpdateIssuesInput {
unassign: Boolean
}
type CalendarFeed {
createdAt: String!
"""
Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session)
"""
token: String!
}
input ChangeIssueStatusInput {
issueId: ID!
@@ -270,6 +279,9 @@ input CreateIssueInput {
"""An area id"""
area: String
assigneeId: ID
"""Only the fields to change"""
customFields: [CustomFieldValueInput!]
description: String
dueDate: String
@@ -343,6 +355,47 @@ type CumulativeFlow {
series: [FlowSeries!]!
}
"""A field the project adds to its tickets; its type never changes"""
type CustomFieldDefinition {
archived: Boolean!
id: String!
name: String!
"""The choices of a SELECT field"""
options: [String!]!
type: CustomFieldType!
}
input CustomFieldDefinitionInput {
archived: Boolean! = false
id: String!
name: String!
"""The choices of a SELECT field"""
options: [String!]! = []
type: CustomFieldType!
}
"""What a custom field holds (DATE: YYYY-MM-DD)"""
enum CustomFieldType {
DATE
NUMBER
SELECT
TEXT
}
"""A ticket's value of a field"""
type CustomFieldValue {
fieldId: String!
value: String!
}
"""A custom field's value; null or '' clears it"""
input CustomFieldValueInput {
fieldId: String!
value: String
}
"""A commit, branch or pull request that mentions the ticket"""
type DevLink {
at: String!
@@ -434,6 +487,9 @@ type Issue {
commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
createdAt: String!
"""Values of the project's custom fields (only those set)"""
customFields: [CustomFieldValue!]!
description: String
"""Commits, branches and pull requests that mention it"""
@@ -507,6 +563,9 @@ input IssueFilterInput {
assigneeId: ID
categories: [StatusCategory!]
"""Only tickets holding these custom values"""
customFields: [CustomFieldValueInput!]
"""Due on or after this day (YYYY-MM-DD)"""
dueFrom: String
@@ -677,6 +736,9 @@ type Mutation {
Add a rule. It acts as TaskStream, within the workflow, and what it does triggers no other rule.
"""
createAutomation(input: CreateAutomationInput!): Automation!
"""A new secret calendar URL; the previous one stops working"""
createCalendarFeed: CalendarFeed!
createIssue(input: CreateIssueInput!): CommandResult!
createProject(input: CreateProjectInput!): CommandResult!
createRelease(input: CreateReleaseInput!): CommandResult!
@@ -737,6 +799,9 @@ type Mutation {
"""Back from the trash"""
restoreIssue(issueId: ID!): CommandResult!
"""My calendar URL stops working"""
revokeCalendarFeed: Boolean!
"""A new secret for a repository; the old one stops working"""
rotateGitRepositorySecret(projectId: ID!, repositoryId: ID!): GitRepository!
@@ -757,6 +822,9 @@ type Mutation {
"""Start a planned sprint (only one under way per project)"""
startSprint(sprintId: ID!): CommandResult!
"""Push on this device"""
subscribePush(subscription: PushSubscriptionInput!): Boolean!
"""Send a ping now and tell how it went"""
testWebhook(webhookId: ID!): WebhookDelivery!
@@ -766,6 +834,9 @@ type Mutation {
trashIssue(issueId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Push off on this device"""
unsubscribePush(endpoint: String!): Boolean!
"""Stop following a ticket"""
unwatchIssue(issueId: ID!): CommandResult!
@@ -844,6 +915,11 @@ type NotificationSettings {
"""Whether this installation can send e-mail at all"""
emailAvailable: Boolean!
"""
VAPID public key to subscribe a device to push with; null: no push here
"""
pushPublicKey: String
}
type Project {
@@ -935,9 +1011,12 @@ enum ProjectRole {
VIEWER
}
"""Statuses (board order), transitions, ticket types and areas"""
"""
Statuses (board order), transitions, ticket types, areas and custom fields
"""
type ProjectSettings {
areas: [AreaOption!]!
customFields: [CustomFieldDefinition!]!
issueTypes: [IssueTypeOption!]!
statuses: [WorkflowStatus!]!
transitions: [StatusTransitions!]!
@@ -948,6 +1027,9 @@ The whole configuration (replaces the previous one). Statuses go in board order.
"""
input ProjectSettingsInput {
areas: [AreaOptionInput!]!
"""Absent: unchanged. Fields cannot be removed: archive them."""
customFields: [CustomFieldDefinitionInput!]
issueTypes: [IssueTypeOptionInput!]!
statuses: [WorkflowStatusInput!]!
transitions: [StatusTransitionsInput!]!
@@ -965,6 +1047,13 @@ type ProjectStats {
total: Int!
}
"""A browser's Web Push subscription (PushSubscription.toJSON())"""
input PushSubscriptionInput {
auth: String!
endpoint: String!
p256dh: String!
}
type QaStats {
failed: Int!
failureRate: Float
@@ -996,6 +1085,9 @@ type Query {
"""The authenticated user"""
me: Me!
"""My calendar URL, if I made one"""
myCalendarFeed: CalendarFeed
"""
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
"""
@@ -1291,6 +1383,9 @@ input UpdateChecklistItemInput {
input UpdateIssueInput {
"""An area id"""
area: String
"""Only the fields to change"""
customFields: [CustomFieldValueInput!]
description: String
dueDate: String

View File

@@ -52,6 +52,11 @@ export const configSchema = Joi.object({
// E-mail copies of notifications (optional): without SMTP_URL nothing is sent.
SMTP_URL: Joi.string().allow('').optional(),
MAIL_FROM: Joi.string().allow('').optional(),
// Push notifications (optional): without both VAPID keys nothing is sent.
// Make them once with `npx web-push generate-vapid-keys`.
VAPID_PUBLIC_KEY: Joi.string().allow('').optional(),
VAPID_PRIVATE_KEY: Joi.string().allow('').optional(),
VAPID_SUBJECT: Joi.string().allow('').optional(),
// Public address of the web app, for links in e-mails.
WEB_URL: Joi.string().allow('').optional(),
});

View File

@@ -132,6 +132,45 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/calendar/{file}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* A calendar to subscribe to (`{token}.ics`, no session)
* @description Unfinished tickets with a due date (assigned or followed), planned and running sprints, and planned releases with a date, as all-day events.
*/
get: operations["CalendarController_ics"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/calendar": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** My calendar URL, if I made one */
get: operations["CalendarController_mine"];
put?: never;
/** A new secret calendar URL (the previous one stops working) */
post: operations["CalendarController_create"];
/** My calendar URL stops working */
delete: operations["CalendarController_revoke"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects": {
parameters: {
query?: never;
@@ -1073,6 +1112,24 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/me/push-subscriptions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Push on this device (a browser's Web Push subscription) */
post: operations["NotificationsController_subscribePush"];
/** Push off on this device */
delete: operations["NotificationsController_unsubscribePush"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/filters": {
parameters: {
query?: never;
@@ -1537,6 +1594,11 @@ export interface components {
email: string;
isActive: boolean;
};
CalendarFeedDto: {
/** @description Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session) */
token: string;
createdAt: string;
};
UserRefDto: {
/** @description Pireon user id */
id: string;
@@ -1604,12 +1666,29 @@ export interface components {
name: string;
archived: boolean;
};
/**
* @description DATE values are YYYY-MM-DD
* @enum {string}
*/
CustomFieldType: "TEXT" | "NUMBER" | "DATE" | "SELECT";
CustomFieldDefinitionDto: {
/** @example BROWSER */
id: string;
/** @example Navegador */
name: string;
/** @description DATE values are YYYY-MM-DD */
type: components["schemas"]["CustomFieldType"];
/** @description The choices of a SELECT field */
options: string[];
archived: boolean;
};
ProjectSettingsDto: {
/** @description In board order */
statuses: components["schemas"]["WorkflowStatusDto"][];
transitions: components["schemas"]["StatusTransitionsDto"][];
issueTypes: components["schemas"]["IssueTypeOptionDto"][];
areas: components["schemas"]["AreaOptionDto"][];
customFields: components["schemas"]["CustomFieldDefinitionDto"][];
};
ProjectDto: {
/** Format: uuid */
@@ -1726,12 +1805,33 @@ export interface components {
/** @default false */
archived: boolean;
};
CustomFieldDefinitionBodyDto: {
/** @example BROWSER */
id: string;
/** @example Navegador */
name: string;
/** @description Cannot change once the field exists. DATE: YYYY-MM-DD */
type: components["schemas"]["CustomFieldType"];
/**
* @description The choices of a SELECT field
* @example [
* "Chrome",
* "Safari",
* "Firefox"
* ]
*/
options?: string[];
/** @default false */
archived: boolean;
};
ProjectSettingsBodyDto: {
/** @description In board order. Values cannot be removed: archive them instead. */
statuses: components["schemas"]["WorkflowStatusBodyDto"][];
transitions: components["schemas"]["StatusTransitionsBodyDto"][];
issueTypes: components["schemas"]["IssueTypeOptionBodyDto"][];
areas: components["schemas"]["AreaOptionBodyDto"][];
/** @description Absent: unchanged. Fields cannot be removed: archive them. */
customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][];
};
/** @enum {string} */
IssuePriority: "LOWEST" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
@@ -1868,6 +1968,15 @@ export interface components {
startDate: string | null;
/** Format: date */
dueDate: string | null;
/**
* @description Custom field id → value (only those set)
* @example {
* "BROWSER": "Safari"
* }
*/
customFields: {
[key: string]: string;
};
links: components["schemas"]["IssueLinkDto"][];
attachments: components["schemas"]["AttachmentDto"][];
/** @description People following it */
@@ -2036,6 +2145,15 @@ export interface components {
startDate?: string | null;
/** Format: date */
dueDate?: string | null;
/**
* @description Custom field id → value. Only the fields to change; null or '' clears one
* @example {
* "BROWSER": "Safari"
* }
*/
customFields?: {
[key: string]: string | null;
};
/**
* @description A ticket type id of the project's settings
* @example BUG
@@ -2084,6 +2202,15 @@ export interface components {
startDate?: string | null;
/** Format: date */
dueDate?: string | null;
/**
* @description Custom field id → value. Only the fields to change; null or '' clears one
* @example {
* "BROWSER": "Safari"
* }
*/
customFields?: {
[key: string]: string | null;
};
/**
* @description A ticket type id
* @example FEATURE
@@ -2487,11 +2614,25 @@ export interface components {
email: boolean;
/** @description Whether this installation can send e-mail */
emailAvailable: boolean;
/** @description VAPID public key to subscribe a device to push with; null: no push here */
pushPublicKey: string | null;
};
NotificationSettingsDto: {
/** @description Copies by e-mail */
email: boolean;
};
PushSubscriptionDto: {
/** @description The push service URL (PushSubscription.endpoint) */
endpoint: string;
/** @description keys.p256dh of the subscription */
p256dh: string;
/** @description keys.auth of the subscription */
auth: string;
};
PushEndpointDto: {
/** @description The endpoint of the device to turn off */
endpoint: string;
};
SavedFilterCriteriaResponseDto: {
types?: string[];
areas?: string[];
@@ -3032,6 +3173,87 @@ export interface operations {
};
};
};
CalendarController_ics: {
parameters: {
query?: never;
header?: never;
path: {
file: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CalendarController_mine: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarFeedDto"];
};
};
/** @description I have none */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CalendarController_create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarFeedDto"];
};
};
};
};
CalendarController_revoke: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
ProjectsController_mine: {
parameters: {
query?: never;
@@ -4208,6 +4430,8 @@ export interface operations {
dueFrom?: string;
/** @description Due on or before this day */
dueTo?: string;
/** @description Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several */
field?: string[];
/** @description RECENT (default): last updated first. RANK: backlog order. */
sort?: components["schemas"]["IssueSort"];
/** @description Matches key, title or description */
@@ -7667,6 +7891,102 @@ export interface operations {
};
};
};
NotificationsController_subscribePush: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PushSubscriptionDto"];
};
};
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @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"];
};
};
};
};
NotificationsController_unsubscribePush: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PushEndpointDto"];
};
};
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SavedFiltersController_list: {
parameters: {
query?: never;

View File

@@ -46,6 +46,12 @@ const issueFields = {
.optional()
.describe('ISO date when work should start (roadmap)'),
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
customFields: z
.record(z.string(), z.string().nullable())
.optional()
.describe(
'Custom field id → value (see get_project settings.customFields): SELECT one of its options, DATE YYYY-MM-DD, NUMBER a number. Only the fields to change; null clears one',
),
};
type IssueFields = {
@@ -57,6 +63,7 @@ type IssueFields = {
storyPoints?: number;
startDate?: string;
dueDate?: string;
customFields?: Record<string, string | null>;
};
@Resolver()
@@ -142,6 +149,12 @@ export class IssuesResolver {
.describe('Only the tickets the user follows'),
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
customFields: z
.record(z.string(), z.string())
.optional()
.describe(
'Only tickets holding these custom values (field id → value)',
),
sort: z
.enum(['RECENT', 'RANK'])
.default('RECENT')
@@ -170,6 +183,7 @@ export class IssuesResolver {
watching?: boolean;
dueFrom?: string;
dueTo?: string;
customFields?: Record<string, string>;
sort: 'RECENT' | 'RANK';
limit: number;
offset: number;
@@ -198,6 +212,11 @@ export class IssuesResolver {
watching: params.watching,
dueFrom: params.dueFrom,
dueTo: params.dueTo,
field: params.customFields
? Object.entries(params.customFields).map(
([id, value]) => `${id}:${value}`,
)
: undefined,
sort: params.sort,
limit: params.limit,
offset: params.offset,

View File

@@ -27,6 +27,7 @@ export const issueLine = (issue: Issue) => ({
votes: issue.votes,
storyPoints: issue.storyPoints,
dueDate: issue.dueDate,
customFields: issue.customFields,
labels: issue.labels,
updatedAt: issue.updatedAt,
});

View File

@@ -28,6 +28,19 @@ server {
proxy_read_timeout 1h;
}
# The service worker and the manifest have no hash in their name: never
# cached, or browsers would keep an old one (exact matches win over the
# regex below).
location = /push-sw.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /manifest.webmanifest {
add_header Cache-Control "no-cache";
default_type application/manifest+json;
try_files $uri =404;
}
# Hashed bundles can be cached forever; index.html never.
location ~* \.(js|css|woff2?|svg|png|ico)$ {
expires 1y;

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,25 @@
{
"name": "TaskStream",
"short_name": "TaskStream",
"description": "Gestión de proyectos y tickets en tiempo real",
"lang": "es",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f7f7fa",
"theme_color": "#a347e1",
"icons": [
{
"src": "/logo-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/logo-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

View File

@@ -0,0 +1,52 @@
/*
* TaskStream's service worker: push notifications only. It caches nothing,
* so a new deploy never leaves an old version of the app behind.
*
* The API sends `{ title, body, url, tag }` (see Notifier's payloadOf).
*/
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) =>
event.waitUntil(self.clients.claim()),
);
self.addEventListener('push', (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = { body: event.data ? event.data.text() : '' };
}
event.waitUntil(
self.registration.showNotification(data.title || 'TaskStream', {
body: data.body || '',
tag: data.tag,
icon: '/logo-192.png',
badge: '/logo-96.png',
data: { url: data.url || '/' },
}),
);
});
// A tap opens the ticket: in a TaskStream tab already open, or in a new one.
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = new URL(
event.notification.data?.url || '/',
self.location.origin,
).href;
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((tabs) => {
for (const tab of tabs) {
if (
new URL(tab.url).origin === self.location.origin &&
'focus' in tab
) {
return tab.focus().then(() => tab.navigate(target));
}
}
return self.clients.openWindow(target);
}),
);
});

View File

@@ -12,6 +12,8 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="icon" type="image/png" sizes="96x96" href="logo-96.png" />
<link rel="apple-touch-icon" href="logo.png" />
<!-- Installable (on iPhone, push only reaches an installed web app) -->
<link rel="manifest" href="manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Inter, like pireon.dev · Geist Mono only for data (ticket keys, counters, versions) -->

View File

@@ -63,6 +63,10 @@ services:
SMTP_URL: ${SMTP_URL:-}
MAIL_FROM: ${MAIL_FROM:-}
WEB_URL: ${WEB_URL:-}
# Push notifications (optional): `npx web-push generate-vapid-keys`.
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
# Outgoing webhooks to private addresses (off: they could reach internal services).
WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false}
# Due-date reminders, checked every hour.

View File

@@ -25,7 +25,7 @@
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |

View File

@@ -82,7 +82,8 @@ store como auditoría, pero desde la API no se puede deshacer.
### Configuración por proyecto
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket** y **áreas** (`Project.settings`). Todos
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados**
(`Project.settings`). Todos
empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o
`PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`).
@@ -112,6 +113,29 @@ Reglas:
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
### Campos personalizados
Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`,
`type` y `archived`, más `options` si es una lista. Un proyecto tiene como mucho 30, y empieza sin ninguno.
| Tipo | Valor |
| -------- | ---------------------------------------------------------------- |
| `TEXT` | Hasta 500 caracteres |
| `NUMBER` | Un número (se guarda en forma canónica: `1,5` → `1.5`) |
| `DATE` | `YYYY-MM-DD` |
| `SELECT` | Una de sus `options` (de 1 a 50, de hasta 60 caracteres, únicas) |
- Como el resto de la configuración, un campo no se borra: se archiva. Su **tipo no cambia** una vez guardado, para que
los valores que ya hay sigan teniendo sentido. Un campo archivado se puede vaciar en un ticket, pero no rellenar.
- Al guardar la configuración, si `customFields` no viene se quedan como estaban (los clientes que no los conocen no
los pierden).
- En el ticket: GraphQL `Issue.customFields` es una lista `{ fieldId, value }` (solo los que tienen valor) y se cambia
con `customFields: [{ fieldId, value }]` en `createIssue` / `updateIssue`; por REST es un objeto
`{ "BROWSER": "Safari" }`. En ambos se mandan solo los campos que cambian, y `null` o `""` vacía uno.
- Filtrar: `issues(filter: { customFields: [{ fieldId, value }] })` o `GET /api/projects/{project}/issues?field=BROWSER:Safari`
(se puede repetir).
- `IssueUpdatedEvent` lleva el mapa entero de valores ya validado, así la proyección solo lo copia.
### Workflow por defecto
```mermaid
@@ -256,6 +280,21 @@ secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (n
y se borran con el proyecto.
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
### Calendario (iCalendar)
Cada persona puede suscribir sus fechas en Google Calendar, Outlook o Apple Calendar con una URL secreta:
`GET /api/calendar/{token}.ics` (sin sesión: las aplicaciones de calendario no pueden iniciarla). Trae, como eventos de
día completo:
- sus tickets sin terminar con fecha límite, asignados o que sigue;
- los sprints planificados o en curso de sus proyectos;
- las releases planificadas con fecha objetivo.
Cada evento enlaza a la web (`WEB_URL`). El token (32 bytes aleatorios) se crea, renueva o retira con `GET`, `POST` y
`DELETE /api/me/calendar` (GraphQL: `myCalendarFeed`, `createCalendarFeed`, `revokeCalendarFeed`); renovarlo deja sin
servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de
fechas y títulos que ya están en el read model.
### Notificaciones
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
@@ -271,6 +310,13 @@ ningún agregado, solo cada uno lee y descarta las suyas.
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
| Llega una propuesta del público | Propietarios y admins del proyecto | `PROPOSED` |
**Push** (Web Push): además de en la campana y, si se pidió, por correo, cada aviso llega a los dispositivos donde la
persona activó «Avisos en este dispositivo», con TaskStream cerrado. El navegador se suscribe con la clave pública
VAPID (`notificationSettings.pushPublicKey`, null si no hay claves) y la API guarda la suscripción
(`subscribePush` / `POST /api/me/push-subscriptions`; se retira con `unsubscribePush` / `DELETE`). El mensaje va
cifrado (`aes128gcm`) y firmado con VAPID, con `{ title, body, url, tag }`; si el servicio push responde 404 o 410, la
suscripción se olvida. Como el correo, un fallo de envío no frena los eventos.
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en
`REMINDERS_TIMEZONE`). Cada aviso tiene id `reminder:<ticket>:<fecha>:<fase>:<persona>`, así que no se repite aunque

View File

@@ -127,6 +127,21 @@ hijas de `/p/:key` reciben la misma `ProjectFacade`.
Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura
de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
### Notificaciones push
En la campana, «Avisos en este dispositivo» (si la API tiene claves VAPID y el navegador admite push) registra
`/push-sw.js`, pide permiso, suscribe el navegador y manda la suscripción a la API (`PushFacade` + el puerto `PushDevice`,
con `BrowserPushDevice` como adaptador). El _service worker_ solo hace push: muestra el aviso y, al pulsarlo, enfoca una
pestaña de TaskStream y la lleva al ticket, o abre una. No guarda nada en caché, así que un despliegue nunca deja una
versión vieja; nginx sirve `push-sw.js` y `manifest.webmanifest` sin caché. Con el manifiesto la web se puede instalar,
que en iPhone es condición para recibir push (iOS 16.4+).
### Calendario
«Mi trabajo» tiene un botón «Calendario» que abre `ts-calendar-feed`: crea la URL secreta, la copia, la renueva o la
desactiva (`CalendarFacade`, sin store). La URL es absoluta, compuesta con `restUrl` y el origen de la página, porque es
lo que se pega en la aplicación de calendario.
### Texto con formato
Descripciones y comentarios se escriben con `ts-rich-editor`, un editor visual sobre
@@ -230,6 +245,11 @@ proyectos: cada fila usa el workflow de su proyecto y los grupos son por categor
En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos
archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma.
Los campos personalizados se definen en Configuración (el tipo se elige al crearlos y luego queda fijo; las opciones
de una lista, una por línea). En la ficha, `ui/custom-field.ts` pone el control de cada tipo: las listas y las fechas
se guardan al elegir; el texto y los números, al salir del campo. En la lista, cada campo de opciones añade un filtro y
los valores del ticket se ven bajo su título.
### Gráficas
Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una
@@ -261,7 +281,7 @@ visualización de la plataforma:
## 4.6 Rutas
| Ruta | Página |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
@@ -269,9 +289,10 @@ visualización de la plataforma:
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos y áreas; archivar en lugar de borrar |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros |

View File

@@ -71,7 +71,7 @@ donde los lee Compose.
## 7.2 Variables de entorno de la API
| Variable | Por defecto | Descripción |
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| ------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | `3000` | Puerto HTTP |
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
@@ -97,6 +97,8 @@ donde los lee Compose.
| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` |
| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada |
| `MAIL_FROM` | `TaskStream <no-reply@pireon.dev>` | Remitente de esos correos |
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | — | Notificaciones push (Web Push). Sin las dos no se envía nada. Se crean una vez con `npx web-push generate-vapid-keys`; cambiarlas anula las suscripciones |
| `VAPID_SUBJECT` | `mailto:no-reply@pireon.dev` | Contacto (`mailto:` o `https:`) que los servicios push pueden usar si algo va mal |
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |

View File

@@ -35,7 +35,7 @@ flowchart LR
57 herramientas, agrupadas por ámbito:
| Ámbito | Herramienta | Qué hace |
| ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| ------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| Usuario | `whoami` | El usuario del token |
| | `search_users` | Buscar personas de Pireon (para asignar) |
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
@@ -50,10 +50,10 @@ flowchart LR
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
| | `get_project_activity` | Actividad reciente |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas…); el backlog: `noSprint` y orden `RANK` |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
| | `create_issue` | Crear un ticket (también colgado de una épica) |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas… |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
| | `assign_issue` | Asignar o desasignar |
| | `set_issue_release` | Meter en una release o sacar |

View File

@@ -28,42 +28,50 @@ cuando hay pistas, por dónde empezar.
## 10.3 Siguientes funcionalidades
### Elegidas para la próxima tanda
### En curso, en este orden
En este orden:
1. **Calendario suscribible (ICS)**: una URL privada por persona con las fechas límite, los sprints y las releases de
sus proyectos, para Google Calendar, Outlook o Apple Calendar. La URL lleva un token propio (revocable), no el de
sesión.
2. **Notificaciones push (Web Push)**: los avisos de la campana como aviso del sistema, aunque la pestaña esté
cerrada. Un _service worker_ en la web (que la deja instalable como PWA), «Activar avisos» en los ajustes de
notificaciones, las suscripciones de cada persona guardadas en la API y el envío con Web Push (claves VAPID, dos
variables nuevas). En iPhone solo con la web instalada en la pantalla de inicio (iOS 16.4+).
3. **Campos personalizados por proyecto**: «Navegador», «Cliente», «Entorno»… de texto, número, fecha o lista de
opciones, definidos en Configuración; editables en la ficha, filtrables y como columna en la vista de lista.
### Valoradas para después
4. **Dependencias en la hoja de ruta**: las relaciones «bloquea» / «depende de» entre épicas, como flechas en la vista
de calendario, para ver qué retrasa a qué.
5. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para
1. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para
planificar el siguiente con datos (el historial de estados ya está en `issue_timeline`).
6. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas,
automatizaciones y etiquetas.
7. **Sincronizar con issues de Gitea o GitHub**: hoy los commits y PR se enlazan a tickets; además, que un issue del
repositorio cree o actualice un ticket, y al revés.
8. **Vistas guardadas en «Mi trabajo»**: montar la portada con filtros guardados de varios proyectos («Bugs críticos de
todos mis proyectos», «Lo que revisa QA esta semana»…).
9. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
2. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas, campos
personalizados, automatizaciones y etiquetas.
3. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
para archivar.
10. **Asistente en la web** (la base ya está en el MCP): resumir un ticket largo con sus comentarios; al crear un
ticket, sugerir tipo, prioridad y área y avisar si se parece a otro; redactar las notas de versión a partir del
borrador. Pendiente de decidir la clave de la API de Claude y su coste.
11. **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.
12. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de
autorización y admitir el registro dinámico de clientes.
Y en el panel del proyecto:
4. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
5. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
curso), cada grupo con enlace a la lista filtrada.
6. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
proyecto (p85 del tiempo de ciclo).
7. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
8. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
con semáforo.
9. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
10. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
sobrecarga y sus bloqueados.
11. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
12. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
siguientes a cada release.
13. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
navegador»).
### Aceptadas, para después de esta tanda
- **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés.
- **Vistas guardadas en «Mi trabajo»**: filtros guardados de varios proyectos («Bugs críticos de todos mis
proyectos»).
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
vista.
- **Definición de «Hecho»**: no pasar a un estado `DONE` con la lista de comprobación a medias (salvo un admin).
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
integración continua.
- **Equipos dentro del proyecto**: asignar a un equipo y mencionarlo (`@frontend`).
- **Presencia**: ver quién está mirando o editando el mismo ticket.
- **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos).
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
- **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página.
- **Archivar un proyecto**: queda en solo lectura en lugar de borrarlo.
### Otras ideas apuntadas, sin decidir
@@ -71,8 +79,13 @@ En este orden:
- **Tickets recurrentes**: «revisar copias cada lunes», creados solos con la plantilla del tipo.
- **Crear tickets por correo**: una dirección por proyecto; lo que llega es un ticket (con adjuntos) y las respuestas,
comentarios. Necesita un buzón de entrada.
- **Tiempo en cada estado**: cuánto pasa un ticket en «En revisión» o «En QA», y dónde se atascan.
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
- **Posponer un ticket**: «recuérdamelo el jueves».
- **Reunión diaria**: qué cerró cada uno ayer, qué tiene hoy y qué le bloquea, sacado del historial.
- **Requisitos al cambiar de estado**: campos obligatorios para entrar en un estado (estimación para «En curso»…).
- **Plazos por prioridad**: «lo crítico, en 24 h», con aviso antes de vencer.
- **Matriz impacto/esfuerzo** para ordenar el backlog.
- **Planning poker** y **retrospectiva del sprint** (las acciones se convierten en tickets).
- **Avisar a quien propuso** cuando su propuesta se hace o se descarta.
## 10.4 Descartado por ahora
@@ -81,6 +94,11 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
- **Registro de horas** dedicadas a cada ticket.
- **Objetivos u OKR** por encima de las épicas.
- **App móvil**: la web ya se adapta a pantallas estrechas.
- **Asistente en la web** (resumir tickets, sugerir tipo, prioridad y área, redactar notas de versión): el MCP ya deja
hacerlo desde un cliente de IA.
- **Avisos a Discord, Slack o Telegram** con mensajes legibles: los webhooks salientes siguen ahí para quien los
necesite.
- **Conector de claude.ai** (OAuth del protocolo en el MCP y registro dinámico de clientes en Pireon).
## 10.5 Hecho desde la primera versión de esta lista
@@ -104,3 +122,11 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.
- Mover tickets entre releases: «Release…» en la edición en bloque, filtro por release en la lista (también desde cada
release, «Ver en la lista»), y al archivar una release, elegir adónde va lo pendiente.
- Calendario suscribible (iCalendar) desde «Mi trabajo»: tickets con fecha, sprints y releases, con URL secreta
renovable.
- Notificaciones push (Web Push) en cada dispositivo, con la web instalable.
- Campos personalizados por proyecto (texto, número, fecha o lista de opciones): se definen en Configuración, se
editan en la ficha, se ven bajo el título en la lista y se filtran por los de opciones (también por GraphQL, REST y
MCP).
- Dependencias en la hoja de ruta: flechas entre las épicas que se bloquean o dependen unas de otras, y aviso cuando
una empieza antes de que acabe la que la bloquea.

Binary file not shown.

Binary file not shown.

View File

@@ -19,6 +19,8 @@ export * from './lib/git.service';
export * from './lib/webhooks.service';
export * from './lib/transfer.service';
export * from './lib/automations.service';
export * from './lib/calendar.service';
export * from './lib/calendar/calendar';
export * from './lib/automations/automations';
export * from './lib/automations/automation-rules';
export * from './lib/transfer/import-export';

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
CreateCalendarFeedCommand,
GetCalendarIcsQuery,
GetMyCalendarFeedQuery,
RevokeCalendarFeedCommand,
} from './calendar/calendar';
import { CalendarFeed } from './ports/calendar-feeds.repository';
/** A person's calendar subscription (iCalendar), behind a secret URL. */
@Injectable()
export class CalendarService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
mine(userId: string): Promise<CalendarFeed | null> {
return this.queryBus.execute(new GetMyCalendarFeedQuery(userId));
}
/** A new secret URL; the previous one, if any, stops working. */
create(userId: string): Promise<CalendarFeed> {
return this.commandBus.execute(new CreateCalendarFeedCommand(userId));
}
revoke(userId: string): Promise<void> {
return this.commandBus.execute(new RevokeCalendarFeedCommand(userId));
}
/** The calendar behind `token`, or null if no feed has it. */
ics(token: string, webUrl: string): Promise<string | null> {
return this.queryBus.execute(new GetCalendarIcsQuery(token, webUrl));
}
}

View File

@@ -0,0 +1,193 @@
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
IssueReadModel,
ProjectReadModel,
ReleaseStatus,
SprintStatus,
} from '@task-stream/api/tracker/domain';
import {
CalendarFeed,
CalendarFeedsRepository,
} from '../ports/calendar-feeds.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UserDirectory } from '../ports/user-directory';
import { CalendarEntry, buildIcs } from './ics';
/** A new secret URL for the person's calendar; the old one stops working. */
export class CreateCalendarFeedCommand extends Command<CalendarFeed> {
constructor(public readonly userId: string) {
super();
}
}
/** The person's calendar URL stops working. */
export class RevokeCalendarFeedCommand extends Command<void> {
constructor(public readonly userId: string) {
super();
}
}
export class GetMyCalendarFeedQuery extends Query<CalendarFeed | null> {
constructor(public readonly userId: string) {
super();
}
}
/** The calendar behind a token, as iCalendar text; null for an unknown one. */
export class GetCalendarIcsQuery extends Query<string | null> {
constructor(
public readonly token: string,
/** Where tickets open (`WEB_URL`), for the events' links; may be empty. */
public readonly webUrl: string,
) {
super();
}
}
@CommandHandler(CreateCalendarFeedCommand)
export class CreateCalendarFeedCommandHandler
implements ICommandHandler<CreateCalendarFeedCommand>
{
constructor(private readonly feeds: CalendarFeedsRepository) {}
async execute(command: CreateCalendarFeedCommand): Promise<CalendarFeed> {
const feed: CalendarFeed = {
userId: command.userId,
// 32 random bytes: nobody guesses it.
token: randomBytes(32).toString('base64url'),
createdAt: new Date().toISOString(),
};
await this.feeds.save(feed);
return feed;
}
}
@CommandHandler(RevokeCalendarFeedCommand)
export class RevokeCalendarFeedCommandHandler
implements ICommandHandler<RevokeCalendarFeedCommand>
{
constructor(private readonly feeds: CalendarFeedsRepository) {}
async execute(command: RevokeCalendarFeedCommand): Promise<void> {
await this.feeds.remove(command.userId);
}
}
@QueryHandler(GetMyCalendarFeedQuery)
export class GetMyCalendarFeedQueryHandler
implements IQueryHandler<GetMyCalendarFeedQuery, CalendarFeed | null>
{
constructor(private readonly feeds: CalendarFeedsRepository) {}
execute(query: GetMyCalendarFeedQuery): Promise<CalendarFeed | null> {
return this.feeds.findByUser(query.userId);
}
}
/**
* What the person has to keep an eye on, as all-day events: their unfinished
* tickets with a due date (assigned or followed), the planned and running
* sprints of their projects and their planned releases with a date. Read at
* every download, so it is as fresh as the calendar app asks.
*/
@QueryHandler(GetCalendarIcsQuery)
export class GetCalendarIcsQueryHandler
implements IQueryHandler<GetCalendarIcsQuery, string | null>
{
constructor(
private readonly feeds: CalendarFeedsRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly users: UserDirectory,
) {}
async execute(query: GetCalendarIcsQuery): Promise<string | null> {
const feed = await this.feeds.findByToken(query.token);
if (!feed) return null;
const projects = await this.findProjects.findByMember(feed.userId);
const byId = new Map(projects.map((p) => [p.id, p]));
const ids = projects.map((p) => p.id);
const [issues, sprints, releases, me] = await Promise.all([
this.findIssues.findDatedFor(feed.userId, ids),
Promise.all(ids.map((id) => this.findSprints.findByProject(id))),
Promise.all(ids.map((id) => this.findReleases.findByProject(id))),
this.users.findById(feed.userId),
]);
const link = linkTo(query.webUrl);
const entries: CalendarEntry[] = [
...issues.flatMap((issue) => {
const project = byId.get(issue.projectId);
return project ? [issueEntry(issue, project, link)] : [];
}),
...sprints
.flat()
.filter(
(s) =>
s.status === SprintStatus.PLANNED ||
s.status === SprintStatus.ACTIVE,
)
.map((s) => ({
uid: `sprint-${s.id}@taskstream`,
start: s.startDate,
end: s.endDate,
summary: `${s.name} · ${byId.get(s.projectId)?.key ?? ''}`.trim(),
description: s.goal ?? undefined,
url: link(`/p/${byId.get(s.projectId)?.key}/sprints`),
})),
...releases
.flat()
.filter((r) => r.status === ReleaseStatus.PLANNED && r.targetDate)
.map((r) => ({
uid: `release-${r.id}@taskstream`,
start: r.targetDate as string,
summary:
`Release ${r.name} · ${byId.get(r.projectId)?.key ?? ''}`.trim(),
description: r.description ?? undefined,
url: link(`/p/${byId.get(r.projectId)?.key}/releases`),
})),
];
return buildIcs(me ? `TaskStream · ${me.name}` : 'TaskStream', entries);
}
}
function issueEntry(
issue: IssueReadModel,
project: ProjectReadModel,
link: (path: string) => string | null,
): CalendarEntry {
const status =
project.settings?.statuses.find((s) => s.id === issue.status)?.name ??
issue.status;
return {
uid: `issue-${issue.id}@taskstream`,
start: issue.dueDate as string,
summary: `${issue.key} · ${issue.title}`,
description: `${project.name} · ${status}`,
url: link(`/p/${project.key}/issues/${issue.key}`),
};
}
function linkTo(webUrl: string): (path: string) => string | null {
const base = webUrl.replace(/\/+$/, '');
return (path) => (base ? `${base}${path}` : null);
}
export const CALENDAR_HANDLERS = [
CreateCalendarFeedCommandHandler,
RevokeCalendarFeedCommandHandler,
GetMyCalendarFeedQueryHandler,
GetCalendarIcsQueryHandler,
];

View File

@@ -0,0 +1,62 @@
import { buildIcs } from './ics';
const NOW = new Date('2026-10-14T09:30:00.000Z');
describe('buildIcs', () => {
it('writes all-day events with an exclusive end', () => {
const ics = buildIcs(
'TaskStream',
[
{
uid: 'issue-1@taskstream',
start: '2026-10-20',
summary: 'WEB-1 · Login',
},
{
uid: 'sprint-1@taskstream',
start: '2026-10-05',
end: '2026-10-19',
summary: 'Sprint 4',
},
],
NOW,
);
expect(ics.startsWith('BEGIN:VCALENDAR\r\nVERSION:2.0\r\n')).toBe(true);
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
expect(ics).toContain('DTSTAMP:20261014T093000Z');
expect(ics).toContain(
'DTSTART;VALUE=DATE:20261020\r\nDTEND;VALUE=DATE:20261021',
);
expect(ics).toContain(
'DTSTART;VALUE=DATE:20261005\r\nDTEND;VALUE=DATE:20261020',
);
expect(ics.split('BEGIN:VEVENT')).toHaveLength(3);
});
it('escapes text and skips what has no date', () => {
const ics = buildIcs(
'Ana; López, QA',
[
{ uid: 'a', start: '2026-10-20', summary: 'Pago; roto, en\nSafari' },
{ uid: 'b', start: '', summary: 'sin fecha' },
],
NOW,
);
expect(ics).toContain('X-WR-CALNAME:Ana\\; López\\, QA');
expect(ics).toContain('SUMMARY:Pago\\; roto\\, en\\nSafari');
expect(ics).not.toContain('sin fecha');
});
it('folds long lines at 75 octets without splitting a character', () => {
const summary = 'Revisión de la exportación á'.repeat(5);
const ics = buildIcs(
'x',
[{ uid: 'u', start: '2026-10-20', summary }],
NOW,
);
for (const line of ics.split('\r\n'))
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75);
// Unfolding gives the text back.
expect(ics.replace(/\r\n /g, '')).toContain(`SUMMARY:${summary}`);
});
});

View File

@@ -0,0 +1,97 @@
/** One all-day event of the calendar feed. */
export interface CalendarEntry {
/** Stable across downloads, so calendars update the event, not duplicate it. */
uid: string;
/** `YYYY-MM-DD`. */
start: string;
/** `YYYY-MM-DD`, inclusive; the same day when absent. */
end?: string;
summary: string;
description?: string;
url?: string | null;
}
const DAY = /^\d{4}-\d{2}-\d{2}/;
/**
* An iCalendar (RFC 5545) document of all-day events: what Google Calendar,
* Outlook and Apple Calendar subscribe to. All-day dates carry no time
* zone, so a deadline falls on its day wherever the reader is.
*/
export function buildIcs(
name: string,
entries: CalendarEntry[],
now = new Date(),
): string {
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Pireon//TaskStream//ES',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${text(name)}`,
// How often to look again: calendars mostly decide for themselves.
'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
'X-PUBLISHED-TTL:PT1H',
];
for (const entry of entries) {
const start = DAY.exec(entry.start)?.[0];
if (!start) continue;
const last = DAY.exec(entry.end ?? '')?.[0] ?? start;
lines.push(
'BEGIN:VEVENT',
`UID:${text(entry.uid)}`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${compact(start)}`,
// DTEND is exclusive: the day after the last one.
`DTEND;VALUE=DATE:${compact(nextDay(last < start ? start : last))}`,
`SUMMARY:${text(entry.summary)}`,
...(entry.description ? [`DESCRIPTION:${text(entry.description)}`] : []),
...(entry.url ? [`URL:${entry.url}`] : []),
'TRANSP:TRANSPARENT',
'END:VEVENT',
);
}
lines.push('END:VCALENDAR');
return lines.map(fold).join('\r\n') + '\r\n';
}
/** TEXT values escape backslashes, semicolons, commas and line breaks. */
function text(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n');
}
const compact = (day: string) => day.replace(/-/g, '');
function nextDay(day: string): string {
const date = new Date(`${day}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + 1);
return date.toISOString().slice(0, 10);
}
/** Lines of more than 75 octets go on in the next one, after a space. */
function fold(line: string): string {
const bytes = Buffer.from(line, 'utf8');
if (bytes.length <= 75) return line;
const parts: string[] = [];
let current = '';
let size = 0;
for (const char of line) {
const length = Buffer.byteLength(char, 'utf8');
// The first line holds 75 octets; the next ones 74 after their space.
if (size + length > (parts.length ? 74 : 75)) {
parts.push(current);
current = '';
size = 0;
}
current += char;
size += length;
}
parts.push(current);
return parts.join('\r\n ');
}

View File

@@ -90,6 +90,7 @@ const FIELD: Record<string, string> = {
storyPoints: 'los puntos',
startDate: 'la fecha de inicio',
dueDate: 'la fecha límite',
customFields: 'los campos personalizados',
};
const list = (items: string[]) =>

View File

@@ -57,6 +57,7 @@ export class IssueCreatedEventHandler
storyPoints: issue.storyPoints,
startDate: issue.startDate ?? null,
dueDate: issue.dueDate,
customFields: issue.customFields ?? {},
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
version: versionOf(event),

View File

@@ -355,6 +355,7 @@ export class IssuesService {
storyPoints: issue.storyPoints,
startDate: issue.startDate,
dueDate: issue.dueDate,
customFields: issue.customFields,
parentId: issue.parentId,
// The original's text is the content, not the type's template.
useTemplate: false,

View File

@@ -4,6 +4,8 @@ import { NotificationReadModel } from '@task-stream/api/tracker/domain';
import { SendDueRemindersCommand } from './notifications/reminders';
import {
MarkNotificationsReadCommand,
SubscribePushCommand,
UnsubscribePushCommand,
UpdateNotificationSettingsCommand,
} from './notifications/notifications.commands';
import {
@@ -37,6 +39,24 @@ export class NotificationsService {
}
/** `ids` omitted: everything. */
/** Push on this device (a browser's Web Push subscription). */
subscribePush(
userId: string,
endpoint: string,
keys: { p256dh: string; auth: string },
): Promise<void> {
return this.commandBus.execute(
new SubscribePushCommand(userId, endpoint, keys),
);
}
/** Push off on this device. */
unsubscribePush(userId: string, endpoint: string): Promise<void> {
return this.commandBus.execute(
new UnsubscribePushCommand(userId, endpoint),
);
}
/** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */
sendDueReminders(day: string): Promise<number> {
return this.commandBus.execute(new SendDueRemindersCommand(day));

View File

@@ -1,5 +1,8 @@
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import { NotificationsRepository } from '../ports/notifications.repository';
import { PushSender } from '../ports/push-sender';
import { PushSubscriptionsRepository } from '../ports/push-subscriptions.repository';
/*
* Per-person state, outside the event store: whether a notification was read
@@ -49,3 +52,64 @@ export class UpdateNotificationSettingsCommandHandler
});
}
}
/** Push on this device: the browser's subscription is kept for its owner. */
export class SubscribePushCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly endpoint: string,
public readonly keys: { p256dh: string; auth: string },
) {
super();
}
}
/** Push off on this device. */
export class UnsubscribePushCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly endpoint: string,
) {
super();
}
}
@CommandHandler(SubscribePushCommand)
export class SubscribePushCommandHandler
implements ICommandHandler<SubscribePushCommand>
{
constructor(
private readonly subscriptions: PushSubscriptionsRepository,
private readonly push: PushSender,
) {}
async execute(command: SubscribePushCommand): Promise<void> {
assertThat(!!this.push.publicKey, 'Push is not set up here');
// Push services are https; anything else is not a browser's endpoint.
assertThat(
/^https:\/\/[^\s]{8,2000}$/.test(command.endpoint),
'Not a push endpoint',
);
assertThat(
!!command.keys?.p256dh && !!command.keys?.auth,
'The subscription keys are missing',
);
await this.subscriptions.save({
userId: command.userId,
endpoint: command.endpoint,
keys: { p256dh: command.keys.p256dh, auth: command.keys.auth },
createdAt: new Date().toISOString(),
});
}
}
@CommandHandler(UnsubscribePushCommand)
export class UnsubscribePushCommandHandler
implements ICommandHandler<UnsubscribePushCommand>
{
constructor(private readonly subscriptions: PushSubscriptionsRepository) {}
async execute(command: UnsubscribePushCommand): Promise<void> {
await this.subscriptions.remove(command.userId, command.endpoint);
}
}

View File

@@ -1,6 +1,7 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
import { NotificationMailer } from '../ports/notification-mailer';
import { PushSender } from '../ports/push-sender';
import { NotificationsRepository } from '../ports/notifications.repository';
export interface MyNotifications {
@@ -13,6 +14,8 @@ export interface MyNotificationSettings {
email: boolean;
/** Whether this installation can send e-mail at all. */
emailAvailable: boolean;
/** VAPID public key to subscribe a device with; null: no push here. */
pushPublicKey: string | null;
}
export class GetMyNotificationsQuery extends Query<MyNotifications> {
@@ -57,6 +60,7 @@ export class GetMyNotificationSettingsQueryHandler
constructor(
private readonly notifications: NotificationsRepository,
private readonly mailer: NotificationMailer,
private readonly push: PushSender,
) {}
async execute(
@@ -66,6 +70,7 @@ export class GetMyNotificationSettingsQueryHandler
return {
email: this.mailer.enabled && settings.email,
emailAvailable: this.mailer.enabled,
pushPublicKey: this.push.publicKey,
};
}
}

View File

@@ -24,6 +24,12 @@ import { NotificationMailer } from '../ports/notification-mailer';
import { NotificationsRepository } from '../ports/notifications.repository';
import { NotificationsEventHandler } from './notifications.event-handler';
import { Notifier } from './notifier';
import {
PushPayload,
PushSender,
PushSubscriptionRecord,
} from '../ports/push-sender';
import { PushSubscriptionsRepository } from '../ports/push-subscriptions.repository';
class InMemoryNotifications extends NotificationsRepository {
readonly all: NotificationReadModel[] = [];
@@ -56,10 +62,14 @@ const at = <T extends TrackerEvent>(event: T, version = 2): T =>
occurredAt: '2026-10-08T10:00:00.000Z',
});
function setup(mailEnabled = false) {
function setup(mailEnabled = false, pushEnabled = false) {
const repo = new InMemoryNotifications();
const published: NotificationReadModel[] = [];
const mailed: string[] = [];
/** Endpoint → what was pushed to it. */
const pushed: [string, PushPayload][] = [];
const devices: PushSubscriptionRecord[] = [];
const forgotten: string[] = [];
const users = new FakeUserDirectory()
.add('ana')
.add('bob')
@@ -90,6 +100,21 @@ function setup(mailEnabled = false) {
},
} as unknown as NotificationMailer,
users,
{
publicKey: pushEnabled ? 'vapid-public' : null,
send: async (device: PushSubscriptionRecord, payload: PushPayload) => {
pushed.push([device.endpoint, payload]);
// A browser that dropped the subscription.
return device.endpoint.endsWith('/gone') ? 'gone' : 'sent';
},
} as unknown as PushSender,
{
forUser: async (userId: string) =>
devices.filter((d) => d.userId === userId),
forget: async (endpoint: string) => {
forgotten.push(endpoint);
},
} as unknown as PushSubscriptionsRepository,
);
const handler = new NotificationsEventHandler(
notifier,
@@ -118,7 +143,7 @@ function setup(mailEnabled = false) {
id === 's-14' ? { id, name: 'Sprint 14' } : null,
} as unknown as FindSprintsRepository,
);
return { repo, published, mailed, handler };
return { repo, published, mailed, pushed, devices, forgotten, handler };
}
describe('notifications', () => {
@@ -340,4 +365,32 @@ describe('notifications', () => {
NotificationKind.ADDED_TO_PROJECT,
]);
});
it('pushes to every device of whoever is told, and forgets dropped ones', async () => {
const { handler, pushed, devices, forgotten } = setup(false, true);
const device = (userId: string, endpoint: string) => ({
userId,
endpoint,
keys: { p256dh: 'p', auth: 'a' },
createdAt: '2026-10-01T00:00:00.000Z',
});
devices.push(
device('bob', 'https://push.example/phone'),
device('bob', 'https://push.example/gone'),
device('qa', 'https://push.example/qa'),
);
await handler.handle(
at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null)),
);
expect(pushed.map(([endpoint]) => endpoint)).toEqual([
'https://push.example/phone',
'https://push.example/gone',
]);
expect(pushed[0][1]).toMatchObject({
title: 'TaskStream · WEB-1',
url: '/p/WEB/issues/WEB-1',
});
expect(forgotten).toEqual(['https://push.example/gone']);
});
});

View File

@@ -9,6 +9,8 @@ import {
import { ChangeFeed } from '../ports/change-feed';
import { NotificationMailer } from '../ports/notification-mailer';
import { NotificationsRepository } from '../ports/notifications.repository';
import { PushPayload, PushSender } from '../ports/push-sender';
import { PushSubscriptionsRepository } from '../ports/push-subscriptions.repository';
import { UserDirectory } from '../ports/user-directory';
import { versionOf } from '../event-handlers/read-model-notifier';
@@ -39,6 +41,8 @@ export class Notifier {
private readonly changeFeed: ChangeFeed,
private readonly mailer: NotificationMailer,
private readonly users: UserDirectory,
private readonly push: PushSender,
private readonly pushSubscriptions: PushSubscriptionsRepository,
) {}
async deliver(
@@ -89,6 +93,33 @@ export class Notifier {
if (this.mailer.enabled && delivered.length) {
await this.email(delivered);
}
if (this.push.publicKey && delivered.length) {
await this.pushToDevices(delivered);
}
}
/**
* To every device where the person turned push on. Like e-mail, a failure
* never blocks the event stream; a device the push service forgot is
* forgotten here too.
*/
private async pushToDevices(
notifications: NotificationReadModel[],
): Promise<void> {
for (const notification of notifications) {
const devices = await this.pushSubscriptions.forUser(notification.userId);
for (const device of devices) {
try {
const result = await this.push.send(device, payloadOf(notification));
if (result === 'gone')
await this.pushSubscriptions.forget(device.endpoint);
} catch (error) {
this.logger.warn(
`Push to ${notification.userId} failed: ${(error as Error).message}`,
);
}
}
}
}
/** A failed e-mail never blocks the event stream: it is logged and skipped. */
@@ -116,3 +147,18 @@ export class Notifier {
}
}
}
/** What the device shows: the ticket (or project), the line and its page. */
export function payloadOf(notification: NotificationReadModel): PushPayload {
const project = encodeURIComponent(notification.projectKey);
return {
title: notification.issueKey
? `TaskStream · ${notification.issueKey}`
: `TaskStream · ${notification.projectKey}`,
body: notification.summary,
url: notification.issueKey
? `/p/${project}/issues/${encodeURIComponent(notification.issueKey)}`
: `/p/${project}`,
tag: notification.id,
};
}

View File

@@ -0,0 +1,20 @@
/** A person's calendar subscription: the secret in its URL. */
export interface CalendarFeed {
userId: string;
/** Random and long; whoever has it reads that person's calendar. */
token: string;
createdAt: string;
}
/**
* One feed per person. The token is kept as it is (not hashed) so the URL
* can be copied again, as calendar apps expect; it only reads dates and
* titles the read model holds anyway.
*/
export abstract class CalendarFeedsRepository {
abstract findByUser(userId: string): Promise<CalendarFeed | null>;
abstract findByToken(token: string): Promise<CalendarFeed | null>;
/** Replaces the person's feed, if any. */
abstract save(feed: CalendarFeed): Promise<void>;
abstract remove(userId: string): Promise<void>;
}

View File

@@ -33,6 +33,8 @@ export interface IssueFilter {
dueTo?: string;
/** Only tickets in no sprint (wins over `sprintId`). */
noSprint?: boolean;
/** Custom field id → the value it must hold. */
customFields?: Record<string, string>;
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */
watching?: boolean;
/** Only the ones this person follows. */
@@ -73,6 +75,14 @@ export abstract class FindIssuesRepository {
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
/**
* Unfinished tickets with a due date, in those projects, assigned to or
* followed by `userId` (their calendar).
*/
abstract findDatedFor(
userId: string,
projectIds: string[],
): Promise<IssueReadModel[]>;
/** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */
abstract findDueBy(day: string): Promise<IssueReadModel[]>;
/** The project's tickets in the trash, newest first. */

View File

@@ -27,3 +27,6 @@ export * from './git-integrations.repository';
export * from './outgoing-webhooks.repository';
export * from './automations.repository';
export * from './rate-limit.store';
export * from './calendar-feeds.repository';
export * from './push-sender';
export * from './push-subscriptions.repository';

View File

@@ -0,0 +1,32 @@
/** A browser's push subscription (Web Push), as the browser hands it over. */
export interface PushSubscriptionRecord {
userId: string;
/** The push service URL of that browser on that device: unique. */
endpoint: string;
keys: { p256dh: string; auth: string };
createdAt: string;
}
/** What the service worker shows: a title, a line and where a tap goes. */
export interface PushPayload {
title: string;
body: string;
/** A path in the web app, e.g. `/p/WEB/issues/WEB-12`. */
url: string;
/** Replaces an earlier notification with the same tag on the device. */
tag: string;
}
/**
* Sends Web Push messages (VAPID). Without keys configured it is off and
* nobody is offered push.
*/
export abstract class PushSender {
/** The VAPID public key browsers subscribe with; null: push is off. */
abstract get publicKey(): string | null;
/** `gone`: the browser dropped the subscription; forget it. */
abstract send(
subscription: PushSubscriptionRecord,
payload: PushPayload,
): Promise<'sent' | 'gone'>;
}

View File

@@ -0,0 +1,12 @@
import { PushSubscriptionRecord } from './push-sender';
/** The devices where each person turned push on. */
export abstract class PushSubscriptionsRepository {
abstract forUser(userId: string): Promise<PushSubscriptionRecord[]>;
/** By endpoint: a device subscribing again replaces its entry. */
abstract save(subscription: PushSubscriptionRecord): Promise<void>;
/** Only the owner's: nobody drops someone else's device. */
abstract remove(userId: string, endpoint: string): Promise<void>;
/** The push service said it no longer exists. */
abstract forget(endpoint: string): Promise<void>;
}

View File

@@ -29,6 +29,8 @@ import {
import {
MarkNotificationsReadCommandHandler,
UpdateNotificationSettingsCommandHandler,
SubscribePushCommandHandler,
UnsubscribePushCommandHandler,
} from './notifications/notifications.commands';
import { SAVED_FILTERS } from './saved-filters/saved-filters';
@@ -43,6 +45,8 @@ import { TransferService } from './transfer.service';
import { TRANSFER_HANDLERS } from './transfer/import-export';
import { AutomationsService } from './automations.service';
import { AUTOMATION_HANDLERS } from './automations/automations';
import { CALENDAR_HANDLERS } from './calendar/calendar';
import { CalendarService } from './calendar.service';
import { AutomationsEventHandler } from './automations/automations.event-handler';
/** Notifications: derived from events, kept and dismissed per person. */
@@ -53,10 +57,13 @@ const NOTIFICATIONS = [
GetMyNotificationSettingsQueryHandler,
MarkNotificationsReadCommandHandler,
UpdateNotificationSettingsCommandHandler,
SubscribePushCommandHandler,
UnsubscribePushCommandHandler,
SendDueRemindersCommandHandler,
];
const SERVICES = [
CalendarService,
ProjectsService,
IssuesService,
CommentsService,
@@ -98,6 +105,7 @@ const SERVICES = [
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,
...AUTOMATION_HANDLERS,
...CALENDAR_HANDLERS,
AutomationsEventHandler,
],
exports: SERVICES,

View File

@@ -1,5 +1,5 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { IssueDetails } from '../issue/issue';
import type { IssueChanges } from '../issue/issue';
import { TrackerEvent } from './tracker.event';
/** Editable fields of a ticket changed. */
@@ -9,7 +9,7 @@ export class IssueUpdatedEvent extends TrackerEvent {
actorId: string,
issueId: string,
projectId: string,
readonly changes: IssueDetails,
readonly changes: IssueChanges,
) {
super(actorId, issueId, projectId);
}

View File

@@ -72,6 +72,9 @@ export class IssueFactory {
storyPoints: details.storyPoints ?? null,
startDate: details.startDate ?? null,
dueDate: details.dueDate ?? null,
customFields: props.customFields
? workflow.customValues({}, props.customFields)
: {},
links: [],
createdAt: now,
updatedAt: now,

View File

@@ -63,8 +63,18 @@ export interface IssueDetails {
/** When work on it should start (roadmap); `dueDate` is when it should end. */
startDate?: string | null;
dueDate?: string | null;
/**
* Values of the project's custom fields, by field id. A change only names
* the fields it touches; null or '' clears one.
*/
customFields?: Record<string, string | number | null>;
}
/** What IssueUpdatedEvent carries: custom values as the whole new map. */
export type IssueChanges = Omit<IssueDetails, 'customFields'> & {
customFields?: Record<string, string>;
};
/** Data of a ticket (what IssueCreatedEvent carries). */
export interface IssueProps {
id: string;
@@ -86,6 +96,8 @@ export interface IssueProps {
storyPoints: number | null;
startDate?: string | null;
dueDate: string | null;
/** Custom field id → value; absent in tickets created before them. */
customFields?: Record<string, string>;
links: IssueLink[];
createdAt: string;
updatedAt: string;
@@ -113,6 +125,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public storyPoints: number | null = null;
public startDate: string | null = null;
public dueDate: string | null = null;
/** Custom field id → value (tickets created before them have none). */
public customFields: Record<string, string> = {};
public links: IssueLink[] = [];
public attachments: IssueAttachment[] = [];
/** People who asked to be told about this ticket. */
@@ -144,9 +158,17 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
changes: IssueDetails,
workflow: ProjectWorkflow = ProjectWorkflow.default(),
): void {
const next = normalizeIssueDetails(changes);
const patch: IssueDetails = {};
for (const field of Object.keys(next) as (keyof IssueDetails)[]) {
const { customFields: customChanges, ...rest } = changes;
const { customFields: _none, ...normalized } = normalizeIssueDetails(rest);
const next: IssueChanges = normalized;
// Custom values go whole into the event: the projection just sets them.
if (customChanges && Object.keys(customChanges).length)
next.customFields = workflow.customValues(
this.customFields ?? {},
customChanges,
);
const patch: IssueChanges = {};
for (const field of Object.keys(next) as (keyof IssueChanges)[]) {
const value = next[field];
if (value === undefined) continue;
if (JSON.stringify(value) === JSON.stringify(this[field])) continue;
@@ -524,6 +546,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
Object.assign(this, {
...issue,
labels: [...issue.labels],
customFields: { ...(issue.customFields ?? {}) },
links: issue.links.map((l) => ({ ...l })),
});
}

View File

@@ -8,9 +8,12 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueFactory } from '../factories/issue.factory';
import { ProjectFactory } from '../factories/project.factory';
import { replay } from '../testing';
import { Issue } from '../issue/issue';
import { Project } from './project';
import { ProjectRole } from './project-role';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
import {
CustomFieldType,
defaultProjectSettings,
ProjectSettings,
StatusCategory,
@@ -203,4 +206,151 @@ describe('Project settings', () => {
),
).toThrow(/Unknown ticket type/);
});
describe('custom fields', () => {
const withFields = (): ProjectSettings => ({
...defaultProjectSettings(),
customFields: [
{
id: 'browser',
name: ' Navegador ',
type: CustomFieldType.SELECT,
options: ['Chrome', ' Safari ', 'Chrome', ''],
archived: false,
},
{
id: 'COST',
name: 'Coste',
type: CustomFieldType.NUMBER,
options: ['ignored'],
archived: false,
},
{
id: 'GO_LIVE',
name: 'Salida',
type: CustomFieldType.DATE,
options: [],
archived: false,
},
{
id: 'CLIENT',
name: 'Cliente',
type: CustomFieldType.TEXT,
options: [],
archived: false,
},
],
});
const newIssue = (project: Project) =>
new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 1,
title: 'Pago roto',
type: 'BUG',
reporterId: 'dev',
customFields: { BROWSER: 'Safari', COST: '1,5' },
},
project.workflow,
);
it('are normalised and kept; saving them unchanged is no change', () => {
const project = newProject();
project.updateSettings('owner', withFields());
expect(project.workflow.customFields[0]).toEqual({
id: 'BROWSER',
name: 'Navegador',
type: CustomFieldType.SELECT,
options: ['Chrome', 'Safari'],
archived: false,
});
expect(project.workflow.customFields[1].options).toEqual([]);
const before = project.getUncommittedEvents().length;
project.updateSettings('owner', structuredClone(project.settings));
// Clients that do not know about them leave them as they are.
const { customFields: _absent, ...older } = structuredClone(
project.settings,
);
project.updateSettings('owner', older);
expect(project.getUncommittedEvents()).toHaveLength(before);
expect(project.workflow.customFields).toHaveLength(4);
});
it('cannot be removed, change type or be a SELECT without options', () => {
const project = newProject();
project.updateSettings('owner', withFields());
const removed = { ...withFields(), customFields: [] };
expect(() => project.updateSettings('owner', removed)).toThrow(
/archívalo/i,
);
const retyped = withFields();
retyped.customFields![1].type = CustomFieldType.TEXT;
expect(() => project.updateSettings('owner', retyped)).toThrow(
/tipo no se puede cambiar/,
);
const empty = withFields();
empty.customFields![0].options = [' '];
expect(() => project.updateSettings('owner', empty)).toThrow(/opciones/);
});
it('check what a ticket holds, by type', () => {
const project = newProject();
project.updateSettings('owner', withFields());
const issue = newIssue(project);
expect(issue.customFields).toEqual({ BROWSER: 'Safari', COST: '1.5' });
const workflow = project.workflow;
const set = (customFields: Record<string, string | number | null>) =>
issue.updateDetails('dev', { customFields }, workflow);
expect(() => set({ BROWSER: 'Edge' })).toThrow(/no option/);
expect(() => set({ COST: 'mucho' })).toThrow(/number/);
expect(() => set({ GO_LIVE: '20/10/2026' })).toThrow(/date/);
expect(() => set({ CLIENT: 'x'.repeat(501) })).toThrow(/500/);
expect(() => set({ NOPE: 'x' })).toThrow(ValidationError);
set({ GO_LIVE: '2026-11-02', COST: 3, BROWSER: null });
const event = issue.getUncommittedEvents().at(-1) as IssueUpdatedEvent;
// The whole map, sorted: the projection just sets it.
expect(event.changes).toEqual({
customFields: { COST: '3', GO_LIVE: '2026-11-02' },
});
expect(Object.keys(event.changes.customFields!)).toEqual([
'COST',
'GO_LIVE',
]);
// Same values again: nothing happens.
const count = issue.getUncommittedEvents().length;
set({ COST: '3' });
expect(issue.getUncommittedEvents()).toHaveLength(count);
});
it('archived ones can be cleared but not set', () => {
const project = newProject();
project.updateSettings('owner', withFields());
const issue = newIssue(project);
const archived = withFields();
archived.customFields![0].archived = true;
project.updateSettings('owner', archived);
expect(() =>
issue.updateDetails(
'dev',
{ customFields: { BROWSER: 'Chrome' } },
project.workflow,
),
).toThrow(BusinessRuleError);
issue.updateDetails(
'dev',
{ customFields: { BROWSER: '' } },
project.workflow,
);
expect(issue.customFields).toEqual({ COST: '1.5' });
// Replaying the events gives the same ticket.
expect(replay(issue, Issue).rebuilt.customFields).toEqual({
COST: '1.5',
});
});
});
});

View File

@@ -94,6 +94,33 @@ export interface AreaOption {
archived: boolean;
}
/** What a custom field holds. */
export enum CustomFieldType {
TEXT = 'TEXT',
NUMBER = 'NUMBER',
/** `YYYY-MM-DD`. */
DATE = 'DATE',
/** One of `options`. */
SELECT = 'SELECT',
}
/**
* A field the project adds to its tickets («Navegador», «Cliente»…). Its
* type never changes once created, so values stay readable.
*/
export interface CustomFieldDefinition {
id: string;
name: string;
type: CustomFieldType;
/** The choices of a SELECT field; empty for the other types. */
options: string[];
archived: boolean;
}
export const MAX_CUSTOM_FIELDS = 30;
export const MAX_CUSTOM_OPTIONS = 50;
export const MAX_CUSTOM_TEXT = 500;
/** Per-project configuration of the tracker. */
export interface ProjectSettings {
/** In board order. New tickets start in the first active TODO status. */
@@ -102,6 +129,11 @@ export interface ProjectSettings {
transitions: Record<string, string[]>;
issueTypes: IssueTypeOption[];
areas: AreaOption[];
/**
* Absent in projects that never defined any. When saving, absent means
* unchanged (clients that predate them keep them).
*/
customFields?: CustomFieldDefinition[];
}
const status = (
@@ -291,6 +323,41 @@ export class ProjectWorkflow {
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
}
/** The project's custom fields, archived ones included. */
get customFields(): CustomFieldDefinition[] {
return this.settings.customFields ?? [];
}
/**
* A ticket's custom values after `changes`: each value checked against
* its field (null or empty clears it). Archived fields can be cleared but
* not set.
*/
customValues(
current: Record<string, string>,
changes: Record<string, string | number | null>,
): Record<string, string> {
const next = { ...current };
for (const [id, raw] of Object.entries(changes)) {
const field = this.customFields.find((f) => f.id === id);
if (!field) throw new ValidationError(`Unknown field ${id}`);
if (raw !== null && typeof raw !== 'string' && typeof raw !== 'number')
throw new ValidationError(`${field.name} has to be text`);
const value = raw === null ? null : String(raw).trim();
if (!value) {
delete next[id];
continue;
}
if (field.archived)
throw new BusinessRuleError(`Field ${field.name} is archived`);
next[id] = customValue(field, value);
}
// Sorted, so the same values always compare equal.
return Object.fromEntries(
Object.entries(next).sort(([a], [b]) => a.localeCompare(b)),
);
}
assertActiveArea(id: string | null | undefined): void {
if (id === null || id === undefined) return;
const area = this.settings.areas.find((a) => a.id === id);
@@ -336,11 +403,26 @@ export function validateProjectSettings(
name: normName(a.name),
archived: !!a.archived,
}));
const customFields = (next.customFields ?? previous.customFields ?? []).map(
normCustomField,
);
assertThat(
customFields.length <= MAX_CUSTOM_FIELDS,
`Un proyecto tiene como mucho ${MAX_CUSTOM_FIELDS} campos`,
);
for (const field of customFields) {
const before = previous.customFields?.find((f) => f.id === field.id);
assertThat(
!before || before.type === field.type,
`El campo «${field.name}» ya tiene valores: su tipo no se puede cambiar`,
);
}
for (const [label, list, before] of [
['estado', statuses, previous.statuses],
['tipo', issueTypes, previous.issueTypes],
['área', areas, previous.areas],
['campo', customFields, previous.customFields ?? []],
] as const) {
const ids = list.map((item) => item.id);
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
@@ -414,7 +496,69 @@ export function validateProjectSettings(
if (to.length) transitions[source] = to;
}
return { statuses, transitions, issueTypes, areas };
return {
statuses,
transitions,
issueTypes,
areas,
// Only when there are some: settings saved unchanged must compare equal.
...(customFields.length ? { customFields } : {}),
};
}
function normCustomField(field: CustomFieldDefinition): CustomFieldDefinition {
const name = normName(field.name);
assertThat(
Object.values(CustomFieldType).includes(field.type),
`Tipo no válido en el campo ${name}`,
);
const options =
field.type === CustomFieldType.SELECT
? [...new Set((field.options ?? []).map((o) => o.trim()).filter(Boolean))]
: [];
if (field.type === CustomFieldType.SELECT)
assertThat(
options.length > 0 &&
options.length <= MAX_CUSTOM_OPTIONS &&
options.every((o) => o.length <= 60),
`El campo «${name}» necesita de 1 a ${MAX_CUSTOM_OPTIONS} opciones de hasta 60 caracteres`,
);
return {
id: normId(field.id),
name,
type: field.type,
options,
archived: !!field.archived,
};
}
/** A value as its field keeps it (numbers and dates in a canonical form). */
function customValue(field: CustomFieldDefinition, value: string): string {
switch (field.type) {
case CustomFieldType.NUMBER: {
const number = Number(value.replace(',', '.'));
assertThat(Number.isFinite(number), `${field.name} has to be a number`);
return String(number);
}
case CustomFieldType.DATE:
assertThat(
/^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value)),
`${field.name} has to be a date (YYYY-MM-DD)`,
);
return value;
case CustomFieldType.SELECT:
assertThat(
field.options.includes(value),
`${field.name} has no option «${value}»`,
);
return value;
default:
assertThat(
value.length <= MAX_CUSTOM_TEXT,
`${field.name} holds ${MAX_CUSTOM_TEXT} characters at most`,
);
return value;
}
}
function normTemplate(

View File

@@ -77,6 +77,8 @@ export class IssueReadModel {
storyPoints!: number | null;
startDate!: string | null;
dueDate!: string | null;
/** The project's custom fields: field id → value (only those with one). */
customFields!: Record<string, string>;
links!: IssueLinkReadModel[];
attachments!: AttachmentReadModel[];
/** People following the ticket. */

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
CalendarFeed,
CalendarFeedsRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { CalendarFeedDoc, ReadModels } from './read-model.schemas';
@Injectable()
export class MongoCalendarFeedsRepository extends CalendarFeedsRepository {
constructor(
@InjectModel(ReadModels.CalendarFeed)
private readonly feeds: Model<CalendarFeedDoc>,
) {
super();
}
async findByUser(userId: string): Promise<CalendarFeed | null> {
const doc = await this.feeds
.findOne({ id: userId })
.lean<CalendarFeedDoc>();
return doc ? toFeed(doc) : null;
}
async findByToken(token: string): Promise<CalendarFeed | null> {
const doc = await this.feeds.findOne({ token }).lean<CalendarFeedDoc>();
return doc ? toFeed(doc) : null;
}
async save(feed: CalendarFeed): Promise<void> {
await this.feeds.updateOne(
{ id: feed.userId },
{ $set: { token: feed.token, createdAt: feed.createdAt } },
{ upsert: true },
);
}
async remove(userId: string): Promise<void> {
await this.feeds.deleteOne({ id: userId });
}
}
function toFeed(doc: CalendarFeedDoc): CalendarFeed {
return { userId: doc.id, token: doc.token, createdAt: doc.createdAt };
}

View File

@@ -266,6 +266,25 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
return docs.map(issueFromDoc);
}
async findDatedFor(
userId: string,
projectIds: string[],
): Promise<IssueReadModel[]> {
if (projectIds.length === 0) return [];
const docs = await this.issues
.find({
projectId: { $in: projectIds },
dueDate: { $ne: null },
trashedAt: null,
statusCategory: { $nin: FINISHED },
$or: [{ 'assignee.id': userId }, { 'watchers.id': userId }],
})
.sort({ dueDate: 1 })
.limit(1000)
.lean<IssueDoc[]>();
return docs.map(issueFromDoc);
}
async findByProject(projectId: string): Promise<IssueReadModel[]> {
const docs = await this.issues
.find({ projectId, trashedAt: null })
@@ -430,6 +449,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
// Full ISO dates on the last day still count.
...(filter.dueTo ? { $lte: `${filter.dueTo}\uffff` } : {}),
};
// Only field ids: a key never becomes an operator.
for (const [field, value] of Object.entries(filter.customFields ?? {}))
if (/^[A-Z][A-Z0-9_]{0,29}$/.test(field) && typeof value === 'string')
query[`customFields.${field}`] = value;
if (filter.noSprint) query['sprint'] = null;
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
if (filter.unassigned) query['assignee'] = null;

View File

@@ -0,0 +1,51 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
PushSubscriptionRecord,
PushSubscriptionsRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { PushSubscriptionDoc, ReadModels } from './read-model.schemas';
/** Endpoints are long URLs: their hash makes a tidy, unique `id`. */
const idOf = (endpoint: string) =>
createHash('sha256').update(endpoint).digest('hex');
@Injectable()
export class MongoPushSubscriptionsRepository extends PushSubscriptionsRepository {
constructor(
@InjectModel(ReadModels.PushSubscription)
private readonly subscriptions: Model<PushSubscriptionDoc>,
) {
super();
}
async forUser(userId: string): Promise<PushSubscriptionRecord[]> {
const docs = await this.subscriptions
.find({ userId })
.lean<PushSubscriptionDoc[]>();
return docs.map(({ userId: owner, endpoint, keys, createdAt }) => ({
userId: owner,
endpoint,
keys,
createdAt,
}));
}
async save(subscription: PushSubscriptionRecord): Promise<void> {
await this.subscriptions.updateOne(
{ id: idOf(subscription.endpoint) },
{ $set: subscription },
{ upsert: true },
);
}
async remove(userId: string, endpoint: string): Promise<void> {
await this.subscriptions.deleteOne({ id: idOf(endpoint), userId });
}
async forget(endpoint: string): Promise<void> {
await this.subscriptions.deleteOne({ id: idOf(endpoint) });
}
}

View File

@@ -64,6 +64,20 @@ export interface GitIntegrationDoc {
secret?: string;
createdAt?: string;
}
export interface PushSubscriptionDoc {
/** sha256 of the endpoint. */
id: string;
userId: string;
endpoint: string;
keys: { p256dh: string; auth: string };
createdAt: string;
}
export interface CalendarFeedDoc {
/** The user's id. */
id: string;
token: string;
createdAt: string;
}
export interface NotificationSettingsDoc {
/** The user's id. */
id: string;
@@ -85,6 +99,8 @@ export const ReadModels = {
OutgoingWebhook: 'OutgoingWebhook',
Automation: 'Automation',
AutomationRun: 'AutomationRun',
CalendarFeed: 'CalendarFeed',
PushSubscription: 'PushSubscription',
} as const;
/*
@@ -183,6 +199,8 @@ class IssueDocument {
@Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] })
attachments!: AttachmentProp[];
@Prop({ type: [String] }) labels!: string[];
/** Field id → value: filtered as `customFields.<id>`. */
@Prop({ type: Object }) customFields!: Record<string, string>;
@Prop({ type: Number }) version!: number;
}
export const IssueSchema = SchemaFactory.createForClass(IssueDocument);
@@ -329,6 +347,31 @@ class AutomationRunDocument {
export const AutomationRunSchema = SchemaFactory.createForClass(
AutomationRunDocument,
);
/** One per person: the secret of their calendar URL. */
@Schema({ ...LOOSE, collection: 'calendar_feeds' })
class CalendarFeedDocument {
/** The user's id. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, required: true, unique: true }) token!: string;
@Prop({ type: String }) createdAt!: string;
}
export const CalendarFeedSchema =
SchemaFactory.createForClass(CalendarFeedDocument);
/** The devices where each person turned push on. */
@Schema({ ...LOOSE, collection: 'push_subscriptions' })
class PushSubscriptionDocument {
/** sha256 of the endpoint. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) userId!: string;
@Prop({ type: String }) endpoint!: string;
@Prop({ type: Object }) keys!: { p256dh: string; auth: string };
@Prop({ type: String }) createdAt!: string;
}
export const PushSubscriptionSchema = SchemaFactory.createForClass(
PushSubscriptionDocument,
);
// Redeliveries happen within minutes; a month of memory is plenty.
AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 });
@@ -347,6 +390,8 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Notification, schema: NotificationSchema },
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
];
/** A document as the model it holds: Mongo's own `_id` left out. */
@@ -370,6 +415,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
parent: rest.parent?.id ? rest.parent : null,
rank: rest.rank ?? implicitRank(rest.number),
startDate: rest.startDate ?? null,
customFields: rest.customFields ?? {},
sprint: rest.sprint?.id ? rest.sprint : null,
// Links recorded before projects had several repositories name none.
devLinks: (rest.devLinks ?? []).map((link) => ({

View File

@@ -0,0 +1,110 @@
import { createECDH, randomBytes } from 'node:crypto';
import { ConfigService } from '@nestjs/config';
import * as webpush from 'web-push';
import { WebPushSender } from './web-push.sender';
// The real library, but nothing leaves the machine.
jest.mock('web-push', () => ({
...jest.requireActual('web-push'),
sendNotification: jest.fn(),
}));
const sendNotification = webpush.sendNotification as jest.MockedFunction<
typeof webpush.sendNotification
>;
/** What a browser hands over: its P-256 public key and an auth secret. */
function device(endpoint = 'https://push.example/abc') {
const ecdh = createECDH('prime256v1');
ecdh.generateKeys();
return {
userId: 'ana',
endpoint,
keys: {
p256dh: ecdh.getPublicKey().toString('base64url'),
auth: randomBytes(16).toString('base64url'),
},
createdAt: '2026-10-15T00:00:00.000Z',
};
}
const VAPID = webpush.generateVAPIDKeys();
function sender(withKeys = true) {
const values: Record<string, string> = withKeys
? {
VAPID_PUBLIC_KEY: VAPID.publicKey,
VAPID_PRIVATE_KEY: VAPID.privateKey,
VAPID_SUBJECT: 'mailto:test@example.org',
}
: {};
return new WebPushSender({
get: (key: string) => values[key],
} as unknown as ConfigService);
}
const PAYLOAD = {
title: 'TaskStream · WEB-1',
body: 'Ana te asignó WEB-1',
url: '/p/WEB/issues/WEB-1',
tag: 'n-1',
};
describe('WebPushSender', () => {
afterEach(() => sendNotification.mockReset());
it('is off without VAPID keys, on with them', () => {
expect(sender(false).publicKey).toBeNull();
expect(sender().publicKey).toBe(VAPID.publicKey);
});
it('builds an encrypted, VAPID-signed message for the device', () => {
sender();
const subscriber = device();
const request = webpush.generateRequestDetails(
{ endpoint: subscriber.endpoint, keys: subscriber.keys },
JSON.stringify(PAYLOAD),
{ TTL: 86_400 },
);
expect(request.headers['Content-Encoding']).toBe('aes128gcm');
expect(String(request.headers['Authorization'])).toMatch(
new RegExp(`^vapid t=.+, k=${VAPID.publicKey}$`),
);
// Encrypted: nothing of the text travels in the clear.
expect(String(request.body)).not.toContain('WEB-1');
});
it('sends the payload as JSON and keeps it a day', async () => {
sendNotification.mockResolvedValue({
statusCode: 201,
body: '',
headers: {},
});
expect(await sender().send(device(), PAYLOAD)).toBe('sent');
const [target, body, options] = sendNotification.mock.calls[0];
expect(target.endpoint).toBe('https://push.example/abc');
expect(JSON.parse(String(body))).toEqual(PAYLOAD);
expect(options).toMatchObject({ TTL: 86_400 });
});
it('tells a subscription the push service dropped (404, 410)', async () => {
for (const status of [404, 410]) {
sendNotification.mockRejectedValueOnce(
new webpush.WebPushError(
'gone',
status,
{},
'',
'https://push.example/abc',
),
);
expect(await sender().send(device(), PAYLOAD)).toBe('gone');
}
});
it('lets other failures through, to be logged', async () => {
sendNotification.mockRejectedValue(
new webpush.WebPushError('busy', 503, {}, '', 'https://push.example/abc'),
);
await expect(sender().send(device(), PAYLOAD)).rejects.toThrow('busy');
});
});

View File

@@ -0,0 +1,61 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
PushPayload,
PushSender,
PushSubscriptionRecord,
} from '@task-stream/api/tracker/application';
import * as webpush from 'web-push';
/**
* Web Push with VAPID keys (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and
* VAPID_SUBJECT, a `mailto:` or https URL the push services can reach
* someone at). Make the keys once with `npx web-push generate-vapid-keys`;
* changing them drops every subscription. Without them push is off.
*/
@Injectable()
export class WebPushSender extends PushSender {
private readonly logger = new Logger(WebPushSender.name);
private readonly key: string | null;
constructor(config: ConfigService) {
super();
const publicKey = config.get<string>('VAPID_PUBLIC_KEY') || '';
const privateKey = config.get<string>('VAPID_PRIVATE_KEY') || '';
const subject =
config.get<string>('VAPID_SUBJECT') || 'mailto:no-reply@pireon.dev';
this.key = publicKey && privateKey ? publicKey : null;
if (this.key) {
webpush.setVapidDetails(subject, publicKey, privateKey);
this.logger.log('Push notifications enabled');
}
}
get publicKey(): string | null {
return this.key;
}
async send(
subscription: PushSubscriptionRecord,
payload: PushPayload,
): Promise<'sent' | 'gone'> {
if (!this.key) return 'sent';
try {
await webpush.sendNotification(
{ endpoint: subscription.endpoint, keys: subscription.keys },
JSON.stringify(payload),
// Kept a day by the push service while the device is off.
{ TTL: 86_400, urgency: 'normal' },
);
return 'sent';
} catch (error) {
// 404 / 410: that browser dropped the subscription for good.
if (
error instanceof webpush.WebPushError &&
(error.statusCode === 404 || error.statusCode === 410)
)
return 'gone';
throw error;
}
}
}

View File

@@ -7,6 +7,9 @@ import {
ChangeFeed,
NotificationMailer,
NotificationsRepository,
CalendarFeedsRepository,
PushSubscriptionsRepository,
PushSender,
SavedFiltersRepository,
GitIntegrationsRepository,
OutgoingWebhooksRepository,
@@ -65,6 +68,9 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
import { RedisChangeFeed } from './realtime/redis-change-feed';
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
import { WebPushSender } from './push/web-push.sender';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
@@ -130,6 +136,12 @@ const PORTS = [
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
{ provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository },
{
provide: PushSubscriptionsRepository,
useClass: MongoPushSubscriptionsRepository,
},
{ provide: PushSender, useClass: WebPushSender },
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
{
provide: GitIntegrationsRepository,

View File

@@ -1,10 +1,37 @@
import { IssueDetails } from '@task-stream/api/tracker/domain';
/** Clients may send explicit nulls; the domain only accepts them on nullable fields. */
export function withoutNulls<T extends Partial<IssueDetails>>(input: T): T {
const details = { ...input };
for (const field of ['title', 'type', 'priority', 'labels'] as const) {
if (details[field] === null) delete details[field];
}
return details;
/** A custom value as GraphQL carries it (a map is not a GraphQL type). */
export interface CustomFieldValue {
fieldId: string;
value?: string | null;
}
/** Custom values as pairs (GraphQL) or as a map (REST) → the domain's map. */
export function customFieldsOf(
values: CustomFieldValue[] | Record<string, string | number | null>,
): Record<string, string | number | null> {
return Array.isArray(values)
? Object.fromEntries(values.map((v) => [v.fieldId, v.value ?? null]))
: values;
}
type IssueInput = Partial<Omit<IssueDetails, 'customFields'>> & {
customFields?:
| CustomFieldValue[]
| Record<string, string | number | null>
| null;
};
/** Clients may send explicit nulls; the domain only accepts them on nullable fields. */
export function withoutNulls<T extends IssueInput>(
input: T,
): Omit<T, 'customFields'> & Pick<IssueDetails, 'customFields'> {
const { customFields, ...rest } = input;
const details: Record<string, unknown> = rest;
for (const field of ['title', 'type', 'priority', 'labels'])
if (details[field] === null) delete details[field];
return {
...(details as Omit<T, 'customFields'>),
...(customFields ? { customFields: customFieldsOf(customFields) } : {}),
};
}

View File

@@ -1,4 +1,5 @@
import {
CustomFieldType,
ProjectSettings,
SettingColor,
StatusCategory,
@@ -26,11 +27,20 @@ export interface SettingsPayload {
template?: { description?: string | null; checklist?: string[] } | null;
}[];
areas: { id: string; name: string; archived: boolean }[];
/** Absent when saving: unchanged. */
customFields?: {
id: string;
name: string;
type: string;
options?: string[];
archived: boolean;
}[];
}
export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
return {
...settings,
customFields: settings.customFields ?? [],
transitions: settings.statuses.map((s) => ({
from: s.id,
to: settings.transitions[s.id] ?? [],
@@ -62,5 +72,14 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
: {}),
})),
areas: payload.areas,
...(payload.customFields
? {
customFields: payload.customFields.map((f) => ({
...f,
type: f.type as CustomFieldType,
options: f.options ?? [],
})),
}
: {}),
};
}

View File

@@ -0,0 +1,32 @@
import { Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { CalendarService } from '@task-stream/api/tracker/application';
import { CalendarFeedGql } from './types';
/** The signed-in person's calendar subscription (iCalendar URL). */
@Resolver(() => CalendarFeedGql)
export class CalendarResolver {
constructor(private readonly calendar: CalendarService) {}
@Query(() => CalendarFeedGql, {
nullable: true,
description: 'My calendar URL, if I made one',
})
myCalendarFeed(@CurrentUser() me: User) {
return this.calendar.mine(me.id);
}
@Mutation(() => CalendarFeedGql, {
description: 'A new secret calendar URL; the previous one stops working',
})
createCalendarFeed(@CurrentUser() me: User) {
return this.calendar.create(me.id);
}
@Mutation(() => Boolean, { description: 'My calendar URL stops working' })
async revokeCalendarFeed(@CurrentUser() me: User) {
await this.calendar.revoke(me.id);
return true;
}
}

View File

@@ -5,6 +5,7 @@ import {
IssueSort,
} from '@task-stream/api/tracker/application';
import {
CustomFieldType,
IssueLinkType,
IssuePriority,
NotificationKind,
@@ -22,6 +23,10 @@ export enum LinkDirection {
}
registerEnumType(IssuePriority, { name: 'IssuePriority' });
registerEnumType(CustomFieldType, {
name: 'CustomFieldType',
description: 'What a custom field holds (DATE: YYYY-MM-DD)',
});
registerEnumType(StatusCategory, {
name: 'StatusCategory',
description:

View File

@@ -1,5 +1,6 @@
import { Field, ID, InputType, Int } from '@nestjs/graphql';
import {
CustomFieldType,
IssueLinkType,
IssuePriority,
ProjectRole,
@@ -95,6 +96,16 @@ export class ProjectMemberInput {
role!: ProjectRole;
}
@InputType({ description: "A custom field's value; null or '' clears it" })
export class CustomFieldValueInput {
@Field() @Matches(SETTING_ID) fieldId!: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
value?: string | null;
}
@InputType({ isAbstract: true })
abstract class IssueFieldsInput {
@Field(() => String, { nullable: true })
@@ -146,6 +157,17 @@ abstract class IssueFieldsInput {
@Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?:
| string
| null;
@Field(() => [CustomFieldValueInput], {
nullable: true,
description: 'Only the fields to change',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(30)
@ValidateNested({ each: true })
@Type(() => CustomFieldValueInput)
customFields?: CustomFieldValueInput[];
}
@InputType()
@@ -537,6 +559,16 @@ export class IssueFilterInput {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dueTo?: string;
@Field(() => [CustomFieldValueInput], {
nullable: true,
description: 'Only tickets holding these custom values',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(30)
@ValidateNested({ each: true })
@Type(() => CustomFieldValueInput)
customFields?: CustomFieldValueInput[];
@Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT })
@IsOptional()
@IsEnum(IssueSort)
@@ -656,6 +688,23 @@ export class AreaOptionInput {
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
}
@InputType()
export class CustomFieldDefinitionInput {
@Field() @Matches(SETTING_ID) id!: string;
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
@Field(() => CustomFieldType) @IsEnum(CustomFieldType) type!: CustomFieldType;
@Field(() => [String], {
defaultValue: [],
description: 'The choices of a SELECT field',
})
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
@MaxLength(60, { each: true })
options!: string[];
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
}
@InputType({
description:
'The whole configuration (replaces the previous one). Statuses go in board order.',
@@ -688,4 +737,26 @@ export class ProjectSettingsInput {
@ValidateNested({ each: true })
@Type(() => AreaOptionInput)
areas!: AreaOptionInput[];
@Field(() => [CustomFieldDefinitionInput], {
nullable: true,
description: 'Absent: unchanged. Fields cannot be removed: archive them.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(30)
@ValidateNested({ each: true })
@Type(() => CustomFieldDefinitionInput)
customFields?: CustomFieldDefinitionInput[];
}
@InputType({
description: "A browser's Web Push subscription (PushSubscription.toJSON())",
})
export class PushSubscriptionInput {
@Field()
@IsUrl({ protocols: ['https'], require_tld: false })
endpoint!: string;
@Field() @IsString() @MaxLength(200) p256dh!: string;
@Field() @IsString() @MaxLength(100) auth!: string;
}

View File

@@ -43,6 +43,7 @@ import {
CommandResultGql,
CommentPageGql,
EpicGql,
CustomFieldValueGql,
IssueGql,
IssuePageGql,
IssueParentGql,
@@ -75,8 +76,10 @@ export class IssueResolver {
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
) {
const { customFields, ...rest } = filter;
return this.issuesService.search(viewerOf(me), {
...filter,
...rest,
customFields: onlyValues(customFields),
limit,
offset,
});
@@ -150,6 +153,16 @@ export class IssueResolver {
});
}
@ResolveField(() => [CustomFieldValueGql], {
description: "Values of the project's custom fields (only those set)",
})
customFields(@Parent() issue: IssueReadModel): CustomFieldValueGql[] {
return Object.entries(issue.customFields ?? {}).map(([fieldId, value]) => ({
fieldId,
value,
}));
}
@ResolveField(() => [String], {
description: "Statuses this ticket can move to next (project's workflow)",
})
@@ -432,3 +445,13 @@ export class IssueResolver {
return this.issuesService.setRelease(me.id, issueId, releaseId ?? null);
}
}
/** A filter's custom values: the ones that name something to match. */
function onlyValues(
values: { fieldId: string; value?: string | null }[] | undefined,
): Record<string, string> | undefined {
const pairs = (values ?? []).filter((v) => v.value);
return pairs.length
? Object.fromEntries(pairs.map((v) => [v.fieldId, v.value as string]))
: undefined;
}

View File

@@ -7,6 +7,7 @@ import {
NotificationGql,
NotificationSettingsGql,
} from './types';
import { PushSubscriptionInput } from './inputs';
/** The signed-in person's notifications. */
@Resolver(() => NotificationGql)
@@ -40,6 +41,27 @@ export class NotificationResolver {
return true;
}
@Mutation(() => Boolean, { description: 'Push on this device' })
async subscribePush(
@CurrentUser() me: User,
@Args('subscription') subscription: PushSubscriptionInput,
) {
await this.notifications.subscribePush(me.id, subscription.endpoint, {
p256dh: subscription.p256dh,
auth: subscription.auth,
});
return true;
}
@Mutation(() => Boolean, { description: 'Push off on this device' })
async unsubscribePush(
@CurrentUser() me: User,
@Args('endpoint') endpoint: string,
) {
await this.notifications.unsubscribePush(me.id, endpoint);
return true;
}
@Mutation(() => NotificationSettingsGql)
updateNotificationSettings(
@CurrentUser() me: User,

View File

@@ -1,5 +1,6 @@
import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
import {
CustomFieldType,
IssueLinkType,
IssuePriority,
NotificationKind,
@@ -84,14 +85,36 @@ export class AreaOptionGql {
@Field() archived!: boolean;
}
@ObjectType('CustomFieldDefinition', {
description:
'A field the project adds to its tickets; its type never changes',
})
export class CustomFieldDefinitionGql {
@Field() id!: string;
@Field() name!: string;
@Field(() => CustomFieldType) type!: CustomFieldType;
@Field(() => [String], { description: 'The choices of a SELECT field' })
options!: string[];
@Field() archived!: boolean;
}
@ObjectType('CustomFieldValue', { description: "A ticket's value of a field" })
export class CustomFieldValueGql {
@Field() fieldId!: string;
@Field() value!: string;
}
@ObjectType('ProjectSettings', {
description: 'Statuses (board order), transitions, ticket types and areas',
description:
'Statuses (board order), transitions, ticket types, areas and custom fields',
})
export class ProjectSettingsGql {
@Field(() => [WorkflowStatusGql]) statuses!: WorkflowStatusGql[];
@Field(() => [StatusTransitionsGql]) transitions!: StatusTransitionsGql[];
@Field(() => [IssueTypeOptionGql]) issueTypes!: IssueTypeOptionGql[];
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
@Field(() => [CustomFieldDefinitionGql])
customFields!: CustomFieldDefinitionGql[];
}
@ObjectType('Project')
@@ -625,6 +648,16 @@ export class MyNotificationsGql {
unread!: number;
}
@ObjectType('CalendarFeed')
export class CalendarFeedGql {
@Field({
description:
'Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session)',
})
token!: string;
@Field() createdAt!: string;
}
@ObjectType('NotificationSettings')
export class NotificationSettingsGql {
@Field({ description: 'Copies by e-mail' }) email!: boolean;
@@ -632,6 +665,12 @@ export class NotificationSettingsGql {
description: 'Whether this installation can send e-mail at all',
})
emailAvailable!: boolean;
@Field(() => String, {
nullable: true,
description:
'VAPID public key to subscribe a device to push with; null: no push here',
})
pushPublicKey!: string | null;
}
@ObjectType('CommandResult', {

View File

@@ -0,0 +1,96 @@
import {
Controller,
Delete,
Get,
Header,
HttpCode,
NotFoundException,
Param,
Post,
Res,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiProduces,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
Public,
} from '@task-stream/api/identity/presenters';
import { CalendarService } from '@task-stream/api/tracker/application';
import type { Response } from 'express';
import { CalendarFeedDto } from './dto/responses.dto';
/**
* A person's calendar as iCalendar, for Google Calendar, Outlook or Apple
* Calendar to subscribe to. The URL carries a secret token instead of a
* session (calendar apps cannot sign in); the person makes, renews or
* drops it under /me/calendar.
*/
@ApiTags('Users')
@Controller()
export class CalendarController {
constructor(
private readonly calendar: CalendarService,
private readonly config: ConfigService,
) {}
@Get('calendar/:file')
@Public()
@Header('Content-Type', 'text/calendar; charset=utf-8')
@Header('Cache-Control', 'private, max-age=300')
@ApiProduces('text/calendar')
@ApiOperation({
summary: 'A calendar to subscribe to (`{token}.ics`, no session)',
description:
'Unfinished tickets with a due date (assigned or followed), planned and running sprints, and planned releases with a date, as all-day events.',
})
async ics(@Param('file') file: string): Promise<string> {
const token = /^([\w-]{20,100})\.ics$/.exec(file)?.[1];
const body = token
? await this.calendar.ics(token, this.config.get<string>('WEB_URL') ?? '')
: null;
if (body === null) throw new NotFoundException('Unknown calendar');
return body;
}
@Get('me/calendar')
@ApiPireonAuth()
@ApiOperation({ summary: 'My calendar URL, if I made one' })
@ApiOkResponse({ type: CalendarFeedDto })
@ApiNoContentResponse({ description: 'I have none' })
async mine(
@CurrentUser() me: User,
@Res({ passthrough: true }) response: Response,
) {
const feed = await this.calendar.mine(me.id);
if (!feed) response.status(204);
return feed ?? undefined;
}
@Post('me/calendar')
@ApiPireonAuth()
@ApiOperation({
summary: 'A new secret calendar URL (the previous one stops working)',
})
@ApiOkResponse({ type: CalendarFeedDto })
@HttpCode(200)
create(@CurrentUser() me: User) {
return this.calendar.create(me.id);
}
@Delete('me/calendar')
@ApiPireonAuth()
@ApiOperation({ summary: 'My calendar URL stops working' })
@ApiNoContentResponse()
@HttpCode(204)
async revoke(@CurrentUser() me: User): Promise<void> {
await this.calendar.revoke(me.id);
}
}

View File

@@ -5,6 +5,7 @@ import {
PartialType,
} from '@nestjs/swagger';
import {
CustomFieldType,
IssueLinkType,
IssuePriority,
ProjectRole,
@@ -23,6 +24,7 @@ import {
IsIn,
IsInt,
IsISO8601,
IsObject,
IsOptional,
IsUUID,
IsString,
@@ -230,6 +232,38 @@ export class AreaOptionBodyDto {
archived = false;
}
export class CustomFieldDefinitionBodyDto {
@ApiProperty({ example: 'BROWSER' }) @Matches(SETTING_ID) id!: string;
@ApiProperty({ example: 'Navegador', maxLength: 40 })
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiProperty({
enum: CustomFieldType,
enumName: 'CustomFieldType',
description: 'Cannot change once the field exists. DATE: YYYY-MM-DD',
})
@IsEnum(CustomFieldType)
type!: CustomFieldType;
@ApiPropertyOptional({
type: [String],
maxItems: 50,
example: ['Chrome', 'Safari', 'Firefox'],
description: 'The choices of a SELECT field',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
@MaxLength(60, { each: true })
options: string[] = [];
@ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional()
@IsBoolean()
archived = false;
}
export class ProjectSettingsBodyDto {
@ApiProperty({
type: [WorkflowStatusBodyDto],
@@ -262,6 +296,17 @@ export class ProjectSettingsBodyDto {
@ValidateNested({ each: true })
@Type(() => AreaOptionBodyDto)
areas!: AreaOptionBodyDto[];
@ApiPropertyOptional({
type: [CustomFieldDefinitionBodyDto],
description: 'Absent: unchanged. Fields cannot be removed: archive them.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(30)
@ValidateNested({ each: true })
@Type(() => CustomFieldDefinitionBodyDto)
customFields?: CustomFieldDefinitionBodyDto[];
}
export class AnalyticsQueryDto {
@@ -553,6 +598,23 @@ export class IssueSearchQueryDto extends PageQueryDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dueTo?: string;
@ApiPropertyOptional({
type: [String],
example: ['BROWSER:Safari'],
description:
'Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several',
})
@IsOptional()
@Transform(({ value }) =>
value === undefined
? undefined
: ([] as unknown[]).concat(value).map(String),
)
@IsArray()
@ArrayMaxSize(30)
@Matches(/^[A-Za-z][A-Za-z0-9_]{0,29}:[\s\S]+$/, { each: true })
field?: string[];
@ApiPropertyOptional({
enum: IssueSort,
enumName: 'IssueSort',
@@ -633,6 +695,17 @@ class IssueFieldsDto {
@IsOptional()
@IsISO8601()
dueDate?: string | null;
@ApiPropertyOptional({
type: 'object',
additionalProperties: { type: 'string', nullable: true },
example: { BROWSER: 'Safari' },
description:
"Custom field id → value. Only the fields to change; null or '' clears one",
})
@IsOptional()
@IsObject()
customFields?: Record<string, string | number | null>;
}
export class CreateIssueDto extends IssueFieldsDto {
@@ -1212,6 +1285,31 @@ export class NotificationSettingsDto {
email!: boolean;
}
export class PushSubscriptionDto {
@ApiProperty({
description: 'The push service URL (PushSubscription.endpoint)',
})
@IsUrl({ protocols: ['https'], require_tld: false })
endpoint!: string;
@ApiProperty({ description: 'keys.p256dh of the subscription' })
@IsString()
@MaxLength(200)
p256dh!: string;
@ApiProperty({ description: 'keys.auth of the subscription' })
@IsString()
@MaxLength(100)
auth!: string;
}
export class PushEndpointDto {
@ApiProperty({ description: 'The endpoint of the device to turn off' })
@IsString()
@MaxLength(2000)
endpoint!: string;
}
// ---- Git --------------------------------------------------------------------
export class AddGitRepositoryDto {

View File

@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
CustomFieldType,
IssueLinkType,
IssuePriority,
NotificationKind,
@@ -78,6 +79,20 @@ export class AreaOptionDto {
@ApiProperty() archived!: boolean;
}
export class CustomFieldDefinitionDto {
@ApiProperty({ example: 'BROWSER' }) id!: string;
@ApiProperty({ example: 'Navegador' }) name!: string;
@ApiProperty({
enum: CustomFieldType,
enumName: 'CustomFieldType',
description: 'DATE values are YYYY-MM-DD',
})
type!: CustomFieldType;
@ApiProperty({ type: [String], description: 'The choices of a SELECT field' })
options!: string[];
@ApiProperty() archived!: boolean;
}
export class ProjectSettingsDto {
@ApiProperty({ type: [WorkflowStatusDto], description: 'In board order' })
statuses!: WorkflowStatusDto[];
@@ -86,6 +101,8 @@ export class ProjectSettingsDto {
@ApiProperty({ type: [IssueTypeOptionDto] })
issueTypes!: IssueTypeOptionDto[];
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
@ApiProperty({ type: [CustomFieldDefinitionDto] })
customFields!: CustomFieldDefinitionDto[];
}
export class ProjectDto {
@@ -426,6 +443,13 @@ export class IssueDto {
startDate!: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
dueDate!: string | null;
@ApiProperty({
type: 'object',
additionalProperties: { type: 'string' },
example: { BROWSER: 'Safari' },
description: 'Custom field id → value (only those set)',
})
customFields!: Record<string, string>;
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
@@ -831,8 +855,24 @@ export class MyNotificationsDto {
unread!: number;
}
export class CalendarFeedDto {
@ApiProperty({
description:
'Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session)',
})
token!: string;
@ApiProperty() createdAt!: string;
}
export class NotificationSettingsResponseDto {
@ApiProperty({ description: 'Copies by e-mail' }) email!: boolean;
@ApiProperty({ description: 'Whether this installation can send e-mail' })
emailAvailable!: boolean;
@ApiProperty({
type: String,
nullable: true,
description:
'VAPID public key to subscribe a device to push with; null: no push here',
})
pushPublicKey!: string | null;
}

View File

@@ -171,6 +171,14 @@ export class IssuesController {
watching: query.watching,
dueFrom: query.dueFrom,
dueTo: query.dueTo,
customFields: query.field?.length
? Object.fromEntries(
query.field.map((f) => [
f.slice(0, f.indexOf(':')),
f.slice(f.indexOf(':') + 1),
]),
)
: undefined,
sort: query.sort,
search: query.search,
limit: query.limit,

View File

@@ -1,5 +1,19 @@
import { Body, Controller, Get, Post, Put, Query } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
@@ -14,6 +28,8 @@ import {
MarkNotificationsReadDto,
MyNotificationsQueryDto,
NotificationSettingsDto,
PushEndpointDto,
PushSubscriptionDto,
} from './dto/requests.dto';
import {
MyNotificationsDto,
@@ -67,4 +83,32 @@ export class NotificationsController {
) {
return this.notifications.updateSettings(me.id, body);
}
@Post('push-subscriptions')
@HttpCode(204)
@ApiOperation({
summary: "Push on this device (a browser's Web Push subscription)",
})
@ApiNoContentResponse()
@ApiCommandErrors()
async subscribePush(
@CurrentUser() me: User,
@Body() body: PushSubscriptionDto,
): Promise<void> {
await this.notifications.subscribePush(me.id, body.endpoint, {
p256dh: body.p256dh,
auth: body.auth,
});
}
@Delete('push-subscriptions')
@HttpCode(204)
@ApiOperation({ summary: 'Push off on this device' })
@ApiNoContentResponse()
async unsubscribePush(
@CurrentUser() me: User,
@Body() body: PushEndpointDto,
): Promise<void> {
await this.notifications.unsubscribePush(me.id, body.endpoint);
}
}

View File

@@ -13,6 +13,8 @@ import { CommentsController } from './http/comments.controller';
import { IssuesController } from './http/issues.controller';
import { ExploreController } from './http/explore.controller';
import { ProposalLimiter } from './http/proposal-limiter';
import { CalendarController } from './http/calendar.controller';
import { CalendarResolver } from './graphql/calendar.resolver';
import { ReminderScheduler } from './schedulers/reminder.scheduler';
import { TrashScheduler } from './schedulers/trash.scheduler';
import { ProjectsController } from './http/projects.controller';
@@ -51,6 +53,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
}),
],
controllers: [
CalendarController,
ProjectsController,
IssuesController,
AttachmentsController,
@@ -67,6 +70,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
providers: [
Refs,
ProposalLimiter,
CalendarResolver,
// Timers: entry points like the HTTP and GraphQL ones.
ReminderScheduler,
TrashScheduler,

View File

@@ -1,43 +1,12 @@
/** Internal type. DO NOT USE DIRECTLY. */
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
/** Internal type. DO NOT USE DIRECTLY. */
export type Incremental<T> =
| T
| {
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
};
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type MeQueryVariables = Exact<{ [key: string]: never }>;
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
export type MeQuery = {
me: { id: string; name: string; email: string; appRoles: Array<string> };
};
export const MeDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
{ kind: 'Field', name: { kind: 'Name', value: 'appRoles' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<MeQuery, MeQueryVariables>;
export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array<string> } };
export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"appRoles"}}]}}]}}]} as unknown as DocumentNode<MeQuery, MeQueryVariables>;

View File

@@ -1,5 +1,7 @@
export * from './lib/notifications';
export * from './lib/facades/projects.facade';
export * from './lib/facades/calendar.facade';
export * from './lib/facades/push.facade';
export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade';
export * from './lib/facades/issue.facade';

View File

@@ -0,0 +1,56 @@
import { inject, Injectable, signal } from '@angular/core';
import { CalendarFeed, CalendarGateway } from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
/**
* My calendar subscription for presenters: the secret URL to paste into a
* calendar app, made, renewed or dropped here. Per person and seldom
* touched, so it lives in this facade alone, without a store.
*/
@Injectable({ providedIn: 'root' })
export class CalendarFacade {
private readonly gateway = inject(CalendarGateway);
readonly feed = signal<CalendarFeed | null>(null);
readonly loaded = signal(false);
readonly busy = signal(false);
readonly error = signal<string | null>(null);
load(): void {
this.run(this.gateway.mine());
}
/** A new URL: the previous one, if any, stops working. */
create(): void {
this.run(this.gateway.create());
}
revoke(): void {
this.busy.set(true);
this.gateway.revoke().subscribe({
next: () => this.done(null),
error: () => this.failed(),
});
}
private run(call: Observable<CalendarFeed | null>): void {
this.busy.set(true);
this.error.set(null);
call.subscribe({
next: (feed) => this.done(feed),
error: () => this.failed(),
});
}
private done(feed: CalendarFeed | null): void {
this.feed.set(feed);
this.loaded.set(true);
this.busy.set(false);
}
private failed(): void {
this.loaded.set(true);
this.busy.set(false);
this.error.set('No se ha podido. Inténtalo de nuevo en un momento.');
}
}

View File

@@ -233,6 +233,7 @@ describe('ProjectFacade', () => {
transitions: [],
issueTypes: [],
areas: [],
customFields: [],
};
project.saveSettings(settings);

View File

@@ -0,0 +1,60 @@
import { inject, Injectable, signal } from '@angular/core';
import {
NotificationsGateway,
PushDevice,
} from '@task-stream/web/tracker/domain';
import { firstValueFrom } from 'rxjs';
/**
* Push on this device: the browser subscribes and the API keeps it, so the
* notifications of the bell also reach the device with TaskStream closed.
*/
@Injectable({ providedIn: 'root' })
export class PushFacade {
private readonly device = inject(PushDevice);
private readonly gateway = inject(NotificationsGateway);
readonly supported = this.device.supported;
readonly on = signal(false);
readonly busy = signal(false);
readonly error = signal<string | null>(null);
/** Whether this browser has push on now. */
async refresh(): Promise<void> {
this.on.set(!!(await this.device.current().catch(() => null)));
}
async turnOn(publicKey: string): Promise<void> {
await this.run(async () => {
const subscription = await this.device.subscribe(publicKey);
await firstValueFrom(this.gateway.subscribePush(subscription));
this.on.set(true);
});
}
async turnOff(): Promise<void> {
await this.run(async () => {
const endpoint = await this.device.unsubscribe();
if (endpoint)
await firstValueFrom(this.gateway.unsubscribePush(endpoint));
this.on.set(false);
});
}
private async run(step: () => Promise<void>): Promise<void> {
this.busy.set(true);
this.error.set(null);
try {
await step();
} catch (error) {
this.error.set(
error instanceof Error && error.message
? error.message
: 'No se han podido activar los avisos',
);
await this.refresh();
} finally {
this.busy.set(false);
}
}
}

View File

@@ -10,6 +10,16 @@ export enum StatusCategory {
CANCELLED = 'CANCELLED',
}
/** What a custom field holds. */
export enum CustomFieldType {
TEXT = 'TEXT',
NUMBER = 'NUMBER',
/** `YYYY-MM-DD`. */
DATE = 'DATE',
/** One of its options. */
SELECT = 'SELECT',
}
export enum IssuePriority {
LOWEST = 'LOWEST',
LOW = 'LOW',

View File

@@ -1,3 +1,4 @@
import { CustomFieldValue } from './workflow';
import {
IssueLinkType,
IssuePriority,
@@ -49,6 +50,8 @@ export interface CreateIssueInput {
storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;
/** Only the fields to change; null or '' clears one. */
customFields?: { fieldId: string; value: string | null }[];
assigneeId?: string | null;
releaseId?: string | null;
/** Epic or ticket the new one breaks down. */
@@ -97,6 +100,8 @@ export interface IssueListFilter {
assigneeId?: string;
unassigned?: boolean;
releaseId?: string;
/** Only tickets holding these custom values. */
customFields?: CustomFieldValue[];
limit: number;
}

View File

@@ -7,7 +7,7 @@ import {
SprintStatus,
StatusCategory,
} from './enums';
import { ProjectSettings } from './workflow';
import { CustomFieldValue, ProjectSettings } from './workflow';
export interface UserRef {
id: string;
@@ -71,6 +71,8 @@ export interface ChildProgress {
export interface Epic {
issue: IssueSummary;
progress: ChildProgress;
/** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */
links: IssueLink[];
}
/** What a saved view narrows the board to; empty: everything. */
@@ -126,6 +128,8 @@ export interface IssueSummary {
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
startDate: string | null;
dueDate: string | null;
/** Values of the project's custom fields (only those set). */
customFields: CustomFieldValue[];
/** In the trash since then (null: not in it). */
trashedAt: string | null;
commentCount: number;
@@ -429,9 +433,25 @@ export interface Notification {
createdAt: string;
}
/** My calendar subscription: a secret URL calendar apps read (iCalendar). */
export interface CalendarFeed {
/** Absolute: what goes into Google Calendar, Outlook or Apple Calendar. */
url: string;
createdAt: string;
}
export interface NotificationSettings {
/** Copies by e-mail. */
email: boolean;
/** Whether this installation can send e-mail at all. */
emailAvailable: boolean;
/** VAPID key to subscribe a device to push with; null: no push here. */
pushPublicKey: string | null;
}
/** A browser's Web Push subscription, as the API takes it. */
export interface PushDeviceSubscription {
endpoint: string;
p256dh: string;
auth: string;
}

View File

@@ -1,5 +1,8 @@
import { IssueLinkType } from './enums';
import {
dependencyClash,
roadmapBar,
roadmapDependencies,
roadmapMonths,
roadmapOffset,
roadmapRange,
@@ -30,4 +33,44 @@ describe('roadmap', () => {
expect(roadmapOffset('2020-01-01', range)).toBe(0);
expect(roadmapOffset('2030-01-01', range)).toBe(100);
});
it('draws each dependency between epics once, first the one that blocks', () => {
const link = (
type: IssueLinkType,
direction: 'OUTWARD' | 'INWARD',
issueId: string,
) => ({ type, direction, issueId, issueKey: issueId.toUpperCase() });
const epics = [
{
issue: { id: 'a' },
links: [
link(IssueLinkType.BLOCKS, 'OUTWARD', 'b'),
link(IssueLinkType.RELATES_TO, 'OUTWARD', 'c'),
link(IssueLinkType.BLOCKS, 'OUTWARD', 'elsewhere'),
],
},
{
issue: { id: 'b' },
links: [
link(IssueLinkType.BLOCKS, 'INWARD', 'a'),
// The same relation told the other way round.
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', 'a'),
],
},
{
issue: { id: 'c' },
links: [link(IssueLinkType.DEPENDS_ON, 'OUTWARD', 'b')],
},
];
expect(roadmapDependencies(epics)).toEqual([
{ before: 'a', after: 'b' },
{ before: 'b', after: 'c' },
]);
});
it('flags an epic that starts before the one it waits for ends', () => {
expect(dependencyClash('2026-11-10', '2026-11-11')).toBe(false);
expect(dependencyClash('2026-11-10', '2026-11-10')).toBe(true);
expect(dependencyClash('2026-11-10', '2026-11-03')).toBe(true);
});
});

View File

@@ -1,3 +1,6 @@
import { IssueLinkType } from './enums';
import { IssueLink } from './models';
const DAY = 86_400_000;
/** Time span a roadmap shows (UTC midnights). */
@@ -70,3 +73,48 @@ export function roadmapMonths(
}
return months;
}
/** An epic that has to finish before another one starts. */
export interface RoadmapDependency {
/** Id of the epic that goes first (it blocks the other). */
before: string;
/** Id of the epic that waits for it. */
after: string;
}
/**
* The «bloquea» / «depende de» relations among these epics, once per pair:
* A blocks B and B depends on A are the same arrow, A → B. Only outward
* links are read (each relation is stored on both tickets).
*/
export function roadmapDependencies(
epics: { issue: { id: string }; links: IssueLink[] }[],
): RoadmapDependency[] {
const ids = new Set(epics.map((e) => e.issue.id));
const seen = new Set<string>();
const out: RoadmapDependency[] = [];
for (const epic of epics)
for (const link of epic.links) {
if (link.direction !== 'OUTWARD' || !ids.has(link.issueId)) continue;
const pair =
link.type === IssueLinkType.BLOCKS
? { before: epic.issue.id, after: link.issueId }
: link.type === IssueLinkType.DEPENDS_ON
? { before: link.issueId, after: epic.issue.id }
: null;
if (!pair || pair.before === pair.after) continue;
const key = `${pair.before}>${pair.after}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(pair);
}
return out;
}
/** The epic that waits starts on or before the day the other one ends. */
export function dependencyClash(
beforeEnds: string,
afterStarts: string,
): boolean {
return at(afterStarts) <= at(beforeEnds);
}

View File

@@ -1,4 +1,4 @@
import { IssuePriority, StatusCategory } from './enums';
import { CustomFieldType, IssuePriority, StatusCategory } from './enums';
/** Colour tokens a status or ticket type can use (`--ts-c-*`). */
export const SETTING_COLORS = [
@@ -74,12 +74,29 @@ export interface AreaOption {
archived: boolean;
}
/** Per-project configuration: statuses (board order), transitions, types and areas. */
/** A field the project adds to its tickets; its type never changes once saved. */
export interface CustomFieldDefinition {
id: string;
name: string;
type: CustomFieldType;
/** The choices of a SELECT field. */
options: string[];
archived: boolean;
}
/** A ticket's value of a custom field. */
export interface CustomFieldValue {
fieldId: string;
value: string;
}
/** Per-project configuration: statuses (board order), transitions, types, areas and custom fields. */
export interface ProjectSettings {
statuses: WorkflowStatus[];
transitions: StatusTransitions[];
issueTypes: IssueTypeOption[];
areas: AreaOption[];
customFields: CustomFieldDefinition[];
}
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
@@ -114,6 +131,7 @@ const EMPTY: ProjectSettings = {
transitions: [],
issueTypes: [],
areas: [],
customFields: [],
};
/**
@@ -167,6 +185,15 @@ export class Workflow {
return this.settings.areas.filter((a) => !a.archived);
}
/** Custom fields to fill in (archived ones only show where they have a value). */
activeCustomFields(): CustomFieldDefinition[] {
return (this.settings.customFields ?? []).filter((f) => !f.archived);
}
customField(id: string): CustomFieldDefinition | undefined {
return this.settings.customFields?.find((f) => f.id === id);
}
/** Most tickets the status should hold (null: no limit). */
wipLimit(id: string): number | null {
return this.status(id).wipLimit ?? null;

View File

@@ -2,21 +2,17 @@ import { Observable } from 'rxjs';
import {
Activity,
Attachment,
Automation,
AutomationInput,
BoardColumn,
CalendarFeed,
PushDeviceSubscription,
CommandResult,
Comment,
Epic,
GitIntegration,
GitRepository,
Automation,
AutomationInput,
ImportReport,
Webhook,
WebhookDelivery,
SavedFilter,
Sprint,
SprintBurndown,
SavedFilterCriteria,
CommandResult,
Issue,
IssuePage,
IssueSummary,
@@ -27,7 +23,13 @@ import {
ProjectStats,
Release,
ReleaseBurnup,
SavedFilter,
SavedFilterCriteria,
Sprint,
SprintBurndown,
UserDirectoryEntry,
Webhook,
WebhookDelivery,
} from '../models/models';
import { ProjectSettings } from '../models/workflow';
import {
@@ -332,6 +334,14 @@ export abstract class UsersGateway {
}
/** The signed-in person's notifications. */
export abstract class CalendarGateway {
/** Mine, if I made one. */
abstract mine(): Observable<CalendarFeed | null>;
/** A new secret URL; the previous one stops working. */
abstract create(): Observable<CalendarFeed>;
abstract revoke(): Observable<void>;
}
export abstract class NotificationsGateway {
abstract mine(
unreadOnly: boolean,
@@ -341,6 +351,26 @@ export abstract class NotificationsGateway {
abstract markRead(ids?: string[]): Observable<void>;
abstract settings(): Observable<NotificationSettings>;
abstract updateSettings(email: boolean): Observable<NotificationSettings>;
/** Push on this device. */
abstract subscribePush(
subscription: PushDeviceSubscription,
): Observable<void>;
/** Push off on this device. */
abstract unsubscribePush(endpoint: string): Observable<void>;
}
/**
* This browser's side of push: its service worker and push subscription.
* Not every browser has it (and on iPhone only an installed web app does).
*/
export abstract class PushDevice {
abstract get supported(): boolean;
/** This browser's subscription, if push is on here. */
abstract current(): Promise<PushDeviceSubscription | null>;
/** Asks for permission and subscribes; throws when refused. */
abstract subscribe(publicKey: string): Promise<PushDeviceSubscription>;
/** Unsubscribes; the endpoint it had, if any. */
abstract unsubscribe(): Promise<string | null>;
}
/** Realtime (GraphQL subscriptions over WebSocket). */

View File

@@ -0,0 +1,81 @@
import { Injectable } from '@angular/core';
import {
PushDevice,
PushDeviceSubscription,
} from '@task-stream/web/tracker/domain';
/** The service worker that shows the notifications (apps/web/public). */
const WORKER = '/push-sw.js';
/** Push through this browser's service worker and PushManager. */
@Injectable()
export class BrowserPushDevice extends PushDevice {
get supported(): boolean {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
async current(): Promise<PushDeviceSubscription | null> {
if (!this.supported) return null;
const registration = await navigator.serviceWorker.getRegistration(WORKER);
const subscription = await registration?.pushManager.getSubscription();
return subscription ? toDevice(subscription) : null;
}
async subscribe(publicKey: string): Promise<PushDeviceSubscription> {
if (!this.supported) throw new Error('Este navegador no admite avisos');
if ((await Notification.requestPermission()) !== 'granted')
throw new Error(
'El navegador no tiene permiso para mostrar avisos: dáselo en los ajustes del sitio.',
);
const registration = await navigator.serviceWorker.register(WORKER);
await navigator.serviceWorker.ready;
try {
const subscription =
(await registration.pushManager.getSubscription()) ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: fromBase64Url(publicKey),
}));
return toDevice(subscription);
} catch {
// The browser's own message is in English and says little.
throw new Error(
'Este navegador no ha dejado activar los avisos. En iPhone, añade antes TaskStream a la pantalla de inicio.',
);
}
}
async unsubscribe(): Promise<string | null> {
if (!this.supported) return null;
const registration = await navigator.serviceWorker.getRegistration(WORKER);
const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return null;
await subscription.unsubscribe();
return subscription.endpoint;
}
}
function toDevice(subscription: PushSubscription): PushDeviceSubscription {
const json = subscription.toJSON();
return {
endpoint: subscription.endpoint,
p256dh: json.keys?.['p256dh'] ?? '',
auth: json.keys?.['auth'] ?? '',
};
}
/** VAPID keys travel as base64url; PushManager wants the bytes. */
function fromBase64Url(text: string): Uint8Array<ArrayBuffer> {
const base64 = (text + '='.repeat((4 - (text.length % 4)) % 4))
.replace(/-/g, '+')
.replace(/_/g, '/');
const raw = atob(base64);
const bytes = new Uint8Array(new ArrayBuffer(raw.length));
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes;
}

View File

@@ -0,0 +1,48 @@
import { inject, Injectable } from '@angular/core';
import { API_CONFIG } from '@task-stream/web/shared/infrastructure';
import { CalendarFeed, CalendarGateway } from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
CreateCalendarFeedDocument,
MyCalendarFeedDocument,
RevokeCalendarFeedDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlCalendarGateway extends CalendarGateway {
private readonly gql = inject(GraphqlOperations);
private readonly api = inject(API_CONFIG);
mine(): Observable<CalendarFeed | null> {
return this.gql
.query(MyCalendarFeedDocument)
.pipe(
map((d) => (d.myCalendarFeed ? this.toFeed(d.myCalendarFeed) : null)),
);
}
create(): Observable<CalendarFeed> {
return this.gql
.mutate(CreateCalendarFeedDocument, {})
.pipe(map((d) => this.toFeed(d.createCalendarFeed)));
}
revoke(): Observable<void> {
return this.gql
.mutate(RevokeCalendarFeedDocument, {})
.pipe(map(() => undefined));
}
/** The API answers the secret; calendar apps need the whole address. */
private toFeed(feed: { token: string; createdAt: string }): CalendarFeed {
const base = new URL(
this.api.restUrl.replace(/\/+$/, '') + '/',
location.origin,
);
return {
url: new URL(`calendar/${feed.token}.ics`, base).toString(),
createdAt: feed.createdAt,
};
}
}

View File

@@ -50,7 +50,12 @@ import {
UpdateIssueDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toCommandResult, toIssue, toIssueSummary } from './mappers';
import {
toCommandResult,
toIssue,
toIssueLink,
toIssueSummary,
} from './mappers';
@Injectable()
export class GraphqlIssuesGateway extends IssuesGateway {
@@ -214,6 +219,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
d.epics.map((epic) => ({
issue: toIssueSummary(epic.issue),
progress: { ...epic.progress },
links: epic.issue.links.map((l) => toIssueLink(l)),
})),
),
);

View File

@@ -3,12 +3,15 @@ import {
Notification,
NotificationSettings,
NotificationsGateway,
PushDeviceSubscription,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
MarkNotificationsReadDocument,
MyNotificationsDocument,
NotificationSettingsDocument,
SubscribePushDocument,
UnsubscribePushDocument,
UpdateNotificationSettingsDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@@ -46,4 +49,16 @@ export class GraphqlNotificationsGateway extends NotificationsGateway {
.mutate(UpdateNotificationSettingsDocument, { email })
.pipe(map((d) => ({ ...d.updateNotificationSettings })));
}
subscribePush(subscription: PushDeviceSubscription): Observable<void> {
return this.gql
.mutate(SubscribePushDocument, { subscription })
.pipe(map(() => undefined));
}
unsubscribePush(endpoint: string): Observable<void> {
return this.gql
.mutate(UnsubscribePushDocument, { endpoint })
.pipe(map(() => undefined));
}
}

View File

@@ -3,6 +3,7 @@ import type {
Comment,
CommandResult,
Issue,
IssueLink,
IssueSummary,
Project,
ProjectSettings,
@@ -39,6 +40,12 @@ export const toProject = (p: ProjectFieldsFragment): Project => ({
export const toIssueSummary = (i: IssueSummaryFieldsFragment): IssueSummary =>
as<IssueSummary>(i);
export const toIssue = (i: IssueFieldsFragment): Issue => as<Issue>(i);
export const toIssueLink = (l: {
type: string;
direction: string;
issueId: string;
issueKey: string;
}): IssueLink => as<IssueLink>({ ...l });
export const toComment = (c: CommentFieldsFragment): Comment => c;
export const toActivity = (a: ActivityFieldsFragment): Activity => a;
export const toCommandResult = (
@@ -98,5 +105,12 @@ export function toSettingsInput(
name: a.name,
archived: a.archived,
})),
customFields: (settings.customFields ?? []).map((f) => ({
id: f.id,
name: f.name,
type: f.type,
options: f.options,
archived: f.archived,
})),
};
}

View File

@@ -0,0 +1,17 @@
query MyCalendarFeed {
myCalendarFeed {
token
createdAt
}
}
mutation CreateCalendarFeed {
createCalendarFeed {
token
createdAt
}
}
mutation RevokeCalendarFeed {
revokeCalendarFeed
}

View File

@@ -51,6 +51,13 @@ fragment ProjectFields on Project {
name
archived
}
customFields {
id
name
type
options
archived
}
}
}
@@ -95,6 +102,10 @@ fragment IssueSummaryFields on Issue {
storyPoints
startDate
dueDate
customFields {
fieldId
value
}
trashedAt
}

View File

@@ -89,6 +89,8 @@ export type CreateIssueInput = {
/** An area id */
area?: string | null | undefined;
assigneeId?: string | null | undefined;
/** Only the fields to change */
customFields?: Array<CustomFieldValueInput> | null | undefined;
description?: string | null | undefined;
dueDate?: string | null | undefined;
/** Figma frame for UI changes */
@@ -141,6 +143,24 @@ export type CreateWebhookInput = {
url: string;
};
export type CustomFieldDefinitionInput = {
archived?: boolean;
id: string;
name: string;
/** The choices of a SELECT field */
options?: Array<string>;
type: CustomFieldType;
};
/** What a custom field holds (DATE: YYYY-MM-DD) */
export type CustomFieldType = 'DATE' | 'NUMBER' | 'SELECT' | 'TEXT';
/** A custom field's value; null or '' clears it */
export type CustomFieldValueInput = {
fieldId: string;
value?: string | null | undefined;
};
export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST';
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
@@ -149,6 +169,8 @@ export type IssueFilterInput = {
areas?: Array<string> | null | undefined;
assigneeId?: string | null | undefined;
categories?: Array<StatusCategory> | null | undefined;
/** Only tickets holding these custom values */
customFields?: Array<CustomFieldValueInput> | null | undefined;
/** Due on or after this day (YYYY-MM-DD) */
dueFrom?: string | null | undefined;
/** Due on or before this day (YYYY-MM-DD) */
@@ -231,11 +253,20 @@ export type ProjectRole = 'ADMIN' | 'MEMBER' | 'OWNER' | 'VIEWER';
/** The whole configuration (replaces the previous one). Statuses go in board order. */
export type ProjectSettingsInput = {
areas: Array<AreaOptionInput>;
/** Absent: unchanged. Fields cannot be removed: archive them. */
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
issueTypes: Array<IssueTypeOptionInput>;
statuses: Array<WorkflowStatusInput>;
transitions: Array<StatusTransitionsInput>;
};
/** A browser's Web Push subscription (PushSubscription.toJSON()) */
export type PushSubscriptionInput = {
auth: string;
endpoint: string;
p256dh: string;
};
export type RankIssueInput = {
/** The ticket that ends up right above (null: the top) */
aboveIssueId?: string | null | undefined;
@@ -291,6 +322,8 @@ export type UpdateChecklistItemInput = {
export type UpdateIssueInput = {
/** An area id */
area?: string | null | undefined;
/** Only the fields to change */
customFields?: Array<CustomFieldValueInput> | null | undefined;
description?: string | null | undefined;
dueDate?: string | null | undefined;
/** Figma frame for UI changes */
@@ -438,6 +471,26 @@ export type DeleteAutomationMutationVariables = Exact<{
export type DeleteAutomationMutation = { deleteAutomation: boolean };
export type MyCalendarFeedQueryVariables = Exact<{ [key: string]: never }>;
export type MyCalendarFeedQuery = {
myCalendarFeed: { token: string; createdAt: string } | null;
};
export type CreateCalendarFeedMutationVariables = Exact<{
[key: string]: never;
}>;
export type CreateCalendarFeedMutation = {
createCalendarFeed: { token: string; createdAt: string };
};
export type RevokeCalendarFeedMutationVariables = Exact<{
[key: string]: never;
}>;
export type RevokeCalendarFeedMutation = { revokeCalendarFeed: boolean };
export type UserRefFieldsFragment = { id: string; name: string };
export type ProjectFieldsFragment = {
@@ -471,6 +524,13 @@ export type ProjectFieldsFragment = {
template: { description: string | null; checklist: Array<string> } | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
customFields: Array<{
id: string;
name: string;
type: CustomFieldType;
options: Array<string>;
archived: boolean;
}>;
};
};
@@ -500,6 +560,7 @@ export type IssueSummaryFieldsFragment = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
export type IssueFieldsFragment = {
@@ -561,6 +622,7 @@ export type IssueFieldsFragment = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
export type CommentFieldsFragment = {
@@ -709,6 +771,7 @@ export type BoardQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
}>;
};
@@ -778,6 +841,7 @@ export type IssueQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
};
@@ -813,6 +877,7 @@ export type SearchIssuesQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
};
};
@@ -981,6 +1046,7 @@ export type MyIssuesQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
};
};
@@ -1017,6 +1083,7 @@ export type IssueChildrenQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
};
};
@@ -1048,11 +1115,18 @@ export type EpicsQuery = {
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
links: Array<{
type: IssueLinkType;
direction: LinkDirection;
issueId: string;
issueKey: string;
}>;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
progress: {
total: number;
@@ -1131,6 +1205,7 @@ export type BacklogQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
};
};
@@ -1201,6 +1276,7 @@ export type TrashQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
};
@@ -1236,6 +1312,7 @@ export type DueIssuesQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
};
};
@@ -1274,6 +1351,7 @@ export type IssueListQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
};
};
@@ -1346,7 +1424,11 @@ export type NotificationSettingsQueryVariables = Exact<{
}>;
export type NotificationSettingsQuery = {
notificationSettings: { email: boolean; emailAvailable: boolean };
notificationSettings: {
email: boolean;
emailAvailable: boolean;
pushPublicKey: string | null;
};
};
export type MarkNotificationsReadMutationVariables = Exact<{
@@ -1360,9 +1442,25 @@ export type UpdateNotificationSettingsMutationVariables = Exact<{
}>;
export type UpdateNotificationSettingsMutation = {
updateNotificationSettings: { email: boolean; emailAvailable: boolean };
updateNotificationSettings: {
email: boolean;
emailAvailable: boolean;
pushPublicKey: string | null;
};
};
export type SubscribePushMutationVariables = Exact<{
subscription: PushSubscriptionInput;
}>;
export type SubscribePushMutation = { subscribePush: boolean };
export type UnsubscribePushMutationVariables = Exact<{
endpoint: string;
}>;
export type UnsubscribePushMutation = { unsubscribePush: boolean };
export type NotificationAddedSubscriptionVariables = Exact<{
[key: string]: never;
}>;
@@ -1419,6 +1517,13 @@ export type MyProjectsQuery = {
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
customFields: Array<{
id: string;
name: string;
type: CustomFieldType;
options: Array<string>;
archived: boolean;
}>;
};
}>;
};
@@ -1462,6 +1567,13 @@ export type ProjectQuery = {
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
customFields: Array<{
id: string;
name: string;
type: CustomFieldType;
options: Array<string>;
archived: boolean;
}>;
};
};
};
@@ -1615,6 +1727,13 @@ export type PublicProjectsQuery = {
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
customFields: Array<{
id: string;
name: string;
type: CustomFieldType;
options: Array<string>;
archived: boolean;
}>;
};
}>;
};
@@ -1963,6 +2082,7 @@ export type IssueChangedSubscription = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
};
};
@@ -2068,6 +2188,13 @@ export type ProjectChangedSubscription = {
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
customFields: Array<{
id: string;
name: string;
type: CustomFieldType;
options: Array<string>;
archived: boolean;
}>;
};
};
};
@@ -2114,6 +2241,13 @@ export type MyProjectsChangedSubscription = {
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
customFields: Array<{
id: string;
name: string;
type: CustomFieldType;
options: Array<string>;
archived: boolean;
}>;
};
};
};
@@ -2477,6 +2611,26 @@ export const ProjectFieldsFragmentDoc = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'options' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -2591,6 +2745,17 @@ export const IssueSummaryFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -2839,6 +3004,17 @@ export const IssueFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -3796,6 +3972,83 @@ export const DeleteAutomationDocument = {
DeleteAutomationMutation,
DeleteAutomationMutationVariables
>;
export const MyCalendarFeedDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'MyCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'myCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<MyCalendarFeedQuery, MyCalendarFeedQueryVariables>;
export const CreateCalendarFeedDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'CreateCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'createCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<
CreateCalendarFeedMutation,
CreateCalendarFeedMutationVariables
>;
export const RevokeCalendarFeedDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'RevokeCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'revokeCalendarFeed' },
},
],
},
},
],
} as unknown as DocumentNode<
RevokeCalendarFeedMutation,
RevokeCalendarFeedMutationVariables
>;
export const GitIntegrationDocument = {
kind: 'Document',
definitions: [
@@ -4476,6 +4729,17 @@ export const BoardDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -4637,6 +4901,17 @@ export const IssueDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -4933,6 +5208,17 @@ export const SearchIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -6251,6 +6537,17 @@ export const MyIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -6426,6 +6723,17 @@ export const IssueChildrenDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -6481,6 +6789,31 @@ export const EpicsDocument = {
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'links' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'type' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'direction' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'issueId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'issueKey' },
},
],
},
},
],
},
},
@@ -6619,6 +6952,17 @@ export const EpicsDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -7116,6 +7460,17 @@ export const BacklogDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -7550,6 +7905,17 @@ export const TrashDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -7725,6 +8091,17 @@ export const DueIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -7915,6 +8292,17 @@ export const IssueListDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -8345,6 +8733,10 @@ export const NotificationSettingsDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'emailAvailable' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pushPublicKey' },
},
],
},
},
@@ -8448,6 +8840,10 @@ export const UpdateNotificationSettingsDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'emailAvailable' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pushPublicKey' },
},
],
},
},
@@ -8459,6 +8855,102 @@ export const UpdateNotificationSettingsDocument = {
UpdateNotificationSettingsMutation,
UpdateNotificationSettingsMutationVariables
>;
export const SubscribePushDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'SubscribePush' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'subscription' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'PushSubscriptionInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'subscribePush' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'subscription' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'subscription' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
SubscribePushMutation,
SubscribePushMutationVariables
>;
export const UnsubscribePushDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnsubscribePush' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'endpoint' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'unsubscribePush' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'endpoint' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'endpoint' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
UnsubscribePushMutation,
UnsubscribePushMutationVariables
>;
export const NotificationAddedDocument = {
kind: 'Document',
definitions: [
@@ -8729,6 +9221,26 @@ export const MyProjectsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'options' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -8952,6 +9464,26 @@ export const ProjectDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'options' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -10026,6 +10558,26 @@ export const PublicProjectsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'options' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -11996,6 +12548,17 @@ export const IssueChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
@@ -12548,6 +13111,26 @@ export const ProjectChangedDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'options' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -12758,6 +13341,26 @@ export const MyProjectsChangedDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'options' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},

View File

@@ -163,6 +163,12 @@ query Epics($projectId: ID!) {
epics(projectId: $projectId) {
issue {
...IssueSummaryFields
links {
type
direction
issueId
issueKey
}
}
progress {
total

View File

@@ -26,6 +26,7 @@ query NotificationSettings {
notificationSettings {
email
emailAvailable
pushPublicKey
}
}
@@ -37,9 +38,18 @@ mutation UpdateNotificationSettings($email: Boolean!) {
updateNotificationSettings(email: $email) {
email
emailAvailable
pushPublicKey
}
}
mutation SubscribePush($subscription: PushSubscriptionInput!) {
subscribePush(subscription: $subscription)
}
mutation UnsubscribePush($endpoint: String!) {
unsubscribePush(endpoint: $endpoint)
}
subscription NotificationAdded {
notificationAdded {
...NotificationFields

View File

@@ -9,6 +9,8 @@ import {
CommentsGateway,
IssuesGateway,
NotificationsGateway,
CalendarGateway,
PushDevice,
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
@@ -28,6 +30,8 @@ import {
} from './adapters/graphql-collaboration.gateways';
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
import { GraphqlCalendarGateway } from './adapters/graphql-calendar.gateway';
import { BrowserPushDevice } from './adapters/browser-push.device';
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
import { HttpProposalsGateway } from './adapters/http-proposals.gateway';
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
@@ -51,6 +55,8 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: ProposalsGateway, useClass: HttpProposalsGateway },
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: CalendarGateway, useClass: GraphqlCalendarGateway },
{ provide: PushDevice, useClass: BrowserPushDevice },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },

View File

@@ -178,6 +178,8 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
margin: 0;
padding: 0.25rem 0;
list-style: none;
/* Selected rows have a background: kept inside the rounded corners. */
overflow: hidden;
}
.ts-backlog__row {
display: grid;

View File

@@ -43,6 +43,7 @@ import {
Attachment,
Comment,
DevLink,
Issue,
IssueLink,
IssueLinkType,
IssuePriority,
@@ -70,6 +71,7 @@ import {
MarkdownComponent,
} from '../ui/markdown';
import { RichEditorComponent } from '../ui/rich-editor';
import { CustomFieldComponent } from '../ui/custom-field';
const NONE = '';
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
@@ -80,6 +82,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
BlockedComponent,
CustomFieldComponent,
ReactionsComponent,
MarkdownComponent,
RichEditorComponent,
@@ -843,6 +846,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>
<pi-label>Área</pi-label>
</pi-select-field>
@for (field of customFields(); track field.id) {
<ts-custom-field
[field]="field"
[value]="customValue(issue, field.id)"
[disabled]="!project.canContribute() || field.archived"
(valueChange)="
update({ customFields: [{ fieldId: field.id, value: $event }] })
"
/>
}
@if (!issue.trashedAt && canTrash(issue)) {
<pi-action-button
@@ -1314,10 +1327,26 @@ export class IssuePage {
storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;
customFields?: { fieldId: string; value: string | null }[];
}): void {
this.facade.update(changes);
}
/** The project's active fields, plus archived ones this ticket still has. */
protected readonly customFields = computed(() => {
const issue = this.facade.issue();
return (this.project.workflow().settings.customFields ?? []).filter(
(f) =>
!f.archived || !!issue?.customFields?.some((v) => v.fieldId === f.id),
);
});
protected customValue(issue: Issue, fieldId: string): string | null {
return (
issue.customFields?.find((v) => v.fieldId === fieldId)?.value ?? null
);
}
protected link(): void {
const targetKey = this.linkTarget();
if (!targetKey) return;

View File

@@ -27,6 +27,7 @@ import {
} from '@task-stream/web/tracker/application';
import {
BulkChange,
CustomFieldType,
IssuePriority,
IssueSummary,
StatusCategory,
@@ -110,6 +111,13 @@ const numberOf = (key: string) => Number(key.split('-').pop());
[options]="releaseFilterOptions()"
[(value)]="releaseFilter"
/>
@for (field of listFields(); track field.id) {
<pi-select-field
[options]="field.options"
[value]="fieldFilters()[field.id] ?? ''"
(valueChange)="setFieldFilter(field.id, $event)"
/>
}
@if (facade.loaded()) {
<span class="ts-muted ts-mono ts-list__count">
{{ facade.total() }} tickets{{
@@ -204,6 +212,9 @@ const numberOf = (key: string) => Number(key.split('-').pop());
↑ {{ parent.key }}</span
>
}
@if (fieldsOf(issue); as fields) {
<span class="ts-list__fields ts-muted">{{ fields }}</span>
}
</td>
<td class="ts-list__type">
<span
@@ -384,6 +395,10 @@ const numberOf = (key: string) => Number(key.split('-').pop());
}
.ts-list__title {
min-width: 16rem;
.ts-list__fields {
display: block;
font-size: 0.75rem;
}
a {
color: inherit;
text-decoration: none;
@@ -513,9 +528,44 @@ export class ListPage {
: `${r.name} (${r.status === 'RELEASED' ? 'publicada' : 'archivada'})`,
})),
]);
/** Custom list fields filter too: field id → the option picked. */
protected readonly fieldFilters = signal<Record<string, string>>({});
protected readonly listFields = computed(() =>
this.project
.workflow()
.activeCustomFields()
.filter((f) => f.type === CustomFieldType.SELECT)
.map((f) => ({
id: f.id,
options: [
{ value: ALL, label: `${f.name}: todos` },
...f.options.map((o) => ({ value: o, label: `${f.name}: ${o}` })),
] as PiSelectFieldOption<string>[],
})),
);
/** «Navegador: Safari · Coste: 120», in the project's order of fields. */
protected fieldsOf(issue: IssueSummary): string {
const values = new Map(
(issue.customFields ?? []).map((v) => [v.fieldId, v.value]),
);
return (this.project.workflow().settings.customFields ?? [])
.filter((f) => values.has(f.id))
.map((f) => `${f.name}: ${values.get(f.id)}`)
.join(' · ');
}
protected setFieldFilter(fieldId: string, value: string | null): void {
this.fieldFilters.update((current) => {
const { [fieldId]: _previous, ...rest } = current;
return value ? { ...rest, [fieldId]: value } : rest;
});
}
private readonly criteria = computed<ListCriteria>(() => {
const scope = this.scope();
const assignee = this.assignee();
const fields = Object.entries(this.fieldFilters());
return {
search: this.search() || undefined,
categories:
@@ -527,6 +577,9 @@ export class ListPage {
unassigned: assignee === NOBODY || undefined,
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
releaseId: this.releaseFilter() || undefined,
customFields: fields.length
? fields.map(([fieldId, value]) => ({ fieldId, value }))
: undefined,
};
});

View File

@@ -6,9 +6,12 @@ import {
DOCUMENT,
inject,
OnInit,
TemplateRef,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiIconComponent, PiSkeletonComponent } from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import {
HomeFacade,
ProjectsFacade,
@@ -28,6 +31,7 @@ import {
statusLook,
typeLook,
} from '../ui/copy';
import { CalendarFeedComponent } from '../ui/calendar-feed';
const VISIBLE = 12;
@@ -40,7 +44,12 @@ const VISIBLE = 12;
@Component({
selector: 'ts-my-work',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent, PiSkeletonComponent],
imports: [
RouterLink,
PiIconComponent,
PiSkeletonComponent,
CalendarFeedComponent,
],
template: `
@if (!home.loaded()) {
<div class="ts-work">
@@ -58,6 +67,14 @@ const VISIBLE = 12;
<span class="ts-muted ts-mono"
>{{ home.mineTotal() }} abiertos</span
>
<button
type="button"
class="ts-plain-link ts-work__calendar"
(click)="openCalendar()"
>
<pi-icon icon="calendar" size="x-small" aria-hidden="true" />
Calendario
</button>
</header>
@if (home.qaFailed()) {
<p class="ts-work__alert" role="status">
@@ -228,6 +245,9 @@ const VISIBLE = 12;
</div>
</div>
}
<ng-template #calendarPanel>
<ts-calendar-feed />
</ng-template>
`,
styles: `
:host {
@@ -250,14 +270,20 @@ const VISIBLE = 12;
.ts-work__panel {
padding: 1.375rem 1.5rem 1.25rem;
}
.ts-work__calendar {
display: inline-flex;
gap: 0.25rem;
align-items: center;
font-size: 0.875rem;
}
.ts-work__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.875rem;
h2 {
margin: 0;
/* Pushes the count and the calendar link to the right. */
margin: 0 auto 0 0;
font-size: 1.125rem;
font-weight: 700;
}
@@ -415,6 +441,9 @@ const VISIBLE = 12;
})
export class MyWorkComponent implements OnInit {
protected readonly home = inject(HomeFacade);
private readonly drawer = inject(DrawerService);
private readonly calendarPanel =
viewChild.required<TemplateRef<unknown>>('calendarPanel');
private readonly projects = inject(ProjectsFacade);
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
@@ -483,4 +512,8 @@ export class MyWorkComponent implements OnInit {
release.projectId
);
}
protected openCalendar(): void {
this.drawer.open('Tu calendario', this.calendarPanel());
}
}

View File

@@ -1,12 +1,17 @@
import { AuthFacade } from '@task-stream/web/identity/application';
import { VoteComponent } from '../ui/vote';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
HostListener,
inject,
signal,
untracked,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiNoteComponent, PiSkeletonComponent } from '@pi/ui';
@@ -16,9 +21,11 @@ import {
provideEpicsFacade,
} from '@task-stream/web/tracker/application';
import {
dependencyClash,
Epic,
ReleaseStatus,
roadmapBar,
roadmapDependencies,
roadmapMonths,
roadmapOffset,
roadmapRange,
@@ -51,7 +58,8 @@ const shortDay = (day: string) =>
<p class="ts-muted">
Las épicas en el tiempo, con los sprints y las releases como
referencias. Las fechas de una épica se ponen en su ficha («Empieza» y
«Fecha límite»).
«Fecha límite»); las flechas van de una épica a las que la esperan
(relaciones «bloquea» y «depende de»).
</p>
</div>
@@ -67,7 +75,22 @@ const shortDay = (day: string) =>
} @else if (!epics.loaded()) {
<pi-skeleton shape="block" height="16rem" />
} @else {
<div class="ts-roadmap ts-panel" role="table" aria-label="Hoja de ruta">
@if (clashes().length) {
<pi-note
gap="xs"
status="pending"
appearance="fill"
type="status"
title="Fechas que no encajan"
[text]="clashText()"
/>
}
<div
#chart
class="ts-roadmap ts-panel"
role="table"
aria-label="Hoja de ruta"
>
<div class="ts-roadmap__row ts-roadmap__months" role="row">
<span class="ts-roadmap__label" role="columnheader"></span>
<div class="ts-roadmap__track" role="columnheader">
@@ -141,6 +164,7 @@ const shortDay = (day: string) =>
<div class="ts-roadmap__track" role="cell">
<a
class="ts-roadmap__bar"
[attr.data-epic]="row.epic.issue.id"
[style.left.%]="row.left"
[style.width.%]="row.width"
[style.--tone]="statusTone(row.epic)"
@@ -153,6 +177,53 @@ const shortDay = (day: string) =>
</div>
}
@if (arrows().length) {
<!-- «Bloquea» / «depende de»: told in each bar's title too. -->
<svg
class="ts-roadmap__arrows"
aria-hidden="true"
[attr.width]="canvas().width"
[attr.height]="canvas().height"
>
<defs>
<marker
id="ts-roadmap-arrow"
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="7"
markerHeight="7"
orient="auto"
>
<path d="M0,0 L8,4 L0,8 z" class="ts-roadmap__head" />
</marker>
<marker
id="ts-roadmap-arrow-clash"
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="7"
markerHeight="7"
orient="auto"
>
<path d="M0,0 L8,4 L0,8 z" class="ts-roadmap__head is-clash" />
</marker>
</defs>
@for (arrow of arrows(); track arrow.id) {
<path
class="ts-roadmap__arrow"
[class.is-clash]="arrow.clash"
[attr.d]="arrow.d"
[attr.marker-end]="
arrow.clash
? 'url(#ts-roadmap-arrow-clash)'
: 'url(#ts-roadmap-arrow)'
"
/>
}
</svg>
}
<span
class="ts-roadmap__today"
aria-hidden="true"
@@ -190,6 +261,10 @@ const shortDay = (day: string) =>
}
`,
styles: `
pi-note {
display: block;
margin-bottom: 0.75rem;
}
.ts-roadmap {
--label: 18rem;
position: relative;
@@ -296,6 +371,30 @@ const shortDay = (day: string) =>
outline-offset: 1px;
}
}
.ts-roadmap__arrows {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
}
.ts-roadmap__arrow {
fill: none;
stroke: var(--ts-muted);
stroke-width: 1.5;
opacity: 0.8;
&.is-clash {
stroke: var(--ts-c-red, var(--ts-brand));
stroke-dasharray: 4 3;
opacity: 1;
}
}
.ts-roadmap__head {
fill: var(--ts-muted);
&.is-clash {
fill: var(--ts-c-red, var(--ts-brand));
}
}
.ts-roadmap__today {
position: absolute;
top: 0.5rem;
@@ -359,21 +458,73 @@ export class RoadmapPage {
protected readonly todayAt = computed(() =>
roadmapOffset(today(), this.range()),
);
protected readonly epicRows = computed(() =>
this.dated()
protected readonly epicRows = computed(() => {
const keys = new Map(this.dated().map((e) => [e.issue.id, e.issue.key]));
const waitsFor = new Map<string, string[]>();
for (const d of roadmapDependencies(this.dated()))
waitsFor.set(d.after, [
...(waitsFor.get(d.after) ?? []),
keys.get(d.before) as string,
]);
return this.dated()
.map((epic) => {
// One date only: a single-day bar on it.
const start = (epic.issue.startDate ?? epic.issue.dueDate) as string;
const end = (epic.issue.dueDate ?? epic.issue.startDate) as string;
const after = waitsFor.get(epic.issue.id);
return {
epic,
start,
end,
...roadmapBar(start, end, this.range()),
title: `${epic.issue.key} · ${shortDay(start)} – ${shortDay(end)} · ${epic.progress.percent}% terminado`,
title: `${epic.issue.key} · ${shortDay(start)} – ${shortDay(end)} · ${epic.progress.percent}% terminado${after ? ` · depende de ${after.join(', ')}` : ''}`,
};
})
.sort((a, b) => a.start.localeCompare(b.start)),
.sort((a, b) => a.start.localeCompare(b.start));
});
/** Dependencies between dated epics, with whether their dates clash. */
private readonly dependencies = computed(() => {
const rows = new Map(this.epicRows().map((r) => [r.epic.issue.id, r]));
return roadmapDependencies(this.dated()).flatMap((d) => {
const before = rows.get(d.before);
const after = rows.get(d.after);
return before && after
? [
{
...d,
before,
after,
clash: dependencyClash(before.end, after.start),
},
]
: [];
});
});
protected readonly clashes = computed(() =>
this.dependencies().filter((d) => d.clash),
);
protected readonly clashText = computed(() =>
this.clashes()
.map(
({ before, after }) =>
`${after.epic.issue.key} empieza el ${shortDay(after.start)}, pero ${before.epic.issue.key}, que la bloquea, acaba el ${shortDay(before.end)}.`,
)
.join(' '),
);
/* Arrows: drawn over the bars, measured once they are on screen. */
private readonly chart = viewChild<ElementRef<HTMLElement>>('chart');
private readonly resized = signal(0);
protected readonly canvas = signal({ width: 0, height: 0 });
protected readonly arrows = signal<
{ id: string; d: string; clash: boolean }[]
>([]);
@HostListener('window:resize')
protected onResize(): void {
this.resized.update((n) => n + 1);
}
protected readonly sprintBands = computed(() =>
this.project.sprints().map((s) => ({
id: s.id,
@@ -404,6 +555,45 @@ export class RoadmapPage {
const projectId = this.project.project()?.id;
if (projectId) untracked(() => this.epics.open(projectId));
});
afterRenderEffect(() => {
this.resized();
const dependencies = this.dependencies();
const host = this.chart()?.nativeElement;
if (!host || !dependencies.length) {
if (untracked(this.arrows).length) this.arrows.set([]);
return;
}
const box = host.getBoundingClientRect();
const bar = (id: string) => {
const rect = host
.querySelector(`[data-epic="${id}"]`)
?.getBoundingClientRect();
return rect
? {
left: rect.left - box.left + host.scrollLeft,
right: rect.right - box.left + host.scrollLeft,
middle: rect.top - box.top + rect.height / 2,
}
: null;
};
this.canvas.set({ width: host.scrollWidth, height: host.scrollHeight });
this.arrows.set(
dependencies.flatMap((d) => {
const from = bar(d.before.epic.issue.id);
const to = bar(d.after.epic.issue.id);
if (!from || !to) return [];
// A curve out of the end of one bar into the start of the other.
const bend = Math.max(24, (to.left - from.right) / 2);
return [
{
id: `${d.before.epic.issue.id}>${d.after.epic.issue.id}`,
clash: d.clash,
d: `M ${from.right} ${from.middle} C ${from.right + bend} ${from.middle}, ${to.left - bend} ${to.middle}, ${to.left - 1} ${to.middle}`,
},
];
}),
);
});
}
protected monthLabel = monthName;

View File

@@ -23,6 +23,7 @@ import { TransferComponent } from '../ui/transfer';
import { AutomationsComponent } from '../ui/automations';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
CustomFieldType,
IssueTypeOption,
ProjectSettings,
SETTING_COLORS,
@@ -31,7 +32,14 @@ import {
} from '@task-stream/web/tracker/domain';
import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy';
type Section = 'statuses' | 'issueTypes' | 'areas';
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields';
const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [
{ id: CustomFieldType.TEXT, label: 'Texto' },
{ id: CustomFieldType.NUMBER, label: 'Número' },
{ id: CustomFieldType.DATE, label: 'Fecha' },
{ id: CustomFieldType.SELECT, label: 'Lista de opciones' },
];
const CATEGORIES = Object.values(StatusCategory);
@@ -75,8 +83,9 @@ function idFrom(name: string): string {
<div>
<h2>Configuración</h2>
<p>
Estados, transiciones, tipos de ticket y áreas de este proyecto. Lo
que ya se usa no se borra: se archiva y deja de ofrecerse.
Estados, transiciones, tipos de ticket, áreas y campos de este
proyecto. Lo que ya se usa no se borra: se archiva y deja de
ofrecerse.
</p>
</div>
</div>
@@ -520,6 +529,92 @@ function idFrom(name: string): string {
}
</ol>
</section>
<!-- Custom fields -->
<section class="ts-panel ts-block" aria-labelledby="cf-title">
<header class="ts-block__head">
<div>
<h3 id="cf-title">Campos personalizados</h3>
<p class="ts-muted">
Datos propios de este proyecto en cada ticket (navegador,
cliente, coste…). El tipo no cambia una vez guardado.
</p>
</div>
<button
type="button"
class="ts-add"
[disabled]="d.customFields.length >= 30"
(click)="addField()"
>
+ Añadir campo
</button>
</header>
@if (!d.customFields.length) {
<p class="ts-muted ts-empty-fields">Ningún campo todavía.</p>
}
<ol class="ts-rows">
@for (field of d.customFields; track $index; let i = $index) {
<li
class="ts-row ts-field-grid"
[class.is-archived]="field.archived"
>
<span class="ts-name">
<input
class="ts-input"
maxlength="40"
[attr.aria-label]="'Nombre del campo ' + (i + 1)"
[value]="field.name"
(input)="
setName('customFields', i, $any($event.target).value)
"
/>
<code class="ts-id">{{ field.id }}</code>
</span>
<select
class="ts-input ts-field-type"
aria-label="Tipo de campo"
[disabled]="!isNew('customFields', field.id)"
[attr.title]="
isNew('customFields', field.id)
? null
: 'Ya guardado: el tipo no se puede cambiar'
"
(change)="setFieldType(i, $any($event.target).value)"
>
@for (t of fieldTypes; track t.id) {
<option [value]="t.id" [selected]="t.id === field.type">
{{ t.label }}
</option>
}
</select>
<ng-container
[ngTemplateOutlet]="archive"
[ngTemplateOutletContext]="{
section: 'customFields',
index: i,
item: field,
}"
/>
@if (field.type === 'SELECT') {
<label class="ts-field-options">
<span>Opciones (una por línea)</span>
<textarea
rows="3"
[attr.aria-label]="'Opciones de ' + field.name"
[value]="
field.options.join(
'
'
)
"
(input)="setOptions(i, $any($event.target).value)"
></textarea>
</label>
}
</li>
}
</ol>
</section>
</div>
</fieldset>
@@ -860,6 +955,36 @@ function idFrom(name: string): string {
align-items: center;
gap: 0.5rem;
}
/* Name and archive on top; the type (and options) below. */
.ts-field-grid {
display: grid;
grid-template-columns: minmax(8rem, 1fr) 6.5rem;
align-items: center;
gap: 0.5rem;
.ts-field-type {
grid-row: 2;
grid-column: 1 / -1;
}
}
.ts-field-options {
grid-column: 1 / -1;
display: grid;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--ts-muted);
textarea {
font: inherit;
color: var(--ts-ink);
background: var(--ts-surface);
border: 1px solid var(--ts-line);
border-radius: 0.5rem;
padding: 0.375rem 0.5rem;
resize: vertical;
}
}
.ts-empty-fields {
margin: 0;
}
.ts-row {
--tone: var(--ts-muted);
padding: 0.375rem 0.5rem;
@@ -1074,6 +1199,7 @@ export class SettingsPage {
protected readonly categories = CATEGORIES;
protected readonly colors = SETTING_COLORS;
protected readonly icons = TYPE_ICONS;
protected readonly fieldTypes = FIELD_TYPES;
/** What the API has; the draft is compared against it. */
private readonly saved = computed(
@@ -1098,7 +1224,7 @@ export class SettingsPage {
const d = this.draft();
if (!d) return [];
const problems: string[] = [];
const lists = [d.statuses, d.issueTypes, d.areas];
const lists = [d.statuses, d.issueTypes, d.areas, d.customFields];
if (lists.some((list) => list.some((item) => !item.name.trim())))
problems.push('Hay nombres vacíos');
if (
@@ -1117,6 +1243,12 @@ export class SettingsPage {
problems.push('Falta un estado activo «Hecho»');
if (!d.issueTypes.some((t) => !t.archived))
problems.push('Hace falta al menos un tipo de ticket activo');
for (const field of d.customFields)
if (
field.type === CustomFieldType.SELECT &&
!field.options.some((o) => o.trim())
)
problems.push(`El campo «${field.name}» necesita alguna opción`);
return problems;
});
@@ -1264,6 +1396,33 @@ export class SettingsPage {
);
}
protected addField(): void {
this.patch((d) =>
d.customFields.push({
id: this.uniqueId(d.customFields, 'NUEVO_CAMPO'),
name: 'Nuevo campo',
type: CustomFieldType.TEXT,
options: [],
archived: false,
}),
);
}
protected setFieldType(index: number, type: CustomFieldType): void {
this.patch((d) => {
const field = d.customFields[index];
field.type = type;
if (type !== CustomFieldType.SELECT) field.options = [];
});
}
/** One per line; blank lines and repeats go when saved. */
protected setOptions(index: number, text: string): void {
this.patch((d) => {
d.customFields[index].options = text.split('\n').slice(0, 50);
});
}
/** Only for values never saved; saved ones are archived instead. */
protected remove(section: Section, index: number): void {
this.patch((d) => {
@@ -1284,10 +1443,19 @@ export class SettingsPage {
protected save(): void {
const draft = this.draft();
if (!draft || !this.dirty() || this.problems().length) return;
this.facade.saveSettings({
// Options as the API keeps them, so the draft is clean once saved.
const cleaned: ProjectSettings = {
...draft,
customFields: draft.customFields.map((f) => ({
...f,
options: [...new Set(f.options.map((o) => o.trim()).filter(Boolean))],
})),
};
this.draft.set(cleaned);
this.facade.saveSettings({
...cleaned,
// Only rows with targets; unknown ids would be rejected anyway.
transitions: draft.transitions.filter((t) => t.to.length),
transitions: cleaned.transitions.filter((t) => t.to.length),
});
}

View File

@@ -0,0 +1,173 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
} from '@angular/core';
import { PiActionButtonComponent, PiNoteComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import { CalendarFacade } from '@task-stream/web/tracker/application';
/**
* My calendar URL: what to paste into Google Calendar, Outlook or Apple
* Calendar to see my due dates, sprints and releases there. Renewing it
* cuts off whoever had the old one.
*/
@Component({
selector: 'ts-calendar-feed',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiActionButtonComponent, PiNoteComponent],
template: `
<div class="ts-cal">
<p class="ts-muted">
Tus tickets sin terminar con fecha límite (los asignados y los que
sigues), los sprints en marcha o planificados y las releases con fecha
de tus proyectos, en tu calendario. Se actualiza solo, normalmente cada
pocas horas.
</p>
@if (calendar.error(); as error) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="Calendario"
[text]="error"
/>
}
@if (!calendar.loaded()) {
<p class="ts-muted">Cargando…</p>
} @else if (calendar.feed(); as feed) {
<label class="ts-cal__label" for="ts-cal-url"
>Dirección del calendario</label
>
<div class="ts-cal__url">
<input
id="ts-cal-url"
readonly
[value]="feed.url"
(focus)="$any($event.target).select()"
/>
<pi-action-button
size="small"
color="primary"
text="Copiar"
(execute)="copy(feed.url)"
/>
</div>
<p class="ts-cal__warn">
Es privada: quien la tenga ve estas fechas y títulos. Si se ha
compartido de más, genera otra.
</p>
<details class="ts-cal__how">
<summary>Cómo añadirla</summary>
<ul>
<li>
<strong>Google Calendar</strong>: Otros calendarios → «+» → Desde
URL.
</li>
<li>
<strong>Outlook</strong>: Agregar calendario → Suscribirse desde
la Web.
</li>
<li>
<strong>Apple Calendar</strong>: Archivo → Nueva suscripción a
calendario.
</li>
</ul>
</details>
<div class="ts-cal__actions">
<pi-action-button
size="small"
appearance="outline"
color="grey"
text="Generar otra"
[disabled]="calendar.busy()"
(execute)="calendar.create()"
/>
<pi-action-button
size="small"
appearance="ghost"
color="grey"
text="Desactivar"
[disabled]="calendar.busy()"
(execute)="calendar.revoke()"
/>
</div>
} @else {
<pi-action-button
size="w100"
color="primary"
text="Crear la dirección del calendario"
[disabled]="calendar.busy()"
(execute)="calendar.create()"
/>
}
</div>
`,
styles: `
.ts-cal {
display: grid;
gap: 0.875rem;
p {
margin: 0;
}
}
.ts-cal__label {
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-cal__url {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: -0.5rem;
input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.625rem;
border: 1px solid var(--ts-line-strong, var(--ts-line));
border-radius: 0.5rem;
background: var(--ts-surface, transparent);
color: inherit;
font: inherit;
font-family: var(--ts-mono, ui-monospace, monospace);
font-size: 0.75rem;
}
}
.ts-cal__warn {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-cal__how {
font-size: 0.875rem;
summary {
cursor: pointer;
}
ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
}
}
.ts-cal__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
`,
})
export class CalendarFeedComponent implements OnInit {
protected readonly calendar = inject(CalendarFacade);
private readonly toast = inject(ToastService);
ngOnInit(): void {
this.calendar.load();
}
protected copy(url: string): void {
navigator.clipboard
?.writeText(url)
.then(() => this.toast.success('Dirección copiada'))
.catch(() => this.toast.error('No se pudo copiar'));
}
}

View File

@@ -0,0 +1,125 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
linkedSignal,
output,
} from '@angular/core';
import {
PiDateFieldComponent,
PiLabelDirective,
PiNumberFieldComponent,
PiSelectFieldComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import {
CustomFieldDefinition,
CustomFieldType,
} from '@task-stream/web/tracker/domain';
/** `YYYY-MM-DD` of a local date. */
const dayOf = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
/**
* One custom field of a ticket, with the control its type asks for. Lists
* and dates are saved when picked; text and numbers when leaving the field.
* Emits the new value (null clears it) only when it changed.
*/
@Component({
selector: 'ts-custom-field',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiDateFieldComponent,
PiLabelDirective,
PiNumberFieldComponent,
PiSelectFieldComponent,
PiTextFieldComponent,
],
template: `
@switch (field().type) {
@case (types.SELECT) {
<pi-select-field
[options]="options()"
[disabled]="disabled()"
[value]="value() ?? ''"
(valueChange)="commit($event || null)"
>
<pi-label>{{ field().name }}</pi-label>
</pi-select-field>
}
@case (types.DATE) {
<pi-date-field
[disabled]="disabled()"
[value]="date()"
(valueChange)="commit($event ? dayOf($event) : null)"
>
<pi-label>{{ field().name }}</pi-label>
</pi-date-field>
}
@case (types.NUMBER) {
<pi-number-field
[disabled]="disabled()"
[(value)]="number"
(touch)="commit(number() === null ? null : '' + number())"
>
<pi-label>{{ field().name }}</pi-label>
</pi-number-field>
}
@default {
<pi-text-field
[disabled]="disabled()"
[(value)]="text"
(touch)="commit(text().trim() || null)"
>
<pi-label>{{ field().name }}</pi-label>
</pi-text-field>
}
}
`,
styles: `
:host {
display: block;
}
`,
})
export class CustomFieldComponent {
readonly field = input.required<CustomFieldDefinition>();
readonly value = input<string | null>(null);
readonly disabled = input(false);
readonly valueChange = output<string | null>();
protected readonly types = CustomFieldType;
protected readonly dayOf = dayOf;
/** What is being typed; follows the saved value when it changes. */
protected readonly text = linkedSignal(() => this.value() ?? '');
protected readonly number = linkedSignal(() => {
const value = this.value();
return value === null || value === '' ? null : Number(value);
});
protected readonly date = computed(() => {
const day = this.value();
return day ? new Date(`${day.slice(0, 10)}T00:00:00`) : null;
});
/** The choices; one no longer offered still shows while the ticket has it. */
protected readonly options = computed<PiSelectFieldOption<string>[]>(() => {
const current = this.value();
const choices = this.field().options;
return [
{ value: '', label: 'Sin valor' },
...choices.map((o) => ({ value: o, label: o })),
...(current && !choices.includes(current)
? [{ value: current, label: current }]
: []),
];
});
protected commit(next: string | null): void {
if (next !== (this.value() || null)) this.valueChange.emit(next);
}
}

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