Skip to main content
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 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

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 → 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
Last modified on October 21, 2026