dorfteich/apps/api/src/import-export/export.controller.ts
Claude Opus 4.8 699c003d04
All checks were successful
CD / Build and push images (push) Successful in 3m57s
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Auth e2e pack (push) Successful in 3m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
Add pond ZIP + per-page docx/odt export (#65)
Two export paths, both permission-aware (permissions.md):

- `GET /ponds/:id/export/markdown` streams a ZIP of the pond's readable
  pages as Markdown (one `<slug>.md` per page, a `media/` directory,
  wikilinks rewritten to relative `[text](slug.md)` links, image sources to
  `media/<id>.<ext>`). The `reader` guard is "may see the pond"; the service
  filters to the pages the requester may actually read, so a label-restricted
  reader gets only their slice. Media is appended as read streams and pages as
  small strings, so memory stays bounded for a large pond (500-page test).
- `POST /pages/:id/export {format: docx|odt}` enqueues a `markdown → pandoc →
  file` conversion job (the #62 queue): embedded images are inlined as data
  URIs so the sidecar embeds them, wikilinks flatten to text. The client polls
  `GET /jobs/:id` and downloads `GET /jobs/:id/result`.

Frontend: office-export buttons in the page menu (`.docx`/`.odt` run the job
and download the result; PDF is a disabled placeholder for Gotenberg, #67) and
a "Download pond as ZIP" link in pond settings. New `export` i18n namespace
(de+en). Markdown copy/download stay as-is (#30).

Robustness: the pond ZIP skips an attachment whose bytes are missing on disk
(data drift) rather than letting an unhandled read-stream error crash the api;
`FileStorageService.exists` gates inclusion, with a defensive stream error
handler. The per-page export drops an unreadable image the same way.

- shared: EXPORT_FORMATS + pageExportInputSchema; export-markdown transform
  helpers (image/wikilink rewrites, MIME→extension).
- deps: archiver (streaming ZIP; v7 for CommonJS compat), fflate (dev, reads
  ZIPs in tests).
- tests: export-markdown unit + export.service.db (ZIP contents & relative
  links, label-restricted omission, docx job with inlined images, 500-page
  streaming, missing-media skip); e2e export pack (ZIP download; `.docx`
  self-skips without a pandoc sidecar, as in the import pack, #64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 10:31:19 +02:00

45 lines
1.8 KiB
TypeScript

import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
import type { Response } from 'express';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { ExportService } from './export.service';
/**
* Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and
* a single page to `.docx`/`.odt`. Per-page Markdown download stays on the pages
* controller (`GET /pages/:id/export/markdown`, #30).
*/
@Controller()
export class ExportController {
constructor(private readonly exports: ExportService) {}
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
* `reader` role is "may see the pond"; the service filters to readable pages,
* so a label-restricted reader gets only their slice. */
@Get('ponds/:pondId/export/markdown')
@RequiresPondRole('reader', { idParam: 'pondId' })
async pondZip(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@Res() response: Response,
): Promise<void> {
await this.exports.streamPondMarkdownZip(request.user!, pondId, response);
}
/** Enqueue a `.docx`/`.odt` export of one page; poll `GET /jobs/:id` and
* download `GET /jobs/:id/result`. */
@Post('pages/:pageId/export')
@RequiresPagePermission('read', { idParam: 'pageId' })
pageExport(
@Param('pageId') pageId: string,
@Body(new ZodValidationPipe(pageExportInputSchema)) input: PageExportInput,
@Req() request: AuthedRequest,
): Promise<ConversionJobView> {
return this.exports.enqueuePageExport(request.user!, pageId, input.format);
}
}