> ## Documentation Index
> Fetch the complete documentation index at: https://skaum.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Protokoll export

> Architecture, renderers and content options for generated meeting minutes

> [!NOTE]
>
> Protokoll export is generally available (shipped in v0.33.0 via VOT-337). The `BetaFeatures::FEATURES[:protokoll_export]` flag and the `require_protokoll_export_beta!` / `beta_feature?(:protokoll_export)` guards are retained defensively — flip to `false` to re-hide the feature without removing code.

## Architecture overview

The export pipeline is a linear fan-out: one aggregation service produces a canonical hash of the meeting; a registry dispatches that hash to one renderer per requested format; each renderer writes its output to ActiveStorage; a background job orchestrates the whole flow and broadcasts status to the secretary panel via Turbo Streams.

```
 Meeting ──► ProtokollAggregationService ──► canonical hash (SCHEMA_VERSION 2.0)
                                                     │
                                                     ▼
                                         ProtokollRendererRegistry
                                                     │
              ┌────────────┬──────────────┬──────────┼──────────┬──────────┐
              ▼            ▼              ▼          ▼          ▼          ▼
        Markdown         JSON           CSV        PDF        DOCX   (future)
        Renderer       Renderer      Renderer   Renderer    Renderer
              └───────────────┬───────────────────┬───────────────┘
                              ▼                   ▼
                     ActiveStorage blob     Turbo::StreamsChannel
                                              (scoped refresh)

 Job orchestration: ProtokollExportJob ── pending → generating → ready / failed
```

## Aggregation service

`ProtokollAggregationService.call(meeting)` returns a symbol-keyed Ruby hash containing everything a renderer needs to produce minutes. The shape is versioned:

* `SCHEMA_VERSION = "2.0"` — additive-only within 2.x; bump the major for breaking changes. 2.0 (VOT-432) renamed the `meeting.status` value `upcoming` → `preparing`.
* Meeting lifecycle (VOT-434, additive): `meeting.activated_at` / `activated_by` and `closed_at` / `closed_by` (`{ name, meeting_participant_id }`); `meeting.started` / `ended` / `reopened` are in `AUDIT_LOG_ALLOWLIST` (the reopen row carries the optional `reason` and `superseded_protokoll_exports`, VOT-437). «Start møte» deletes every earlier audit row, so a started meeting's log begins with `meeting.started`. The Markdown, PDF and DOCX covers print the state (`protokoll.meeting_status.*`: Forberedelse / Aktivt / Avsluttet; the historical `upcoming` renders as Forberedelse), the planned dates (labelled as planned) and «Møtet startet» / «Møtet avsluttet» with names (`meeting_lifecycle_metadata`). Only the JSON carries the audit log.
* Attendance (VOT-436, additive): every participant carries `attendance` / `absent_since` / `registered_in_error` / `registered_in_error_at`, and the top-level `attendance` section holds the present/absent counts and the attendance changes from the audit log («Feilregistrert» participants left out). `can_vote` is computed at export time; a poll's eligible set is the one frozen in its snapshot.
* The service **always** returns the full hash. Renderers filter via their `content_options` argument.
* `PollSettingsSummary.deviations(poll)` is the single source of truth for noteworthy poll settings deviations, shared between the aggregation service and future secretary UI.
* Per-poll tally values (per-option counts, total votes cast, abstain count, winning option IDs, eliminated option IDs, decision result) come from the canonical `PollResultSnapshot` + `PollOptionTally` rows written atomically at poll close (VOT-353). The aggregation service does not query live `votes` rows for closed polls; the snapshot-aware methods on `Poll` (e.g. `#results`, `#total_votes_cast`, `#winning_options`) handle the snapshot-vs-live branching transparently. Polls closed before VOT-353 shipped fall back to the original live-query path with no behavior change.

## Renderer pipeline

| Renderer | Output | Notes |
| - | - | - |
| `ProtokollMarkdownRenderer` | UTF-8 Markdown string | Strikethrough (`~~title~~`) for withdrawn options |
| `ProtokollJsonRenderer` | JSON string (string-keyed) | Keys are omitted (not `null`) when a content option disables them |
| `ProtokollCsvRenderer` | ZIP archive | Multiple CSVs: participants, agenda, polls, options |
| `ProtokollPdfRenderer` | Binary PDF (Prawn, A4, Helvetica) | Cover page, page numbers, optional branding color |
| `ProtokollDocxRenderer` | Binary DOCX (Caracal) | Uses structural headings for Word outline navigation |

