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:
@@ -32,3 +32,5 @@ export * from './lib/saved-filters/saved-filters';
|
||||
export * from './lib/saved-filters/my-views';
|
||||
export * from './lib/ports/my-views.repository';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
24
libs/api/tracker/application/src/lib/ports/presence.store.ts
Normal file
24
libs/api/tracker/application/src/lib/ports/presence.store.ts
Normal 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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
62
libs/api/tracker/application/src/lib/presence.service.ts
Normal file
62
libs/api/tracker/application/src/lib/presence.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
||||
import {
|
||||
PresenceEntry,
|
||||
PresenceStore,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
/**
|
||||
* One hash per ticket (field: user id, value: the entry). The hash expires
|
||||
* when nobody beats on it; stale fields are skipped and dropped on reading.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisPresenceStore extends PresenceStore {
|
||||
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {
|
||||
super();
|
||||
}
|
||||
|
||||
private key(issueId: string): string {
|
||||
return `task-stream:presence:${issueId}`;
|
||||
}
|
||||
|
||||
async touch(
|
||||
issueId: string,
|
||||
entry: PresenceEntry,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
const key = this.key(issueId);
|
||||
await this.redis
|
||||
.multi()
|
||||
.hset(key, entry.user.id, JSON.stringify(entry))
|
||||
.pexpire(key, ttlMs)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async list(issueId: string, since: number): Promise<PresenceEntry[]> {
|
||||
const key = this.key(issueId);
|
||||
const all = await this.redis.hgetall(key);
|
||||
const fresh: PresenceEntry[] = [];
|
||||
const stale: string[] = [];
|
||||
for (const [userId, raw] of Object.entries(all)) {
|
||||
try {
|
||||
const entry = JSON.parse(raw) as PresenceEntry;
|
||||
if (Date.parse(entry.at) >= since) fresh.push(entry);
|
||||
else stale.push(userId);
|
||||
} catch {
|
||||
stale.push(userId);
|
||||
}
|
||||
}
|
||||
if (stale.length) await this.redis.hdel(key, ...stale);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async leave(issueId: string, userId: string): Promise<void> {
|
||||
await this.redis.hdel(this.key(issueId), userId);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
UpsertMaterializedTimelineRepository,
|
||||
UserDirectory,
|
||||
RateLimitStore,
|
||||
PresenceStore,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { IdentityUserDirectory } from './identity/identity-user-directory';
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repos
|
||||
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
|
||||
import { RedisPresenceStore } from './redis/redis-presence.store';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
|
||||
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
|
||||
@@ -160,6 +162,7 @@ const PORTS = [
|
||||
// Realtime → Redis pub/sub
|
||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||
{ provide: RateLimitStore, useClass: RedisRateLimitStore },
|
||||
{ provide: PresenceStore, useClass: RedisPresenceStore },
|
||||
// Identity context (anti-corruption layer)
|
||||
{ provide: UserDirectory, useClass: IdentityUserDirectory },
|
||||
];
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
BlockersService,
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
PresenceService,
|
||||
WorkflowsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
IssueGql,
|
||||
IssuePageGql,
|
||||
IssueParentGql,
|
||||
PresenceGql,
|
||||
} from './types';
|
||||
|
||||
@Resolver(() => IssueGql)
|
||||
@@ -57,8 +59,30 @@ export class IssueResolver {
|
||||
private readonly commentsService: CommentsService,
|
||||
private readonly workflows: WorkflowsService,
|
||||
private readonly blockers: BlockersService,
|
||||
private readonly presence: PresenceService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => [PresenceGql], {
|
||||
description:
|
||||
'I am on this ticket (send every ~10 s while the page is open): answers who else is, the ones editing first',
|
||||
})
|
||||
touchPresence(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('editing', { defaultValue: false }) editing: boolean,
|
||||
) {
|
||||
return this.presence.touch({ id: me.id, name: me.name }, issueId, editing);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { description: "I left this ticket's page" })
|
||||
async leavePresence(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
) {
|
||||
await this.presence.leave(me.id, issueId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => IssueGql)
|
||||
issue(
|
||||
|
||||
@@ -1063,3 +1063,13 @@ export class ProjectInsightsGql {
|
||||
})
|
||||
fields!: FieldBreakdownGql[];
|
||||
}
|
||||
|
||||
@ObjectType('Presence', {
|
||||
description: 'Someone on a ticket right now (heartbeat in the last 30 s)',
|
||||
})
|
||||
export class PresenceGql {
|
||||
@Field(() => UserRefGql) user!: UserRefGql;
|
||||
@Field({ description: 'Editing the ticket or writing a comment' })
|
||||
editing!: boolean;
|
||||
@Field({ description: 'Last heartbeat' }) at!: string;
|
||||
}
|
||||
|
||||
@@ -1188,6 +1188,16 @@ export class SetIssueParentDto {
|
||||
parent!: string | null;
|
||||
}
|
||||
|
||||
export class PresenceBodyDto {
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'Editing the ticket or writing a comment',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
editing = false;
|
||||
}
|
||||
|
||||
export class ChecklistItemBodyDto {
|
||||
@ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 })
|
||||
@IsString()
|
||||
|
||||
@@ -764,6 +764,14 @@ export class ProjectStatsDto {
|
||||
byAssignee!: CountByAssigneeDto[];
|
||||
}
|
||||
|
||||
export class PresenceDto {
|
||||
@ApiProperty({ type: UserRefDto }) user!: UserRefDto;
|
||||
@ApiProperty({ description: 'Editing the ticket or writing a comment' })
|
||||
editing!: boolean;
|
||||
@ApiProperty({ format: 'date-time', description: 'Last heartbeat' })
|
||||
at!: string;
|
||||
}
|
||||
|
||||
export class ActivityDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty({ example: 'IssueStatusChanged' }) type!: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
import {
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
PresenceService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||
import { withoutNulls } from '../common/issue-input';
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
AddCommentDto,
|
||||
CreateIssueDto,
|
||||
IssueSearchQueryDto,
|
||||
PresenceBodyDto,
|
||||
LinkIssueDto,
|
||||
LinkTypeParamDto,
|
||||
MyIssuesQueryDto,
|
||||
@@ -60,6 +63,7 @@ import {
|
||||
EpicDto,
|
||||
IssueDto,
|
||||
IssuePageDto,
|
||||
PresenceDto,
|
||||
} from './dto/responses.dto';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import { toBulkChange } from '../common/bulk';
|
||||
@@ -85,8 +89,40 @@ export class IssuesController {
|
||||
private readonly issues: IssuesService,
|
||||
private readonly comments: CommentsService,
|
||||
private readonly refs: Refs,
|
||||
private readonly presence: PresenceService,
|
||||
) {}
|
||||
|
||||
@Put('issues/:issue/presence')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'I am on this ticket (heartbeat)',
|
||||
description:
|
||||
'Send it every ~10 s while the page is open; entries fade after 30 s without one. Answers who else is there, the ones editing first.',
|
||||
})
|
||||
@ApiOkResponse({ type: [PresenceDto] })
|
||||
@ApiReadErrors()
|
||||
async touchPresence(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: PresenceBodyDto,
|
||||
) {
|
||||
return this.presence.touch(
|
||||
{ id: me.id, name: me.name },
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.editing,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('issues/:issue/presence')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: "I left this ticket's page" })
|
||||
@ApiNoContentResponse()
|
||||
@ApiReadErrors()
|
||||
async leavePresence(@CurrentUser() me: User, @Param('issue') issue: string) {
|
||||
await this.presence.leave(me.id, await this.refs.issueId(me.id, issue));
|
||||
}
|
||||
|
||||
@Put('issues/:issue/watch')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
|
||||
Reference in New Issue
Block a user