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:
2026-10-12 19:57:21 +02:00
parent 74903f7842
commit c91df110f8
50 changed files with 615 additions and 321 deletions

View File

@@ -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,
},

View File

@@ -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(
{

View File

@@ -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,

View File

@@ -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');

View File

@@ -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';

View File

@@ -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>;
}