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,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. */
|
||||
|
||||
Reference in New Issue
Block a user