feat: a calendar to subscribe to, and the backlog's corners

- Each person can subscribe their dates in Google Calendar, Outlook or
  Apple Calendar: GET /api/calendar/{token}.ics (no session) brings
  their unfinished tickets with a due date (assigned or followed), the
  planned and running sprints and the planned releases of their
  projects, as all-day events linking to the web. The token is made,
  renewed or dropped under /me/calendar (and in GraphQL); in the web,
  from «Calendario» in «Mi trabajo».
- The backlog panel keeps the selected rows' background inside its
  rounded corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 22:48:33 +02:00
parent 993f80b4b9
commit f45069a327
38 changed files with 1417 additions and 54 deletions

View File

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

View File

@@ -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' })