feat(records): implement native eAkte vertical

This commit is contained in:
2026-08-06 01:43:23 +02:00
parent 59860f7ac0
commit f316523a1d
28 changed files with 5700 additions and 80 deletions
+4 -3
View File
@@ -8,7 +8,7 @@
## Scope ## Scope
This repository owns the GovOPlaN Records platform module seed. This repository owns the GovOPlaN eAkte and institutional Records domain.
Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff. Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.
@@ -17,7 +17,8 @@ Records management for file plans, records classification, retention schedules,
- Depend on kernel contracts from `govoplan-core` and access contracts from `govoplan-access`. - Depend on kernel contracts from `govoplan-core` and access contracts from `govoplan-access`.
- Keep domain behavior in this module; expose integration through manifests, capabilities, API routes, events, typed DTOs, and documentation topics. - Keep domain behavior in this module; expose integration through manifests, capabilities, API routes, events, typed DTOs, and documentation topics.
- Do not import internals from sibling feature modules. Use optional dependencies and capabilities for cross-module behavior. - Do not import internals from sibling feature modules. Use optional dependencies and capabilities for cross-module behavior.
- Keep the seed non-invasive until runtime routes, persistence, and WebUI flows are intentionally designed. - Preserve exact source revisions and current source authorization at every filing boundary.
- Keep lifecycle and archive effects explicit, idempotent, recoverable, and separately governed.
## Local Workflow ## Local Workflow
@@ -26,5 +27,5 @@ Use Gitea issues as the canonical backlog and state log. The shared workflow is
Focused verification: Focused verification:
```bash ```bash
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests PYTHONPATH=src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
``` ```
+32 -9
View File
@@ -4,9 +4,15 @@
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
`govoplan-records` is the GovOPlaN platform module seed for records management for file plans, records classification, retention schedules, disposal holds, and archive handoff. `govoplan-records` owns GovOPlaN's native eAkte boundary: versioned file plans
and record classes, stable record identities, immutable record revisions,
volumes, exact filing references, and record chronology. Source modules retain
authority over their objects and bytes.
This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation. The first working vertical slice includes PostgreSQL/SQLite models and an
Alembic migration, tenant-scoped APIs, optimistic concurrency and idempotent
filing, temporal reads, search registration, a full-height Records workspace,
and provider-neutral filing from Files and Cases.
## Initial Ownership ## Initial Ownership
@@ -29,29 +35,46 @@ Detailed boundary notes are in [docs/RECORDS_DOMAIN_BOUNDARY.md](docs/RECORDS_DO
## Integrations ## Integrations
Expected optional integrations: Implemented optional integrations:
- files - files
- dms - cases
- docs - search
Planned optional integrations include:
- forms runtime and decisions
- Campaigns, Postbox, and Reporting
- DMS/archive providers
- policy - policy
- audit - audit
- transparency - transparency
## Current Boundary
The current kernel supports planned and open records. Restricted per-record
grants, closure, retention calculation, holds, appraisal, disposition,
transfer, destruction, and external archive effects remain explicit later
work packages. Enabling Records does not trigger destructive or external
effects.
The detailed target and implementation sequence are documented in
[docs/EAKTE_ARCHITECTURE.md](docs/EAKTE_ARCHITECTURE.md).
## Development Install ## Development Install
From the core checkout: From the workspace:
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-records
./.venv/bin/python -m pip install -e ../govoplan-records /mnt/DATA/git/govoplan/.venv/bin/python -m pip install -e .
``` ```
Focused manifest verification: Focused manifest verification:
```bash ```bash
cd /mnt/DATA/git/govoplan-records cd /mnt/DATA/git/govoplan-records
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests PYTHONPATH=src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
``` ```
## Gitea Workflow ## Gitea Workflow
+22
View File
@@ -15,6 +15,28 @@ specialist procedure. It must not duplicate all document editing or storage.
Implementation is tracked in Implementation is tracked in
[Records #1](https://git.add-ideas.de/GovOPlaN/govoplan-records/issues/1). [Records #1](https://git.add-ideas.de/GovOPlaN/govoplan-records/issues/1).
## Implementation Status
The native foundation is implemented through the work packages tracked by
Records #2-#4:
- versioned file plans and record classes;
- stable records, immutable revisions, volumes, exact record items, and
chronology;
- tenant isolation, optimistic concurrency, replay-safe writes, independent
valid/recorded time, purpose capture, institutional context, and search;
- a full-height eAkte workspace with file plan, list, details, chronology,
temporal status, create/edit, and filing actions;
- a provider-neutral Core filing contract with exact Files-version and
Cases-revision providers.
The remaining delivery order is intentionally visible rather than implied:
Records #5 owns closure, retention, holds, appraisal, and disposition; #6 owns
recovery and signed evidence; #7 requires selection and target testing of an
archive/xdomea provider; and #8 proves the reference journey. Restricted
per-record access grants also remain a dedicated access-policy slice. No
archive or destructive effect is currently claimed.
## Ownership Boundary ## Ownership Boundary
Records owns: Records owns:
+18 -10
View File
@@ -28,22 +28,30 @@ Records management for file plans, records classification, retention schedules,
- audit - audit
- transparency - transparency
## Seed State ## Implemented State
The current repository state is intentionally small: The native kernel currently provides:
- module manifest and entry point - versioned file-plan nodes and record classes;
- tenant-level permission definitions - stable record identities, immutable OCC-guarded revisions, volumes, exact
- manager and viewer role templates filed items, and chronology;
- documentation topic describing the module boundary - independent valid and recorded time with current/at/all temporal reads;
- Gitea issue workflow templates - tenant, purpose, actor/capacity, source authority, institutional context,
- manifest contract test provenance, and idempotency fields;
- provider-neutral exact-source capabilities, initially implemented by Files
file versions and Cases revisions;
- tenant APIs, search projection, uninstall/retirement guards, and a Records
workspace using shared WebUI controls.
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear. Restricted object grants and the lifecycle after the planned/open stages remain
separate governed slices. Archive transfer and destructive effects are not
implemented by this kernel.
## First Implementation Slice ## First Implementation Slice
Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links. Complete restricted access, closure and retention calculation, holds,
appraisal, disposition, transfer, recovery evidence, and one target-tested
archive provider without moving source-module ownership into Records.
The complete native/external boundary, temporal and purpose-aware record model, The complete native/external boundary, temporal and purpose-aware record model,
disposition lifecycle, German public-sector provider profiles, and staged disposition lifecycle, German public-sector provider profiles, and staged
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@govoplan/records", "name": "@govoplan/records",
"version": "0.1.18", "version": "0.1.18",
"private": true, "private": true,
"description": "GovOPlaN Records platform module seed.", "description": "GovOPlaN eAkte and institutional records module.",
"type": "module", "type": "module",
"peerDependencies": {} "peerDependencies": {}
} }
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-records" name = "govoplan-records"
version = "0.1.18" version = "0.1.18"
description = "GovOPlaN Records platform module seed." description = "GovOPlaN eAkte and institutional records module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
@@ -0,0 +1,19 @@
from govoplan_records.backend.db.models import (
RecordChronologyEntry,
RecordClassRevision,
RecordFilePlanRevision,
RecordIdentity,
RecordItem,
RecordRevision,
RecordVolumeRevision,
)
__all__ = [
"RecordChronologyEntry",
"RecordClassRevision",
"RecordFilePlanRevision",
"RecordIdentity",
"RecordItem",
"RecordRevision",
"RecordVolumeRevision",
]
+398
View File
@@ -0,0 +1,398 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class RecordFilePlanRevision(Base, TimestampMixin):
__tablename__ = "record_file_plan_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "node_id", "revision", name="uq_record_file_plan_revision"
),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_file_plan_idempotency"
),
Index("ix_record_file_plan_current", "tenant_id", "node_id", "superseded_at"),
Index("ix_record_file_plan_tree", "tenant_id", "parent_node_id", "code"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
node_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("record_file_plan_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
parent_node_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
code: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False, index=True
)
valid_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
valid_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
institutional_context: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
class RecordClassRevision(Base, TimestampMixin):
__tablename__ = "record_class_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "class_id", "revision", name="uq_record_class_revision"
),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_class_idempotency"
),
Index("ix_record_class_current", "tenant_id", "class_id", "superseded_at"),
Index(
"ix_record_class_catalog",
"tenant_id",
"file_plan_node_id",
"active",
"label",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
class_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("record_class_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
file_plan_node_id: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_requirements: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
allowed_source_types: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
retention_period_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
closure_trigger: Mapped[str | None] = mapped_column(String(255), nullable=True)
access_mode: Mapped[str] = mapped_column(
String(30), default="tenant", nullable=False
)
active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False, index=True
)
valid_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
valid_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
institutional_context: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
class RecordIdentity(Base, TimestampMixin):
__tablename__ = "record_identities"
__table_args__ = (
UniqueConstraint("tenant_id", "record_id", name="uq_record_identity_tenant_id"),
UniqueConstraint(
"tenant_id", "record_number", name="uq_record_identity_tenant_number"
),
Index("ix_record_identity_catalog", "tenant_id", "record_number"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
record_number: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class RecordRevision(Base, TimestampMixin):
__tablename__ = "record_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "record_id", "revision", name="uq_record_revision"
),
Index("ix_record_current", "tenant_id", "record_id", "superseded_at"),
Index(
"ix_record_catalog", "tenant_id", "state", "class_id", "file_plan_node_id"
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
identity_id: Mapped[str] = mapped_column(
ForeignKey("record_identities.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("record_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
class_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
file_plan_node_id: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
state: Mapped[str] = mapped_column(
String(40), default="open", nullable=False, index=True
)
source_authority_mode: Mapped[str] = mapped_column(
String(40), default="native_authoritative", nullable=False
)
access_mode: Mapped[str] = mapped_column(
String(30), default="tenant", nullable=False
)
purpose: Mapped[str] = mapped_column(String(255), nullable=False)
classification: Mapped[str | None] = mapped_column(
String(120), nullable=True, index=True
)
responsible_unit_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
responsible_function_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
external_reference: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
institutional_context: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
search_text: Mapped[str] = mapped_column(Text, nullable=False)
valid_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
valid_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class RecordVolumeRevision(Base, TimestampMixin):
__tablename__ = "record_volume_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "volume_id", "revision", name="uq_record_volume_revision"
),
Index("ix_record_volume_current", "tenant_id", "volume_id", "superseded_at"),
Index("ix_record_volume_order", "tenant_id", "record_id", "sequence"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
volume_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("record_volume_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
label: Mapped[str] = mapped_column(String(500), nullable=False)
state: Mapped[str] = mapped_column(
String(40), default="open", nullable=False, index=True
)
valid_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
valid_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class RecordItem(Base, TimestampMixin):
__tablename__ = "record_items"
__table_args__ = (
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_item_idempotency"
),
UniqueConstraint(
"tenant_id", "record_id", "sequence", name="uq_record_item_sequence"
),
Index(
"ix_record_item_source",
"tenant_id",
"source_module",
"resource_type",
"resource_id",
),
Index("ix_record_item_record", "tenant_id", "record_id", "sequence"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
volume_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
source_module: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
resource_id: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
source_revision: Mapped[str] = mapped_column(String(255), nullable=False)
label: Mapped[str] = mapped_column(String(500), nullable=False)
relationship: Mapped[str] = mapped_column(String(120), nullable=False)
filing_reason: Mapped[str] = mapped_column(Text, nullable=False)
purpose: Mapped[str] = mapped_column(String(255), nullable=False)
authority_mode: Mapped[str] = mapped_column(String(40), nullable=False)
content_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
content_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
source_valid_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
source_valid_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
source_recorded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
launch_url: Mapped[str | None] = mapped_column(String(1500), nullable=True)
filed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
filed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
actor_assignment_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
actor_delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
institutional_context: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
source_metadata: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
filing_metadata: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
supersedes_item_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
class RecordChronologyEntry(Base, TimestampMixin):
__tablename__ = "record_chronology_entries"
__table_args__ = (
UniqueConstraint("tenant_id", "event_id", name="uq_record_chronology_event"),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_chronology_idempotency"
),
Index("ix_record_chronology_record", "tenant_id", "record_id", "occurred_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
record_revision: Mapped[int] = mapped_column(Integer, nullable=False)
summary: Mapped[str] = mapped_column(String(500), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
actor_assignment_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
actor_delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
purpose: Mapped[str] = mapped_column(String(255), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
institutional_context: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
__all__ = [
"RecordChronologyEntry",
"RecordClassRevision",
"RecordFilePlanRevision",
"RecordIdentity",
"RecordItem",
"RecordRevision",
"RecordVolumeRevision",
]
+407 -44
View File
@@ -1,8 +1,42 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from pathlib import Path
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.information_governance import (
InformationGovernanceDimension,
ModuleInformationGovernance,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.records import CAPABILITY_RECORDS_FILING
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_records.backend.db import models as record_models
from govoplan_records.backend.search_source import create_records_search_source
from govoplan_records.backend.service import SqlRecordRegistry
MODULE_ID = "records" MODULE_ID = "records"
MODULE_NAME = "Records" MODULE_NAME = "Records"
@@ -12,11 +46,18 @@ WRITE_SCOPE = "records:workspace:write"
ADMIN_SCOPE = "records:workspace:admin" ADMIN_SCOPE = "records:workspace:admin"
OPTIONAL_DEPENDENCIES = ( OPTIONAL_DEPENDENCIES = (
"files", "files",
"cases",
"forms_runtime",
"decisions",
"campaigns",
"postbox",
"reporting",
"dms", "dms",
"docs", "docs",
"policy", "policy",
"audit", "audit",
"transparency", "transparency",
"search",
) )
@@ -34,108 +75,430 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
) )
def _router(context: ModuleContext):
from govoplan_records.backend.router import create_router
return create_router(context.registry)
def _records_registry(context: ModuleContext) -> SqlRecordRegistry:
return SqlRecordRegistry(context.registry)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
records = (
session.query(record_models.RecordIdentity)
.filter(record_models.RecordIdentity.tenant_id == tenant_id)
.count()
)
open_records = (
session.query(record_models.RecordRevision)
.filter(
record_models.RecordRevision.tenant_id == tenant_id,
record_models.RecordRevision.superseded_at.is_(None),
record_models.RecordRevision.state == "open",
)
.count()
)
return {"records": records, "open_records": open_records}
PERMISSIONS = ( PERMISSIONS = (
_permission(READ_SCOPE, "View records workspace", "Read records, configuration, and workflow context."), _permission(
_permission(WRITE_SCOPE, "Manage records workspace", "Create and update records and workflow state."), READ_SCOPE,
_permission(ADMIN_SCOPE, "Administer records workspace", "Configure records policies, templates, and tenant-level administration."), "View records workspace",
"Read currently authorized records, contents, chronology, and file-plan context.",
),
_permission(
WRITE_SCOPE,
"Manage records workspace",
"Create and revise records, create volumes, and file exact source revisions.",
),
_permission(
ADMIN_SCOPE,
"Administer records workspace",
"Version file-plan nodes and record classes and administer Records configuration.",
),
) )
ROLE_TEMPLATES = ( ROLE_TEMPLATES = (
RoleTemplate( RoleTemplate(
slug="records_manager", slug="records_manager",
name="Records manager", name="Records manager",
description="Manage records and workflow state.", description="Create, revise, structure, and file content into records.",
permissions=(READ_SCOPE, WRITE_SCOPE), permissions=(READ_SCOPE, WRITE_SCOPE),
), ),
RoleTemplate( RoleTemplate(
slug="records_viewer", slug="records_viewer",
name="Records viewer", name="Records viewer",
description="Read records and workflow context.", description="Read records and their governed chronology.",
permissions=(READ_SCOPE,), permissions=(READ_SCOPE,),
), ),
RoleTemplate(
slug="records_administrator",
name="Records administrator",
description="Configure file plans and record classes and manage records.",
permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
),
) )
DOCUMENTATION = ( DOCUMENTATION = (
DocumentationTopic( DocumentationTopic(
id=f"{MODULE_ID}.module-boundary", id="records.workspace",
title=f"{MODULE_NAME} module boundary", title="eAkte workspace",
summary="Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.", summary="Create and browse institutional records, their exact filed items, and chronology.",
body=( body=(
"This repository is currently a platform module seed. It registers the domain boundary, " "Records owns the stable record identity, file-plan classification, immutable revisions, "
"permission surface, role templates, and documentation metadata before runtime APIs, " "volumes, filing decisions, and chronology. Files and other source modules continue to own "
"database models, migrations, and WebUI routes are introduced." "their content. Filing resolves and preserves an exact source revision only after the source "
"module confirms current access. The titlebar temporal selection changes valid and recorded "
"time while current authorization always remains in force."
), ),
layer="available", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"), audience=("user", "records_manager", "operator", "module_admin", "auditor"),
order=100, order=100,
related_modules=OPTIONAL_DEPENDENCIES, related_modules=OPTIONAL_DEPENDENCIES,
links=( links=(
DocumentationLink(
label="Repository domain boundary",
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
kind="repository",
),
DocumentationLink( DocumentationLink(
label="eAkte architecture", label="eAkte architecture",
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md", href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
kind="repository", kind="repository",
), ),
), ),
translations={
"de": {
"title": "eAkte-Arbeitsbereich",
"summary": "Institutionelle Akten, exakt veraktete Objekte und die Chronologie anlegen und einsehen.",
"body": (
"Records verwaltet die stabile Aktenidentität, Aktenplanklassifikation, unveränderliche "
"Revisionen, Bände, Veraktungsentscheidungen und die Chronologie. Dateien und andere "
"Quellmodule bleiben Eigentümer ihrer Inhalte. Bei der Veraktung wird erst nach aktueller "
"Zugriffsprüfung durch das Quellmodul eine exakte Quellrevision festgehalten. Die temporale "
"Auswahl in der Titelleiste ändert Gültigkeits- und Erfassungszeit; die aktuelle Berechtigung "
"gilt stets weiter."
),
}
},
metadata={ metadata={
"seed": True, "help_contexts": [
"domain_objects": ['file plans', 'records classification', 'retention schedule application', 'disposal holds', 'archive handoff state', 'legal record identity'], "records.workspace",
"first_slice": "Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links.", "records.file-plan",
"records.record-list",
"records.record-detail",
"records.record-items",
"records.chronology",
"records.action.create",
"records.action.edit",
"records.field.record-number",
"records.field.state",
"records.field.title",
"records.field.class",
"records.field.classification",
"records.field.description",
"records.field.change-reason",
],
}, },
), ),
DocumentationTopic( DocumentationTopic(
id=f"{MODULE_ID}.eakte-architecture", id="records.filing",
title="eAkte and digital record lifecycle", title="Exact record filing",
summary="Defines native and external record operation, filing, temporal and purpose-aware access, retention, holds, appraisal, transfer, and disposition.", summary="File immutable Files or Cases revisions through a provider-neutral capability.",
body=( body=(
"Records owns the legal and institutional record identity, file plan, filing decisions, " "Every filing requires a record, purpose, filing reason, idempotency key, and exact source "
"retention and disposition lifecycle, and transfer evidence. Files owns bytes, DMS owns " "revision. Records stores source identity, authority mode, digest and content metadata where "
"document editing, Policy owns reusable rules, and external archives remain supported " "available, represented valid time, source recorded time, filing actor and capacity, and an "
"through explicit source-authority and provider profiles." "immutable chronology entry. A repeated idempotency key replays only the identical request."
), ),
layer="available", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "records_manager", "operator", "module_admin", "product_owner"), audience=("user", "records_manager", "operator", "module_admin", "auditor"),
order=110, order=110,
related_modules=OPTIONAL_DEPENDENCIES, related_modules=("files", "cases", "policy", "audit"),
links=( links=(
DocumentationLink( DocumentationLink(
label="eAkte architecture", label="Records domain boundary",
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md", href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
kind="repository", kind="repository",
), ),
), ),
translations={
"de": {
"title": "Exakte Veraktung",
"summary": "Unveränderliche Datei- oder Vorgangsrevisionen über eine anbieterneutrale Schnittstelle verakten.",
"body": (
"Jede Veraktung benötigt eine Akte, einen Zweck, eine Veraktungsbegründung, einen "
"Idempotenzschlüssel und eine exakte Quellrevision. Records speichert Quellidentität, "
"Autoritätsmodus, soweit verfügbar Prüfsumme und Inhaltsmetadaten, Gültigkeits- und "
"Erfassungszeit der Quelle, handelnde Person und Funktion sowie einen unveränderlichen "
"Chronologieeintrag. Ein wiederholter Idempotenzschlüssel gibt nur dieselbe Anfrage erneut aus."
),
}
},
metadata={ metadata={
"kind": "concept", "help_contexts": [
"help_contexts": ["records.page", "records.record", "records.disposition"], "records.action.file",
"known_limit": "The architecture is accepted, but persistence and user-visible record workflows remain a scaffold.", "records.field.source-module",
"records.field.source-object",
"records.field.source-revision",
"records.field.purpose",
"records.field.filing-reason",
],
}, },
), ),
DocumentationTopic(
id="records.lifecycle-limitations",
title="Records lifecycle limitations",
summary="Identifies lifecycle controls intentionally deferred beyond the native kernel.",
body=(
"The current vertical supports planned and open records. Restricted object grants, closure, "
"retention calculation, holds, appraisal, disposition, transfer, destruction, and external "
"archive effects are separate governed work packages. No destructive effect is implied by "
"enabling Records."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
order=120,
related_modules=("policy", "approvals", "audit", "dms"),
translations={
"de": {
"title": "Grenzen des Aktenlebenszyklus",
"summary": "Kennzeichnet bewusst nach dem nativen Kern umzusetzende Lebenszyklussteuerungen.",
"body": (
"Der aktuelle Stand unterstützt geplante und offene Akten. Objektbezogene Freigaben, "
"Abschluss, Aufbewahrungsberechnung, Sperren, Bewertung, Aussonderung, Übergabe, Vernichtung "
"und externe Archiveffekte sind getrennte gesteuerte Arbeitspakete. Die Aktivierung von "
"Records löst keine vernichtende Wirkung aus."
),
}
},
metadata={"known_limit": True},
),
) )
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
version=MODULE_VERSION, version=MODULE_VERSION,
dependencies=("access",), dependencies=("access",),
optional_dependencies=OPTIONAL_DEPENDENCIES, optional_dependencies=OPTIONAL_DEPENDENCIES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
route_factory=_router,
nav_items=(
NavItem(
path="/records",
label="Records",
icon="archive",
required_any=(READ_SCOPE,),
order=47,
surface_id="records.navigation",
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/records-webui",
routes=(
FrontendRoute(
path="/records",
component="RecordsPage",
required_any=(READ_SCOPE,),
order=47,
surface_id="records.workspace",
),
),
nav_items=(
NavItem(
path="/records",
label="Records",
icon="archive",
required_any=(READ_SCOPE,),
order=47,
surface_id="records.navigation",
),
),
view_surfaces=(
ViewSurface(
id="records.workspace.file-plan",
module_id=MODULE_ID,
kind="section",
label="File plan",
parent_id="records.workspace",
order=10,
),
ViewSurface(
id="records.workspace.list",
module_id=MODULE_ID,
kind="section",
label="Record list",
parent_id="records.workspace",
order=20,
),
ViewSurface(
id="records.workspace.detail",
module_id=MODULE_ID,
kind="section",
label="Record detail",
parent_id="records.workspace",
order=30,
),
ViewSurface(
id="records.workspace.file",
module_id=MODULE_ID,
kind="action",
label="File source revision",
parent_id="records.workspace.detail",
order=40,
),
),
),
provides_interfaces=(
ModuleInterfaceProvider(name="records.registry", version="1.0.0"),
ModuleInterfaceProvider(name="records.filing", version="1.0.0"),
),
capability_factories={CAPABILITY_RECORDS_FILING: _records_registry},
capability_documentation={
CAPABILITY_RECORDS_FILING: CapabilityDocumentation(
label="Record filing",
summary="Resolves authorized exact source revisions and files immutable record items.",
contract_version="1.0.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
record_models.RecordChronologyEntry,
record_models.RecordItem,
record_models.RecordVolumeRevision,
record_models.RecordRevision,
record_models.RecordIdentity,
record_models.RecordClassRevision,
record_models.RecordFilePlanRevision,
label="Records",
),
retirement_notes=(
"Destructive retirement requires a database snapshot and removes record identities, "
"file plans, exact filing references, and chronology. Source content remains provider-owned."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
record_models.RecordIdentity,
record_models.RecordRevision,
record_models.RecordItem,
record_models.RecordChronologyEntry,
record_models.RecordClassRevision,
record_models.RecordFilePlanRevision,
label="Records",
),
),
tenant_summary_providers=(_tenant_summary,),
search_sources=(
SearchSourceProviderRegistration(
id="records.objects", factory=create_records_search_source
),
),
documentation=DOCUMENTATION, documentation=DOCUMENTATION,
information_governance=ModuleInformationGovernance(
temporal_browsing=InformationGovernanceDimension(
adoption="enforced",
object_types=(
"record",
"record_volume",
"record_item",
"record_class",
"file_plan_node",
),
evidence=(
"src/govoplan_records/backend/service.py",
"tests/test_records.py",
),
),
purpose_aware_access=InformationGovernanceDimension(
adoption="partial",
object_types=("record", "record_item"),
evidence=("src/govoplan_records/backend/service.py",),
limitation=(
"Purposes are mandatory and preserved for record operations, but Policy-backed "
"object-level purpose constraints and restricted-record grants are not implemented yet."
),
),
retention=InformationGovernanceDimension(
adoption="contract_only",
limitation=(
"Record classes preserve retention inputs; closure, holds, calculation, appraisal, "
"and disposition are tracked in Records #5."
),
),
institutional_context=InformationGovernanceDimension(
adoption="enforced",
object_types=(
"record",
"record_item",
"record_event",
"record_class",
"file_plan_node",
),
evidence=(
"src/govoplan_records/backend/db/models.py",
"src/govoplan_records/backend/service.py",
"tests/test_records.py",
),
),
),
architecture=declared_module_architecture( architecture=declared_module_architecture(
layer="content_records_evidence", layer="content_records_evidence",
kind="domain", kind="domain",
maturity="scaffold", maturity="vertical_slice",
documentation_ref="docs/RECORDS_DOMAIN_BOUNDARY.md", documentation_ref="docs/EAKTE_ARCHITECTURE.md",
known_limits=("Record declaration, retention, hold, transfer, and disposal are not implemented yet.",), test_ref="tests/test_records.py",
owned_concepts=("record", "record classification", "disposition"), known_limits=(
non_owned_concepts=("file content", "audit event", "domain object"), "Restricted object grants and lifecycle stages after open are tracked separately.",
"Archive transfer and destructive effects are not part of the native kernel.",
),
supported_authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
),
owned_concepts=(
"record",
"record class",
"file plan",
"record volume",
"record item",
"filing decision",
"record chronology",
),
non_owned_concepts=(
"file content",
"source object",
"case lifecycle",
"workflow execution",
"generic policy",
"audit event",
"archive preservation provider",
),
reference_packages=(
"product.service-to-decision",
"product.monthly-data-operations",
),
migration_docs=("docs/EAKTE_ARCHITECTURE.md",),
recovery_docs=("docs/EAKTE_ARCHITECTURE.md",),
security_docs=("docs/EAKTE_ARCHITECTURE.md",),
operations_docs=("docs/EAKTE_ARCHITECTURE.md",),
), ),
) )
@@ -0,0 +1 @@
"""Records Alembic migrations."""
@@ -0,0 +1,401 @@
"""v0.1.18 Records kernel.
Revision ID: 6e4a2c8f1d9b
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "6e4a2c8f1d9b"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"record_file_plan_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("node_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("parent_node_id", sa.String(length=255), nullable=True),
sa.Column("code", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("institutional_context", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["record_file_plan_revisions.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_file_plan_idempotency"
),
sa.UniqueConstraint(
"tenant_id", "node_id", "revision", name="uq_record_file_plan_revision"
),
)
_indexes(
"record_file_plan_revisions",
"tenant_id",
"node_id",
"previous_revision_id",
"parent_node_id",
"code",
"active",
"recorded_at",
"superseded_at",
"changed_by",
)
op.create_index(
"ix_record_file_plan_current",
"record_file_plan_revisions",
["tenant_id", "node_id", "superseded_at"],
)
op.create_index(
"ix_record_file_plan_tree",
"record_file_plan_revisions",
["tenant_id", "parent_node_id", "code"],
)
op.create_table(
"record_class_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("class_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("file_plan_node_id", sa.String(length=255), nullable=False),
sa.Column("key", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("metadata_requirements", sa.JSON(), nullable=False),
sa.Column("allowed_source_types", sa.JSON(), nullable=False),
sa.Column("retention_period_days", sa.Integer(), nullable=True),
sa.Column("closure_trigger", sa.String(length=255), nullable=True),
sa.Column("access_mode", sa.String(length=30), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("institutional_context", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"], ["record_class_revisions.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "class_id", "revision", name="uq_record_class_revision"
),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_class_idempotency"
),
)
_indexes(
"record_class_revisions",
"tenant_id",
"class_id",
"previous_revision_id",
"file_plan_node_id",
"key",
"active",
"recorded_at",
"superseded_at",
"changed_by",
)
op.create_index(
"ix_record_class_current",
"record_class_revisions",
["tenant_id", "class_id", "superseded_at"],
)
op.create_index(
"ix_record_class_catalog",
"record_class_revisions",
["tenant_id", "file_plan_node_id", "active", "label"],
)
op.create_table(
"record_identities",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("record_id", sa.String(length=255), nullable=False),
sa.Column("record_number", sa.String(length=255), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "record_id", name="uq_record_identity_tenant_id"
),
sa.UniqueConstraint(
"tenant_id", "record_number", name="uq_record_identity_tenant_number"
),
)
_indexes(
"record_identities", "tenant_id", "record_id", "record_number", "created_by"
)
op.create_index(
"ix_record_identity_catalog",
"record_identities",
["tenant_id", "record_number"],
)
op.create_table(
"record_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("record_id", sa.String(length=255), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("class_id", sa.String(length=255), nullable=False),
sa.Column("file_plan_node_id", sa.String(length=255), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("state", sa.String(length=40), nullable=False),
sa.Column("source_authority_mode", sa.String(length=40), nullable=False),
sa.Column("access_mode", sa.String(length=30), nullable=False),
sa.Column("purpose", sa.String(length=255), nullable=False),
sa.Column("classification", sa.String(length=120), nullable=True),
sa.Column("responsible_unit_id", sa.String(length=255), nullable=True),
sa.Column("responsible_function_id", sa.String(length=255), nullable=True),
sa.Column("external_reference", sa.JSON(), nullable=False),
sa.Column("institutional_context", sa.JSON(), nullable=False),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["identity_id"], ["record_identities.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["previous_revision_id"], ["record_revisions.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "record_id", "revision", name="uq_record_revision"
),
)
_indexes(
"record_revisions",
"tenant_id",
"record_id",
"identity_id",
"previous_revision_id",
"class_id",
"file_plan_node_id",
"state",
"classification",
"responsible_unit_id",
"responsible_function_id",
"recorded_at",
"superseded_at",
"changed_by",
)
op.create_index(
"ix_record_current",
"record_revisions",
["tenant_id", "record_id", "superseded_at"],
)
op.create_index(
"ix_record_catalog",
"record_revisions",
["tenant_id", "state", "class_id", "file_plan_node_id"],
)
op.create_table(
"record_volume_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("volume_id", sa.String(length=255), nullable=False),
sa.Column("record_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("label", sa.String(length=500), nullable=False),
sa.Column("state", sa.String(length=40), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["record_volume_revisions.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "volume_id", "revision", name="uq_record_volume_revision"
),
)
_indexes(
"record_volume_revisions",
"tenant_id",
"volume_id",
"record_id",
"previous_revision_id",
"state",
"recorded_at",
"superseded_at",
"changed_by",
)
op.create_index(
"ix_record_volume_current",
"record_volume_revisions",
["tenant_id", "volume_id", "superseded_at"],
)
op.create_index(
"ix_record_volume_order",
"record_volume_revisions",
["tenant_id", "record_id", "sequence"],
)
op.create_table(
"record_items",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("record_id", sa.String(length=255), nullable=False),
sa.Column("volume_id", sa.String(length=255), nullable=True),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("source_module", sa.String(length=100), nullable=False),
sa.Column("resource_type", sa.String(length=120), nullable=False),
sa.Column("resource_id", sa.String(length=500), nullable=False),
sa.Column("source_revision", sa.String(length=255), nullable=False),
sa.Column("label", sa.String(length=500), nullable=False),
sa.Column("relationship", sa.String(length=120), nullable=False),
sa.Column("filing_reason", sa.Text(), nullable=False),
sa.Column("purpose", sa.String(length=255), nullable=False),
sa.Column("authority_mode", sa.String(length=40), nullable=False),
sa.Column("content_sha256", sa.String(length=64), nullable=True),
sa.Column("content_type", sa.String(length=255), nullable=True),
sa.Column("size_bytes", sa.BigInteger(), nullable=True),
sa.Column("source_valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_recorded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("launch_url", sa.String(length=1500), nullable=True),
sa.Column("filed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("filed_by", sa.String(length=255), nullable=True),
sa.Column("actor_assignment_id", sa.String(length=255), nullable=True),
sa.Column("actor_delegation_id", sa.String(length=255), nullable=True),
sa.Column("institutional_context", sa.JSON(), nullable=False),
sa.Column("source_metadata", sa.JSON(), nullable=False),
sa.Column("filing_metadata", sa.JSON(), nullable=False),
sa.Column("supersedes_item_id", sa.String(length=36), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_item_idempotency"
),
sa.UniqueConstraint(
"tenant_id", "record_id", "sequence", name="uq_record_item_sequence"
),
)
_indexes(
"record_items",
"tenant_id",
"record_id",
"volume_id",
"source_module",
"resource_type",
"resource_id",
"filed_at",
"filed_by",
"supersedes_item_id",
)
op.create_index(
"ix_record_item_source",
"record_items",
["tenant_id", "source_module", "resource_type", "resource_id"],
)
op.create_index(
"ix_record_item_record", "record_items", ["tenant_id", "record_id", "sequence"]
)
op.create_table(
"record_chronology_entries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("record_id", sa.String(length=255), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("event_type", sa.String(length=120), nullable=False),
sa.Column("record_revision", sa.Integer(), nullable=False),
sa.Column("summary", sa.String(length=500), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("actor_assignment_id", sa.String(length=255), nullable=True),
sa.Column("actor_delegation_id", sa.String(length=255), nullable=True),
sa.Column("purpose", sa.String(length=255), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("institutional_context", sa.JSON(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "event_id", name="uq_record_chronology_event"),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_chronology_idempotency"
),
)
_indexes(
"record_chronology_entries",
"tenant_id",
"record_id",
"event_id",
"event_type",
"occurred_at",
"actor_id",
)
op.create_index(
"ix_record_chronology_record",
"record_chronology_entries",
["tenant_id", "record_id", "occurred_at"],
)
def downgrade() -> None:
op.drop_table("record_chronology_entries")
op.drop_table("record_items")
op.drop_table("record_volume_revisions")
op.drop_table("record_revisions")
op.drop_table("record_identities")
op.drop_table("record_class_revisions")
op.drop_table("record_file_plan_revisions")
def _indexes(table: str, *columns: str) -> None:
for column in columns:
op.create_index(op.f(f"ix_{table}_{column}"), table, [column], unique=False)
@@ -0,0 +1 @@
"""Records migration revisions."""
+274
View File
@@ -0,0 +1,274 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.records import RecordFilingRequest, RecordSourceLocator
from govoplan_core.db.session import get_session
from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE
from govoplan_records.backend.schemas import (
FilePlanNodeWriteRequest,
RecordCatalogResponse,
RecordClassWriteRequest,
RecordCreateRequest,
RecordDetailResponse,
RecordItemCreateRequest,
RecordListResponse,
RecordSourceProviderResponse,
RecordUpdateRequest,
RecordVolumeCreateRequest,
)
from govoplan_records.backend.service import (
RecordConflictError,
RecordNotFoundError,
RecordSourceUnavailableError,
RecordStoreError,
SqlRecordRegistry,
)
def create_router(registry: object | None = None) -> APIRouter:
router = APIRouter(prefix="/records", tags=["records"])
records = SqlRecordRegistry(registry)
@router.get("/catalog", response_model=RecordCatalogResponse)
def api_catalog(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> RecordCatalogResponse:
_require(principal, READ_SCOPE)
return RecordCatalogResponse(**records.catalog(session, principal))
@router.post(
"/catalog/file-plan",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_write_file_plan_node(
payload: FilePlanNodeWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, ADMIN_SCOPE)
return _write(
session,
lambda: records.write_file_plan_node(
session, principal, payload=payload.model_dump(mode="python")
),
)
@router.post(
"/catalog/classes",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_write_record_class(
payload: RecordClassWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, ADMIN_SCOPE)
return _write(
session,
lambda: records.write_record_class(
session, principal, payload=payload.model_dump(mode="python")
),
)
@router.get("/sources", response_model=RecordSourceProviderResponse)
def api_source_providers(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> RecordSourceProviderResponse:
_require(principal, WRITE_SCOPE)
return RecordSourceProviderResponse(
providers=records.source_providers(session, principal)
)
@router.get("", response_model=RecordListResponse)
def api_list_records(
query: str | None = Query(default=None, max_length=500),
record_state: str | None = Query(default=None, alias="state", max_length=40),
class_id: str | None = Query(default=None, max_length=255),
file_plan_node_id: str | None = Query(default=None, max_length=255),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> RecordListResponse:
_require(principal, READ_SCOPE)
items, total = records.list_records(
session,
principal,
query=query,
state=record_state,
class_id=class_id,
file_plan_node_id=file_plan_node_id,
offset=offset,
limit=limit,
)
return RecordListResponse(
records=items, total=total, offset=offset, limit=limit
)
@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED)
def api_create_record(
payload: RecordCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.create_record(
session, principal, payload=payload.model_dump(mode="python")
),
)
@router.get("/{record_id}", response_model=RecordDetailResponse)
def api_get_record(
record_id: str,
revision: int | None = Query(default=None, ge=1),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> RecordDetailResponse:
_require(principal, READ_SCOPE)
try:
return RecordDetailResponse(
**records.get_record(
session, principal, record_id=record_id, revision=revision
)
)
except RecordStoreError as exc:
raise _http_error(exc) from exc
@router.patch("/{record_id}", response_model=dict[str, Any])
def api_update_record(
record_id: str,
payload: RecordUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.update_record(
session,
principal,
record_id=record_id,
payload=payload.model_dump(mode="python", exclude_unset=True),
),
)
@router.post(
"/{record_id}/volumes",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_create_volume(
record_id: str,
payload: RecordVolumeCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
return _write(
session,
lambda: records.create_volume(
session,
principal,
record_id=record_id,
payload=payload.model_dump(mode="python"),
),
)
@router.post(
"/{record_id}/items",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_file_item(
record_id: str,
payload: RecordItemCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
request = RecordFilingRequest(
tenant_id=principal.tenant_id,
record_id=record_id,
source=RecordSourceLocator(
tenant_id=principal.tenant_id,
source_module=payload.source.source_module,
resource_type=payload.source.resource_type,
resource_id=payload.source.resource_id,
source_revision=payload.source.source_revision,
metadata=payload.source.metadata,
),
purpose=payload.purpose,
filing_reason=payload.filing_reason,
idempotency_key=payload.idempotency_key,
volume_id=payload.volume_id,
relationship=payload.relationship,
institutional_context=payload.institutional_context,
metadata=payload.metadata,
)
def operation() -> dict[str, Any]:
result = records.file(session, principal, request=request)
return {
"record_id": result.record_id,
"item_id": result.item_id,
"sequence": result.sequence,
"filed_at": result.filed_at,
"replayed": result.replayed,
"source": {
"source_module": result.source.locator.source_module,
"resource_type": result.source.locator.resource_type,
"resource_id": result.source.locator.resource_id,
"source_revision": result.source.locator.source_revision,
"label": result.source.label,
},
}
return _write(session, operation)
return router
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _write(session: Session, operation):
try:
result = operation()
session.commit()
return result
except (RecordStoreError, IntegrityError) as exc:
session.rollback()
if isinstance(exc, IntegrityError):
raise HTTPException(
status_code=409, detail="The record write conflicts with existing data."
) from exc
raise _http_error(exc) from exc
def _http_error(exc: RecordStoreError) -> HTTPException:
if isinstance(exc, RecordNotFoundError):
code = 404
elif isinstance(exc, RecordConflictError):
code = 409
elif isinstance(exc, RecordSourceUnavailableError):
code = 503
else:
code = 422
return HTTPException(status_code=code, detail=str(exc))
__all__ = ["create_router"]
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class FilePlanNodeWriteRequest(StrictModel):
node_id: str = Field(min_length=1, max_length=255)
code: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=500)
parent_node_id: str | None = Field(default=None, max_length=255)
description: str | None = Field(default=None, max_length=10_000)
active: bool = True
valid_from: datetime | None = None
valid_to: datetime | None = None
recorded_at: datetime
expected_revision: int | None = Field(default=None, ge=1)
idempotency_key: str = Field(min_length=1, max_length=255)
institutional_context: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_interval(self):
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
raise ValueError("valid_to must be after valid_from")
return self
class RecordClassWriteRequest(StrictModel):
class_id: str = Field(min_length=1, max_length=255)
file_plan_node_id: str = Field(min_length=1, max_length=255)
key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=10_000)
metadata_requirements: list[str] = Field(default_factory=list, max_length=100)
allowed_source_types: list[str] = Field(default_factory=list, max_length=100)
retention_period_days: int | None = Field(default=None, ge=0, le=365_000)
closure_trigger: str | None = Field(default=None, max_length=255)
access_mode: Literal["tenant", "restricted"] = "tenant"
active: bool = True
valid_from: datetime | None = None
valid_to: datetime | None = None
recorded_at: datetime
expected_revision: int | None = Field(default=None, ge=1)
idempotency_key: str = Field(min_length=1, max_length=255)
institutional_context: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_interval(self):
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
raise ValueError("valid_to must be after valid_from")
return self
class RecordCreateRequest(StrictModel):
record_id: str | None = Field(default=None, max_length=255)
record_number: str = Field(min_length=1, max_length=255)
class_id: str = Field(min_length=1, max_length=255)
file_plan_node_id: str = Field(min_length=1, max_length=255)
title: str = Field(min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=20_000)
state: Literal["planned", "open"] = "open"
source_authority_mode: Literal[
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
] = "native_authoritative"
access_mode: Literal["tenant", "restricted"] = "tenant"
purpose: str = Field(min_length=1, max_length=255)
classification: str | None = Field(default=None, max_length=120)
responsible_unit_id: str | None = Field(default=None, max_length=255)
responsible_function_id: str | None = Field(default=None, max_length=255)
external_reference: dict[str, Any] = Field(default_factory=dict)
institutional_context: dict[str, Any] = Field(default_factory=dict)
valid_from: datetime | None = None
valid_to: datetime | None = None
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=2_000)
idempotency_key: str = Field(min_length=1, max_length=255)
@model_validator(mode="after")
def validate_interval(self):
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
raise ValueError("valid_to must be after valid_from")
return self
class RecordUpdateRequest(StrictModel):
expected_revision: int = Field(ge=1)
title: str | None = Field(default=None, min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=20_000)
class_id: str | None = Field(default=None, min_length=1, max_length=255)
file_plan_node_id: str | None = Field(default=None, min_length=1, max_length=255)
state: Literal["planned", "open"] | None = None
access_mode: Literal["tenant", "restricted"] | None = None
purpose: str = Field(min_length=1, max_length=255)
classification: str | None = Field(default=None, max_length=120)
responsible_unit_id: str | None = Field(default=None, max_length=255)
responsible_function_id: str | None = Field(default=None, max_length=255)
institutional_context: dict[str, Any] | None = None
valid_from: datetime | None = None
valid_to: datetime | None = None
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=2_000)
idempotency_key: str = Field(min_length=1, max_length=255)
@model_validator(mode="after")
def validate_interval(self):
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
raise ValueError("valid_to must be after valid_from")
return self
class RecordVolumeCreateRequest(StrictModel):
volume_id: str | None = Field(default=None, max_length=255)
label: str = Field(min_length=1, max_length=500)
valid_from: datetime | None = None
valid_to: datetime | None = None
recorded_at: datetime
purpose: str = Field(min_length=1, max_length=255)
idempotency_key: str = Field(min_length=1, max_length=255)
class RecordSourceLocatorRequest(StrictModel):
source_module: str = Field(min_length=1, max_length=100)
resource_type: str = Field(min_length=1, max_length=120)
resource_id: str = Field(min_length=1, max_length=500)
source_revision: str = Field(min_length=1, max_length=255)
metadata: dict[str, Any] = Field(default_factory=dict)
class RecordItemCreateRequest(StrictModel):
source: RecordSourceLocatorRequest
purpose: str = Field(min_length=1, max_length=255)
filing_reason: str = Field(min_length=1, max_length=2_000)
idempotency_key: str = Field(min_length=1, max_length=255)
volume_id: str | None = Field(default=None, max_length=255)
relationship: str = Field(default="contains", min_length=1, max_length=120)
institutional_context: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class RecordListResponse(StrictModel):
records: list[dict[str, Any]]
total: int
offset: int
limit: int
class RecordCatalogResponse(StrictModel):
file_plan: list[dict[str, Any]]
classes: list[dict[str, Any]]
class RecordDetailResponse(StrictModel):
record: dict[str, Any]
volumes: list[dict[str, Any]]
items: list[dict[str, Any]]
chronology: list[dict[str, Any]]
access_explanation: dict[str, Any]
class RecordSourceProviderResponse(StrictModel):
providers: list[dict[str, Any]]
__all__ = [
"FilePlanNodeWriteRequest",
"RecordCatalogResponse",
"RecordClassWriteRequest",
"RecordCreateRequest",
"RecordDetailResponse",
"RecordItemCreateRequest",
"RecordListResponse",
"RecordSourceProviderResponse",
"RecordUpdateRequest",
"RecordVolumeCreateRequest",
]
@@ -0,0 +1,163 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchResourceType,
)
from govoplan_records.backend.db.models import RecordIdentity, RecordRevision
PROVIDER_ID = "records.objects"
RESOURCE_TYPE = "record"
READ_SCOPE = "records:workspace:read"
ADMIN_SCOPE = "records:workspace:admin"
class RecordsSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="records",
resource_type=RESOURCE_TYPE,
label="Records",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
if request.provider_id != PROVIDER_ID or request.resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Records search source.")
db = _session(session)
statement = (
select(RecordRevision, RecordIdentity)
.join(RecordIdentity, RecordIdentity.id == RecordRevision.identity_id)
.where(
RecordRevision.tenant_id == request.tenant_id,
RecordRevision.superseded_at.is_(None),
)
)
if request.cursor:
statement = statement.where(RecordRevision.record_id > request.cursor)
rows = list(
db.execute(
statement.order_by(RecordRevision.record_id).limit(request.limit + 1)
).all()
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
high_watermark = db.scalar(
select(func.max(RecordRevision.recorded_at)).where(
RecordRevision.tenant_id == request.tenant_id,
RecordRevision.superseded_at.is_(None),
)
)
return SearchBackfillPage(
documents=tuple(_document(row, identity) for row, identity in selected),
next_cursor=selected[-1][0].record_id if has_more and selected else None,
complete=not has_more,
high_watermark=high_watermark.isoformat() if high_watermark else None,
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {item.reference.key: False for item in requests}
if not isinstance(principal, ApiPrincipal) or not (
principal.has(READ_SCOPE) or principal.has(ADMIN_SCOPE)
):
return decisions
db = _session(session)
eligible = [
request
for request in requests
if request.reference.tenant_id == principal.tenant_id
and request.reference.module_id == "records"
and request.reference.resource_type == RESOURCE_TYPE
]
resource_ids = {request.reference.resource_id for request in eligible}
available_ids = (
set(
db.scalars(
select(RecordRevision.record_id).where(
RecordRevision.tenant_id == principal.tenant_id,
RecordRevision.record_id.in_(resource_ids),
RecordRevision.superseded_at.is_(None),
)
).all()
)
if resource_ids
else set()
)
for request in eligible:
reference = request.reference
decisions[reference.key] = reference.resource_id in available_ids
return decisions
def create_records_search_source(_context: ModuleContext) -> RecordsSearchSource:
return RecordsSearchSource()
def _document(row: RecordRevision, identity: RecordIdentity) -> SearchDocument:
return SearchDocument(
tenant_id=row.tenant_id,
module_id="records",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=row.record_id,
title=row.title,
url=f"/records?recordId={quote(row.record_id, safe='')}",
summary=(row.description or identity.record_number)[:4000],
body=row.search_text[:200_000],
keywords=tuple(
value[:200]
for value in (
identity.record_number,
row.classification or "",
row.state,
)
if value
),
visibility="restricted",
acl_tokens=(f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"),
metadata={
"record_number": identity.record_number,
"class_id": row.class_id,
"file_plan_node_id": row.file_plan_node_id,
"state": row.state,
"classification": row.classification,
},
source_revision=str(row.revision),
source_updated_at=row.recorded_at,
requires_authorization_recheck=True,
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Records search requires a SQLAlchemy session.")
return value
__all__ = ["PROVIDER_ID", "RecordsSearchSource", "create_records_search_source"]
File diff suppressed because it is too large Load Diff
+34 -12
View File
@@ -2,22 +2,44 @@ from __future__ import annotations
import unittest import unittest
from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest from govoplan_core.core.records import CAPABILITY_RECORDS_FILING
from govoplan_records.backend.manifest import (
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
get_manifest,
)
class ManifestSeedTests(unittest.TestCase): class ManifestTests(unittest.TestCase):
def test_manifest_registers_seed_contract(self) -> None: def test_manifest_registers_records_vertical(self) -> None:
manifest = get_manifest() manifest = get_manifest()
self.assertEqual(manifest.id, "records") self.assertEqual("records", manifest.id)
self.assertEqual(manifest.name, "Records") self.assertEqual("Records", manifest.name)
self.assertEqual(manifest.dependencies, ("access",)) self.assertEqual(("access",), manifest.dependencies)
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}) self.assertEqual(
self.assertEqual({role.slug for role in manifest.role_templates}, {"records_manager", "records_viewer"}) {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
self.assertTrue(manifest.documentation) {permission.scope for permission in manifest.permissions},
self.assertIsNone(manifest.route_factory) )
self.assertIsNone(manifest.migration_spec) self.assertEqual(
self.assertIsNone(manifest.frontend) {"records_manager", "records_viewer", "records_administrator"},
{role.slug for role in manifest.role_templates},
)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertIn(CAPABILITY_RECORDS_FILING, manifest.capability_factories)
self.assertEqual(
"vertical_slice",
manifest.architecture.maturity if manifest.architecture else None,
)
self.assertEqual(
"enforced", manifest.information_governance.temporal_browsing.adoption
)
self.assertEqual(
"partial", manifest.information_governance.purpose_aware_access.adoption
)
if __name__ == "__main__": if __name__ == "__main__":
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_records.backend.manifest import get_manifest
class RecordsMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_records_kernel_and_head(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-records-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'records.db'}"
migrate_database(
database_url=url,
enabled_modules=("records",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
inspector = inspect(engine)
self.assertTrue(
{
"record_chronology_entries",
"record_class_revisions",
"record_file_plan_revisions",
"record_identities",
"record_items",
"record_revisions",
"record_volume_revisions",
}.issubset(inspector.get_table_names())
)
self.assertIn(
"source_revision",
{item["name"] for item in inspector.get_columns("record_items")},
)
with engine.connect() as connection:
self.assertIn(
"6e4a2c8f1d9b",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+405
View File
@@ -0,0 +1,405 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.records import (
RecordFilingRequest,
RecordSourceLocator,
RecordSourceReference,
)
from govoplan_core.core.temporal import (
TemporalDataContext,
bind_temporal_data_context,
reset_temporal_data_context,
)
from govoplan_records.backend.db.models import (
RecordChronologyEntry,
RecordClassRevision,
RecordFilePlanRevision,
RecordIdentity,
RecordItem,
RecordRevision,
RecordVolumeRevision,
)
from govoplan_records.backend.service import RecordConflictError, SqlRecordRegistry
NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "account-1"
acting_assignment_id: str = "assignment-1"
acting_delegation_id: str | None = None
class SourceProvider:
provider_id = "files"
def resource_types(self):
return ("file_version",)
def resolve(self, session, principal, *, locator, purpose):
del session, purpose
if principal.tenant_id != locator.tenant_id:
raise ValueError("Source access denied.")
return RecordSourceReference(
locator=locator,
label="Decision.pdf",
authority_mode="external_authoritative",
content_sha256="a" * 64,
content_type="application/pdf",
size_bytes=2048,
valid_from=NOW,
recorded_at=NOW,
launch_url="/files?fileId=file-1",
)
class Registry:
def __init__(self) -> None:
self.provider = SourceProvider()
def capability_names(self):
return ("records.source.files",)
def tenant_capability(self, name, session, *, tenant_id):
del session
return (
self.provider
if name == "records.source.files" and tenant_id == "tenant-1"
else None
)
class RecordsTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
for table in (
RecordFilePlanRevision.__table__,
RecordClassRevision.__table__,
RecordIdentity.__table__,
RecordRevision.__table__,
RecordVolumeRevision.__table__,
RecordItem.__table__,
RecordChronologyEntry.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
self.principal = Principal()
self.records = SqlRecordRegistry(Registry())
self._seed_catalog()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed_catalog(self) -> None:
self.records.write_file_plan_node(
self.session,
self.principal,
payload={
"node_id": "plan-permits",
"code": "10.20",
"label": "Permits",
"recorded_at": NOW,
"valid_from": NOW,
"idempotency_key": "catalog-node-1",
"institutional_context": {"organization_unit_id": "unit-1"},
},
)
self.records.write_record_class(
self.session,
self.principal,
payload={
"class_id": "class-permit",
"file_plan_node_id": "plan-permits",
"key": "permit.application",
"label": "Permit application",
"allowed_source_types": ["files:file_version"],
"retention_period_days": 3650,
"access_mode": "tenant",
"recorded_at": NOW,
"valid_from": NOW,
"idempotency_key": "catalog-class-1",
"institutional_context": {"organization_unit_id": "unit-1"},
},
)
self.session.commit()
def _create_record(self) -> dict[str, object]:
result = self.records.create_record(
self.session,
self.principal,
payload={
"record_id": "record-1",
"record_number": "2026/0001",
"class_id": "class-permit",
"file_plan_node_id": "plan-permits",
"title": "Permit application Ada Example",
"description": "Assisted service journey",
"state": "open",
"source_authority_mode": "native_authoritative",
"access_mode": "tenant",
"purpose": "process permit application",
"institutional_context": {
"organization_unit_id": "unit-1",
"case_id": "case-1",
},
"recorded_at": NOW + timedelta(minutes=1),
"valid_from": NOW,
"change_reason": "Application received.",
"idempotency_key": "record-create-1",
},
)
self.session.commit()
return result
def test_record_creation_revision_and_temporal_reconstruction(self) -> None:
first = self._create_record()
replay = self._create_record()
self.assertEqual(first["record_id"], replay["record_id"])
revised = self.records.update_record(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_revision": 1,
"title": "Permit application Ada Example - verified",
"purpose": "verify application",
"recorded_at": NOW + timedelta(minutes=2),
"change_reason": "Identity verified.",
"idempotency_key": "record-update-1",
},
)
self.session.commit()
self.assertEqual(2, revised["revision"])
self.assertEqual("process permit application", revised["purpose"])
token = bind_temporal_data_context(
TemporalDataContext(
validity_mode="at",
valid_at=NOW + timedelta(hours=1),
recorded_at=NOW + timedelta(minutes=1, seconds=30),
)
)
try:
historical = self.records.get_record(
self.session, self.principal, record_id="record-1"
)
self.assertEqual(1, historical["record"]["revision"])
self.assertEqual(
"Permit application Ada Example", historical["record"]["title"]
)
finally:
reset_temporal_data_context(token)
with self.assertRaisesRegex(RecordConflictError, "current revision is 2"):
self.records.update_record(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_revision": 1,
"title": "Stale title",
"purpose": "update record",
"recorded_at": NOW + timedelta(minutes=3),
"change_reason": "Stale edit.",
"idempotency_key": "record-update-stale",
},
)
def test_update_replay_returns_the_original_revision(self) -> None:
self._create_record()
first_payload = {
"expected_revision": 1,
"title": "First revised title",
"purpose": "correct title",
"recorded_at": NOW + timedelta(minutes=2),
"change_reason": "First correction.",
"idempotency_key": "record-update-replay",
}
first = self.records.update_record(
self.session,
self.principal,
record_id="record-1",
payload=first_payload,
)
self.records.update_record(
self.session,
self.principal,
record_id="record-1",
payload={
"expected_revision": 2,
"title": "Second revised title",
"purpose": "correct title again",
"recorded_at": NOW + timedelta(minutes=3),
"change_reason": "Second correction.",
"idempotency_key": "record-update-second",
},
)
self.session.commit()
replay = self.records.update_record(
self.session,
self.principal,
record_id="record-1",
payload=first_payload,
)
self.assertEqual(2, first["revision"])
self.assertEqual(2, replay["revision"])
self.assertEqual("First revised title", replay["title"])
def test_file_plan_revisions_reject_indirect_parent_cycles(self) -> None:
self.records.write_file_plan_node(
self.session,
self.principal,
payload={
"node_id": "plan-child",
"parent_node_id": "plan-permits",
"code": "10.20.1",
"label": "Child",
"recorded_at": NOW + timedelta(minutes=1),
"idempotency_key": "catalog-child",
},
)
self.records.write_file_plan_node(
self.session,
self.principal,
payload={
"node_id": "plan-grandchild",
"parent_node_id": "plan-child",
"code": "10.20.1.1",
"label": "Grandchild",
"recorded_at": NOW + timedelta(minutes=2),
"idempotency_key": "catalog-grandchild",
},
)
with self.assertRaisesRegex(ValueError, "create a cycle"):
self.records.write_file_plan_node(
self.session,
self.principal,
payload={
"node_id": "plan-permits",
"parent_node_id": "plan-grandchild",
"code": "10.20",
"label": "Permits",
"recorded_at": NOW + timedelta(minutes=3),
"expected_revision": 1,
"idempotency_key": "catalog-cycle",
},
)
def test_exact_source_filing_is_authorized_idempotent_and_reconstructable(
self,
) -> None:
self._create_record()
request = RecordFilingRequest(
tenant_id="tenant-1",
record_id="record-1",
source=RecordSourceLocator(
tenant_id="tenant-1",
source_module="files",
resource_type="file_version",
resource_id="file-1",
source_revision="version-7",
),
purpose="document decision basis",
filing_reason="Submitted evidence belongs to the permit record.",
idempotency_key="filing-1",
institutional_context={"case_id": "case-1"},
)
first = self.records.file(self.session, self.principal, request=request)
self.session.commit()
second = self.records.file(self.session, self.principal, request=request)
self.assertEqual(first.item_id, second.item_id)
self.assertTrue(second.replayed)
detail = self.records.get_record(
self.session, self.principal, record_id="record-1"
)
self.assertEqual("version-7", detail["items"][0]["source"]["source_revision"])
self.assertEqual("a" * 64, detail["items"][0]["content_sha256"])
self.assertEqual(
["record.item_filed", "record.created"],
[entry["event_type"] for entry in detail["chronology"]],
)
def test_filing_rejects_a_volume_from_another_record(self) -> None:
self._create_record()
second = self.records.create_record(
self.session,
self.principal,
payload={
"record_id": "record-2",
"record_number": "2026/0002",
"class_id": "class-permit",
"file_plan_node_id": "plan-permits",
"title": "Second permit record",
"state": "open",
"purpose": "process second permit",
"recorded_at": NOW + timedelta(minutes=2),
"change_reason": "Second application received.",
"idempotency_key": "record-create-2",
},
)
volume = self.records.create_volume(
self.session,
self.principal,
record_id=str(second["record_id"]),
payload={
"volume_id": "volume-2",
"label": "Second record volume",
"recorded_at": NOW + timedelta(minutes=3),
"purpose": "structure record",
"idempotency_key": "volume-create-2",
},
)
request = RecordFilingRequest(
tenant_id="tenant-1",
record_id="record-1",
volume_id=str(volume["volume_id"]),
source=RecordSourceLocator(
tenant_id="tenant-1",
source_module="files",
resource_type="file_version",
resource_id="file-1",
source_revision="version-7",
),
purpose="document decision basis",
filing_reason="Attempted cross-record volume filing.",
idempotency_key="filing-cross-volume",
)
with self.assertRaisesRegex(ValueError, "does not belong to this record"):
self.records.file(self.session, self.principal, request=request)
def test_tenant_isolation_and_source_discovery(self) -> None:
self._create_record()
records, total = self.records.list_records(self.session, Principal("tenant-2"))
self.assertEqual([], records)
self.assertEqual(0, total)
self.assertEqual(
[
{
"id": "files",
"source_module": "files",
"resource_types": ["file_version"],
}
],
self.records.source_providers(self.session, self.principal),
)
if __name__ == "__main__":
unittest.main()
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_records.backend.db.models import RecordIdentity, RecordRevision
from govoplan_records.backend.search_source import (
ADMIN_SCOPE,
PROVIDER_ID,
READ_SCOPE,
RESOURCE_TYPE,
RecordsSearchSource,
)
NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC)
class RecordsSearchSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
RecordIdentity.__table__.create(self.engine)
RecordRevision.__table__.create(self.engine)
self.session = Session(self.engine)
identity = RecordIdentity(
id="identity-1",
tenant_id="tenant-1",
record_id="record-1",
record_number="EA-2026-0001",
)
self.session.add_all(
(
identity,
RecordRevision(
tenant_id="tenant-1",
record_id="record-1",
identity_id=identity.id,
revision=1,
class_id="class-1",
file_plan_node_id="plan-1",
title="Permit decision",
description="Decision record",
state="open",
source_authority_mode="native_authoritative",
access_mode="tenant",
purpose="case-work",
institutional_context={"organization_unit_id": "unit-1"},
external_reference={},
search_text="ea-2026-0001 permit decision",
valid_from=NOW,
recorded_at=NOW,
snapshot={},
),
RecordIdentity(
id="identity-2",
tenant_id="tenant-2",
record_id="record-2",
record_number="EA-OTHER",
),
)
)
self.session.flush()
self.session.add(
RecordRevision(
tenant_id="tenant-2",
record_id="record-2",
identity_id="identity-2",
revision=1,
class_id="class-1",
file_plan_node_id="plan-1",
title="Other tenant",
state="open",
source_authority_mode="native_authoritative",
access_mode="tenant",
purpose="case-work",
institutional_context={},
external_reference={},
search_text="other tenant",
valid_from=NOW,
recorded_at=NOW,
snapshot={},
)
)
self.session.commit()
self.source = RecordsSearchSource()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_backfill_and_live_authorization_are_tenant_scoped(self) -> None:
page = self.source.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="records-rebuild-1",
),
)
self.assertEqual(
("record-1",), tuple(document.resource_id for document in page.documents)
)
reference = SearchResourceReference(
tenant_id="tenant-1",
module_id="records",
resource_type=RESOURCE_TYPE,
resource_id="record-1",
)
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
self.assertTrue(
self.source.authorize(
self.session,
_principal({READ_SCOPE}),
requests=(request,),
)[reference.key]
)
self.assertTrue(
self.source.authorize(
self.session,
_principal({ADMIN_SCOPE}),
requests=(request,),
)[reference.key]
)
self.assertFalse(
self.source.authorize(
self.session,
_principal(set()),
requests=(request,),
)[reference.key]
)
other_tenant_reference = SearchResourceReference(
tenant_id="tenant-2",
module_id="records",
resource_type=RESOURCE_TYPE,
resource_id="record-2",
)
self.assertFalse(
self.source.authorize(
self.session,
_principal({READ_SCOPE}),
requests=(
SearchAuthorizationRequest(
reference=other_tenant_reference,
source_revision="1",
),
),
)[other_tenant_reference.key]
)
def _principal(scopes: set[str]) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/records-webui",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/records.css": "./src/styles/records.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+184
View File
@@ -0,0 +1,184 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type FilePlanNode = {
node_id: string;
revision: number;
parent_node_id?: string | null;
code: string;
label: string;
description?: string | null;
active: boolean;
valid_from?: string | null;
valid_to?: string | null;
recorded_at: string;
institutional_context: Record<string, unknown>;
};
export type RecordClass = {
class_id: string;
revision: number;
file_plan_node_id: string;
key: string;
label: string;
description?: string | null;
metadata_requirements: string[];
allowed_source_types: string[];
retention_period_days?: number | null;
closure_trigger?: string | null;
access_mode: "tenant" | "restricted";
active: boolean;
};
export type RecordCatalog = {
file_plan: FilePlanNode[];
classes: RecordClass[];
};
export type RecordEntry = {
record_id: string;
record_number: string;
revision: number;
class_id: string;
file_plan_node_id: string;
title: string;
description?: string | null;
state: "planned" | "open" | string;
source_authority_mode: string;
access_mode: "tenant" | "restricted";
purpose: string;
classification?: string | null;
responsible_unit_id?: string | null;
responsible_function_id?: string | null;
external_reference: Record<string, unknown>;
institutional_context: Record<string, unknown>;
valid_from?: string | null;
valid_to?: string | null;
recorded_at: string;
};
export type RecordVolume = {
volume_id: string;
sequence: number;
label: string;
state: string;
recorded_at: string;
};
export type RecordItem = {
item_id: string;
sequence: number;
volume_id?: string | null;
source: {
source_module: string;
resource_type: string;
resource_id: string;
source_revision: string;
};
label: string;
relationship: string;
filing_reason: string;
purpose: string;
authority_mode: string;
content_sha256?: string | null;
content_type?: string | null;
size_bytes?: number | null;
launch_url?: string | null;
filed_at: string;
filed_by?: string | null;
};
export type RecordChronology = {
event_id: string;
event_type: string;
record_revision: number;
summary: string;
occurred_at: string;
actor_id?: string | null;
purpose: string;
payload: Record<string, unknown>;
};
export type RecordDetail = {
record: RecordEntry;
volumes: RecordVolume[];
items: RecordItem[];
chronology: RecordChronology[];
access_explanation: {
decision: string;
reason: string;
purpose: string;
current_authorization: boolean;
access_mode: string;
limitations: string[];
};
};
export type RecordSourceProvider = {
id: string;
source_module: string;
resource_types: string[];
};
export function listRecords(
settings: ApiSettings,
options: {
query?: string;
state?: string;
classId?: string;
filePlanNodeId?: string;
offset?: number;
limit?: number;
},
signal?: AbortSignal
): Promise<{ records: RecordEntry[]; total: number; offset: number; limit: number }> {
return apiFetch(settings, apiPath("/api/v1/records", {
query: options.query,
state: options.state,
class_id: options.classId,
file_plan_node_id: options.filePlanNodeId,
offset: options.offset,
limit: options.limit ?? 50
}), { signal });
}
export function getRecord(settings: ApiSettings, recordId: string, signal?: AbortSignal): Promise<RecordDetail> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, { signal });
}
export function getRecordCatalog(settings: ApiSettings, signal?: AbortSignal): Promise<RecordCatalog> {
return apiFetch(settings, "/api/v1/records/catalog", { signal });
}
export function getRecordSources(settings: ApiSettings, signal?: AbortSignal): Promise<{ providers: RecordSourceProvider[] }> {
return apiFetch(settings, "/api/v1/records/sources", { signal });
}
export function createRecord(settings: ApiSettings, payload: Record<string, unknown>): Promise<RecordEntry> {
return apiFetch(settings, "/api/v1/records", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateRecord(
settings: ApiSettings,
recordId: string,
payload: Record<string, unknown>
): Promise<RecordEntry> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function fileRecordItem(
settings: ApiSettings,
recordId: string,
payload: Record<string, unknown>
): Promise<Record<string, unknown>> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/items`, {
method: "POST",
body: JSON.stringify(payload)
});
}
+802
View File
@@ -0,0 +1,802 @@
import {
Archive,
FilePlus2,
Pencil,
Plus,
RefreshCw,
Search
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent
} from "react";
import { useSearchParams } from "react-router";
import {
Button,
DataGrid,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
i18nMessage,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
useTemporalDataContext,
type DataGridColumn,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createRecord,
fileRecordItem,
getRecord,
getRecordCatalog,
getRecordSources,
listRecords,
updateRecord,
type FilePlanNode,
type RecordCatalog,
type RecordClass,
type RecordDetail,
type RecordEntry,
type RecordSourceProvider
} from "../../api/records";
import {
RECORDS_DOCUMENTATION,
RECORDS_FIELD_DOCUMENTATION
} from "./interfacePatterns";
const EMPTY_CATALOG: RecordCatalog = { file_plan: [], classes: [] };
const PAGE_SIZE = 50;
export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const [searchParams, setSearchParams] = useSearchParams();
const { selection, selectionKey, isDefault: temporalIsDefault } = useTemporalDataContext();
const [catalog, setCatalog] = useState<RecordCatalog>(EMPTY_CATALOG);
const [records, setRecords] = useState<RecordEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState("");
const [selectedRecordId, setSelectedRecordId] = useState(
() => searchParams.get("recordId") ?? ""
);
const [detail, setDetail] = useState<RecordDetail | null>(null);
const [sources, setSources] = useState<RecordSourceProvider[]>([]);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState("");
const [detailError, setDetailError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false);
const canWrite = auth.scopes.includes("records:workspace:write") ||
auth.scopes.includes("records:workspace:admin");
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
useEffect(() => {
const controller = new AbortController();
setError("");
Promise.all([
getRecordCatalog(settings, controller.signal),
canWrite
? getRecordSources(settings, controller.signal)
: Promise.resolve({ providers: [] as RecordSourceProvider[] })
]).
then(([nextCatalog, sourceResult]) => {
setCatalog(nextCatalog);
setSources(sourceResult.providers);
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(errorMessage(reason, "The Records catalog could not be loaded."));
}
});
return () => controller.abort();
}, [canWrite, reloadKey, selectionKey, settings]);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
listRecords(settings, {
query: submittedQuery,
filePlanNodeId: selectedNodeId || undefined,
offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE
}, controller.signal).
then((result) => {
setRecords(result.records);
setTotal(result.total);
const lastPage = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
if (page > lastPage) setPage(lastPage);
setSelectedRecordId((current) => {
if (current && result.records.some((item) => item.record_id === current)) return current;
return result.records[0]?.record_id ?? "";
});
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(errorMessage(reason, "Records could not be loaded."));
}
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [page, reloadKey, selectedNodeId, selectionKey, settings, submittedQuery]);
useEffect(() => {
if (!selectedRecordId) {
setDetail(null);
setDetailError("");
return undefined;
}
const controller = new AbortController();
setDetailLoading(true);
setDetailError("");
getRecord(settings, selectedRecordId, controller.signal).
then(setDetail).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setDetail(null);
setDetailError(errorMessage(reason, "The record could not be loaded."));
}
}).
finally(() => setDetailLoading(false));
return () => controller.abort();
}, [reloadKey, selectedRecordId, selectionKey, settings]);
useEffect(() => {
const current = searchParams.get("recordId") ?? "";
if (current === selectedRecordId) return;
const next = new URLSearchParams(searchParams);
if (selectedRecordId) next.set("recordId", selectedRecordId);
else next.delete("recordId");
setSearchParams(next, { replace: true });
}, [searchParams, selectedRecordId, setSearchParams]);
const classesById = useMemo(
() => new Map(catalog.classes.map((item) => [item.class_id, item])),
[catalog.classes]
);
const filePlanRows = useMemo(() => orderedFilePlan(catalog.file_plan), [catalog.file_plan]);
const columns = useMemo<DataGridColumn<RecordEntry>[]>(() => [
{
id: "number",
header: "Record number",
width: 145,
minWidth: 120,
resizable: true,
value: (row) => row.record_number,
render: (row) => <span className="records-number">{row.record_number}</span>
},
{
id: "title",
header: "Title",
width: "1fr",
minWidth: 180,
resizable: true,
value: (row) => row.title,
render: (row) => (
<button
type="button"
className={`records-record-link${selectedRecordId === row.record_id ? " selected" : ""}`}
onClick={() => setSelectedRecordId(row.record_id)}
>
{row.title}
</button>
)
},
{
id: "state",
header: "State",
width: 110,
minWidth: 95,
value: (row) => row.state,
render: (row) => <StatusBadge status={row.state === "open" ? "active" : "warning"} label={humanize(row.state)} />
}
], [selectedRecordId]);
function submitSearch(event: FormEvent) {
event.preventDefault();
setPage(1);
setSubmittedQuery(query.trim());
}
return (
<main className="records-page" data-help-context-id="records.workspace">
<div className="records-shell">
<div className="records-toolbar">
<Button
type="button"
variant="primary"
onClick={() => setCreateOpen(true)}
disabledReason={!canWrite ? "Your account may view records but may not create them." : catalog.classes.length === 0 ? "Configure a record class before creating a record." : undefined}
helpContextId="records.action.create"
>
<Plus size={16} aria-hidden="true" />
New record
</Button>
<Button type="button" variant="ghost" onClick={reload} disabledReason={loading ? "Records are already loading." : undefined}>
<RefreshCw size={16} aria-hidden="true" />
Refresh
</Button>
<form className="records-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search records"
placeholder="Search records"
/>
</form>
<span className="records-result-count">
{i18nMessage("i18n:govoplan-records.record_count", { value0: total })}
</span>
{!temporalIsDefault && (
<StatusBadge
status="warning"
label={selection.validityMode === "all" ? "All valid-time data" : "Historical data"}
/>
)}
<DocumentationHelpLink reference={RECORDS_DOCUMENTATION} />
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="records-workspace">
<aside className="records-file-plan" data-help-context-id="records.file-plan">
<div className="records-pane-heading">
<div>
<span>File plan</span>
<strong>{catalog.file_plan.length}</strong>
</div>
</div>
<PageScrollViewport className="records-file-plan-scroll">
<button
type="button"
className={`records-plan-row${selectedNodeId === "" ? " selected" : ""}`}
onClick={() => {
setPage(1);
setSelectedNodeId("");
}}
>
<Archive size={16} aria-hidden="true" />
<span>All records</span>
</button>
{filePlanRows.map(({ node, depth }) => (
<button
type="button"
key={node.node_id}
className={`records-plan-row${selectedNodeId === node.node_id ? " selected" : ""}`}
style={{ paddingInlineStart: `${12 + depth * 18}px` }}
onClick={() => {
setPage(1);
setSelectedNodeId(node.node_id);
}}
>
<span className="records-plan-code">{node.code}</span>
<span>{node.label}</span>
</button>
))}
{!loading && catalog.file_plan.length === 0 && (
<p className="records-empty-note">No file-plan nodes are configured.</p>
)}
</PageScrollViewport>
</aside>
<section className="records-list-pane" data-help-context-id="records.record-list">
<div className="records-pane-heading">
<div>
<span>Records</span>
<strong>{selectedNodeId ? catalog.file_plan.find((item) => item.node_id === selectedNodeId)?.label : "All"}</strong>
</div>
</div>
<div className="records-list-grid">
{loading ? (
<LoadingIndicator label="Loading records" />
) : (
<DataGrid
id="records-workspace-list"
rows={records}
columns={columns}
getRowKey={(row) => row.record_id}
emptyText="No matching records."
initialFit="container"
resizeBehavior="cover"
pagination={{
mode: "server",
page,
pageSize: PAGE_SIZE,
totalRows: total,
disabled: loading,
onPageChange: setPage
}}
/>
)}
</div>
</section>
<section className="records-detail-pane" data-help-context-id="records.record-detail">
{detailLoading && <LoadingIndicator label="Loading record" />}
{detailError && <DismissibleAlert tone="danger" resetKey={detailError}>{detailError}</DismissibleAlert>}
{!detailLoading && !detailError && !detail && (
<div className="records-detail-empty">
<Archive size={28} aria-hidden="true" />
<p>Select a record to inspect its contents and chronology.</p>
</div>
)}
{!detailLoading && detail && (
<RecordDetailPanel
detail={detail}
recordClass={classesById.get(detail.record.class_id)}
canWrite={canWrite}
hasSources={sources.length > 0}
onEdit={() => setEditOpen(true)}
onFile={() => setFileOpen(true)}
/>
)}
</section>
</div>
</div>
<RecordDialog
open={createOpen}
mode="create"
settings={settings}
catalog={catalog}
onClose={() => setCreateOpen(false)}
onSaved={(saved) => {
setCreateOpen(false);
setSelectedNodeId("");
setSelectedRecordId(saved.record_id);
reload();
}}
/>
<RecordDialog
open={editOpen}
mode="edit"
settings={settings}
catalog={catalog}
record={detail?.record ?? null}
onClose={() => setEditOpen(false)}
onSaved={(saved) => {
setEditOpen(false);
setSelectedRecordId(saved.record_id);
reload();
}}
/>
<FilingDialog
open={fileOpen}
settings={settings}
record={detail?.record ?? null}
providers={sources}
onClose={() => setFileOpen(false)}
onSaved={() => {
setFileOpen(false);
reload();
}}
/>
</main>
);
}
function RecordDetailPanel({
detail,
recordClass,
canWrite,
hasSources,
onEdit,
onFile
}: {
detail: RecordDetail;
recordClass?: RecordClass;
canWrite: boolean;
hasSources: boolean;
onEdit: () => void;
onFile: () => void;
}) {
const record = detail.record;
return (
<PageScrollViewport className="records-detail-scroll">
<div className="records-detail-header">
<div>
<span className="records-eyebrow">{record.record_number}</span>
<h1>{record.title}</h1>
</div>
<div className="records-detail-actions">
<Button type="button" variant="ghost" onClick={onEdit} disabledReason={!canWrite ? "Your account may not revise records." : undefined}>
<Pencil size={16} aria-hidden="true" />
Edit
</Button>
<Button type="button" variant="primary" onClick={onFile} disabledReason={!canWrite ? "Your account may not file record items." : !hasSources ? "No enabled source module provides exact record references." : undefined} helpContextId="records.action.file">
<FilePlus2 size={16} aria-hidden="true" />
File item
</Button>
</div>
</div>
<div className="records-facts">
<div><span>State</span><StatusBadge status={record.state === "open" ? "active" : "warning"} label={humanize(record.state)} /></div>
<div><span>Record class</span><strong>{recordClass?.label ?? record.class_id}</strong></div>
<div><span>Revision</span><strong>{record.revision}</strong></div>
<div><span>Source authority</span><strong>{humanize(record.source_authority_mode)}</strong></div>
<div><span>Valid from</span><strong>{formatDateTime(record.valid_from)}</strong></div>
<div><span>Recorded at</span><strong>{formatDateTime(record.recorded_at)}</strong></div>
<div><span>Classification</span><strong>{record.classification || "Not classified"}</strong></div>
<div><span>Retention input</span><strong>{recordClass?.retention_period_days == null ? "Not configured" : `${recordClass.retention_period_days} days`}</strong></div>
</div>
{record.description && <p className="records-description">{record.description}</p>}
<section className="records-detail-section" data-help-context-id="records.record-items">
<div className="records-section-heading">
<h2>Contents</h2>
<span>{detail.items.length}</span>
</div>
{detail.items.length === 0 ? (
<p className="records-empty-note">No items have been filed in this temporal view.</p>
) : (
<div className="records-item-list">
{detail.items.map((item) => (
<div className="records-item-row" key={item.item_id}>
<div>
{item.launch_url ? <a href={item.launch_url}>{item.label}</a> : <strong>{item.label}</strong>}
<span>{item.source.source_module} · {humanize(item.source.resource_type)} · revision {item.source.source_revision}</span>
</div>
<div>
<span>{formatBytes(item.size_bytes)}</span>
<time>{formatDateTime(item.filed_at)}</time>
</div>
<p>{item.filing_reason}</p>
</div>
))}
</div>
)}
</section>
<section className="records-detail-section" data-help-context-id="records.chronology">
<div className="records-section-heading">
<h2>Chronology</h2>
<span>{detail.chronology.length}</span>
</div>
<div className="records-chronology">
{detail.chronology.map((entry) => (
<div key={entry.event_id}>
<span className="records-timeline-marker" aria-hidden="true" />
<div>
<strong>{entry.summary}</strong>
<span>{humanize(entry.event_type)} · {entry.purpose}</span>
</div>
<time>{formatDateTime(entry.occurred_at)}</time>
</div>
))}
</div>
</section>
<section className="records-detail-section records-access-explanation">
<div className="records-section-heading"><h2>Access and purpose</h2></div>
<p>{detail.access_explanation.reason}</p>
<dl>
<div><dt>Record purpose</dt><dd>{record.purpose}</dd></div>
<div><dt>Authorization</dt><dd>{detail.access_explanation.current_authorization ? "Current authorization applied" : "Not evaluated"}</dd></div>
</dl>
{detail.access_explanation.limitations.map((limitation) => (
<DismissibleAlert key={limitation} tone="warning" dismissible={false} compact>{limitation}</DismissibleAlert>
))}
</section>
</PageScrollViewport>
);
}
function RecordDialog({
open,
mode,
settings,
catalog,
record,
onClose,
onSaved
}: {
open: boolean;
mode: "create" | "edit";
settings: PlatformRouteContext["settings"];
catalog: RecordCatalog;
record?: RecordEntry | null;
onClose: () => void;
onSaved: (record: RecordEntry) => void;
}) {
const firstClass = catalog.classes.find((item) => item.active);
const [recordNumber, setRecordNumber] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [classId, setClassId] = useState(firstClass?.class_id ?? "");
const [state, setState] = useState<"planned" | "open">("open");
const [classification, setClassification] = useState("");
const [purpose, setPurpose] = useState("");
const [changeReason, setChangeReason] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setRecordNumber(record?.record_number ?? "");
setTitle(record?.title ?? "");
setDescription(record?.description ?? "");
setClassId(record?.class_id ?? firstClass?.class_id ?? "");
setState(record?.state === "planned" ? "planned" : "open");
setClassification(record?.classification ?? "");
setPurpose(record?.purpose ?? "");
setChangeReason("");
setError("");
}, [firstClass?.class_id, open, record]);
const selectedClass = catalog.classes.find((item) => item.class_id === classId);
async function submit(event: FormEvent) {
event.preventDefault();
if (!selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim())) return;
setSaving(true);
setError("");
try {
const common = {
class_id: selectedClass.class_id,
file_plan_node_id: selectedClass.file_plan_node_id,
title: title.trim(),
description: description.trim() || null,
state,
classification: classification.trim() || null,
purpose: purpose.trim(),
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: randomId()
};
const saved = mode === "create"
? await createRecord(settings, {
...common,
record_number: recordNumber.trim(),
source_authority_mode: "native_authoritative",
access_mode: "tenant",
valid_from: new Date().toISOString(),
institutional_context: {}
})
: await updateRecord(settings, record!.record_id, {
...common,
expected_revision: record!.revision
});
onSaved(saved);
} catch (reason) {
setError(errorMessage(reason, "The record could not be saved."));
} finally {
setSaving(false);
}
}
return (
<Dialog
open={open}
title={mode === "create" ? "Create record" : "Edit record"}
onClose={onClose}
closeDisabled={saving}
portal
className="records-dialog"
helpContextId={mode === "create" ? "records.action.create" : "records.action.edit"}
footer={
<>
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button type="submit" form="records-record-form" variant="primary" disabledReason={saving ? "The record is being saved." : !selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim()) ? "Complete all required record fields." : undefined}>
{saving ? "Saving" : "Save record"}
</Button>
</>
}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="records-record-form" className="records-dialog-form" onSubmit={submit}>
<FormField label="Record number" helpContextId="records.field.record-number">
<input value={recordNumber} onChange={(event) => setRecordNumber(event.target.value)} disabled={mode === "edit"} required />
</FormField>
<FormField label="State" helpContextId="records.field.state">
<select value={state} onChange={(event) => setState(event.target.value as "planned" | "open")}>
<option value="planned">Planned</option>
<option value="open">Open</option>
</select>
</FormField>
<FormField label="Title" helpContextId="records.field.title">
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
</FormField>
<FormField label="Record class" helpContextId="records.field.class">
<select value={classId} onChange={(event) => setClassId(event.target.value)} required>
{catalog.classes.filter((item) => item.active).map((item) => <option key={item.class_id} value={item.class_id}>{item.label}</option>)}
</select>
</FormField>
<FormField label="Classification" helpContextId="records.field.classification">
<input value={classification} onChange={(event) => setClassification(event.target.value)} />
</FormField>
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
</FormField>
<FormField label="Description" helpContextId="records.field.description">
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={4} />
</FormField>
<FormField label="Change reason" helpContextId="records.field.change-reason">
<textarea value={changeReason} onChange={(event) => setChangeReason(event.target.value)} rows={3} required />
</FormField>
</form>
</Dialog>
);
}
function FilingDialog({
open,
settings,
record,
providers,
onClose,
onSaved
}: {
open: boolean;
settings: PlatformRouteContext["settings"];
record: RecordEntry | null;
providers: RecordSourceProvider[];
onClose: () => void;
onSaved: () => void;
}) {
const [sourceModule, setSourceModule] = useState("");
const [resourceType, setResourceType] = useState("");
const [resourceId, setResourceId] = useState("");
const [sourceRevision, setSourceRevision] = useState("");
const [purpose, setPurpose] = useState("");
const [filingReason, setFilingReason] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const provider = providers.find((item) => item.source_module === sourceModule);
useEffect(() => {
if (!open) return;
const first = providers[0];
setSourceModule(first?.source_module ?? "");
setResourceType(first?.resource_types[0] ?? "");
setResourceId("");
setSourceRevision("");
setPurpose(record?.purpose ?? "");
setFilingReason("");
setError("");
}, [open, providers, record?.purpose]);
useEffect(() => {
if (provider?.resource_types.includes(resourceType)) return;
setResourceType(provider?.resource_types[0] ?? "");
}, [provider, resourceType]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!record || !sourceModule || !resourceType || !resourceId.trim() || !sourceRevision.trim() || !purpose.trim() || !filingReason.trim()) return;
setSaving(true);
setError("");
try {
await fileRecordItem(settings, record.record_id, {
source: {
source_module: sourceModule,
resource_type: resourceType,
resource_id: resourceId.trim(),
source_revision: sourceRevision.trim(),
metadata: {}
},
purpose: purpose.trim(),
filing_reason: filingReason.trim(),
relationship: "contains",
idempotency_key: randomId(),
institutional_context: record.institutional_context,
metadata: {}
});
onSaved();
} catch (reason) {
setError(errorMessage(reason, "The source revision could not be filed."));
} finally {
setSaving(false);
}
}
return (
<Dialog
open={open}
title="File exact source revision"
onClose={onClose}
closeDisabled={saving}
portal
className="records-dialog"
helpContextId="records.action.file"
footer={
<>
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button type="submit" form="records-filing-form" variant="primary" disabledReason={saving ? "The item is being filed." : !sourceModule || !resourceType || !resourceId.trim() || !sourceRevision.trim() || !purpose.trim() || !filingReason.trim() ? "Complete the exact source and filing reason." : undefined}>
{saving ? "Filing" : "File item"}
</Button>
</>
}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<DismissibleAlert tone="info" dismissible={false} compact>
The source module verifies your current access and resolves this exact revision before Records stores the reference.
</DismissibleAlert>
<form id="records-filing-form" className="records-dialog-form" onSubmit={submit}>
<FormField label="Source module" helpContextId="records.field.source-module">
<select value={sourceModule} onChange={(event) => setSourceModule(event.target.value)} required>
{providers.map((item) => <option key={item.source_module} value={item.source_module}>{humanize(item.source_module)}</option>)}
</select>
</FormField>
<FormField label="Source type" helpContextId="records.field.source-object">
<select value={resourceType} onChange={(event) => setResourceType(event.target.value)} required>
{(provider?.resource_types ?? []).map((item) => <option key={item} value={item}>{humanize(item)}</option>)}
</select>
</FormField>
<FormField label="Source object ID" helpContextId="records.field.source-object">
<input value={resourceId} onChange={(event) => setResourceId(event.target.value)} required />
</FormField>
<FormField label="Exact source revision" documentation={RECORDS_FIELD_DOCUMENTATION.sourceRevision}>
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} required />
</FormField>
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
</FormField>
<FormField label="Filing reason" documentation={RECORDS_FIELD_DOCUMENTATION.filingReason}>
<textarea value={filingReason} onChange={(event) => setFilingReason(event.target.value)} rows={4} required />
</FormField>
</form>
</Dialog>
);
}
function orderedFilePlan(nodes: FilePlanNode[]): Array<{ node: FilePlanNode; depth: number }> {
const children = new Map<string, FilePlanNode[]>();
for (const node of nodes) {
const parent = node.parent_node_id ?? "";
children.set(parent, [...(children.get(parent) ?? []), node]);
}
for (const values of children.values()) values.sort((left, right) => left.code.localeCompare(right.code));
const result: Array<{ node: FilePlanNode; depth: number }> = [];
const visited = new Set<string>();
function visit(parent: string, depth: number) {
for (const node of children.get(parent) ?? []) {
if (visited.has(node.node_id)) continue;
visited.add(node.node_id);
result.push({ node, depth });
visit(node.node_id, depth + 1);
}
}
visit("", 0);
for (const node of nodes) {
if (!visited.has(node.node_id)) result.push({ node, depth: 0 });
}
return result;
}
function formatDateTime(value?: string | null): string {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Not set";
}
function formatBytes(value?: number | null): string {
if (value == null) return "Size unavailable";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function randomId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function errorMessage(reason: unknown, fallback: string): string {
return reason instanceof Error && reason.message ? reason.message : fallback;
}
@@ -0,0 +1,23 @@
export const RECORDS_DOCUMENTATION = {
topicId: "records.workspace",
contextId: "records.workspace",
documentationType: "user" as const
};
export const RECORDS_FIELD_DOCUMENTATION = {
purpose: {
topicId: "records.filing",
contextId: "records.field.purpose",
documentationType: "user" as const
},
filingReason: {
topicId: "records.filing",
contextId: "records.field.filing-reason",
documentationType: "user" as const
},
sourceRevision: {
topicId: "records.filing",
contextId: "records.field.source-revision",
documentationType: "user" as const
}
};
+160
View File
@@ -0,0 +1,160 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-records.records": "Records",
"i18n:govoplan-records.navigation": "Records navigation",
"i18n:govoplan-records.workspace": "eAkte workspace",
"i18n:govoplan-records.file_plan": "File plan",
"i18n:govoplan-records.record_list": "Record list",
"i18n:govoplan-records.record_detail": "Record detail",
"i18n:govoplan-records.file_source": "File source revision",
"i18n:govoplan-records.record_count": "{value0} records",
"Records": "Records",
"New record": "New record",
"Refresh": "Refresh",
"Search records": "Search records",
"All valid-time data": "All valid-time data",
"Historical data": "Historical data",
"File plan": "File plan",
"All records": "All records",
"All": "All",
"Record number": "Record number",
"Title": "Title",
"State": "State",
"No matching records.": "No matching records.",
"The Records catalog could not be loaded.": "The Records catalog could not be loaded.",
"No file-plan nodes are configured.": "No file-plan nodes are configured.",
"Loading records": "Loading records",
"Loading record": "Loading record",
"Select a record to inspect its contents and chronology.": "Select a record to inspect its contents and chronology.",
"Edit": "Edit",
"File item": "File item",
"Record class": "Record class",
"Revision": "Revision",
"Source authority": "Source authority",
"Valid from": "Valid from",
"Recorded at": "Recorded at",
"Classification": "Classification",
"Retention input": "Retention input",
"Not classified": "Not classified",
"Not configured": "Not configured",
"Contents": "Contents",
"No items have been filed in this temporal view.": "No items have been filed in this temporal view.",
"Chronology": "Chronology",
"Access and purpose": "Access and purpose",
"Record purpose": "Record purpose",
"Authorization": "Authorization",
"Current authorization applied": "Current authorization applied",
"Not evaluated": "Not evaluated",
"Create record": "Create record",
"Edit record": "Edit record",
"Cancel": "Cancel",
"Saving": "Saving",
"Save record": "Save record",
"Planned": "Planned",
"Open": "Open",
"Purpose": "Purpose",
"Description": "Description",
"Change reason": "Change reason",
"File exact source revision": "File exact source revision",
"Filing": "Filing",
"Source module": "Source module",
"Source type": "Source type",
"Source object ID": "Source object ID",
"Exact source revision": "Exact source revision",
"Filing reason": "Filing reason",
"Not set": "Not set",
"Size unavailable": "Size unavailable",
"Your account may view records but may not create them.": "Your account may view records but may not create them.",
"Configure a record class before creating a record.": "Configure a record class before creating a record.",
"Records are already loading.": "Records are already loading.",
"Your account may not revise records.": "Your account may not revise records.",
"Your account may not file record items.": "Your account may not file record items.",
"No enabled source module provides exact record references.": "No enabled source module provides exact record references.",
"The record is being saved.": "The record is being saved.",
"Complete all required record fields.": "Complete all required record fields.",
"The item is being filed.": "The item is being filed.",
"Complete the exact source and filing reason.": "Complete the exact source and filing reason.",
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "The source module verifies your current access and resolves this exact revision before Records stores the reference."
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-records.records": "Akten",
"i18n:govoplan-records.navigation": "Aktennavigation",
"i18n:govoplan-records.workspace": "eAkte-Arbeitsbereich",
"i18n:govoplan-records.file_plan": "Aktenplan",
"i18n:govoplan-records.record_list": "Aktenliste",
"i18n:govoplan-records.record_detail": "Aktendetails",
"i18n:govoplan-records.file_source": "Quellrevision verakten",
"i18n:govoplan-records.record_count": "{value0} Akten",
"Records": "Akten",
"New record": "Neue Akte",
"Refresh": "Aktualisieren",
"Search records": "Akten durchsuchen",
"All valid-time data": "Alle Gültigkeitszeiträume",
"Historical data": "Historische Daten",
"File plan": "Aktenplan",
"All records": "Alle Akten",
"All": "Alle",
"Record number": "Aktenzeichen",
"Title": "Titel",
"State": "Status",
"No matching records.": "Keine passenden Akten.",
"The Records catalog could not be loaded.": "Der Aktenkatalog konnte nicht geladen werden.",
"No file-plan nodes are configured.": "Es sind keine Aktenplanpositionen konfiguriert.",
"Loading records": "Akten werden geladen",
"Loading record": "Akte wird geladen",
"Select a record to inspect its contents and chronology.": "Wählen Sie eine Akte aus, um Inhalt und Chronologie einzusehen.",
"Edit": "Bearbeiten",
"File item": "Objekt verakten",
"Record class": "Aktenklasse",
"Revision": "Revision",
"Source authority": "Quellautorität",
"Valid from": "Gültig ab",
"Recorded at": "Erfasst am",
"Classification": "Klassifikation",
"Retention input": "Aufbewahrungsvorgabe",
"Not classified": "Nicht klassifiziert",
"Not configured": "Nicht konfiguriert",
"Contents": "Inhalt",
"No items have been filed in this temporal view.": "In dieser temporalen Ansicht sind keine Objekte veraktet.",
"Chronology": "Chronologie",
"Access and purpose": "Zugriff und Zweck",
"Record purpose": "Aktenzweck",
"Authorization": "Berechtigung",
"Current authorization applied": "Aktuelle Berechtigung angewendet",
"Not evaluated": "Nicht geprüft",
"Create record": "Akte anlegen",
"Edit record": "Akte bearbeiten",
"Cancel": "Abbrechen",
"Saving": "Speichert",
"Save record": "Akte speichern",
"Planned": "Geplant",
"Open": "Offen",
"Purpose": "Zweck",
"Description": "Beschreibung",
"Change reason": "Änderungsbegründung",
"File exact source revision": "Exakte Quellrevision verakten",
"Filing": "Veraktet",
"Source module": "Quellmodul",
"Source type": "Quelltyp",
"Source object ID": "ID des Quellobjekts",
"Exact source revision": "Exakte Quellrevision",
"Filing reason": "Veraktungsbegründung",
"Not set": "Nicht gesetzt",
"Size unavailable": "Größe nicht verfügbar",
"Your account may view records but may not create them.": "Ihr Konto darf Akten einsehen, aber nicht anlegen.",
"Configure a record class before creating a record.": "Konfigurieren Sie eine Aktenklasse, bevor Sie eine Akte anlegen.",
"Records are already loading.": "Akten werden bereits geladen.",
"Your account may not revise records.": "Ihr Konto darf Akten nicht ändern.",
"Your account may not file record items.": "Ihr Konto darf keine Objekte verakten.",
"No enabled source module provides exact record references.": "Kein aktiviertes Quellmodul stellt exakte Aktenreferenzen bereit.",
"The record is being saved.": "Die Akte wird gespeichert.",
"Complete all required record fields.": "Füllen Sie alle erforderlichen Aktenfelder aus.",
"The item is being filed.": "Das Objekt wird veraktet.",
"Complete the exact source and filing reason.": "Vervollständigen Sie die exakte Quelle und die Veraktungsbegründung.",
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "Das Quellmodul prüft Ihre aktuelle Berechtigung und löst diese exakte Revision auf, bevor Records die Referenz speichert."
};
export const generatedTranslations: PlatformTranslations = { en, de };
+2
View File
@@ -0,0 +1,2 @@
export { default, recordsModule } from "./module";
export * from "./api/records";
+54
View File
@@ -0,0 +1,54 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/records.css";
const RecordsPage = lazy(() => import("./features/records/RecordsPage"));
export const recordsModule: PlatformWebModule = {
id: "records",
label: "i18n:govoplan-records.records",
version: "0.1.18",
optionalDependencies: [
"files",
"cases",
"forms_runtime",
"decisions",
"campaigns",
"postbox",
"reporting",
"dms",
"policy",
"audit",
"search"
],
translations: generatedTranslations,
routes: [
{
path: "/records",
anyOf: ["records:workspace:read"],
order: 47,
surfaceId: "records.workspace",
render: (context) => createElement(RecordsPage, context)
}
],
navItems: [
{
to: "/records",
label: "i18n:govoplan-records.records",
iconName: "archive",
anyOf: ["records:workspace:read"],
order: 47,
surfaceId: "records.navigation"
}
],
viewSurfaces: [
{ id: "records.workspace.file-plan", moduleId: "records", kind: "section", label: "i18n:govoplan-records.file_plan", parentId: "records.workspace", order: 10 },
{ id: "records.workspace.list", moduleId: "records", kind: "section", label: "i18n:govoplan-records.record_list", parentId: "records.workspace", order: 20 },
{ id: "records.workspace.detail", moduleId: "records", kind: "section", label: "i18n:govoplan-records.record_detail", parentId: "records.workspace", order: 30 },
{ id: "records.workspace.file", moduleId: "records", kind: "action", label: "i18n:govoplan-records.file_source", parentId: "records.workspace.detail", order: 40 }
]
};
export default recordsModule;
+514
View File
@@ -0,0 +1,514 @@
.records-page,
.records-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.records-shell {
display: flex;
flex-direction: column;
background: var(--panel);
}
.records-toolbar {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 9px 14px;
border-bottom: var(--border-line-dark);
background: var(--panel-header);
}
.records-search {
display: flex;
align-items: center;
gap: 8px;
width: min(420px, 100%);
margin-left: 8px;
}
.records-search input {
min-width: 130px;
height: 36px;
flex: 1;
}
.records-result-count {
margin-left: auto;
color: var(--muted);
font-size: var(--font-size-sm);
white-space: nowrap;
}
.records-shell > .alert {
margin: 10px 14px 0;
}
.records-workspace {
display: grid;
grid-template-columns: minmax(210px, 260px) minmax(360px, 0.8fr) minmax(420px, 1.25fr);
flex: 1;
min-height: 0;
overflow: hidden;
}
.records-file-plan,
.records-list-pane,
.records-detail-pane {
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--panel-soft);
}
.records-file-plan,
.records-list-pane {
border-right: var(--border-line-dark);
}
.records-file-plan,
.records-list-pane {
display: flex;
flex-direction: column;
}
.records-pane-heading {
display: flex;
align-items: center;
min-height: 52px;
padding: 8px 13px;
border-bottom: var(--border-line);
background: var(--panel);
}
.records-pane-heading > div {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
}
.records-pane-heading span {
color: var(--muted);
font-size: var(--font-size-sm);
font-weight: 700;
}
.records-pane-heading strong {
overflow: hidden;
color: var(--text-strong);
font-size: var(--font-size-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.records-file-plan-scroll,
.records-detail-scroll {
flex: 1;
min-height: 0;
}
.records-file-plan-scroll {
padding: 7px;
}
.records-plan-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 100%;
min-height: 36px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
font: inherit;
padding: 7px 9px;
text-align: left;
}
.records-plan-row:hover,
.records-plan-row:focus-visible,
.records-plan-row.selected {
background: var(--primary-soft);
color: var(--text-strong);
outline: none;
}
.records-plan-row.selected {
box-shadow: inset 3px 0 0 var(--accent);
}
.records-plan-row > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-plan-code {
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.records-list-grid {
flex: 1;
min-height: 0;
overflow: auto;
padding: 10px;
}
.records-list-grid > .data-grid-shell {
min-height: 100%;
border-radius: 0;
}
.records-record-link {
display: block;
width: 100%;
overflow: hidden;
border: 0;
background: transparent;
color: var(--text-strong);
cursor: pointer;
font: inherit;
font-weight: 700;
padding: 0;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-record-link:hover,
.records-record-link:focus-visible,
.records-record-link.selected {
color: var(--accent);
outline: none;
text-decoration: underline;
}
.records-number {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.records-detail-pane {
display: flex;
flex-direction: column;
}
.records-detail-scroll {
padding: 18px 20px 32px;
}
.records-detail-empty {
display: grid;
place-items: center;
align-content: center;
gap: 10px;
height: 100%;
color: var(--muted);
padding: 30px;
text-align: center;
}
.records-detail-header,
.records-detail-actions,
.records-section-heading {
display: flex;
align-items: center;
}
.records-detail-header {
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 15px;
border-bottom: var(--border-line-dark);
}
.records-detail-header h1 {
margin: 3px 0 0;
font-size: 1.35rem;
letter-spacing: 0;
line-height: 1.25;
}
.records-eyebrow {
color: var(--muted);
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
}
.records-detail-actions {
flex: 0 0 auto;
gap: 8px;
}
.records-facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
margin-top: 16px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--line);
}
.records-facts > div {
display: flex;
min-width: 0;
min-height: 64px;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 10px;
background: var(--surface);
}
.records-facts span:not(.status-badge),
.records-item-row span,
.records-item-row time,
.records-chronology span,
.records-chronology time {
color: var(--muted);
font-size: 12px;
}
.records-facts strong {
overflow-wrap: anywhere;
}
.records-description {
margin: 16px 0 0;
color: var(--text-soft);
line-height: 1.55;
}
.records-detail-section {
margin-top: 24px;
}
.records-section-heading {
justify-content: space-between;
gap: 12px;
min-height: 34px;
border-bottom: var(--border-line);
}
.records-section-heading h2 {
margin: 0;
font-size: 1rem;
letter-spacing: 0;
}
.records-section-heading > span {
display: inline-grid;
min-width: 24px;
height: 24px;
place-items: center;
border-radius: 50%;
background: var(--surface-strong);
color: var(--muted);
font-size: 12px;
}
.records-item-list {
border-bottom: var(--border-line);
}
.records-item-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 5px 18px;
padding: 11px 5px;
border-bottom: var(--border-line);
}
.records-item-row:last-child {
border-bottom: 0;
}
.records-item-row > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.records-item-row > div:nth-child(2) {
align-items: end;
}
.records-item-row a,
.records-item-row strong {
overflow: hidden;
color: var(--text-strong);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-item-row p {
grid-column: 1 / -1;
margin: 2px 0 0;
color: var(--text-soft);
font-size: var(--font-size-sm);
}
.records-chronology > div {
position: relative;
display: grid;
grid-template-columns: 16px minmax(0, 1fr) auto;
gap: 10px;
min-height: 52px;
padding: 10px 4px;
}
.records-chronology > div:not(:last-child)::before {
position: absolute;
top: 26px;
bottom: -10px;
left: 11px;
width: 1px;
background: var(--line-dark);
content: "";
}
.records-timeline-marker {
z-index: 1;
width: 9px;
height: 9px;
align-self: start;
margin: 5px 0 0 3px;
border: 2px solid var(--accent);
border-radius: 50%;
background: var(--panel-soft);
}
.records-chronology > div > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.records-chronology time {
white-space: nowrap;
}
.records-access-explanation > p {
color: var(--text-soft);
}
.records-access-explanation dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.records-access-explanation dl > div {
padding: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.records-access-explanation dt {
color: var(--muted);
font-size: 12px;
}
.records-access-explanation dd {
margin: 4px 0 0;
font-weight: 700;
}
.records-empty-note {
margin: 12px;
color: var(--muted);
font-size: var(--font-size-sm);
}
.records-dialog {
width: min(760px, calc(100vw - 32px));
}
.records-dialog-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.records-dialog-form > .form-field:has(textarea) {
grid-column: 1 / -1;
}
@media (max-width: 1260px) {
.records-workspace {
grid-template-columns: minmax(190px, 220px) minmax(330px, 0.85fr) minmax(390px, 1fr);
}
.records-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.records-toolbar {
flex-wrap: wrap;
}
.records-search {
order: 5;
width: 100%;
margin-left: 0;
}
.records-workspace {
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
}
.records-detail-pane {
grid-column: 1 / -1;
min-height: 380px;
border-top: var(--border-line-dark);
}
}
@media (max-width: 640px) {
.records-workspace {
display: flex;
overflow-y: auto;
flex-direction: column;
}
.records-file-plan,
.records-list-pane,
.records-detail-pane {
min-height: 330px;
border-right: 0;
border-bottom: var(--border-line-dark);
}
.records-detail-header,
.records-detail-actions {
align-items: stretch;
flex-direction: column;
}
.records-facts,
.records-access-explanation dl,
.records-dialog-form {
grid-template-columns: 1fr;
}
.records-dialog-form > .form-field:has(textarea) {
grid-column: auto;
}
}