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
182 lines
6.5 KiB
TypeScript
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>`
|
|
);
|
|
}
|
|
}
|