All renderers include `ProtokollRendererSupport` — shared `content_options` filtering, terminology lookup (`term_for`), participant resolution, and vote-detail-level logic. They receive the aggregation hash only, never ActiveRecord models.

Attendance (VOT-436): the Markdown, PDF, DOCX and CSV participant lists come from `listed_participants`, which leaves «Feilregistrert» participants out, and carry an «Oppmøte» column (`attendance_label`). Markdown, PDF and DOCX add an «Oppmøte» section after the list (`attendance_summary`: counts, who is absent — «meldt forfall» when absent since before `meeting.activated_at`, otherwise "fra `<time>`" — and the changes during the meeting). The section follows `include_participants_list`; a hash without `attendance` (exported before VOT-436) renders as before. The JSON renderer passes `participants` (all, flagged) and `attendance` through.

## Content options schema

`ProtokollExport::CONTENT_OPTIONS_DEFAULTS` is a deep-frozen constant with the following shape:

| Key | Default | Meaning |
| - | - | - |
| `include_uncompleted_items` | `false` | Include items not marked complete |
| `include_participants_list` | `true` | Include the full participants table |
| `vote_detail` | `"summary_and_final"` | One of `"final_only"`, `"summary_and_final"`, `"full_rounds"` |
| `per_item.description` | `true` | Include agenda item rich-text body |
| `per_item.poll_settings` | `"deviations_only"` | `"always"`, `"deviations_only"`, or `"never"` |
| `per_item.secretary_comment` | `true` | Include secretary notes per item |
| `per_round.eligible_voter_count` | `true` | Show eligible voter count per round |
| `per_round.non_voter_names` | `false` | Show names of non-voters (privacy-sensitive) |

`ProtokollExport#content_options_with_defaults` deep-merges stored options over defaults and symbolizes string keys from JSON storage.

For the JSON-specific key-omission contract see [Protokoll JSON Schema](/utvikling/protokoll-json-schema). That reference is pinned to the aggregation output by `test/services/protokoll_json_schema_doc_test.rb` (VOT-438): a new key fails the suite until it is documented.

## Job and broadcast

* `ProtokollExportJob.perform_later(export_id)` orchestrates a single export.
* State machine: `pending` → `generating` → `ready` or `failed`.
* **Ready-with-warnings**: at least one file rendered; unregistered formats skipped with a warning recorded on the export.
* **Failed**: zero files rendered.
* `ProtokollExportError < StandardError` is a permanent failure — discarded, no retry.
* Transient `StandardError` retries 3× with polynomial backoff.
* Turbo broadcast: `Turbo::StreamsChannel.broadcast_refresh_to(meeting, "protokoll_export")` — scoped so the secretary panel refreshes its exports list.
* `meeting_status_at_export` snapshots the meeting status at job-start time, so mid-meeting exports stay distinguishable from post-meeting ones. Exports made while preparing never survive: «Start møte» deletes them with their files (VOT-434), so no «Prøve» label is needed.
* `superseded_at` (VOT-437) is set on every existing export by «Gjenåpne møte»; the secretary panel shows «Erstattet».

### Failed-export UI (VOT-371)

* The secretary panel renders a **failed** export's `failure_reason` as always-visible body text (a warning-styled note), not only a hover tooltip — so it is readable on touch devices too.
* A **Retry** ("Prøv igjen") button posts to `POST /meetings/:id/protokoll_exports/:id/retry` (`Meetings::ProtokollExportsController#retry`), guarded by the same `require_secretary_or_chair` + `require_beta_feature` before-actions as `#create`.
* Retry re-runs the export via `ProtokollExportCreator` with the failed export's stored `formats` and `content_options`, so behavior matches a fresh export and it flows through the normal `pending → generating → ready/failed` lifecycle and Turbo refresh. The retry **creates a new export**; the original failed record is preserved in history for auditability.

## References

* `app/services/protokoll_aggregation_service.rb`
* `app/services/protokoll_renderer_registry.rb`
* `app/services/protokoll_renderer_support.rb`
* `app/services/protokoll_markdown_renderer.rb`
* `app/services/protokoll_json_renderer.rb`
* `app/services/protokoll_csv_renderer.rb`
* `app/services/protokoll_pdf_renderer.rb`
* `app/services/protokoll_docx_renderer.rb`
* `app/services/poll_settings_summary.rb`
* `app/services/protokoll_export_creator.rb`
* `app/controllers/meetings/protokoll_exports_controller.rb`
* `app/jobs/protokoll_export_job.rb`
* `app/models/protokoll_export.rb`


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.