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 { 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 { 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(); 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(); 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 { 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) => `` + `${escapeHtml(task.text)}` + `${task.mentions.map((m) => `@${escapeHtml(m.displayName)}`).join(', ')}` + `${task.startDate ?? ''}${task.dueDate ?? ''}` + `${escapeHtml(page.title)}`, ), ); if (rows.length === 0) { return `
${escapeHtml(t('empty'))}
`; } return ( `` + `` + `` + `` + `` + `${rows.join('')}
${escapeHtml(t('colDone'))}${escapeHtml(t('colTask'))}${escapeHtml(t('colMentions'))}${escapeHtml(t('colStart'))}${escapeHtml(t('colDue'))}${escapeHtml(t('colPage'))}
` ); } }