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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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