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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user