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>
523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
import { IssuePriority } from '../models/enums';
|
|
import { Home } from '../models/home';
|
|
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
|
import {
|
|
DashboardShare,
|
|
SharedBlock,
|
|
SharedDashboard,
|
|
} from '../models/sharing';
|
|
import { Observable } from 'rxjs';
|
|
import {
|
|
Activity,
|
|
Attachment,
|
|
Automation,
|
|
AutomationInput,
|
|
BoardColumn,
|
|
CalendarFeed,
|
|
PushDeviceSubscription,
|
|
CommandResult,
|
|
AlertIntegration,
|
|
Comment,
|
|
Decision,
|
|
Typing,
|
|
Epic,
|
|
GitIntegration,
|
|
GitRepository,
|
|
ImportReport,
|
|
Issue,
|
|
IssuePage,
|
|
IssueSummary,
|
|
Notification,
|
|
NotificationSettings,
|
|
Project,
|
|
ProjectAnalytics,
|
|
ProjectStats,
|
|
Release,
|
|
ReleaseBurnup,
|
|
SavedFilter,
|
|
Presence,
|
|
MyView,
|
|
MyViewCriteria,
|
|
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>;
|
|
/** My active projects at a glance. */
|
|
abstract portfolio(): Observable<PortfolioProject[]>;
|
|
/** The home page beyond «my work». */
|
|
abstract myHome(): Observable<Home>;
|
|
/** The dashboard; with `since`, also what changed after it. */
|
|
abstract insights(
|
|
projectId: string,
|
|
since: string | null,
|
|
): Observable<ProjectInsights>;
|
|
/** Archives the project (read-only) or brings it back. Owner only. */
|
|
abstract setArchived(
|
|
projectId: string,
|
|
archived: boolean,
|
|
): Observable<CommandResult>;
|
|
/** 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>;
|
|
/** My pinned tickets, most recently pinned first. */
|
|
abstract pins(): Observable<IssueSummary[]>;
|
|
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
|
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
|
/** Follows the ticket with everything under it (or stops). */
|
|
abstract followTree(issueId: string, on: boolean): Observable<void>;
|
|
/** Reminds me of the ticket then (replaces the waiting one). */
|
|
abstract setReminder(
|
|
issueId: string,
|
|
at: string,
|
|
note: string | null,
|
|
): Observable<void>;
|
|
abstract clearReminder(issueId: string): Observable<void>;
|
|
/**
|
|
* Decisions (comments marked as such) of all my projects by their text,
|
|
* most recent first: here and not in CommentsGateway, for the palette.
|
|
*/
|
|
abstract searchDecisions(
|
|
search: string,
|
|
limit: number,
|
|
): Observable<Decision[]>;
|
|
/** 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). */
|
|
/** Alerts → bugs (owners and admins). */
|
|
export abstract class AlertsGateway {
|
|
abstract integration(projectId: string): Observable<AlertIntegration>;
|
|
/** Turns it on, or changes what it opens; the URL stays. */
|
|
abstract save(
|
|
projectId: string,
|
|
settings: { typeId: string | null; priority: IssuePriority },
|
|
): Observable<AlertIntegration>;
|
|
/** A new URL; the old one stops working. */
|
|
abstract rotateToken(projectId: string): Observable<AlertIntegration>;
|
|
abstract disable(projectId: string): Observable<void>;
|
|
}
|
|
|
|
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>;
|
|
/** My views of «my work», by name. */
|
|
abstract myViews(): Observable<MyView[]>;
|
|
abstract saveMyView(input: {
|
|
name: string;
|
|
criteria: MyViewCriteria;
|
|
}): Observable<string>;
|
|
abstract removeMyView(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>;
|
|
/**
|
|
* Settles a conversation (or reopens it); a conclusion is added as a
|
|
* reply marked as a decision.
|
|
*/
|
|
abstract resolveThread(
|
|
commentId: string,
|
|
resolved: boolean,
|
|
conclusion: string | null,
|
|
): Observable<CommandResult>;
|
|
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
|
|
abstract markDecision(
|
|
commentId: string,
|
|
on: boolean,
|
|
): 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[]>;
|
|
/** What happened that day (`YYYY-MM-DD`, UTC), newest first (200 at most). */
|
|
abstract onDay(projectId: string, day: 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>;
|
|
}
|
|
|
|
/** The project's dashboard for clients, behind a secret link. */
|
|
export abstract class SharingGateway {
|
|
/** The project's link, if it has one (owners and admins). */
|
|
abstract share(projectId: string): Observable<DashboardShare | null>;
|
|
/** Turns it on or changes its blocks; `renew` makes a new secret. */
|
|
abstract save(
|
|
projectId: string,
|
|
blocks: SharedBlock[],
|
|
renew: boolean,
|
|
): Observable<DashboardShare>;
|
|
abstract remove(projectId: string): Observable<void>;
|
|
/** What a link shows, for anyone (null: no such link). */
|
|
abstract shared(token: string): Observable<SharedDashboard | null>;
|
|
}
|
|
|
|
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>;
|
|
}
|
|
|
|
/** Who is on a ticket's page: a heartbeat answers who else is. */
|
|
export abstract class PresenceGateway {
|
|
abstract touch(issueId: string, editing: boolean): Observable<Presence[]>;
|
|
abstract leave(issueId: string): Observable<void>;
|
|
/** I am writing (an answer to `threadId`, or a new comment). */
|
|
abstract typing(issueId: string, threadId: string | null): Observable<void>;
|
|
/** Who is writing on the ticket, as they type (me included). */
|
|
abstract typists(issueId: string): Observable<Typing>;
|
|
}
|