diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 587b059..07b8e65 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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", @@ -9967,6 +10075,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": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index b1625db..435787f 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -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! @@ -677,6 +686,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 +749,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! @@ -996,6 +1011,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]) """ diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 1812ae2..fe282a2 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -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; @@ -1537,6 +1576,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; @@ -3032,6 +3076,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; diff --git a/docs/03-api.md b/docs/03-api.md index f337120..9da730d 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -256,6 +256,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 diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 443e0b6..d71d756 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -127,6 +127,12 @@ 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. +### 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 diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 6d505ee..23efe13 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -32,14 +32,11 @@ cuando hay pistas, por dónde empezar. 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é +1. **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 +2. **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 @@ -104,3 +101,5 @@ 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. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 2f73bbf..a9b4919 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 16b7ac4..a10b480 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index d408268..8c44dd9 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -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'; diff --git a/libs/api/tracker/application/src/lib/calendar.service.ts b/libs/api/tracker/application/src/lib/calendar.service.ts new file mode 100644 index 0000000..0920db6 --- /dev/null +++ b/libs/api/tracker/application/src/lib/calendar.service.ts @@ -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 { + return this.queryBus.execute(new GetMyCalendarFeedQuery(userId)); + } + + /** A new secret URL; the previous one, if any, stops working. */ + create(userId: string): Promise { + return this.commandBus.execute(new CreateCalendarFeedCommand(userId)); + } + + revoke(userId: string): Promise { + return this.commandBus.execute(new RevokeCalendarFeedCommand(userId)); + } + + /** The calendar behind `token`, or null if no feed has it. */ + ics(token: string, webUrl: string): Promise { + return this.queryBus.execute(new GetCalendarIcsQuery(token, webUrl)); + } +} diff --git a/libs/api/tracker/application/src/lib/calendar/calendar.ts b/libs/api/tracker/application/src/lib/calendar/calendar.ts new file mode 100644 index 0000000..b000274 --- /dev/null +++ b/libs/api/tracker/application/src/lib/calendar/calendar.ts @@ -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 { + constructor(public readonly userId: string) { + super(); + } +} + +/** The person's calendar URL stops working. */ +export class RevokeCalendarFeedCommand extends Command { + constructor(public readonly userId: string) { + super(); + } +} + +export class GetMyCalendarFeedQuery extends Query { + constructor(public readonly userId: string) { + super(); + } +} + +/** The calendar behind a token, as iCalendar text; null for an unknown one. */ +export class GetCalendarIcsQuery extends Query { + 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 +{ + constructor(private readonly feeds: CalendarFeedsRepository) {} + + async execute(command: CreateCalendarFeedCommand): Promise { + 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 +{ + constructor(private readonly feeds: CalendarFeedsRepository) {} + + async execute(command: RevokeCalendarFeedCommand): Promise { + await this.feeds.remove(command.userId); + } +} + +@QueryHandler(GetMyCalendarFeedQuery) +export class GetMyCalendarFeedQueryHandler + implements IQueryHandler +{ + constructor(private readonly feeds: CalendarFeedsRepository) {} + + execute(query: GetMyCalendarFeedQuery): Promise { + 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 +{ + 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 { + 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, +]; diff --git a/libs/api/tracker/application/src/lib/calendar/ics.spec.ts b/libs/api/tracker/application/src/lib/calendar/ics.spec.ts new file mode 100644 index 0000000..0e301e1 --- /dev/null +++ b/libs/api/tracker/application/src/lib/calendar/ics.spec.ts @@ -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}`); + }); +}); diff --git a/libs/api/tracker/application/src/lib/calendar/ics.ts b/libs/api/tracker/application/src/lib/calendar/ics.ts new file mode 100644 index 0000000..4f66b2d --- /dev/null +++ b/libs/api/tracker/application/src/lib/calendar/ics.ts @@ -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 '); +} diff --git a/libs/api/tracker/application/src/lib/ports/calendar-feeds.repository.ts b/libs/api/tracker/application/src/lib/ports/calendar-feeds.repository.ts new file mode 100644 index 0000000..84f9f29 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/calendar-feeds.repository.ts @@ -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; + abstract findByToken(token: string): Promise; + /** Replaces the person's feed, if any. */ + abstract save(feed: CalendarFeed): Promise; + abstract remove(userId: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index c1954d2..b9c5cd1 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -73,6 +73,14 @@ export abstract class FindIssuesRepository { abstract stats(projectId: string): Promise; /** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */ abstract findOpenAssignedTo(userId: string): Promise; + /** + * Unfinished tickets with a due date, in those projects, assigned to or + * followed by `userId` (their calendar). + */ + abstract findDatedFor( + userId: string, + projectIds: string[], + ): Promise; /** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */ abstract findDueBy(day: string): Promise; /** The project's tickets in the trash, newest first. */ diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index 46631ba..119bdfa 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -27,3 +27,4 @@ export * from './git-integrations.repository'; export * from './outgoing-webhooks.repository'; export * from './automations.repository'; export * from './rate-limit.store'; +export * from './calendar-feeds.repository'; diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 5308f57..9ea0d7b 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -43,6 +43,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. */ @@ -57,6 +59,7 @@ const NOTIFICATIONS = [ ]; const SERVICES = [ + CalendarService, ProjectsService, IssuesService, CommentsService, @@ -98,6 +101,7 @@ const SERVICES = [ ...WEBHOOK_HANDLERS, ...TRANSFER_HANDLERS, ...AUTOMATION_HANDLERS, + ...CALENDAR_HANDLERS, AutomationsEventHandler, ], exports: SERVICES, diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/calendar-feeds.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/calendar-feeds.repository.ts new file mode 100644 index 0000000..f57505c --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/calendar-feeds.repository.ts @@ -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, + ) { + super(); + } + + async findByUser(userId: string): Promise { + const doc = await this.feeds + .findOne({ id: userId }) + .lean(); + return doc ? toFeed(doc) : null; + } + + async findByToken(token: string): Promise { + const doc = await this.feeds.findOne({ token }).lean(); + return doc ? toFeed(doc) : null; + } + + async save(feed: CalendarFeed): Promise { + await this.feeds.updateOne( + { id: feed.userId }, + { $set: { token: feed.token, createdAt: feed.createdAt } }, + { upsert: true }, + ); + } + + async remove(userId: string): Promise { + await this.feeds.deleteOne({ id: userId }); + } +} + +function toFeed(doc: CalendarFeedDoc): CalendarFeed { + return { userId: doc.id, token: doc.token, createdAt: doc.createdAt }; +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 2accac6..91a210c 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -266,6 +266,25 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { return docs.map(issueFromDoc); } + async findDatedFor( + userId: string, + projectIds: string[], + ): Promise { + 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(); + return docs.map(issueFromDoc); + } + async findByProject(projectId: string): Promise { const docs = await this.issues .find({ projectId, trashedAt: null }) diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index ea7e733..65a50f0 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -64,6 +64,12 @@ export interface GitIntegrationDoc { secret?: 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 +91,7 @@ export const ReadModels = { OutgoingWebhook: 'OutgoingWebhook', Automation: 'Automation', AutomationRun: 'AutomationRun', + CalendarFeed: 'CalendarFeed', } as const; /* @@ -329,6 +336,17 @@ 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); + // Redeliveries happen within minutes; a month of memory is plenty. AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 }); @@ -347,6 +365,7 @@ 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 }, ]; /** A document as the model it holds: Mongo's own `_id` left out. */ diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index ae6db9f..63c3a98 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -7,6 +7,7 @@ import { ChangeFeed, NotificationMailer, NotificationsRepository, + CalendarFeedsRepository, SavedFiltersRepository, GitIntegrationsRepository, OutgoingWebhooksRepository, @@ -65,6 +66,7 @@ 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 { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; @@ -130,6 +132,7 @@ 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: SavedFiltersRepository, useClass: MongoSavedFiltersRepository }, { provide: GitIntegrationsRepository, diff --git a/libs/api/tracker/presenters/src/lib/graphql/calendar.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/calendar.resolver.ts new file mode 100644 index 0000000..7c561ab --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/graphql/calendar.resolver.ts @@ -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; + } +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 6c88fa4..0fd44af 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -625,6 +625,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; diff --git a/libs/api/tracker/presenters/src/lib/http/calendar.controller.ts b/libs/api/tracker/presenters/src/lib/http/calendar.controller.ts new file mode 100644 index 0000000..64190b7 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/calendar.controller.ts @@ -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 { + const token = /^([\w-]{20,100})\.ics$/.exec(file)?.[1]; + const body = token + ? await this.calendar.ics(token, this.config.get('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 { + await this.calendar.revoke(me.id); + } +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 13d8afd..7cccac3 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -831,6 +831,15 @@ 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' }) diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index e0c49f9..e3a5ec2 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -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, diff --git a/libs/web/identity/infrastructure/src/lib/graphql/generated.ts b/libs/web/identity/infrastructure/src/lib/graphql/generated.ts index 221741f..837b0dc 100644 --- a/libs/web/identity/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/identity/infrastructure/src/lib/graphql/generated.ts @@ -1,43 +1,12 @@ /** Internal type. DO NOT USE DIRECTLY. */ type Exact = { [K in keyof T]: T[K] }; /** Internal type. DO NOT USE DIRECTLY. */ -export type Incremental = - | T - | { - [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never; - }; +export type Incremental = 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 }; -}; -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; +export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array } }; + + +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; \ No newline at end of file diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index 556c893..32e0576 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/notifications'; export * from './lib/facades/projects.facade'; +export * from './lib/facades/calendar.facade'; export * from './lib/facades/project.facade'; export * from './lib/facades/board.facade'; export * from './lib/facades/issue.facade'; diff --git a/libs/web/tracker/application/src/lib/facades/calendar.facade.ts b/libs/web/tracker/application/src/lib/facades/calendar.facade.ts new file mode 100644 index 0000000..9e85c83 --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/calendar.facade.ts @@ -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(null); + readonly loaded = signal(false); + readonly busy = signal(false); + readonly error = signal(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): 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.'); + } +} diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index e7bff1b..2c00ef7 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -429,6 +429,13 @@ 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; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 9d3838f..c73d8d1 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -2,21 +2,16 @@ import { Observable } from 'rxjs'; import { Activity, Attachment, + Automation, + AutomationInput, BoardColumn, + CalendarFeed, + CommandResult, Comment, Epic, GitIntegration, GitRepository, - Automation, - AutomationInput, ImportReport, - Webhook, - WebhookDelivery, - SavedFilter, - Sprint, - SprintBurndown, - SavedFilterCriteria, - CommandResult, Issue, IssuePage, IssueSummary, @@ -27,7 +22,13 @@ import { ProjectStats, Release, ReleaseBurnup, + SavedFilter, + SavedFilterCriteria, + Sprint, + SprintBurndown, UserDirectoryEntry, + Webhook, + WebhookDelivery, } from '../models/models'; import { ProjectSettings } from '../models/workflow'; import { @@ -332,6 +333,14 @@ export abstract class UsersGateway { } /** The signed-in person's notifications. */ +export abstract class CalendarGateway { + /** Mine, if I made one. */ + abstract mine(): Observable; + /** A new secret URL; the previous one stops working. */ + abstract create(): Observable; + abstract revoke(): Observable; +} + export abstract class NotificationsGateway { abstract mine( unreadOnly: boolean, diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-calendar.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-calendar.gateway.ts new file mode 100644 index 0000000..4b82bf9 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-calendar.gateway.ts @@ -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 { + return this.gql + .query(MyCalendarFeedDocument) + .pipe( + map((d) => (d.myCalendarFeed ? this.toFeed(d.myCalendarFeed) : null)), + ); + } + + create(): Observable { + return this.gql + .mutate(CreateCalendarFeedDocument, {}) + .pipe(map((d) => this.toFeed(d.createCalendarFeed))); + } + + revoke(): Observable { + 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, + }; + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/calendar.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/calendar.graphql new file mode 100644 index 0000000..a7f983d --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/graphql/calendar.graphql @@ -0,0 +1,17 @@ +query MyCalendarFeed { + myCalendarFeed { + token + createdAt + } +} + +mutation CreateCalendarFeed { + createCalendarFeed { + token + createdAt + } +} + +mutation RevokeCalendarFeed { + revokeCalendarFeed +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index d94082f..9937aff 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -438,6 +438,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 = { @@ -3796,6 +3816,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; +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: [ diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index 0284b9d..991e9d8 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -9,6 +9,7 @@ import { CommentsGateway, IssuesGateway, NotificationsGateway, + CalendarGateway, ProjectsGateway, ReleasesGateway, SavedFiltersGateway, @@ -28,6 +29,7 @@ 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 { HttpAttachmentsGateway } from './adapters/http-attachments.gateway'; import { HttpProposalsGateway } from './adapters/http-proposals.gateway'; import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway'; @@ -51,6 +53,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: ProposalsGateway, useClass: HttpProposalsGateway }, { provide: UsersGateway, useClass: GraphqlUsersGateway }, { provide: NotificationsGateway, useClass: GraphqlNotificationsGateway }, + { provide: CalendarGateway, useClass: GraphqlCalendarGateway }, { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, { provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway }, { provide: SprintsGateway, useClass: GraphqlSprintsGateway }, diff --git a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts index f110485..7265b7e 100644 --- a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts @@ -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; diff --git a/libs/web/tracker/presenters/src/lib/pages/my-work.ts b/libs/web/tracker/presenters/src/lib/pages/my-work.ts index ef53cfd..60da027 100644 --- a/libs/web/tracker/presenters/src/lib/pages/my-work.ts +++ b/libs/web/tracker/presenters/src/lib/pages/my-work.ts @@ -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()) {
@@ -58,6 +67,14 @@ const VISIBLE = 12; {{ home.mineTotal() }} abiertos + @if (home.qaFailed()) {

@@ -228,6 +245,9 @@ const VISIBLE = 12;

} + + + `, 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>('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()); + } } diff --git a/libs/web/tracker/presenters/src/lib/ui/calendar-feed.ts b/libs/web/tracker/presenters/src/lib/ui/calendar-feed.ts new file mode 100644 index 0000000..e59b93a --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/calendar-feed.ts @@ -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: ` +
+

+ 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. +

+ @if (calendar.error(); as error) { + + } + @if (!calendar.loaded()) { +

Cargando…

+ } @else if (calendar.feed(); as feed) { + +
+ + +
+

+ Es privada: quien la tenga ve estas fechas y títulos. Si se ha + compartido de más, genera otra. +

+
+ Cómo añadirla +
    +
  • + Google Calendar: Otros calendarios → «+» → Desde + URL. +
  • +
  • + Outlook: Agregar calendario → Suscribirse desde + la Web. +
  • +
  • + Apple Calendar: Archivo → Nueva suscripción a + calendario. +
  • +
+
+
+ + +
+ } @else { + + } +
+ `, + 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')); + } +}