Files
task-stream/libs/web/tracker/domain/src/lib/ports/gateways.ts
Josep Piguillem 233c46e9fa feat: push notifications on each device (Web Push)
- What reaches the bell also reaches the devices where the person
  turned «Avisos en este dispositivo» on, with TaskStream closed: the
  Notifier sends each new notification encrypted (aes128gcm) and signed
  with VAPID, and forgets subscriptions the push service dropped
  (404/410). A failure never blocks the event stream.
- The API keeps one subscription per device (push_subscriptions) and
  exposes the VAPID public key in the notification settings; GraphQL
  subscribePush/unsubscribePush, REST /me/push-subscriptions. Without
  VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY push is off.
- The web registers /push-sw.js, a service worker that only shows the
  notification and opens its ticket on a tap (no caching, served
  without cache by nginx), and a manifest that makes it installable,
  which iPhones need for push.
- The bell's toggles show their text next to them.

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

413 lines
14 KiB
TypeScript

import { Observable } from 'rxjs';
import {
Activity,
Attachment,
Automation,
AutomationInput,
BoardColumn,
CalendarFeed,
PushDeviceSubscription,
CommandResult,
Comment,
Epic,
GitIntegration,
GitRepository,
ImportReport,
Issue,
IssuePage,
IssueSummary,
Notification,
NotificationSettings,
Project,
ProjectAnalytics,
ProjectStats,
Release,
ReleaseBurnup,
SavedFilter,
SavedFilterCriteria,
Sprint,
SprintBurndown,
UserDirectoryEntry,
Webhook,
WebhookDelivery,
} 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>;
/** Its unfinished tickets go to `moveTo` (an open release) or leave it. */
abstract archive(
releaseId: string,
moveTo?: string | null,
): 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 CalendarGateway {
/** Mine, if I made one. */
abstract mine(): Observable<CalendarFeed | null>;
/** A new secret URL; the previous one stops working. */
abstract create(): Observable<CalendarFeed>;
abstract revoke(): Observable<void>;
}
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>;
/** Push on this device. */
abstract subscribePush(
subscription: PushDeviceSubscription,
): Observable<void>;
/** Push off on this device. */
abstract unsubscribePush(endpoint: string): Observable<void>;
}
/**
* This browser's side of push: its service worker and push subscription.
* Not every browser has it (and on iPhone only an installed web app does).
*/
export abstract class PushDevice {
abstract get supported(): boolean;
/** This browser's subscription, if push is on here. */
abstract current(): Promise<PushDeviceSubscription | null>;
/** Asks for permission and subscribes; throws when refused. */
abstract subscribe(publicKey: string): Promise<PushDeviceSubscription>;
/** Unsubscribes; the endpoint it had, if any. */
abstract unsubscribe(): Promise<string | null>;
}
/** 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>;
}