6 Commits

17 changed files with 800 additions and 14 deletions

View File

@@ -153,16 +153,24 @@ on throwaway databases.
| --- | --- | --- | | --- | --- | --- |
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. | | `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. | | `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
| `CELERY_QUEUES` | `send_email,append_sent,default` | Queue list expected by worker/process manager definitions. | | `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
Worker command: Worker command:
```bash ```bash
python -m celery -A govoplan_core.celery_app:celery worker \ python -m celery -A govoplan_core.celery_app:celery worker \
--queues send_email,append_sent,default \ --queues send_email,append_sent,notifications,calendar,default \
--loglevel INFO --loglevel INFO
``` ```
Run Celery beat as a separately supervised process. Its built-in one-minute
schedule recovers Calendar outbox rows left behind by broker failures, process
crashes, and expired worker leases:
```bash
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
```
### Storage ### Storage
| Setting | Default | Notes | | Setting | Default | Notes |
@@ -275,7 +283,7 @@ the checked in `.env.example`. It runs:
- explicit `ENABLED_MODULES` - explicit `ENABLED_MODULES`
- explicit migrations and `--with-dev-data` bootstrap - explicit migrations and `--with-dev-data` bootstrap
- API via the module-aware devserver - API via the module-aware devserver
- a Celery worker for `send_email,append_sent,default` - a Celery worker for `send_email,append_sent,notifications,calendar,default`
- WebUI through the Vite dev server - WebUI through the Vite dev server
- durable local files under `runtime/production-like/files` - durable local files under `runtime/production-like/files`

View File

@@ -39,6 +39,10 @@ contestability, responsibility, and traceability at the point of action.
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records | | UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments | | UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs | | UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from celery import Celery from celery import Celery
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
@@ -27,10 +28,18 @@ celery.conf.update(
"govoplan.campaigns.append_sent": {"queue": "append_sent"}, "govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"}, "govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"}, "govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
}, },
worker_prefetch_multiplier=1, worker_prefetch_multiplier=1,
task_acks_late=True, task_acks_late=True,
task_reject_on_worker_lost=True, task_reject_on_worker_lost=True,
beat_schedule={
"calendar-outbox-every-minute": {
"task": "govoplan.calendar.dispatch_outbox",
"schedule": 60.0,
"args": (None, 100),
},
},
) )
@@ -67,6 +76,16 @@ def _notification_dispatch() -> NotificationDispatchProvider:
return capability return capability
def _calendar_outbox() -> CalendarOutboxProvider | None:
registry = _platform_registry()
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_CALENDAR_OUTBOX)
if not isinstance(capability, CalendarOutboxProvider):
raise RuntimeError("Calendar outbox capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0) @celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str): def send_email(self, job_id: str):
"""Send one explicitly queued campaign job. """Send one explicitly queued campaign job.
@@ -111,3 +130,18 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
with get_database().SessionLocal() as session: with get_database().SessionLocal() as session:
return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit)) return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
"""Drain durable Calendar operations; retry timing lives in the database."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _calendar_outbox()
if provider is None:
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
class CalendarCapabilityError(ValueError):
"""Stable error raised by Calendar capability implementations."""
@dataclass(frozen=True, slots=True)
class CalendarEventRequest:
summary: str
start_at: datetime
calendar_id: str | None = None
description: str | None = None
location: str | None = None
status: str = "CONFIRMED"
transparency: str = "OPAQUE"
classification: str = "PUBLIC"
end_at: datetime | None = None
timezone: str | None = None
attendees: tuple[Mapping[str, object], ...] = ()
categories: tuple[str, ...] = ()
related_to: tuple[Mapping[str, object], ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CalendarEventRef:
id: str
calendar_id: str
uid: str
external_state: str = "local"
outbox_operation_id: str | None = None
@runtime_checkable
class CalendarSchedulingProvider(Protocol):
def list_freebusy(
self,
session: object,
*,
tenant_id: str,
start_at: datetime,
end_at: datetime,
calendar_ids: Sequence[str] | None = None,
) -> Sequence[Mapping[str, object]]:
...
def create_event(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarEventRequest,
) -> CalendarEventRef:
...
@runtime_checkable
class CalendarOutboxProvider(Protocol):
"""Stable worker boundary for Calendar-owned external side effects."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_CALENDAR_SCHEDULING):
return None
capability = registry.capability(CAPABILITY_CALENDAR_SCHEDULING)
return capability if isinstance(capability, CalendarSchedulingProvider) else None
def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
return None
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
return capability if isinstance(capability, CalendarOutboxProvider) else None

View File

@@ -5,7 +5,7 @@ import json
import os import os
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Literal from typing import Literal
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from sqlalchemy.engine import make_url from sqlalchemy.engine import make_url
@@ -274,7 +274,8 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0 REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
CORS_ORIGINS=https://govoplan.example.org CORS_ORIGINS=https://govoplan.example.org
AUTH_COOKIE_SECURE=true AUTH_COOKIE_SECURE=true
@@ -317,7 +318,8 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0 REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173

View File

@@ -0,0 +1,215 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_POLL_SCHEDULING = "poll.scheduling"
class PollCapabilityError(ValueError):
"""Stable error raised by Poll capability implementations."""
@dataclass(frozen=True, slots=True)
class PollOptionRequest:
label: str
key: str | None = None
description: str | None = None
value: Mapping[str, object] | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollCreateCommand:
title: str
kind: str
status: str
visibility: str
result_visibility: str
description: str | None = None
context_module: str | None = None
context_resource_type: str | None = None
context_resource_id: str | None = None
workflow_state: str | None = None
workflow_steps: tuple[Mapping[str, object], ...] = ()
allow_anonymous: bool = False
allow_response_update: bool = True
min_choices: int = 1
max_choices: int | None = None
opens_at: datetime | None = None
closes_at: datetime | None = None
options: tuple[PollOptionRequest, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollUpdateCommand:
"""Scheduling-owned Poll fields synchronized as one policy snapshot."""
title: str
description: str | None
visibility: str
result_visibility: str
allow_anonymous: bool
allow_response_update: bool
closes_at: datetime | None
@dataclass(frozen=True, slots=True)
class PollInvitationCommand:
respondent_id: str | None = None
respondent_label: str | None = None
email: str | None = None
expires_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollAnswerRequest:
option_id: str | None = None
option_key: str | None = None
value: object = None
rank: int | None = None
@dataclass(frozen=True, slots=True)
class PollSubmitResponseCommand:
respondent_id: str
respondent_label: str | None = None
answers: tuple[PollAnswerRequest, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollOptionRef:
id: str
position: int
@dataclass(frozen=True, slots=True)
class PollRef:
id: str
status: str
options: tuple[PollOptionRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PollInvitationRef:
id: str
token: str
@dataclass(frozen=True, slots=True)
class PollResponseRef:
invitation_id: str | None
submitted_at: datetime
# Optional for backwards compatibility with existing providers. A
# consumer can use it to reconcile an authenticated response without
# trusting client-supplied invitation metadata.
respondent_id: str | None = None
@runtime_checkable
class PollSchedulingProvider(Protocol):
def create_poll(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
command: PollCreateCommand,
) -> PollRef:
...
def create_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollInvitationCommand,
) -> PollInvitationRef:
...
def update_poll(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollUpdateCommand,
) -> PollRef:
...
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
...
def close_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
...
def decide_poll(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
option_id: str | None,
) -> PollRef:
...
def get_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
...
def set_workflow_context(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
workflow_state: str,
workflow_steps: Sequence[Mapping[str, object]],
context_module: str,
context_resource_type: str,
context_resource_id: str,
) -> PollRef:
...
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]:
...
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]:
...
@runtime_checkable
class PollResponseSubmissionProvider(Protocol):
"""Optional extension for modules that collect responses through Poll."""
def submit_response(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollSubmitResponseCommand,
) -> PollResponseRef:
...
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
return None
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
return capability if isinstance(capability, PollSchedulingProvider) else None
def poll_response_submission_provider(
registry: object | None,
) -> PollResponseSubmissionProvider | None:
provider = poll_scheduling_provider(registry)
return provider if isinstance(provider, PollResponseSubmissionProvider) else None

View File

@@ -49,7 +49,12 @@ class Settings(BaseSettings):
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS") auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64") master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,notifications,default", alias="CELERY_QUEUES") celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
calendar_outbox_terminal_retention_days: int = Field(
default=90,
ge=0,
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
)
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR") mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production. # Development bootstrap only. Do not use this in production.

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
class CalendarOutboxWorkerTests(unittest.TestCase):
def test_worker_commits_provider_outcome(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
provider = MagicMock()
provider.dispatch_due.return_value = {
"processed": 1,
"succeeded": 1,
"retrying": 0,
"failed": 0,
"operations": [{"id": "operation-1", "status": "succeeded"}],
}
with (
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
patch("govoplan_core.db.session.get_database", return_value=database),
):
result = dispatch_calendar_outbox.run("tenant-1", 25)
provider.dispatch_due.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
session.commit.assert_called_once_with()
self.assertEqual(result["succeeded"], 1)
def test_calendar_worker_route_and_periodic_recovery_are_registered(self) -> None:
self.assertEqual(
celery.conf.task_routes["govoplan.calendar.dispatch_outbox"],
{"queue": "calendar"},
)
schedule = celery.conf.beat_schedule["calendar-outbox-every-minute"]
self.assertEqual(schedule["task"], "govoplan.calendar.dispatch_outbox")
self.assertEqual(schedule["schedule"], 60.0)
if __name__ == "__main__":
unittest.main()

View File

@@ -3,11 +3,24 @@ from __future__ import annotations
import unittest import unittest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from pydantic import ValidationError
from govoplan_core.core.install_config import env_template, validate_runtime_configuration from govoplan_core.core.install_config import env_template, validate_runtime_configuration
from govoplan_core.settings import Settings
class InstallConfigTests(unittest.TestCase): class InstallConfigTests(unittest.TestCase):
def test_calendar_outbox_retention_setting_is_validated(self) -> None:
self.assertEqual(Settings().calendar_outbox_terminal_retention_days, 90)
self.assertEqual(
Settings(
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="0"
).calendar_outbox_terminal_retention_days,
0,
)
with self.assertRaises(ValidationError):
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None: def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
result = validate_runtime_configuration({}, profile="self-hosted") result = validate_runtime_configuration({}, profile="self-hosted")
@@ -68,8 +81,10 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted) self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
self.assertIn("MASTER_KEY_B64=<generate", self_hosted) self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like) self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like) self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -21,6 +21,7 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173", "preview": "vite preview --host 127.0.0.1 --port 4173",
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs", "audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js", "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs", "test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js" "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"

View File

@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("../src/components/FileDropZone.tsx", import.meta.url), "utf8");
const normalized = source.replace(/\s+/g, " ");
function assertIncludes(fragment, message) {
if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
}
assertIncludes(
"function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) { event.preventDefault(); event.stopPropagation();",
"drag enter/over must prevent the browser default and stop propagation"
);
assertIncludes("onDragEnter={prepareFileDrag}", "drag enter must use the guarded file-drag handler");
assertIncludes("onDragOver={prepareFileDrag}", "drag over must use the guarded file-drag handler");
assertIncludes(
"onDragLeave={(event) => { event.preventDefault(); event.stopPropagation();",
"drag leave must prevent the browser default and stop propagation"
);
assertIncludes(
"onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setDragActive(false);",
"drop must prevent the browser default and stop propagation before resolving files"
);
assertIncludes(
"if (interactionDisabled) { onRejectedDrop?.(\"disabled\"); return; }",
"disabled drops must route the disabled rejection reason"
);
assertIncludes(
"void resolveDroppedFiles(event.dataTransfer).then((files) => {",
"drop must route the event DataTransfer through the shared resolver"
);
assertIncludes(
"if (files.length === 0) { onRejectedDrop?.(\"empty\"); return; } void handleFiles(files);",
"resolved files and empty drops must be routed through their explicit callbacks"
);
assertIncludes(
".catch(() => onRejectedDrop?.(\"unreadable\"))",
"resolver failures must route the unreadable rejection reason"
);
console.log("FileDropZone event and result routing structure is intact.");

View File

@@ -5,6 +5,7 @@ const packageByModule = {
admin: "@govoplan/admin-webui", admin: "@govoplan/admin-webui",
addresses: "@govoplan/addresses-webui", addresses: "@govoplan/addresses-webui",
audit: "@govoplan/audit-webui", audit: "@govoplan/audit-webui",
calendar: "@govoplan/calendar-webui",
campaigns: "@govoplan/campaign-webui", campaigns: "@govoplan/campaign-webui",
dashboard: "@govoplan/dashboard-webui", dashboard: "@govoplan/dashboard-webui",
docs: "@govoplan/docs-webui", docs: "@govoplan/docs-webui",
@@ -25,6 +26,7 @@ const cases = [
{ name: "access-with-admin", modules: ["access", "admin"] }, { name: "access-with-admin", modules: ["access", "admin"] },
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] }, { name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
{ name: "dashboard-only", modules: ["dashboard"] }, { name: "dashboard-only", modules: ["dashboard"] },
{ name: "calendar-only", modules: ["calendar"] },
{ name: "files-only", modules: ["files"] }, { name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] }, { name: "mail-only", modules: ["mail"] },
{ name: "organizations-only", modules: ["organizations"] }, { name: "organizations-only", modules: ["organizations"] },
@@ -35,7 +37,7 @@ const cases = [
{ name: "scheduling-only", modules: ["scheduling"] }, { name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] }, { name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "scheduling"] } { name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "calendar", "scheduling"] }
]; ];
const npmExec = process.env.npm_execpath; const npmExec = process.env.npm_execpath;

View File

@@ -1,5 +1,8 @@
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react"; import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type ReactNode } from "react";
import { UploadCloud } from "lucide-react"; import { UploadCloud } from "lucide-react";
import { resolveDroppedFiles } from "./resolveDroppedFiles";
type RejectedDropReason = "disabled" | "empty" | "unreadable";
export type FileDropZoneProps = { export type FileDropZoneProps = {
accept?: string; accept?: string;
@@ -14,6 +17,7 @@ export type FileDropZoneProps = {
note?: ReactNode; note?: ReactNode;
className?: string; className?: string;
inputLabel?: string; inputLabel?: string;
onRejectedDrop?: (reason: RejectedDropReason) => void;
onFiles: (files: File[]) => void | Promise<void>; onFiles: (files: File[]) => void | Promise<void>;
}; };
@@ -30,6 +34,7 @@ export default function FileDropZone({
note, note,
className = "", className = "",
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608", inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
onRejectedDrop,
onFiles onFiles
}: FileDropZoneProps) { }: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -52,6 +57,13 @@ export default function FileDropZone({
} }
} }
function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy";
if (!interactionDisabled) setDragActive(true);
}
return ( return (
<> <>
<div <div
@@ -70,15 +82,29 @@ export default function FileDropZone({
inputRef.current?.click(); inputRef.current?.click();
} }
}} }}
onDragOver={(event) => { onDragEnter={prepareFileDrag}
onDragOver={prepareFileDrag}
onDragLeave={(event) => {
event.preventDefault(); event.preventDefault();
if (!interactionDisabled) setDragActive(true); event.stopPropagation();
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
setDragActive(false);
}} }}
onDragLeave={() => setDragActive(false)}
onDrop={(event) => { onDrop={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
setDragActive(false); setDragActive(false);
if (!interactionDisabled) void handleFiles(event.dataTransfer.files); if (interactionDisabled) {
onRejectedDrop?.("disabled");
return;
}
void resolveDroppedFiles(event.dataTransfer).then((files) => {
if (files.length === 0) {
onRejectedDrop?.("empty");
return;
}
void handleFiles(files);
}).catch(() => onRejectedDrop?.("unreadable"));
}}> }}>
{showProgress ? {showProgress ?

View File

@@ -0,0 +1,101 @@
type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike;
type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise<File> };
type FileSystemDirectoryHandleLike = {
kind: "directory";
values?: () => AsyncIterable<FileSystemHandleLike>;
};
type WebKitFileEntryLike = {
isFile: true;
isDirectory: false;
file: (
success: (file: File) => void,
error?: (error: DOMException) => void
) => void;
};
type WebKitDirectoryEntryLike = { isFile: false; isDirectory: true };
type WebKitEntryLike = WebKitFileEntryLike | WebKitDirectoryEntryLike;
type DropItemWithFileHandles = DataTransferItem & {
getAsFileSystemHandle?: () => Promise<FileSystemHandleLike | null>;
webkitGetAsEntry?: () => unknown;
};
/**
* Resolve a browser file drop exactly once, preferring the standard file list
* before progressively trying item and directory-drag compatibility APIs.
*/
export async function resolveDroppedFiles(
dataTransfer: Pick<DataTransfer, "files" | "items">
): Promise<File[]> {
const directFiles = Array.from(dataTransfer.files);
if (directFiles.length > 0) return directFiles;
const items = Array.from(dataTransfer.items).filter(
(item) => item.kind === "file"
) as DropItemWithFileHandles[];
const itemFiles = items
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (itemFiles.length > 0) return itemFiles;
const handleFiles = await filesFromDataTransferHandles(items);
if (handleFiles.length > 0) return handleFiles;
return filesFromWebKitEntries(items);
}
async function filesFromDataTransferHandles(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const files: File[] = [];
for (const item of items) {
let handle: FileSystemHandleLike | null | undefined;
try {
handle = await item.getAsFileSystemHandle?.();
} catch {
handle = null;
}
if (!handle) continue;
files.push(...await filesFromFileSystemHandle(handle));
}
return files;
}
async function filesFromFileSystemHandle(
handle: FileSystemHandleLike
): Promise<File[]> {
if (handle.kind === "file") return [await handle.getFile()];
const values = handle.values?.();
if (!values) return [];
const files: File[] = [];
for await (const child of values) {
files.push(...await filesFromFileSystemHandle(child));
}
return files;
}
async function filesFromWebKitEntries(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const entries = items.flatMap((item) => {
const entry = item.webkitGetAsEntry?.() as unknown;
return isWebKitEntryLike(entry) ? [entry] : [];
});
const files = await Promise.all(entries.map(fileFromWebKitEntry));
return files.filter((file): file is File => Boolean(file));
}
function isWebKitEntryLike(value: unknown): value is WebKitEntryLike {
if (!value || typeof value !== "object") return false;
const entry = value as { isFile?: unknown; isDirectory?: unknown };
return (
typeof entry.isFile === "boolean" &&
typeof entry.isDirectory === "boolean"
);
}
function fileFromWebKitEntry(entry: WebKitEntryLike): Promise<File | null> {
if (!entry.isFile) return Promise.resolve(null);
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}

View File

@@ -402,6 +402,22 @@ export type OrganizationFunctionPickerUiCapability = {
renderLabel?: (context: OrganizationFunctionLabelContext) => ReactNode; renderLabel?: (context: OrganizationFunctionLabelContext) => ReactNode;
}; };
export type CalendarPickerProps = PlatformRouteContext & {
value: string;
onChange: (calendarId: string) => void;
id?: string;
name?: string;
label?: ReactNode;
emptyLabel?: ReactNode;
disabled?: boolean;
required?: boolean;
className?: string;
};
export type CalendarPickerUiCapability = {
CalendarPicker: ComponentType<CalendarPickerProps>;
};
export type MailSecurity = "plain" | "tls" | "starttls"; export type MailSecurity = "plain" | "tls" | "starttls";
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign"; export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";

View File

@@ -0,0 +1,150 @@
import { resolveDroppedFiles } from "../src/components/resolveDroppedFiles";
type FileDropItem = Omit<DataTransferItem, "getAsFileSystemHandle" | "webkitGetAsEntry"> & {
getAsFileSystemHandle?: () => Promise<unknown>;
webkitGetAsEntry?: () => unknown;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertFiles(actual: File[], expected: File[], message: string): void {
assert(actual.length === expected.length, `${message}: expected ${expected.length} file(s), got ${actual.length}`);
expected.forEach((file, index) => {
assert(actual[index] === file, `${message}: file ${index + 1} did not preserve object identity`);
});
}
function transfer(files: File[] = [], items: FileDropItem[] = []): Pick<DataTransfer, "files" | "items"> {
return {
files: files as unknown as FileList,
items: items as unknown as DataTransferItemList
};
}
function item(overrides: Partial<FileDropItem> = {}): FileDropItem {
return {
kind: "file",
type: "application/octet-stream",
getAsFile: () => null,
getAsString: () => undefined,
...overrides
} as FileDropItem;
}
function namedFile(name: string): File {
return new File([name], name, { type: "text/plain" });
}
async function* handles(...entries: unknown[]): AsyncIterable<unknown> {
for (const entry of entries) yield entry;
}
async function run(): Promise<void> {
const direct = namedFile("direct.txt");
let directItemCalls = 0;
const directResult = await resolveDroppedFiles(transfer([direct], [item({
getAsFile: () => {
directItemCalls += 1;
return direct;
}
})]));
assertFiles(directResult, [direct], "native DataTransfer.files takes precedence");
assert(directItemCalls === 0, "native files must not also be resolved from items");
const itemA = namedFile("item-a.txt");
const itemB = namedFile("item-b.txt");
let itemHandleCalls = 0;
const itemResult = await resolveDroppedFiles(transfer([], [
item({
getAsFile: () => itemA,
getAsFileSystemHandle: async () => {
itemHandleCalls += 1;
return { kind: "file", getFile: async () => itemA };
}
}),
item({ getAsFile: () => itemB }),
item({ kind: "string", getAsFile: () => null })
]));
assertFiles(itemResult, [itemA, itemB], "DataTransferItem.getAsFile fallback resolves file items");
assert(itemHandleCalls === 0, "item files must not also be resolved from file-system handles");
const topLevelFile = namedFile("top-level.txt");
const rootFile = namedFile("root.txt");
const nestedFile = namedFile("nested.txt");
const deepFile = namedFile("deep.txt");
const deepDirectory = {
kind: "directory",
values: () => handles({ kind: "file", getFile: async () => deepFile })
};
const nestedDirectory = {
kind: "directory",
values: () => handles(
{ kind: "file", getFile: async () => nestedFile },
deepDirectory
)
};
const rootDirectory = {
kind: "directory",
values: () => handles(
{ kind: "file", getFile: async () => rootFile },
nestedDirectory
)
};
const handleResult = await resolveDroppedFiles(transfer([], [
item({
getAsFileSystemHandle: async () => ({
kind: "file",
getFile: async () => topLevelFile
})
}),
item({ getAsFileSystemHandle: async () => rootDirectory })
]));
assertFiles(
handleResult,
[topLevelFile, rootFile, nestedFile, deepFile],
"File System Access file handles resolve and directory handles recurse in enumeration order"
);
const legacy = namedFile("legacy.txt");
const legacyResult = await resolveDroppedFiles(transfer([], [item({
getAsFileSystemHandle: async () => {
throw new Error("file-system handles unavailable");
},
webkitGetAsEntry: () => ({
isFile: true,
isDirectory: false,
file: (resolve: (file: File) => void) => resolve(legacy)
})
})]));
assertFiles(legacyResult, [legacy], "legacy webkit file entry remains a final fallback");
const emptyResult = await resolveDroppedFiles(transfer([], [
item({ kind: "string" }),
item({
getAsFileSystemHandle: async () => ({ kind: "directory" }),
webkitGetAsEntry: () => ({ isFile: false, isDirectory: true })
})
]));
assertFiles(emptyResult, [], "empty and non-readable directory paths resolve to an empty drop");
let unreadableRejected = false;
try {
await resolveDroppedFiles(transfer([], [item({
getAsFileSystemHandle: async () => ({
kind: "file",
getFile: async () => {
throw new Error("permission denied");
}
})
})]));
} catch (error) {
unreadableRejected = error instanceof Error && error.message === "permission denied";
}
assert(unreadableRejected, "an unreadable file handle rejects so the component can report the unreadable reason");
}
void run().catch((error) => {
throw error;
});

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".file-drop-test-build",
"rootDir": "."
},
"include": [
"tests/file-drop-resolver.test.ts",
"src/components/resolveDroppedFiles.ts"
]
}