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,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 };
}

View File

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

View File

@@ -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. */

View File

@@ -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,