Compare commits
5 Commits
993f80b4b9
...
21bc598590
| Author | SHA1 | Date | |
|---|---|---|---|
| 21bc598590 | |||
| f7e850068b | |||
| 233c46e9fa | |||
| 5ee627e689 | |||
| f45069a327 |
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
apps/web/public/logo-192.png
Normal file
BIN
apps/web/public/logo-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/public/logo-512.png
Normal file
BIN
apps/web/public/logo-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
25
apps/web/public/manifest.webmanifest
Normal file
25
apps/web/public/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
52
apps/web/public/push-sw.js
Normal file
52
apps/web/public/push-sw.js
Normal 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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
@@ -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';
|
||||
|
||||
36
libs/api/tracker/application/src/lib/calendar.service.ts
Normal file
36
libs/api/tracker/application/src/lib/calendar.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
193
libs/api/tracker/application/src/lib/calendar/calendar.ts
Normal file
193
libs/api/tracker/application/src/lib/calendar/calendar.ts
Normal 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,
|
||||
];
|
||||
62
libs/api/tracker/application/src/lib/calendar/ics.spec.ts
Normal file
62
libs/api/tracker/application/src/lib/calendar/ics.spec.ts
Normal 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}`);
|
||||
});
|
||||
});
|
||||
97
libs/api/tracker/application/src/lib/calendar/ics.ts
Normal file
97
libs/api/tracker/application/src/lib/calendar/ics.ts
Normal 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 ');
|
||||
}
|
||||
@@ -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[]) =>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
32
libs/api/tracker/application/src/lib/ports/push-sender.ts
Normal file
32
libs/api/tracker/application/src/lib/ports/push-sender.ts
Normal 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'>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import { IssueDetails } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** 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 Partial<IssueDetails>>(input: T): T {
|
||||
const details = { ...input };
|
||||
for (const field of ['title', 'type', 'priority', 'labels'] as const) {
|
||||
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;
|
||||
return {
|
||||
...(details as Omit<T, 'customFields'>),
|
||||
...(customFields ? { customFields: customFieldsOf(customFields) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 ?? [],
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,7 @@ describe('ProjectFacade', () => {
|
||||
transitions: [],
|
||||
issueTypes: [],
|
||||
areas: [],
|
||||
customFields: [],
|
||||
};
|
||||
|
||||
project.saveSettings(settings);
|
||||
|
||||
60
libs/web/tracker/application/src/lib/facades/push.facade.ts
Normal file
60
libs/web/tracker/application/src/lib/facades/push.facade.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
query MyCalendarFeed {
|
||||
myCalendarFeed {
|
||||
token
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateCalendarFeed {
|
||||
createCalendarFeed {
|
||||
token
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
mutation RevokeCalendarFeed {
|
||||
revokeCalendarFeed
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +1442,24 @@ 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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -163,6 +163,12 @@ query Epics($projectId: ID!) {
|
||||
epics(projectId: $projectId) {
|
||||
issue {
|
||||
...IssueSummaryFields
|
||||
links {
|
||||
type
|
||||
direction
|
||||
issueId
|
||||
issueKey
|
||||
}
|
||||
}
|
||||
progress {
|
||||
total
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
173
libs/web/tracker/presenters/src/lib/ui/calendar-feed.ts
Normal file
173
libs/web/tracker/presenters/src/lib/ui/calendar-feed.ts
Normal 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'));
|
||||
}
|
||||
}
|
||||
125
libs/web/tracker/presenters/src/lib/ui/custom-field.ts
Normal file
125
libs/web/tracker/presenters/src/lib/ui/custom-field.ts
Normal 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
Reference in New Issue
Block a user