Files
task-stream/libs/web/tracker/domain/src/lib/ports/gateways.ts
Josep Piguillem c91df110f8 feat: steadier live updates and notices for public proposals
- The web pings the live connection, reconnects when it goes quiet and
  reloads what each screen shows after reconnecting.
- Dropping a card on a column it cannot go to puts it back.
- Owners and admins get a notice when a proposal arrives.
- The proposal limit lives in Redis, so it holds across instances.
- REST tells whether I voted; pasted images leave the attachment list.
- Deleting a project removes its Git integration; no lint warnings left.
- Reactions and the blocked badge are components of their own.
- Docs: what is done and what is left (chapter 10), Word and PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:57:21 +02:00

379 lines
12 KiB
TypeScript

import { Observable } from 'rxjs';
import {
Activity,
Attachment,
BoardColumn,
Comment,
Epic,
GitIntegration,
GitRepository,
Automation,
AutomationInput,
ImportReport,
Webhook,
WebhookDelivery,
SavedFilter,
Sprint,
SprintBurndown,
SavedFilterCriteria,
CommandResult,
Issue,
IssuePage,
IssueSummary,
Notification,
NotificationSettings,
Project,
ProjectAnalytics,
ProjectStats,
Release,
ReleaseBurnup,
UserDirectoryEntry,
} from '../models/models';
import { ProjectSettings } from '../models/workflow';
import {
BoardFilter,
ChangeStatusInput,
CreateIssueInput,
CreateProjectInput,
CreateReleaseInput,
LinkIssuesInput,
MemberInput,
IssueListFilter,
ProposalInput,
ProposalResult,
MyIssuesFilter,
UpdateIssueInput,
UpdateProjectInput,
CreateSprintInput,
BulkChange,
BulkResult,
UpdateSprintInput,
} from '../models/inputs';
/*
* Outbound ports of the tracker. The application layer depends on these
* abstract classes; the infrastructure layer implements them over GraphQL.
*/
export abstract class ProjectsGateway {
abstract myProjects(): Observable<Project[]>;
/** Projects anyone can read (no sign-in needed). */
abstract publicProjects(): Observable<Project[]>;
abstract project(idOrKey: string): Observable<Project>;
abstract stats(projectId: string): Observable<ProjectStats>;
/** Statistics of the last `days` days. */
abstract analytics(
projectId: string,
days: number,
): Observable<ProjectAnalytics>;
/** Replaces statuses, transitions, types and areas (owners and admins). */
abstract updateSettings(
projectId: string,
settings: ProjectSettings,
): Observable<CommandResult>;
abstract create(input: CreateProjectInput): Observable<CommandResult>;
/** Name, description or visibility (owners and admins). */
abstract update(input: UpdateProjectInput): Observable<CommandResult>;
/** Owner only; `confirmKey` must be the project key. */
abstract delete(
projectId: string,
confirmKey: string,
): Observable<CommandResult>;
abstract addMember(input: MemberInput): Observable<CommandResult>;
abstract changeMemberRole(input: MemberInput): Observable<CommandResult>;
abstract removeMember(
projectId: string,
userId: string,
): Observable<CommandResult>;
}
export abstract class IssuesGateway {
abstract board(filter: BoardFilter): Observable<BoardColumn[]>;
abstract issue(idOrKey: string): Observable<Issue>;
abstract search(projectId: string, text: string): Observable<IssueSummary[]>;
/** A project's tickets for the list view (most recently updated first). */
abstract list(filter: IssueListFilter): Observable<IssuePage>;
/** Tickets across all my projects (most recently updated first). */
abstract mine(filter: MyIssuesFilter): Observable<IssuePage>;
abstract create(input: CreateIssueInput): Observable<CommandResult>;
/** Votes for it (`on`) or takes the vote back. */
abstract vote(issueId: string, on: boolean): Observable<CommandResult>;
/** A copy of what the ticket says; the result carries its key. */
abstract duplicate(issueId: string): Observable<CommandResult>;
abstract update(input: UpdateIssueInput): Observable<CommandResult>;
abstract changeStatus(input: ChangeStatusInput): Observable<CommandResult>;
abstract assign(
issueId: string,
assigneeId: string | null,
): Observable<CommandResult>;
abstract link(input: LinkIssuesInput): Observable<CommandResult>;
abstract unlink(input: LinkIssuesInput): Observable<CommandResult>;
abstract setRelease(
issueId: string,
releaseId: string | null,
): Observable<CommandResult>;
/** The signed-in person follows (or stops following) the ticket. */
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
/** Tickets hanging from `issueId`. */
abstract children(
projectId: string,
issueId: string,
): Observable<IssueSummary[]>;
/** Places the ticket under `parentId` (null: back to the top). */
abstract setParent(
issueId: string,
parentId: string | null,
): Observable<CommandResult>;
abstract epics(projectId: string): Observable<Epic[]>;
/** Tickets due between two days (`YYYY-MM-DD`, both included). */
abstract due(
projectId: string,
from: string,
to: string,
): Observable<IssueSummary[]>;
/** To the trash (whoever opened it, or a manager). */
abstract trash(issueId: string): Observable<CommandResult>;
abstract restore(issueId: string): Observable<CommandResult>;
/** The project's tickets in the trash, newest first. */
abstract trashed(projectId: string): Observable<IssueSummary[]>;
/** The same change on several tickets; each may refuse on its own. */
abstract bulkUpdate(
issueIds: string[],
change: BulkChange,
): Observable<BulkResult>;
/** Tickets still to start (TODO category), in backlog order. */
abstract backlog(projectId: string): Observable<IssuePage>;
/** Moves a ticket between two others of the backlog (null: an end). */
abstract rank(
issueId: string,
aboveIssueId: string | null,
belowIssueId: string | null,
): Observable<CommandResult>;
abstract addChecklistItem(
issueId: string,
text: string,
): Observable<CommandResult>;
abstract updateChecklistItem(
issueId: string,
itemId: string,
changes: { text?: string; done?: boolean },
): Observable<CommandResult>;
abstract removeChecklistItem(
issueId: string,
itemId: string,
): Observable<CommandResult>;
}
/** The project's automation rules (owners and admins). */
export abstract class AutomationsGateway {
abstract list(projectId: string): Observable<Automation[]>;
abstract create(
projectId: string,
rule: AutomationInput,
): Observable<Automation>;
abstract update(
automationId: string,
changes: { name?: string; active?: boolean },
): Observable<Automation>;
abstract remove(automationId: string): Observable<void>;
}
/** Tickets in and out as CSV. */
export abstract class TransferGateway {
abstract exportCsv(projectId: string): Observable<Blob>;
/** `dryRun`: only tell what would be created. */
abstract importCsv(
projectId: string,
file: File,
dryRun: boolean,
): Observable<ImportReport>;
}
/** The project's outgoing webhooks (owners and admins). */
export abstract class WebhooksGateway {
abstract list(projectId: string): Observable<Webhook[]>;
abstract events(): Observable<string[]>;
/** The secret comes back only here. */
abstract create(
projectId: string,
url: string,
events: string[],
): Observable<{ id: string; secret: string }>;
abstract update(
webhookId: string,
changes: { url?: string; events?: string[]; active?: boolean },
): Observable<Webhook>;
abstract remove(webhookId: string): Observable<void>;
abstract test(webhookId: string): Observable<WebhookDelivery>;
}
/** The project's Git webhook (owners and admins configure it). */
export abstract class GitGateway {
abstract integration(projectId: string): Observable<GitIntegration>;
/** A repository with its own secret (the first turns the integration on). */
abstract addRepository(
projectId: string,
name: string,
): Observable<GitRepository>;
abstract rotateSecret(
projectId: string,
repositoryId: string,
): Observable<GitRepository>;
abstract removeRepository(
projectId: string,
repositoryId: string,
): Observable<void>;
}
/** Saved views of a project's board (mine and the shared ones). */
export abstract class SavedFiltersGateway {
abstract list(projectId: string): Observable<SavedFilter[]>;
abstract save(input: {
projectId: string;
name: string;
shared: boolean;
criteria: SavedFilterCriteria;
}): Observable<string>;
abstract remove(id: string): Observable<void>;
}
export abstract class CommentsGateway {
abstract list(issueId: string): Observable<Comment[]>;
abstract add(
issueId: string,
body: string,
replyTo?: string | null,
): Observable<CommandResult>;
abstract edit(commentId: string, body: string): Observable<CommandResult>;
/** Adds (`on`) or takes back one's reaction. */
abstract react(
commentId: string,
emoji: string,
on: boolean,
): Observable<CommandResult>;
abstract remove(commentId: string): Observable<CommandResult>;
}
/** A ticket's files. Uploads and downloads go through the REST API. */
/** The ticket's acknowledgement and the new file's id. */
export type AttachmentUploaded = CommandResult & { attachmentId: string };
/** The public form: no session needed. */
export abstract class ProposalsGateway {
abstract submit(
projectKey: string,
proposal: ProposalInput,
): Observable<ProposalResult>;
}
export abstract class AttachmentsGateway {
abstract upload(
issueId: string,
file: File,
commentId?: string | null,
): Observable<AttachmentUploaded>;
/** The file's bytes, to show (images) or save. */
abstract download(issueId: string, attachment: Attachment): Observable<Blob>;
abstract remove(
issueId: string,
attachmentId: string,
): Observable<CommandResult>;
}
export abstract class ReleasesGateway {
abstract list(projectId: string): Observable<Release[]>;
/** Planned releases of all my projects, soonest target date first. */
abstract upcoming(limit: number): Observable<Release[]>;
abstract create(input: CreateReleaseInput): Observable<CommandResult>;
abstract publish(
releaseId: string,
force: boolean,
): Observable<CommandResult>;
abstract archive(releaseId: string): Observable<CommandResult>;
abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
/** A Markdown draft from the release's finished tickets (not saved). */
abstract notesDraft(releaseId: string): Observable<string>;
/** null removes them. */
abstract writeNotes(
releaseId: string,
notes: string | null,
): Observable<CommandResult>;
}
export abstract class SprintsGateway {
/** With their progress: under way, planned, then closed. */
abstract list(projectId: string): Observable<Sprint[]>;
abstract burndown(sprintId: string): Observable<SprintBurndown>;
abstract create(input: CreateSprintInput): Observable<CommandResult>;
abstract update(input: UpdateSprintInput): Observable<CommandResult>;
abstract start(sprintId: string): Observable<CommandResult>;
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */
abstract close(
sprintId: string,
carryOverTo: string | null,
): Observable<CommandResult>;
abstract remove(sprintId: string): Observable<CommandResult>;
abstract setIssueSprint(
issueId: string,
sprintId: string | null,
): Observable<CommandResult>;
}
export abstract class ActivityGateway {
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
}
export abstract class UsersGateway {
abstract search(term: string): Observable<UserDirectoryEntry[]>;
}
/** The signed-in person's notifications. */
export abstract class NotificationsGateway {
abstract mine(
unreadOnly: boolean,
limit: number,
): Observable<{ items: Notification[]; unread: number }>;
/** Without ids: all of them. */
abstract markRead(ids?: string[]): Observable<void>;
abstract settings(): Observable<NotificationSettings>;
abstract updateSettings(email: boolean): Observable<NotificationSettings>;
}
/** Realtime (GraphQL subscriptions over WebSocket). */
export interface IssueChanged {
action: string;
version: number;
actorId: string;
issue: IssueSummary;
}
export interface CommentChanged {
action: string;
commentId: string;
comment: Comment | null;
}
/** A project changed, or was deleted (then `project` is its last state). */
export interface ProjectChanged {
project: Project;
deleted: boolean;
}
/** Realtime needs a session: without one, these streams stay silent. */
export abstract class TrackerRealtimeGateway {
abstract issueChanges(projectId: string): Observable<IssueChanged>;
abstract commentChanges(issueId: string): Observable<CommentChanged>;
abstract activity(projectId: string): Observable<Activity>;
abstract releaseChanges(projectId: string): Observable<Release>;
/** Something about a sprint of the project changed (or it was deleted). */
abstract sprintChanges(projectId: string): Observable<{ sprintId: string }>;
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
abstract myProjectsChanges(): Observable<ProjectChanged>;
abstract notifications(): Observable<Notification>;
/**
* Each time the live connection comes back after a cut: what happened
* meanwhile was not received, so views reload.
*/
abstract reconnects(): Observable<void>;
}