feat: steadier live updates and notices for public proposals
- The web pings the live connection, reconnects when it goes quiet and reloads what each screen shows after reconnecting. - Dropping a card on a column it cannot go to puts it back. - Owners and admins get a notice when a proposal arrives. - The proposal limit lives in Redis, so it holds across instances. - REST tells whether I voted; pasted images leave the attachment list. - Deleting a project removes its Git integration; no lint warnings left. - Reactions and the blocked badge are components of their own. - Docs: what is done and what is left (chapter 10), Word and PDF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -177,9 +177,10 @@ export function normalizeRule(
|
||||
},
|
||||
action: {
|
||||
kind,
|
||||
// `check` passed, so there is a value whenever one is needed.
|
||||
value: check
|
||||
? kind === AutomationActionKind.ADD_LABEL
|
||||
? value!.trim().toLowerCase()
|
||||
? (value ?? '').trim().toLowerCase()
|
||||
: value
|
||||
: null,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { ChangeFeed } from '../ports/change-feed';
|
||||
@@ -7,7 +8,8 @@ import { versionOf } from './read-model-notifier';
|
||||
|
||||
/**
|
||||
* Removes the project (and its activity and timeline) from the read model,
|
||||
* and tells its members, whose project lists drop it. Tickets, comments and
|
||||
* forgets its Git repositories' secrets, and tells its members, whose project
|
||||
* lists drop it. Tickets, comments and
|
||||
* releases go as the cascade saga deletes them.
|
||||
*/
|
||||
@EventsHandler(ProjectDeletedEvent)
|
||||
@@ -18,11 +20,14 @@ export class ProjectDeletedEventHandler
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
private readonly gitIntegrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectDeletedEvent) {
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
await this.upsertMaterializedProjectRepository.remove(event.projectId);
|
||||
// No webhook of it should be accepted any more.
|
||||
await this.gitIntegrations.saveAll(event.projectId, []);
|
||||
if (!project) return; // Already removed (event handled twice).
|
||||
await this.changeFeed.publishProject(
|
||||
{
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
mentionedUserIds,
|
||||
NotificationKind,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectRole,
|
||||
StatusCategory,
|
||||
SYSTEM_ACTOR_ID,
|
||||
TrackerEvent,
|
||||
withoutMentionMarkup,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
@@ -117,6 +120,25 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
const memberIds = new Set(project.members.map((m) => m.user.id));
|
||||
|
||||
const drafts: NotificationDraft[] = [];
|
||||
// A proposal from the public form: whoever runs the project should know.
|
||||
if (
|
||||
event instanceof IssueCreatedEvent &&
|
||||
event.actorId === SYSTEM_ACTOR_ID &&
|
||||
event.issue.labels?.includes(PROPOSAL_LABEL)
|
||||
) {
|
||||
for (const member of project.members) {
|
||||
if (
|
||||
member.role !== ProjectRole.OWNER &&
|
||||
member.role !== ProjectRole.ADMIN
|
||||
)
|
||||
continue;
|
||||
drafts.push({
|
||||
userId: member.user.id,
|
||||
kind: NotificationKind.PROPOSED,
|
||||
summary: `Nueva propuesta en ${project.key}: ${ticket}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event instanceof IssueCreatedEvent && assigneeId) {
|
||||
drafts.push({
|
||||
userId: assigneeId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssuePriority,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
@@ -100,7 +101,7 @@ function setup(mailEnabled = false) {
|
||||
name: 'Web',
|
||||
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
|
||||
user: { id, name: id },
|
||||
role: ProjectRole.MEMBER,
|
||||
role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER,
|
||||
})),
|
||||
}),
|
||||
} as unknown as FindProjectsRepository,
|
||||
@@ -298,6 +299,28 @@ describe('notifications', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('tells whoever runs the project of a proposal from the public form', async () => {
|
||||
const { handler, repo } = setup();
|
||||
await handler.handle(
|
||||
at(
|
||||
new IssueCreatedEvent('system', {
|
||||
id: 'i-9',
|
||||
projectId: 'p-1',
|
||||
key: 'WEB-9',
|
||||
title: 'Exportar a PDF',
|
||||
reporterId: 'system',
|
||||
assigneeId: null,
|
||||
labels: ['propuesta'],
|
||||
} as never),
|
||||
),
|
||||
);
|
||||
|
||||
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
||||
['ana', NotificationKind.PROPOSED],
|
||||
]);
|
||||
expect(repo.all[0].summary).toContain('Nueva propuesta en WEB');
|
||||
});
|
||||
|
||||
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
|
||||
const { handler, repo, mailed } = setup(true);
|
||||
repo.emailOn.add('bob');
|
||||
|
||||
@@ -26,3 +26,4 @@ export * from './upsert-materialized-sprint.repository';
|
||||
export * from './git-integrations.repository';
|
||||
export * from './outgoing-webhooks.repository';
|
||||
export * from './automations.repository';
|
||||
export * from './rate-limit.store';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Counts hits per key within a window, shared by every instance of the API
|
||||
* (so a limit holds however many there are).
|
||||
*/
|
||||
export abstract class RateLimitStore {
|
||||
/** Counts one more for `key` and answers how many within the window. */
|
||||
abstract hit(key: string, windowMs: number): Promise<number>;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export enum NotificationKind {
|
||||
MENTIONED = 'MENTIONED',
|
||||
/** Someone answered a comment of yours. */
|
||||
REPLIED = 'REPLIED',
|
||||
/** A bug report or an idea came in through a project's public form. */
|
||||
PROPOSED = 'PROPOSED',
|
||||
/** A ticket you follow changed. */
|
||||
WATCHED = 'WATCHED',
|
||||
/** You were added to a project. */
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
||||
import { RateLimitStore } from '@task-stream/api/tracker/application';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
/** A fixed window per key: the first hit opens it, it expires on its own. */
|
||||
@Injectable()
|
||||
export class RedisRateLimitStore extends RateLimitStore {
|
||||
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {
|
||||
super();
|
||||
}
|
||||
|
||||
async hit(key: string, windowMs: number): Promise<number> {
|
||||
const name = `task-stream:rate:${key}`;
|
||||
const count = await this.redis.incr(name);
|
||||
if (count === 1) await this.redis.pexpire(name, windowMs);
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
UpsertMaterializedReleaseRepository,
|
||||
UpsertMaterializedTimelineRepository,
|
||||
UserDirectory,
|
||||
RateLimitStore,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { IdentityUserDirectory } from './identity/identity-user-directory';
|
||||
import {
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository';
|
||||
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||
@@ -144,6 +146,7 @@ const PORTS = [
|
||||
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
||||
// Realtime → Redis pub/sub
|
||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||
{ provide: RateLimitStore, useClass: RedisRateLimitStore },
|
||||
// Identity context (anti-corruption layer)
|
||||
{ provide: UserDirectory, useClass: IdentityUserDirectory },
|
||||
];
|
||||
|
||||
@@ -443,6 +443,10 @@ export class IssueDto {
|
||||
})
|
||||
blockedBy!: IssueParentDto[];
|
||||
@ApiProperty({ description: 'How many want it done' }) votes!: number;
|
||||
@ApiProperty({
|
||||
description: 'Whether I voted for it (false without a token)',
|
||||
})
|
||||
voted!: boolean;
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Place in the backlog and its board column (ascending, as text)',
|
||||
|
||||
@@ -92,7 +92,7 @@ export class ExploreController {
|
||||
): Promise<ProposalResultDto> {
|
||||
// Bots fill in every field: pretend it worked, and keep nothing.
|
||||
if (body.website?.trim()) return { key: null, path: null };
|
||||
this.limiter.take(addressOf(req));
|
||||
await this.limiter.take(addressOf(req));
|
||||
const result = await this.projects.propose(
|
||||
project,
|
||||
body.kind,
|
||||
|
||||
@@ -135,7 +135,10 @@ export class IssuesController {
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
return { ...page, items: await this.refs.issueDtos(page.items) };
|
||||
return {
|
||||
...page,
|
||||
items: await this.refs.issueDtos(page.items, viewerOf(me)),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('projects/:project/issues')
|
||||
@@ -173,7 +176,10 @@ export class IssuesController {
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
return { ...page, items: await this.refs.issueDtos(page.items) };
|
||||
return {
|
||||
...page,
|
||||
items: await this.refs.issueDtos(page.items, viewerOf(me)),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('projects/:project/issues')
|
||||
@@ -219,7 +225,7 @@ export class IssuesController {
|
||||
return Promise.all(
|
||||
epics.map(async (epic) => ({
|
||||
...epic,
|
||||
issue: await this.refs.issueDto(epic.issue),
|
||||
issue: await this.refs.issueDto(epic.issue, viewerOf(me)),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -234,7 +240,10 @@ export class IssuesController {
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('issue') issue: string,
|
||||
) {
|
||||
return this.refs.issueDto(await this.issues.findOne(viewerOf(me), issue));
|
||||
return this.refs.issueDto(
|
||||
await this.issues.findOne(viewerOf(me), issue),
|
||||
viewerOf(me),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('issues/:issue')
|
||||
@@ -392,6 +401,7 @@ export class IssuesController {
|
||||
viewerOf(me),
|
||||
await this.refs.projectId(viewerOf(me), project),
|
||||
),
|
||||
viewerOf(me),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ export class ProjectsController {
|
||||
return Promise.all(
|
||||
columns.map(async (column) => ({
|
||||
...column,
|
||||
issues: await this.refs.issueDtos(column.issues),
|
||||
issues: await this.refs.issueDtos(column.issues, viewerOf(me)),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { RateLimitStore } from '@task-stream/api/tracker/application';
|
||||
|
||||
/** Proposals from one address within the window. */
|
||||
const LIMIT = 5;
|
||||
const WINDOW_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* Keeps one address from flooding a project with proposals. In memory:
|
||||
* enough for one instance, and an approximation with several (each counts
|
||||
* its own), which is fine for what it guards against.
|
||||
* Keeps one address from flooding a project with proposals. Counted in
|
||||
* Redis, so the limit holds across every instance of the API.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProposalLimiter {
|
||||
private readonly sent = new Map<string, number[]>();
|
||||
constructor(private readonly store: RateLimitStore) {}
|
||||
|
||||
take(address: string): void {
|
||||
const now = Date.now();
|
||||
const recent = (this.sent.get(address) ?? []).filter(
|
||||
(at) => now - at < WINDOW_MS,
|
||||
);
|
||||
if (recent.length >= LIMIT)
|
||||
async take(address: string): Promise<void> {
|
||||
const count = await this.store.hit(`proposals:${address}`, WINDOW_MS);
|
||||
if (count > LIMIT)
|
||||
throw new HttpException(
|
||||
'Too many proposals from here; try again in a few minutes',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
recent.push(now);
|
||||
this.sent.set(address, recent);
|
||||
// Forget quiet addresses now and then, so the map does not grow forever.
|
||||
if (this.sent.size > 10_000)
|
||||
for (const [key, times] of this.sent)
|
||||
if (times.every((at) => now - at >= WINDOW_MS)) this.sent.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,19 +37,27 @@ export class Refs {
|
||||
return (await this.issues.findOne(viewerId, ref)).id;
|
||||
}
|
||||
|
||||
/** The ticket as the REST API returns it: read model, allowed transitions and what blocks it. */
|
||||
async issueDto(issue: IssueReadModel) {
|
||||
/**
|
||||
* The ticket as the REST API returns it: read model, allowed transitions,
|
||||
* what blocks it and whether `viewerId` voted for it.
|
||||
*/
|
||||
async issueDto(issue: IssueReadModel, viewerId: string | null) {
|
||||
const [allowedTransitions, blockedBy] = await Promise.all([
|
||||
this.workflows.allowedTransitions(issue),
|
||||
this.blockers.blockedBy(issue),
|
||||
]);
|
||||
// Who voted is only for telling someone whether they did.
|
||||
const { voterIds: _voters, ...shown } = issue;
|
||||
return { ...shown, allowedTransitions, blockedBy };
|
||||
const { voterIds, ...shown } = issue;
|
||||
return {
|
||||
...shown,
|
||||
allowedTransitions,
|
||||
blockedBy,
|
||||
voted: !!viewerId && voterIds.includes(viewerId),
|
||||
};
|
||||
}
|
||||
|
||||
issueDtos(issues: IssueReadModel[]) {
|
||||
return Promise.all(issues.map((issue) => this.issueDto(issue)));
|
||||
issueDtos(issues: IssueReadModel[], viewerId: string | null) {
|
||||
return Promise.all(issues.map((issue) => this.issueDto(issue, viewerId)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,10 @@ export class ReleasesController {
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return { ...found, items: await this.refs.issueDtos(found.items) };
|
||||
return {
|
||||
...found,
|
||||
items: await this.refs.issueDtos(found.items, viewerOf(me)),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('releases/:releaseId/publish')
|
||||
|
||||
@@ -223,7 +223,10 @@ export class SprintsController {
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
return { ...page, items: await this.refs.issueDtos(page.items) };
|
||||
return {
|
||||
...page,
|
||||
items: await this.refs.issueDtos(page.items, viewerOf(me)),
|
||||
};
|
||||
}
|
||||
|
||||
@Put('issues/:issue/sprint')
|
||||
|
||||
Reference in New Issue
Block a user