Files
task-stream/libs/web/tracker/application/src/lib/facades/issue.facade.ts
Josep Piguillem b88296c22b feat: «Recordármelo», a reminder of a ticket only for me
On a ticket, «Recordármelo» offers in an hour, this evening, tomorrow or
next Monday at 9, in a week, or any date and time, with an optional note.
The ticket shows the waiting one («Te lo recordaré el mié 21 a las 9:00»)
to change or drop. Every minute a scheduler tells the ones whose time
came as a REMINDER notification (bell, push, e-mail), once, and forgets
those whose ticket went to the trash or out of reach. One per person and
ticket, private, no history (Mongo `issue_reminders`).

API: setReminder / clearReminder, Issue.myReminder, myReminders;
PUT/DELETE /api/issues/{issue}/reminder, GET /api/me/reminders. MCP:
remind_me.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:08:50 +02:00

299 lines
8.9 KiB
TypeScript

import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
Attachment,
AttachmentsGateway,
IssueLink,
IssuesGateway,
IssueLinkType,
IssueSummary,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import { issuePageEvents } from '../events/issue.events';
import { IssueStore } from '../stores/issue.store';
/**
* One ticket for presenters: details, comments and history, live. Commands act
* on the open ticket, so pages don't pass its id.
*/
@Injectable()
export class IssueFacade {
private readonly store = inject(IssueStore);
private readonly dispatch = injectDispatch(issuePageEvents);
private readonly files = inject(AttachmentsGateway);
private readonly issues = inject(IssuesGateway);
readonly issue = this.store.issue;
readonly comments = this.store.comments;
readonly history = this.store.history;
readonly outwardLinks = this.store.outwardLinks;
readonly inwardLinks = this.store.inwardLinks;
/** Tickets hanging from the open one, and how far they have got. */
readonly children = this.store.children;
readonly childProgress = this.store.childProgress;
/** Ticked steps of the open ticket's checklist. */
readonly checklistProgress = this.store.checklistProgress;
readonly loading = this.store.loading;
readonly error = this.store.error;
/** Signed-in user's id (authors can edit their comments). */
readonly me = this.store.me;
/** Whether the signed-in person follows the open ticket. */
readonly watching = this.store.watching;
/** Loads the ticket (by id or key, e.g. BE-3) and follows its changes. */
open(idOrKey: string): void {
this.dispatch.opened({ idOrKey });
}
/** QA_FAILED and CANCELLED need a reason. */
changeStatus(status: string, reason?: string | null): void {
this.withIssue((issueId) =>
this.dispatch.statusChangeRequested({ issueId, status, reason }),
);
}
assign(assigneeId: string | null): void {
this.withIssue((issueId) =>
this.dispatch.assignRequested({ issueId, assigneeId }),
);
}
update(changes: Omit<UpdateIssueInput, 'issueId'>): void {
this.withIssue((issueId) =>
this.dispatch.updateRequested({ issueId, ...changes }),
);
}
/** Relates this ticket with another one by its key (e.g. BE-3). */
link(type: IssueLinkType, targetKey: string): void {
this.withIssue((issueId) =>
this.dispatch.linkByKeyRequested({ issueId, type, targetKey }),
);
}
unlink(link: IssueLink): void {
this.withIssue((issueId) =>
this.dispatch.unlinkRequested({
issueId,
type: link.type,
targetIssueId: link.issueId,
}),
);
}
/** null: back to the backlog. */
setSprint(sprintId: string | null): void {
this.withIssue((issueId) =>
this.dispatch.sprintChangeRequested({ issueId, sprintId }),
);
}
setRelease(releaseId: string | null): void {
this.withIssue((issueId) =>
this.dispatch.releaseChangeRequested({ issueId, releaseId }),
);
}
/** `files` are attached under the new comment; `replyTo` answers one. */
addComment(
body: string,
files: File[] = [],
replyTo: string | null = null,
): void {
this.withIssue((issueId) =>
this.dispatch.commentAddRequested({ issueId, body, files, replyTo }),
);
}
/** To the trash (true) or back from it (false). */
setTrashed(trashed: boolean): void {
this.withIssue((issueId) =>
this.dispatch.trashRequested({ issueId, trashed }),
);
}
/** Follow the ticket (or stop): be told when it moves or gets a comment. */
setWatching(watching: boolean): void {
this.withIssue((issueId) =>
this.dispatch.watchRequested({ issueId, watching }),
);
}
/** Remind me of the ticket then (null: drop it); one per ticket. */
setReminder(at: string | null, note: string | null = null): void {
this.withIssue((issueId) =>
this.dispatch.reminderRequested({
issueId,
at,
note: note?.trim() || null,
}),
);
}
/** Follow the ticket with everything under it (an epic), or stop. */
setFollowingTree(on: boolean): void {
this.withIssue((issueId) =>
this.dispatch.followTreeRequested({ issueId, on }),
);
}
/** Pin the ticket for me (or unpin it): at hand on my home page and in search. */
setPinned(pinned: boolean): void {
this.withIssue((issueId) =>
this.dispatch.pinRequested({ issueId, pinned }),
);
}
/** Places the open ticket under another one by its key; null: back to the top. */
/**
* Tickets to link to or hang the open one from: the most recently updated
* of all my projects (links may cross projects), the open one aside.
*/
candidates(): Observable<IssueSummary[]> {
return this.issues
.mine({ assignedToMe: false, limit: 200 })
.pipe(
map((page) =>
page.items.filter((item) => item.id !== this.store.issue()?.id),
),
);
}
setParent(parentKey: string | null): void {
this.withIssue((issueId) =>
this.dispatch.parentRequested({ issueId, parentKey }),
);
}
/** Creates a ticket under the open one. */
addSubtask(title: string, type: string): void {
const issue = this.store.issue();
if (!issue || !title.trim()) return;
this.dispatch.subtaskCreateRequested({
projectId: issue.projectId,
parentId: issue.id,
type,
title: title.trim(),
});
}
addChecklistItem(text: string): void {
if (!text.trim()) return;
this.withIssue((issueId) =>
this.dispatch.checklistAddRequested({ issueId, text: text.trim() }),
);
}
updateChecklistItem(
itemId: string,
changes: { text?: string; done?: boolean },
): void {
this.withIssue((issueId) =>
this.dispatch.checklistUpdateRequested({ issueId, itemId, changes }),
);
}
removeChecklistItem(itemId: string): void {
this.withIssue((issueId) =>
this.dispatch.checklistRemoveRequested({ issueId, itemId }),
);
}
/** Attaches files to the ticket (or under one of its comments). */
attach(files: File[], commentId: string | null = null): void {
if (!files.length) return;
this.withIssue((issueId) =>
this.dispatch.attachRequested({ issueId, files, commentId }),
);
}
removeAttachment(attachmentId: string): void {
this.withIssue((issueId) =>
this.dispatch.attachmentRemoveRequested({ issueId, attachmentId }),
);
}
/** The file's bytes, to show an image or save the file. */
fileOf(attachment: Attachment): Observable<Blob> {
const issueId = this.store.issue()?.id ?? '';
return this.files.download(issueId, attachment);
}
/** A file's bytes by id alone (an image inside a description or comment). */
fileById(attachmentId: string): Observable<Blob> {
return this.fileOf({ id: attachmentId } as Attachment);
}
/**
* Uploads an image pasted into a text and answers its id, to write
* `![name](attachment:<id>)`. It also shows among the ticket's files.
*/
uploadImage(file: File): Observable<string> {
const issueId = this.store.issue()?.id ?? '';
return this.files.upload(issueId, file).pipe(
map((uploaded) => {
this.dispatch.imagePasted({ issueId });
return uploaded.attachmentId;
}),
);
}
/** Votes for the open ticket (`on`) or takes the vote back. */
vote(on: boolean): void {
this.withIssue((issueId) => this.dispatch.voteRequested({ issueId, on }));
}
/** Adds (`on`) or takes back the signed-in person's reaction. */
react(commentId: string, emoji: string, on: boolean): void {
this.dispatch.reactRequested({ commentId, emoji, on });
}
/**
* Settles a conversation (or reopens it); a conclusion is added as a
* reply marked as a decision.
*/
resolveThread(
commentId: string,
resolved: boolean,
conclusion: string | null = null,
): void {
this.dispatch.threadResolveRequested({
commentId,
resolved,
conclusion: conclusion?.trim() || null,
});
}
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
markDecision(commentId: string, on: boolean): void {
this.dispatch.decisionRequested({ commentId, on });
}
/** A copy of what the open ticket says; answers its key. */
duplicate(): Observable<string | null> {
const issueId = this.store.issue()?.id ?? '';
return this.issues
.duplicate(issueId)
.pipe(map((result) => result.key ?? null));
}
editComment(commentId: string, body: string): void {
this.dispatch.commentEditRequested({ commentId, body });
}
deleteComment(commentId: string): void {
this.dispatch.commentDeleteRequested({ commentId });
}
private withIssue(command: (issueId: string) => void): void {
const issueId = this.store.issue()?.id;
if (issueId) command(issueId);
}
}
/** Store + facade of a ticket, for the issue route's component. */
export function provideIssueFacade(): Provider[] {
return [IssueStore, IssueFacade];
}