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:
@@ -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 },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user