diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 0bcf62e..2c43b62 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -6245,6 +6245,44 @@ "security": [] } }, + "/api/explore/projects/{project}/showcase": { + "get": { + "description": "For public websites: epics with their dates and progress, the latest releases (with the notes of shipped ones), the sprint under way and open tickets labelled good-first-issue. Paths point into the web app.", + "operationId": "ExploreController_showcase", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShowcaseDto" + } + } + } + }, + "404": { + "description": "Not a public project" + } + }, + "summary": "A public project's showcase (no token needed)", + "tags": [ + "Explore" + ], + "security": [] + } + }, "/api/me/notifications": { "get": { "operationId": "NotificationsController_mine", @@ -11526,6 +11564,210 @@ "markdown" ] }, + "ShowcaseProjectDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "key": { + "type": "string", + "example": "WEB" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "key", + "name", + "description" + ] + }, + "ShowcaseSprintDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "goal": { + "type": "string", + "nullable": true + }, + "startDate": { + "type": "string", + "format": "date" + }, + "endDate": { + "type": "string", + "format": "date" + }, + "percent": { + "type": "number" + } + }, + "required": [ + "name", + "goal", + "startDate", + "endDate", + "percent" + ] + }, + "ShowcaseEpicDto": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "startDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "dueDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "percent": { + "type": "number" + }, + "done": { + "type": "boolean" + }, + "path": { + "type": "string", + "example": "/p/WEB/issues/WEB-3", + "description": "In the web app" + } + }, + "required": [ + "key", + "title", + "startDate", + "dueDate", + "percent", + "done", + "path" + ] + }, + "ShowcaseReleaseDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/ReleaseStatus" + } + ] + }, + "targetDate": { + "type": "string", + "nullable": true + }, + "releasedAt": { + "type": "string", + "nullable": true + }, + "percent": { + "type": "number" + }, + "notes": { + "type": "string", + "nullable": true, + "description": "Shipped ones only" + } + }, + "required": [ + "name", + "status", + "targetDate", + "releasedAt", + "percent", + "notes" + ] + }, + "ShowcaseIssueDto": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string", + "description": "A ticket type id" + }, + "path": { + "type": "string", + "example": "/p/WEB/issues/WEB-9" + } + }, + "required": [ + "key", + "title", + "type", + "path" + ] + }, + "ShowcaseDto": { + "type": "object", + "properties": { + "project": { + "$ref": "#/components/schemas/ShowcaseProjectDto" + }, + "activeSprint": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ShowcaseSprintDto" + } + ] + }, + "roadmap": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShowcaseEpicDto" + } + }, + "releases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShowcaseReleaseDto" + } + }, + "goodFirst": { + "description": "Open tickets labelled good-first-issue", + "type": "array", + "items": { + "$ref": "#/components/schemas/ShowcaseIssueDto" + } + } + }, + "required": [ + "project", + "activeSprint", + "roadmap", + "releases", + "goodFirst" + ] + }, "Object": { "type": "object", "properties": {} diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 394fa99..01fb9e5 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -917,6 +917,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/explore/projects/{project}/showcase": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * A public project's showcase (no token needed) + * @description For public websites: epics with their dates and progress, the latest releases (with the notes of shipped ones), the sprint under way and open tickets labelled good-first-issue. Paths point into the web app. + */ + get: operations["ExploreController_showcase"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/me/notifications": { parameters: { query?: never; @@ -2115,6 +2135,63 @@ export interface components { */ markdown: string; }; + ShowcaseProjectDto: { + /** Format: uuid */ + id: string; + /** @example WEB */ + key: string; + name: string; + description: string | null; + }; + ShowcaseSprintDto: { + name: string; + goal: string | null; + /** Format: date */ + startDate: string; + /** Format: date */ + endDate: string; + percent: number; + }; + ShowcaseEpicDto: { + key: string; + title: string; + /** Format: date */ + startDate: string | null; + /** Format: date */ + dueDate: string | null; + percent: number; + done: boolean; + /** + * @description In the web app + * @example /p/WEB/issues/WEB-3 + */ + path: string; + }; + ShowcaseReleaseDto: { + name: string; + status: components["schemas"]["ReleaseStatus"]; + targetDate: string | null; + releasedAt: string | null; + percent: number; + /** @description Shipped ones only */ + notes: string | null; + }; + ShowcaseIssueDto: { + key: string; + title: string; + /** @description A ticket type id */ + type: string; + /** @example /p/WEB/issues/WEB-9 */ + path: string; + }; + ShowcaseDto: { + project: components["schemas"]["ShowcaseProjectDto"]; + activeSprint: components["schemas"]["ShowcaseSprintDto"] | null; + roadmap: components["schemas"]["ShowcaseEpicDto"][]; + releases: components["schemas"]["ShowcaseReleaseDto"][]; + /** @description Open tickets labelled good-first-issue */ + goodFirst: components["schemas"]["ShowcaseIssueDto"][]; + }; Object: Record; /** @enum {string} */ NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; @@ -6652,6 +6729,35 @@ export interface operations { }; }; }; + ExploreController_showcase: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShowcaseDto"]; + }; + }; + /** @description Not a public project */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; NotificationsController_mine: { parameters: { query?: { diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 3ddb481..bd4a88a 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -24,6 +24,7 @@ | ☑️ 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 | +| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | diff --git a/docs/03-api.md b/docs/03-api.md index 3ba4a39..8d4dab8 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -167,6 +167,10 @@ stateDiagram-v2 actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el event store. +- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de + fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas + versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta + `good-first-issue`. Sin token, con `Cache-Control: public, max-age=300`; un proyecto privado responde 404. - **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/ admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` / @@ -534,6 +538,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | | Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | | Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | +| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts index f9928da..4c61628 100644 --- a/libs/api/tracker/application/src/lib/projects.service.ts +++ b/libs/api/tracker/application/src/lib/projects.service.ts @@ -1,3 +1,4 @@ +import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query'; import { Injectable } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { @@ -32,6 +33,11 @@ export class ProjectsService { private readonly queryBus: QueryBus, ) {} + /** A public project's roadmap, releases, sprint and good first tickets (no token). */ + showcase(project: string): Promise { + return this.queryBus.execute(new GetShowcaseQuery(project)); + } + create( actorId: string, input: { diff --git a/libs/api/tracker/application/src/lib/queries/get-showcase.query.spec.ts b/libs/api/tracker/application/src/lib/queries/get-showcase.query.spec.ts new file mode 100644 index 0000000..cb6a8ea --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-showcase.query.spec.ts @@ -0,0 +1,123 @@ +import { NotFoundError } from '@task-stream/api/shared/domain'; +import { ReleaseStatus, StatusCategory } from '@task-stream/api/tracker/domain'; +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 { + GOOD_FIRST_LABEL, + GetShowcaseQuery, + GetShowcaseQueryHandler, +} from './get-showcase.query'; + +const project = { + id: 'p1', + key: 'WEB', + name: 'Web', + description: null, + isPublic: true, +}; +const progress = { + total: 4, + finished: 1, + percent: 25, + storyPoints: 0, + finishedStoryPoints: 0, + byStatus: [], +}; + +function handler(isPublic = true) { + const search = jest.fn(async () => ({ + items: [{ key: 'WEB-9', title: 'Typo', type: 'TASK' }], + total: 1, + })); + const issues = { + epics: async () => [ + { + issue: { + key: 'WEB-1', + title: 'Pagos', + startDate: '2026-10-01', + dueDate: '2026-11-30', + statusCategory: StatusCategory.IN_PROGRESS, + }, + progress, + }, + ], + search, + releaseProgress: async () => progress, + sprintProgress: async () => progress, + } as unknown as FindIssuesRepository; + const projects = { + findById: async () => null, + findByKey: async () => ({ ...project, isPublic }), + } as unknown as FindProjectsRepository; + const releases = { + findByProject: async () => [ + { + id: 'r1', + name: 'v1', + status: ReleaseStatus.RELEASED, + targetDate: '2026-09-01', + releasedAt: '2026-09-02', + notes: '## v1', + }, + { + id: 'r2', + name: 'v2', + status: ReleaseStatus.PLANNED, + targetDate: '2026-12-01', + releasedAt: null, + notes: 'draft', + }, + { + id: 'r0', + name: 'old', + status: ReleaseStatus.ARCHIVED, + targetDate: null, + releasedAt: null, + notes: null, + }, + ], + } as unknown as FindReleasesRepository; + const sprints = { + findActive: async () => null, + } as unknown as FindSprintsRepository; + return { + handler: new GetShowcaseQueryHandler(projects, issues, releases, sprints), + search, + }; +} + +describe('public showcase', () => { + it('shows epics, releases (notes only once shipped) and good first tickets', async () => { + const { handler: h, search } = handler(); + const showcase = await h.execute(new GetShowcaseQuery('web')); + expect(showcase.roadmap[0]).toMatchObject({ + key: 'WEB-1', + percent: 25, + path: '/p/WEB/issues/WEB-1', + }); + expect(showcase.releases.map((r) => [r.name, r.notes])).toEqual([ + ['v2', null], + ['v1', '## v1'], + ]); + expect(showcase.goodFirst).toEqual([ + { + key: 'WEB-9', + title: 'Typo', + type: 'TASK', + path: '/p/WEB/issues/WEB-9', + }, + ]); + expect(search).toHaveBeenCalledWith( + expect.objectContaining({ labels: [GOOD_FIRST_LABEL] }), + ); + }); + + it('knows nothing of private projects', async () => { + await expect( + handler(false).handler.execute(new GetShowcaseQuery('WEB')), + ).rejects.toThrow(NotFoundError); + }); +}); diff --git a/libs/api/tracker/application/src/lib/queries/get-showcase.query.ts b/libs/api/tracker/application/src/lib/queries/get-showcase.query.ts new file mode 100644 index 0000000..2f163a7 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-showcase.query.ts @@ -0,0 +1,141 @@ +import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs'; +import { NotFoundError } from '@task-stream/api/shared/domain'; +import { ReleaseStatus, StatusCategory } from '@task-stream/api/tracker/domain'; +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'; + +/** Tickets with this label are offered to newcomers on public pages. */ +export const GOOD_FIRST_LABEL = 'good-first-issue'; + +/** + * What a public project shows to the world in one call: where it is going + * (epics with dates, releases), what it is doing (the sprint under way) and + * where someone new could help. Paths are relative to the web app. + */ +export interface Showcase { + project: { + id: string; + key: string; + name: string; + description: string | null; + }; + activeSprint: { + name: string; + goal: string | null; + startDate: string; + endDate: string; + percent: number; + } | null; + roadmap: { + key: string; + title: string; + startDate: string | null; + dueDate: string | null; + percent: number; + done: boolean; + path: string; + }[]; + releases: { + name: string; + status: ReleaseStatus; + targetDate: string | null; + releasedAt: string | null; + percent: number; + notes: string | null; + }[]; + goodFirst: { key: string; title: string; type: string; path: string }[]; +} + +export class GetShowcaseQuery extends Query { + constructor(public readonly project: string) { + super(); + } +} + +const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED]; + +@QueryHandler(GetShowcaseQuery) +export class GetShowcaseQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findProjects: FindProjectsRepository, + private readonly findIssues: FindIssuesRepository, + private readonly findReleases: FindReleasesRepository, + private readonly findSprints: FindSprintsRepository, + ) {} + + /** Public projects only: anything else is not there. */ + async execute(query: GetShowcaseQuery): Promise { + const project = + (await this.findProjects.findById(query.project)) ?? + (await this.findProjects.findByKey(query.project.toUpperCase())); + if (!project?.isPublic) throw new NotFoundError('Project', query.project); + const path = (key: string) => `/p/${project.key}/issues/${key}`; + const [epics, releases, sprint, goodFirst] = await Promise.all([ + this.findIssues.epics(project.id), + this.findReleases.findByProject(project.id), + this.findSprints.findActive(project.id), + this.findIssues.search({ + projectId: project.id, + labels: [GOOD_FIRST_LABEL], + categories: [StatusCategory.TODO], + limit: 20, + offset: 0, + }), + ]); + const visible = releases + .filter((r) => r.status !== ReleaseStatus.ARCHIVED) + .sort((a, b) => + (b.releasedAt ?? b.targetDate ?? '').localeCompare( + a.releasedAt ?? a.targetDate ?? '', + ), + ) + .slice(0, 6); + const progress = await Promise.all( + visible.map((r) => this.findIssues.releaseProgress(r.id)), + ); + return { + project: { + id: project.id, + key: project.key, + name: project.name, + description: project.description, + }, + activeSprint: sprint + ? { + name: sprint.name, + goal: sprint.goal, + startDate: sprint.startDate, + endDate: sprint.endDate, + percent: (await this.findIssues.sprintProgress(sprint.id)).percent, + } + : null, + roadmap: epics.map(({ issue, progress: p }) => ({ + key: issue.key, + title: issue.title, + startDate: issue.startDate, + dueDate: issue.dueDate, + percent: p.percent, + done: FINISHED.includes(issue.statusCategory), + path: path(issue.key), + })), + releases: visible.map((r, i) => ({ + name: r.name, + status: r.status, + targetDate: r.targetDate, + releasedAt: r.releasedAt, + percent: progress[i].percent, + notes: r.status === ReleaseStatus.RELEASED ? r.notes : null, + })), + goodFirst: goodFirst.items.map((i) => ({ + key: i.key, + title: i.title, + type: i.type, + path: path(i.key), + })), + }; + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index 73b1b45..cbb15c7 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -21,3 +21,4 @@ export * from './get-epics.query'; export * from './sprint.queries'; export * from './get-release-notes-draft.query'; export * from './get-trash.query'; +export * from './get-showcase.query'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index 858b559..a2c1940 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -20,6 +20,7 @@ import { GetEpicsQueryHandler } from './get-epics.query-handler'; import { SPRINT_QUERY_HANDLERS } from './sprint.queries'; import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query'; import { GetTrashQueryHandler } from './get-trash.query'; +import { GetShowcaseQueryHandler } from './get-showcase.query'; import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler'; export const QUERY_HANDLERS = [ @@ -44,6 +45,7 @@ export const QUERY_HANDLERS = [ ...SPRINT_QUERY_HANDLERS, GetReleaseNotesDraftQueryHandler, GetTrashQueryHandler, + GetShowcaseQueryHandler, SearchMyIssuesQueryHandler, GetAttachmentQueryHandler, ]; 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 05c8b04..b723862 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 @@ -234,6 +234,72 @@ export class BulkResultDto { @ApiProperty({ type: [BulkIssueResultDto] }) results!: BulkIssueResultDto[]; } +class ShowcaseProjectDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ example: 'WEB' }) key!: string; + @ApiProperty() name!: string; + @ApiProperty({ type: String, nullable: true }) description!: string | null; +} + +class ShowcaseSprintDto { + @ApiProperty() name!: string; + @ApiProperty({ type: String, nullable: true }) goal!: string | null; + @ApiProperty({ format: 'date' }) startDate!: string; + @ApiProperty({ format: 'date' }) endDate!: string; + @ApiProperty() percent!: number; +} + +class ShowcaseEpicDto { + @ApiProperty() key!: string; + @ApiProperty() title!: string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + startDate!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + dueDate!: string | null; + @ApiProperty() percent!: number; + @ApiProperty() done!: boolean; + @ApiProperty({ + example: '/p/WEB/issues/WEB-3', + description: 'In the web app', + }) + path!: string; +} + +class ShowcaseReleaseDto { + @ApiProperty() name!: string; + @ApiProperty({ enum: ReleaseStatus, enumName: 'ReleaseStatus' }) + status!: ReleaseStatus; + @ApiProperty({ type: String, nullable: true }) targetDate!: string | null; + @ApiProperty({ type: String, nullable: true }) releasedAt!: string | null; + @ApiProperty() percent!: number; + @ApiProperty({ + type: String, + nullable: true, + description: 'Shipped ones only', + }) + notes!: string | null; +} + +class ShowcaseIssueDto { + @ApiProperty() key!: string; + @ApiProperty() title!: string; + @ApiProperty({ description: 'A ticket type id' }) type!: string; + @ApiProperty({ example: '/p/WEB/issues/WEB-9' }) path!: string; +} + +export class ShowcaseDto { + @ApiProperty({ type: ShowcaseProjectDto }) project!: ShowcaseProjectDto; + @ApiProperty({ type: ShowcaseSprintDto, nullable: true }) + activeSprint!: ShowcaseSprintDto | null; + @ApiProperty({ type: [ShowcaseEpicDto] }) roadmap!: ShowcaseEpicDto[]; + @ApiProperty({ type: [ShowcaseReleaseDto] }) releases!: ShowcaseReleaseDto[]; + @ApiProperty({ + type: [ShowcaseIssueDto], + description: 'Open tickets labelled good-first-issue', + }) + goodFirst!: ShowcaseIssueDto[]; +} + export class GitDeliveryResultDto { @ApiProperty({ description: 'Tickets that got something new' }) recorded!: number; diff --git a/libs/api/tracker/presenters/src/lib/http/explore.controller.ts b/libs/api/tracker/presenters/src/lib/http/explore.controller.ts index 8dd6929..cb3cbf1 100644 --- a/libs/api/tracker/presenters/src/lib/http/explore.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/explore.controller.ts @@ -1,8 +1,14 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Header, Param } from '@nestjs/common'; +import { + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; import { Public } from '@task-stream/api/identity/presenters'; import { ProjectsService } from '@task-stream/api/tracker/application'; -import { ProjectDto } from './dto/responses.dto'; +import { ProjectDto, ShowcaseDto } from './dto/responses.dto'; import { toProjectDto } from './refs'; /** What anyone can read without signing in. */ @@ -22,4 +28,23 @@ export class ExploreController { async publicProjects() { return (await this.projects.findPublic()).map(toProjectDto); } + + @Get('projects/:project/showcase') + @Public() + @Header('Cache-Control', 'public, max-age=300') + @ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', + }) + @ApiOperation({ + summary: "A public project's showcase (no token needed)", + description: + 'For public websites: epics with their dates and progress, the latest releases (with the notes of shipped ones), the sprint under way and open tickets labelled good-first-issue. Paths point into the web app.', + }) + @ApiOkResponse({ type: ShowcaseDto }) + @ApiNotFoundResponse({ description: 'Not a public project' }) + showcase(@Param('project') project: string) { + return this.projects.showcase(project); + } }