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:
@@ -19,6 +19,8 @@ export * from './lib/git.service';
|
||||
export * from './lib/webhooks.service';
|
||||
export * from './lib/transfer.service';
|
||||
export * from './lib/automations.service';
|
||||
export * from './lib/calendar.service';
|
||||
export * from './lib/calendar/calendar';
|
||||
export * from './lib/automations/automations';
|
||||
export * from './lib/automations/automation-rules';
|
||||
export * from './lib/transfer/import-export';
|
||||
|
||||
36
libs/api/tracker/application/src/lib/calendar.service.ts
Normal file
36
libs/api/tracker/application/src/lib/calendar.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
CreateCalendarFeedCommand,
|
||||
GetCalendarIcsQuery,
|
||||
GetMyCalendarFeedQuery,
|
||||
RevokeCalendarFeedCommand,
|
||||
} from './calendar/calendar';
|
||||
import { CalendarFeed } from './ports/calendar-feeds.repository';
|
||||
|
||||
/** A person's calendar subscription (iCalendar), behind a secret URL. */
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
mine(userId: string): Promise<CalendarFeed | null> {
|
||||
return this.queryBus.execute(new GetMyCalendarFeedQuery(userId));
|
||||
}
|
||||
|
||||
/** A new secret URL; the previous one, if any, stops working. */
|
||||
create(userId: string): Promise<CalendarFeed> {
|
||||
return this.commandBus.execute(new CreateCalendarFeedCommand(userId));
|
||||
}
|
||||
|
||||
revoke(userId: string): Promise<void> {
|
||||
return this.commandBus.execute(new RevokeCalendarFeedCommand(userId));
|
||||
}
|
||||
|
||||
/** The calendar behind `token`, or null if no feed has it. */
|
||||
ics(token: string, webUrl: string): Promise<string | null> {
|
||||
return this.queryBus.execute(new GetCalendarIcsQuery(token, webUrl));
|
||||
}
|
||||
}
|
||||
193
libs/api/tracker/application/src/lib/calendar/calendar.ts
Normal file
193
libs/api/tracker/application/src/lib/calendar/calendar.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueReadModel,
|
||||
ProjectReadModel,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
CalendarFeed,
|
||||
CalendarFeedsRepository,
|
||||
} from '../ports/calendar-feeds.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { CalendarEntry, buildIcs } from './ics';
|
||||
|
||||
/** A new secret URL for the person's calendar; the old one stops working. */
|
||||
export class CreateCalendarFeedCommand extends Command<CalendarFeed> {
|
||||
constructor(public readonly userId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The person's calendar URL stops working. */
|
||||
export class RevokeCalendarFeedCommand extends Command<void> {
|
||||
constructor(public readonly userId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetMyCalendarFeedQuery extends Query<CalendarFeed | null> {
|
||||
constructor(public readonly userId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The calendar behind a token, as iCalendar text; null for an unknown one. */
|
||||
export class GetCalendarIcsQuery extends Query<string | null> {
|
||||
constructor(
|
||||
public readonly token: string,
|
||||
/** Where tickets open (`WEB_URL`), for the events' links; may be empty. */
|
||||
public readonly webUrl: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(CreateCalendarFeedCommand)
|
||||
export class CreateCalendarFeedCommandHandler
|
||||
implements ICommandHandler<CreateCalendarFeedCommand>
|
||||
{
|
||||
constructor(private readonly feeds: CalendarFeedsRepository) {}
|
||||
|
||||
async execute(command: CreateCalendarFeedCommand): Promise<CalendarFeed> {
|
||||
const feed: CalendarFeed = {
|
||||
userId: command.userId,
|
||||
// 32 random bytes: nobody guesses it.
|
||||
token: randomBytes(32).toString('base64url'),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await this.feeds.save(feed);
|
||||
return feed;
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RevokeCalendarFeedCommand)
|
||||
export class RevokeCalendarFeedCommandHandler
|
||||
implements ICommandHandler<RevokeCalendarFeedCommand>
|
||||
{
|
||||
constructor(private readonly feeds: CalendarFeedsRepository) {}
|
||||
|
||||
async execute(command: RevokeCalendarFeedCommand): Promise<void> {
|
||||
await this.feeds.remove(command.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetMyCalendarFeedQuery)
|
||||
export class GetMyCalendarFeedQueryHandler
|
||||
implements IQueryHandler<GetMyCalendarFeedQuery, CalendarFeed | null>
|
||||
{
|
||||
constructor(private readonly feeds: CalendarFeedsRepository) {}
|
||||
|
||||
execute(query: GetMyCalendarFeedQuery): Promise<CalendarFeed | null> {
|
||||
return this.feeds.findByUser(query.userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the person has to keep an eye on, as all-day events: their unfinished
|
||||
* tickets with a due date (assigned or followed), the planned and running
|
||||
* sprints of their projects and their planned releases with a date. Read at
|
||||
* every download, so it is as fresh as the calendar app asks.
|
||||
*/
|
||||
@QueryHandler(GetCalendarIcsQuery)
|
||||
export class GetCalendarIcsQueryHandler
|
||||
implements IQueryHandler<GetCalendarIcsQuery, string | null>
|
||||
{
|
||||
constructor(
|
||||
private readonly feeds: CalendarFeedsRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async execute(query: GetCalendarIcsQuery): Promise<string | null> {
|
||||
const feed = await this.feeds.findByToken(query.token);
|
||||
if (!feed) return null;
|
||||
const projects = await this.findProjects.findByMember(feed.userId);
|
||||
const byId = new Map(projects.map((p) => [p.id, p]));
|
||||
const ids = projects.map((p) => p.id);
|
||||
const [issues, sprints, releases, me] = await Promise.all([
|
||||
this.findIssues.findDatedFor(feed.userId, ids),
|
||||
Promise.all(ids.map((id) => this.findSprints.findByProject(id))),
|
||||
Promise.all(ids.map((id) => this.findReleases.findByProject(id))),
|
||||
this.users.findById(feed.userId),
|
||||
]);
|
||||
const link = linkTo(query.webUrl);
|
||||
const entries: CalendarEntry[] = [
|
||||
...issues.flatMap((issue) => {
|
||||
const project = byId.get(issue.projectId);
|
||||
return project ? [issueEntry(issue, project, link)] : [];
|
||||
}),
|
||||
...sprints
|
||||
.flat()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.status === SprintStatus.PLANNED ||
|
||||
s.status === SprintStatus.ACTIVE,
|
||||
)
|
||||
.map((s) => ({
|
||||
uid: `sprint-${s.id}@taskstream`,
|
||||
start: s.startDate,
|
||||
end: s.endDate,
|
||||
summary: `${s.name} · ${byId.get(s.projectId)?.key ?? ''}`.trim(),
|
||||
description: s.goal ?? undefined,
|
||||
url: link(`/p/${byId.get(s.projectId)?.key}/sprints`),
|
||||
})),
|
||||
...releases
|
||||
.flat()
|
||||
.filter((r) => r.status === ReleaseStatus.PLANNED && r.targetDate)
|
||||
.map((r) => ({
|
||||
uid: `release-${r.id}@taskstream`,
|
||||
start: r.targetDate as string,
|
||||
summary:
|
||||
`Release ${r.name} · ${byId.get(r.projectId)?.key ?? ''}`.trim(),
|
||||
description: r.description ?? undefined,
|
||||
url: link(`/p/${byId.get(r.projectId)?.key}/releases`),
|
||||
})),
|
||||
];
|
||||
return buildIcs(me ? `TaskStream · ${me.name}` : 'TaskStream', entries);
|
||||
}
|
||||
}
|
||||
|
||||
function issueEntry(
|
||||
issue: IssueReadModel,
|
||||
project: ProjectReadModel,
|
||||
link: (path: string) => string | null,
|
||||
): CalendarEntry {
|
||||
const status =
|
||||
project.settings?.statuses.find((s) => s.id === issue.status)?.name ??
|
||||
issue.status;
|
||||
return {
|
||||
uid: `issue-${issue.id}@taskstream`,
|
||||
start: issue.dueDate as string,
|
||||
summary: `${issue.key} · ${issue.title}`,
|
||||
description: `${project.name} · ${status}`,
|
||||
url: link(`/p/${project.key}/issues/${issue.key}`),
|
||||
};
|
||||
}
|
||||
|
||||
function linkTo(webUrl: string): (path: string) => string | null {
|
||||
const base = webUrl.replace(/\/+$/, '');
|
||||
return (path) => (base ? `${base}${path}` : null);
|
||||
}
|
||||
|
||||
export const CALENDAR_HANDLERS = [
|
||||
CreateCalendarFeedCommandHandler,
|
||||
RevokeCalendarFeedCommandHandler,
|
||||
GetMyCalendarFeedQueryHandler,
|
||||
GetCalendarIcsQueryHandler,
|
||||
];
|
||||
62
libs/api/tracker/application/src/lib/calendar/ics.spec.ts
Normal file
62
libs/api/tracker/application/src/lib/calendar/ics.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { buildIcs } from './ics';
|
||||
|
||||
const NOW = new Date('2026-10-14T09:30:00.000Z');
|
||||
|
||||
describe('buildIcs', () => {
|
||||
it('writes all-day events with an exclusive end', () => {
|
||||
const ics = buildIcs(
|
||||
'TaskStream',
|
||||
[
|
||||
{
|
||||
uid: 'issue-1@taskstream',
|
||||
start: '2026-10-20',
|
||||
summary: 'WEB-1 · Login',
|
||||
},
|
||||
{
|
||||
uid: 'sprint-1@taskstream',
|
||||
start: '2026-10-05',
|
||||
end: '2026-10-19',
|
||||
summary: 'Sprint 4',
|
||||
},
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(ics.startsWith('BEGIN:VCALENDAR\r\nVERSION:2.0\r\n')).toBe(true);
|
||||
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
|
||||
expect(ics).toContain('DTSTAMP:20261014T093000Z');
|
||||
expect(ics).toContain(
|
||||
'DTSTART;VALUE=DATE:20261020\r\nDTEND;VALUE=DATE:20261021',
|
||||
);
|
||||
expect(ics).toContain(
|
||||
'DTSTART;VALUE=DATE:20261005\r\nDTEND;VALUE=DATE:20261020',
|
||||
);
|
||||
expect(ics.split('BEGIN:VEVENT')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('escapes text and skips what has no date', () => {
|
||||
const ics = buildIcs(
|
||||
'Ana; López, QA',
|
||||
[
|
||||
{ uid: 'a', start: '2026-10-20', summary: 'Pago; roto, en\nSafari' },
|
||||
{ uid: 'b', start: '', summary: 'sin fecha' },
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(ics).toContain('X-WR-CALNAME:Ana\\; López\\, QA');
|
||||
expect(ics).toContain('SUMMARY:Pago\\; roto\\, en\\nSafari');
|
||||
expect(ics).not.toContain('sin fecha');
|
||||
});
|
||||
|
||||
it('folds long lines at 75 octets without splitting a character', () => {
|
||||
const summary = 'Revisión de la exportación á'.repeat(5);
|
||||
const ics = buildIcs(
|
||||
'x',
|
||||
[{ uid: 'u', start: '2026-10-20', summary }],
|
||||
NOW,
|
||||
);
|
||||
for (const line of ics.split('\r\n'))
|
||||
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75);
|
||||
// Unfolding gives the text back.
|
||||
expect(ics.replace(/\r\n /g, '')).toContain(`SUMMARY:${summary}`);
|
||||
});
|
||||
});
|
||||
97
libs/api/tracker/application/src/lib/calendar/ics.ts
Normal file
97
libs/api/tracker/application/src/lib/calendar/ics.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/** One all-day event of the calendar feed. */
|
||||
export interface CalendarEntry {
|
||||
/** Stable across downloads, so calendars update the event, not duplicate it. */
|
||||
uid: string;
|
||||
/** `YYYY-MM-DD`. */
|
||||
start: string;
|
||||
/** `YYYY-MM-DD`, inclusive; the same day when absent. */
|
||||
end?: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}/;
|
||||
|
||||
/**
|
||||
* An iCalendar (RFC 5545) document of all-day events: what Google Calendar,
|
||||
* Outlook and Apple Calendar subscribe to. All-day dates carry no time
|
||||
* zone, so a deadline falls on its day wherever the reader is.
|
||||
*/
|
||||
export function buildIcs(
|
||||
name: string,
|
||||
entries: CalendarEntry[],
|
||||
now = new Date(),
|
||||
): string {
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
|
||||
const lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Pireon//TaskStream//ES',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
`X-WR-CALNAME:${text(name)}`,
|
||||
// How often to look again: calendars mostly decide for themselves.
|
||||
'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
|
||||
'X-PUBLISHED-TTL:PT1H',
|
||||
];
|
||||
for (const entry of entries) {
|
||||
const start = DAY.exec(entry.start)?.[0];
|
||||
if (!start) continue;
|
||||
const last = DAY.exec(entry.end ?? '')?.[0] ?? start;
|
||||
lines.push(
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${text(entry.uid)}`,
|
||||
`DTSTAMP:${stamp}`,
|
||||
`DTSTART;VALUE=DATE:${compact(start)}`,
|
||||
// DTEND is exclusive: the day after the last one.
|
||||
`DTEND;VALUE=DATE:${compact(nextDay(last < start ? start : last))}`,
|
||||
`SUMMARY:${text(entry.summary)}`,
|
||||
...(entry.description ? [`DESCRIPTION:${text(entry.description)}`] : []),
|
||||
...(entry.url ? [`URL:${entry.url}`] : []),
|
||||
'TRANSP:TRANSPARENT',
|
||||
'END:VEVENT',
|
||||
);
|
||||
}
|
||||
lines.push('END:VCALENDAR');
|
||||
return lines.map(fold).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
/** TEXT values escape backslashes, semicolons, commas and line breaks. */
|
||||
function text(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r?\n/g, '\\n');
|
||||
}
|
||||
|
||||
const compact = (day: string) => day.replace(/-/g, '');
|
||||
|
||||
function nextDay(day: string): string {
|
||||
const date = new Date(`${day}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Lines of more than 75 octets go on in the next one, after a space. */
|
||||
function fold(line: string): string {
|
||||
const bytes = Buffer.from(line, 'utf8');
|
||||
if (bytes.length <= 75) return line;
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let size = 0;
|
||||
for (const char of line) {
|
||||
const length = Buffer.byteLength(char, 'utf8');
|
||||
// The first line holds 75 octets; the next ones 74 after their space.
|
||||
if (size + length > (parts.length ? 74 : 75)) {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
size = 0;
|
||||
}
|
||||
current += char;
|
||||
size += length;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.join('\r\n ');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** A person's calendar subscription: the secret in its URL. */
|
||||
export interface CalendarFeed {
|
||||
userId: string;
|
||||
/** Random and long; whoever has it reads that person's calendar. */
|
||||
token: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One feed per person. The token is kept as it is (not hashed) so the URL
|
||||
* can be copied again, as calendar apps expect; it only reads dates and
|
||||
* titles the read model holds anyway.
|
||||
*/
|
||||
export abstract class CalendarFeedsRepository {
|
||||
abstract findByUser(userId: string): Promise<CalendarFeed | null>;
|
||||
abstract findByToken(token: string): Promise<CalendarFeed | null>;
|
||||
/** Replaces the person's feed, if any. */
|
||||
abstract save(feed: CalendarFeed): Promise<void>;
|
||||
abstract remove(userId: string): Promise<void>;
|
||||
}
|
||||
@@ -73,6 +73,14 @@ export abstract class FindIssuesRepository {
|
||||
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
|
||||
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
|
||||
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
||||
/**
|
||||
* Unfinished tickets with a due date, in those projects, assigned to or
|
||||
* followed by `userId` (their calendar).
|
||||
*/
|
||||
abstract findDatedFor(
|
||||
userId: string,
|
||||
projectIds: string[],
|
||||
): Promise<IssueReadModel[]>;
|
||||
/** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */
|
||||
abstract findDueBy(day: string): Promise<IssueReadModel[]>;
|
||||
/** The project's tickets in the trash, newest first. */
|
||||
|
||||
@@ -27,3 +27,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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
CalendarFeed,
|
||||
CalendarFeedsRepository,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { Model } from 'mongoose';
|
||||
import { CalendarFeedDoc, ReadModels } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoCalendarFeedsRepository extends CalendarFeedsRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.CalendarFeed)
|
||||
private readonly feeds: Model<CalendarFeedDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findByUser(userId: string): Promise<CalendarFeed | null> {
|
||||
const doc = await this.feeds
|
||||
.findOne({ id: userId })
|
||||
.lean<CalendarFeedDoc>();
|
||||
return doc ? toFeed(doc) : null;
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<CalendarFeed | null> {
|
||||
const doc = await this.feeds.findOne({ token }).lean<CalendarFeedDoc>();
|
||||
return doc ? toFeed(doc) : null;
|
||||
}
|
||||
|
||||
async save(feed: CalendarFeed): Promise<void> {
|
||||
await this.feeds.updateOne(
|
||||
{ id: feed.userId },
|
||||
{ $set: { token: feed.token, createdAt: feed.createdAt } },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
async remove(userId: string): Promise<void> {
|
||||
await this.feeds.deleteOne({ id: userId });
|
||||
}
|
||||
}
|
||||
|
||||
function toFeed(doc: CalendarFeedDoc): CalendarFeed {
|
||||
return { userId: doc.id, token: doc.token, createdAt: doc.createdAt };
|
||||
}
|
||||
@@ -266,6 +266,25 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
return docs.map(issueFromDoc);
|
||||
}
|
||||
|
||||
async findDatedFor(
|
||||
userId: string,
|
||||
projectIds: string[],
|
||||
): Promise<IssueReadModel[]> {
|
||||
if (projectIds.length === 0) return [];
|
||||
const docs = await this.issues
|
||||
.find({
|
||||
projectId: { $in: projectIds },
|
||||
dueDate: { $ne: null },
|
||||
trashedAt: null,
|
||||
statusCategory: { $nin: FINISHED },
|
||||
$or: [{ 'assignee.id': userId }, { 'watchers.id': userId }],
|
||||
})
|
||||
.sort({ dueDate: 1 })
|
||||
.limit(1000)
|
||||
.lean<IssueDoc[]>();
|
||||
return docs.map(issueFromDoc);
|
||||
}
|
||||
|
||||
async findByProject(projectId: string): Promise<IssueReadModel[]> {
|
||||
const docs = await this.issues
|
||||
.find({ projectId, trashedAt: null })
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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