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.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 themeeting.statusvalueupcoming→preparing.- Meeting lifecycle (VOT-434, additive):
meeting.activated_at/activated_byandclosed_at/closed_by({ name, meeting_participant_id });meeting.started/ended/reopenedare inAUDIT_LOG_ALLOWLIST(the reopen row carries the optionalreasonandsuperseded_protokoll_exports, VOT-437). «Start møte» deletes every earlier audit row, so a started meeting’s log begins withmeeting.started. The Markdown, PDF and DOCX covers print the state (protokoll.meeting_status.*: Forberedelse / Aktivt / Avsluttet; the historicalupcomingrenders 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-levelattendancesection holds the present/absent counts and the attendance changes from the audit log («Feilregistrert» participants left out).can_voteis 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_optionsargument. 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+PollOptionTallyrows written atomically at poll close (VOT-353). The aggregation service does not query livevotesrows for closed polls; the snapshot-aware methods onPoll(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
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:
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. 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→readyorfailed. - Ready-with-warnings: at least one file rendered; unregistered formats skipped with a warning recorded on the export.
- Failed: zero files rendered.
ProtokollExportError < StandardErroris a permanent failure — discarded, no retry.- Transient
StandardErrorretries 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_exportsnapshots 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_reasonas 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 samerequire_secretary_or_chair+require_beta_featurebefore-actions as#create. - Retry re-runs the export via
ProtokollExportCreatorwith the failed export’s storedformatsandcontent_options, so behavior matches a fresh export and it flows through the normalpending → generating → ready/failedlifecycle 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.rbapp/services/protokoll_renderer_registry.rbapp/services/protokoll_renderer_support.rbapp/services/protokoll_markdown_renderer.rbapp/services/protokoll_json_renderer.rbapp/services/protokoll_csv_renderer.rbapp/services/protokoll_pdf_renderer.rbapp/services/protokoll_docx_renderer.rbapp/services/poll_settings_summary.rbapp/services/protokoll_export_creator.rbapp/controllers/meetings/protokoll_exports_controller.rbapp/jobs/protokoll_export_job.rbapp/models/protokoll_export.rb