feat: presence on tickets

A ticket's page beats every 10 s (touchPresence / PUT
/issues/{issue}/presence, with whether I am editing) and gets back who
else is there, editing ones first. Entries live in Redis, one hash per
ticket, and fade after 30 s without a beat; leaving the page takes me out
at once. Only for people who can read the ticket; never in the event
store. The ticket shows «Bob está editando · Ana también lo está viendo»
above its title; editing means a ticket or comment form is open or a
comment is being written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:53:47 +02:00
parent 869acd3f1f
commit f60d4c8346
33 changed files with 1197 additions and 22 deletions

View File

@@ -0,0 +1,24 @@
/** Someone on a ticket's page just now. */
export interface PresenceEntry {
user: { id: string; name: string };
/** Editing the ticket or writing a comment, not only looking. */
editing: boolean;
/** Last heartbeat (ISO). */
at: string;
}
/**
* Who is on each ticket's page, shared by every instance of the API.
* Entries fade on their own: a page that stops beating is gone.
*/
export abstract class PresenceStore {
/** Records (or refreshes) one person on a ticket; lasts `ttlMs`. */
abstract touch(
issueId: string,
entry: PresenceEntry,
ttlMs: number,
): Promise<void>;
/** Everyone whose heartbeat is newer than `since` (ms epoch). */
abstract list(issueId: string, since: number): Promise<PresenceEntry[]>;
abstract leave(issueId: string, userId: string): Promise<void>;
}

View File

@@ -0,0 +1,69 @@
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { PRESENCE_TTL_MS, PresenceService } from './presence.service';
import { ReadAccess } from './queries/read-access';
class MemoryPresence extends PresenceStore {
readonly entries = new Map<string, Map<string, PresenceEntry>>();
async touch(issueId: string, entry: PresenceEntry) {
const here = this.entries.get(issueId) ?? new Map();
here.set(entry.user.id, entry);
this.entries.set(issueId, here);
}
async list(issueId: string, since: number) {
return [...(this.entries.get(issueId)?.values() ?? [])].filter(
(e) => Date.parse(e.at) >= since,
);
}
async leave(issueId: string, userId: string) {
this.entries.get(issueId)?.delete(userId);
}
}
const issues = {
findById: async (id: string) =>
id === 'i1'
? ({ id: 'i1', projectId: 'p1', trashedAt: null } as IssueReadModel)
: null,
} as unknown as FindIssuesRepository;
const access = {
assertCanView: async (_projectId: string, viewerId: string) => {
if (viewerId === 'eve') throw new ForbiddenError('No');
return 'MEMBER';
},
} as unknown as ReadAccess;
describe('PresenceService', () => {
it('tells who else is on a ticket, the ones editing first', async () => {
const store = new MemoryPresence();
const presence = new PresenceService(store, issues, access);
await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false);
await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true);
const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false);
expect(seen.map((p) => [p.user.name, p.editing])).toEqual([
['Cris', true],
['Ana', false],
]);
// A page that stops beating fades out; leaving takes one out at once.
store.entries.get('i1')!.get('ana')!.at = new Date(
Date.now() - PRESENCE_TTL_MS - 1000,
).toISOString();
await presence.leave('cris', 'i1');
expect(
await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false),
).toEqual([]);
});
it('only for whoever can read the ticket', async () => {
const presence = new PresenceService(new MemoryPresence(), issues, access);
await expect(
presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false),
).rejects.toBeInstanceOf(ForbiddenError);
await expect(
presence.touch({ id: 'ana', name: 'Ana' }, 'nope', false),
).rejects.toBeInstanceOf(NotFoundError);
});
});

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { ReadAccess } from './queries/read-access';
/** A page beats every 10 s; three missed beats and it is gone. */
export const PRESENCE_TTL_MS = 30_000;
/**
* Who else is on a ticket right now, and whether they are editing it. Not
* history: kept in Redis for a few seconds, never in the event store.
*/
@Injectable()
export class PresenceService {
constructor(
private readonly store: PresenceStore,
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
/**
* I am on the ticket (editing or not): answers who else is there, the
* ones editing first. Only for people who can read it.
*/
async touch(
viewer: { id: string; name: string },
issueId: string,
editing: boolean,
): Promise<PresenceEntry[]> {
await this.assertCanView(viewer.id, issueId);
const now = Date.now();
await this.store.touch(
issueId,
{
user: { id: viewer.id, name: viewer.name },
editing,
at: new Date(now).toISOString(),
},
PRESENCE_TTL_MS,
);
const here = await this.store.list(issueId, now - PRESENCE_TTL_MS);
return here
.filter((entry) => entry.user.id !== viewer.id)
.sort(
(a, b) =>
Number(b.editing) - Number(a.editing) ||
a.user.name.localeCompare(b.user.name, 'es'),
);
}
/** I left the ticket's page. */
leave(viewerId: string, issueId: string): Promise<void> {
return this.store.leave(issueId, viewerId);
}
private async assertCanView(viewerId: string, issueId: string) {
const issue = await this.findIssues.findById(issueId);
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
await this.access.assertCanView(issue.projectId, viewerId);
}
}

View File

@@ -45,6 +45,7 @@ import { WEBHOOK_HANDLERS } from './webhooks/webhooks';
import { TransferService } from './transfer.service';
import { TRANSFER_HANDLERS } from './transfer/import-export';
import { AutomationsService } from './automations.service';
import { PresenceService } from './presence.service';
import { AUTOMATION_HANDLERS } from './automations/automations';
import { CALENDAR_HANDLERS } from './calendar/calendar';
import { CalendarService } from './calendar.service';
@@ -79,6 +80,7 @@ const SERVICES = [
WebhooksService,
TransferService,
AutomationsService,
PresenceService,
ProposalQuota,
];