dorfteich/apps/api/src/pages/tasks.service.ts
Claude Fable 5 8719b0ee1e #168: Formulare — Fehler-Verdrahtung und Namenslücken
Der Field-Baustein verdrahtet Hinweis/Fehler jetzt per aria-describedby
und aria-invalid mit dem Eingabefeld (cloneElement auf das einzelne
Kind; Fragmente bleiben unangetastet) — Screenreader nennen den Fehler
damit auch beim Feld-Fokus. Quota-Typ-Select mit Namen; die leeren
Aktions-/Erledigt-Spaltenköpfe in API-Tokens, Feed-Tokens, Sitzungen
und der Aufgabenübersicht (NodeView UND Server-Renderpfad) tragen
visually-hidden-Beschriftungen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:26:54 +02:00

182 lines
6.5 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import {
collectSubtreeIds,
extractTaskRows,
type TaskOverviewPage,
type TaskRow,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { apiI18n } from '../i18n/api-i18n';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { escapeHtml } from '../public/html-shell';
import { docFromStateAndUpdates } from './yjs-content';
/**
* The task overview's collection (issue #154): every task-list line of a page
* and its live subtree, permission-filtered per source page — a page the
* viewer may not read contributes nothing (same no-leak rule as the
* transclusion expansion). Rows are extracted at read time from the stored
* Yjs states; subtrees are small (depth ≤ 6), so no derived table is needed.
*/
@Injectable()
export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {}
async collect(
user: User | null,
pondSlug: string,
pageSlug: string,
): Promise<TaskOverviewPage[]> {
const pond = await this.prisma.pond.findFirst({ where: { slug: pondSlug, deletedAt: null } });
if (!pond) throw new NotFoundException();
const root = await this.prisma.page.findFirst({
where: { pondId: pond.id, slug: pageSlug, deletedAt: null },
select: { id: true, pondId: true },
});
if (!root || !(await this.permissions.canAccessPage(user, root, 'read'))) {
throw new NotFoundException();
}
return this.collectForPage(user, pond.id, root.id);
}
async collectForPage(
user: User | null,
pondId: string,
rootPageId: string,
): Promise<TaskOverviewPage[]> {
const tree = await this.prisma.page.findMany({
where: { pondId, deletedAt: null },
select: { id: true, parentId: true },
});
const subtree = collectSubtreeIds(tree, rootPageId);
const pages = await this.prisma.page.findMany({
where: { id: { in: [...subtree] } },
select: {
id: true,
slug: true,
title: true,
ydocState: true,
labels: { select: { labelId: true } },
},
orderBy: { title: 'asc' },
});
const readable = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
'read',
);
// The live document is the base state plus the update log (merged back
// only at compaction) — apply both, or fresh edits would be invisible.
const updateRows = await this.prisma.pageUpdate.findMany({
where: { pageId: { in: [...subtree] } },
orderBy: { seq: 'asc' },
select: { pageId: true, update: true },
});
const updatesByPage = new Map<string, Uint8Array[]>();
for (const row of updateRows) {
const list = updatesByPage.get(row.pageId) ?? [];
list.push(row.update);
updatesByPage.set(row.pageId, list);
}
const result: TaskOverviewPage[] = [];
// The root page leads; the readable descendants follow alphabetically.
const ordered = [
...pages.filter((page) => page.id === rootPageId),
...pages.filter((page) => page.id !== rootPageId),
];
const mentionIds = new Set<string>();
const raw: { page: (typeof pages)[number]; rows: TaskRow[] }[] = [];
for (const page of ordered) {
if (!readable.has(page.id)) continue;
let rows: TaskRow[] = [];
try {
rows = extractTaskRows(
docFromStateAndUpdates(page.ydocState, updatesByPage.get(page.id) ?? []),
);
} catch {
// An undecodable state contributes nothing rather than failing the view.
rows = [];
}
if (rows.length === 0) continue;
rows.forEach((row) => row.mentions.forEach((m) => m.userId && mentionIds.add(m.userId)));
raw.push({ page, rows });
}
const users = mentionIds.size
? await this.prisma.user.findMany({
where: { id: { in: [...mentionIds] } },
select: { id: true, username: true, displayName: true },
})
: [];
const userById = new Map(users.map((user_) => [user_.id, user_]));
for (const { page, rows } of raw) {
result.push({
pageId: page.id,
slug: page.slug,
title: page.title,
tasks: rows.map((row) => ({
id: row.id,
checked: row.checked,
text: row.text,
mentions: row.mentions
.map((mention) => {
const resolved = mention.userId ? userById.get(mention.userId) : undefined;
return resolved
? {
id: resolved.id,
username: resolved.username,
displayName: resolved.displayName,
}
: { id: '', username: mention.username, displayName: mention.username };
})
.filter((mention) => mention.username),
startDate: row.startDate,
dueDate: row.dueDate,
})),
});
}
return result;
}
/** Static table for the public view / exports (issue #154) — read-only. */
async renderStaticTable(
user: User | null,
pondId: string,
rootPageId: string,
lang: 'de' | 'en',
): Promise<string> {
const pages = await this.collectForPage(user, pondId, rootPageId);
const t = (key: string): string => apiI18n.t(`tasks:${key}`, { lng: lang });
const rows = pages.flatMap((page) =>
page.tasks.map(
(task) =>
`<tr><td><input type="checkbox" disabled${task.checked ? ' checked' : ''}></td>` +
`<td>${escapeHtml(task.text)}</td>` +
`<td>${task.mentions.map((m) => `@${escapeHtml(m.displayName)}`).join(', ')}</td>` +
`<td>${task.startDate ?? ''}</td><td>${task.dueDate ?? ''}</td>` +
`<td><a class="wikilink" href="${escapeHtml(page.slug)}" data-wikilink="${escapeHtml(page.slug)}">${escapeHtml(page.title)}</a></td></tr>`,
),
);
if (rows.length === 0) {
return `<div class="dt-task-overview-empty">${escapeHtml(t('empty'))}</div>`;
}
return (
`<table class="dt-task-overview-table"><thead><tr>` +
`<th><span class="visually-hidden">${escapeHtml(t('colDone'))}</span></th>` +
`<th>${escapeHtml(t('colTask'))}</th><th>${escapeHtml(t('colMentions'))}</th>` +
`<th>${escapeHtml(t('colStart'))}</th><th>${escapeHtml(t('colDue'))}</th>` +
`<th>${escapeHtml(t('colPage'))}</th>` +
`</tr></thead><tbody>${rows.join('')}</tbody></table>`
);
}
}