Compare commits

...

4 Commits

24 changed files with 5197 additions and 815 deletions

View File

@@ -118,3 +118,88 @@ participant pages, Calendar hold cleanup after decision, and advanced scoring
constraints such as required participants and quorum rules.
The active backlog lives in Gitea issues.
## Signed-participation policy gate
Scheduling persists the response policy and snapshots it onto each governed
Poll invitation. Public access uses
`/scheduling/public/{request_id}/{token}`; Poll's ordinary public routes reject
these gateway-bound tokens, so callers cannot bypass Scheduling's password and
request checks. Passwords are write-only, stored only as salted PBKDF2 hashes,
and failed checks are throttled through Redis when configured with a bounded
in-process fallback.
Poll is the transactional authority for response rules. Its governed submission
holds the Poll-row lock while enforcing single choice, `Maybe`, participant
email, comments, idempotency and per-option capacity. Scheduling then updates
its participant projection in the same database transaction. Candidate-slot
add/remove and participant-link revocation also go through the governed Poll
capability; exact retries are idempotent, and option mutations invalidate only
the affected answers.
Public submissions lock the Scheduling request and participant rows before
entering Poll, matching organizer edit lock order and making first-use email
binding atomic. Both authenticated and public submission paths independently
require the Scheduling request to be collecting and its deadline not to have
passed, so a stale or incorrectly reopened Poll projection cannot accept a
response. Changing a request deadline transactionally updates each governed
invitation's expiry in Poll under owner- and gateway-bound row locks. The same
link and invitation identity survive future, past, extended, and cleared
deadlines: a past deadline makes the link unusable, a later extension makes the
same link usable again, and clearing the deadline removes its expiry. No raw
replacement token crosses the PATCH response, and existing responses plus
participant status remain attached to the same durable respondent identity.
Open lifecycle decision: cancellation closes the backing Poll, so submission
fails, but an otherwise valid invitation can still resolve the reduced public
view and show that the request was cancelled. Decide whether cancellation
should revoke links immediately or retain that acknowledgement view for a
bounded period; links without a deadline would otherwise remain readable.
If the governed capability is absent, API responses advertise that policy
enforcement is unavailable and restricted links fail closed. Plaintext
passwords, token hashes, response-gateway internals and the participant roster
are not exposed by the public response model.
Authenticated participant list/detail projections likewise omit tenant,
organizer, Poll and Calendar identifiers, connector metadata, invitation and
identity bindings. Free/busy conflicts are reduced to useful time/status
fields. Organizers and scheduling administrators retain the full management
projection; participants retain candidate-slot revisions, their own marker and
email, response settings, aggregates, and any roster names/statuses permitted
by the configured privacy policy.
The WebUI package exposes typed clients for the public access and submission
endpoints. A signed-out browser page cannot yet be registered by a module:
Core's `App` renders `PublicLandingPage` directly whenever `auth` is absent and
only mounts module route contributions inside the authenticated branch. Until
Core gains an explicit, allowlisted `publicRoutes` contract, notification action
URLs under `/scheduling/public/{request}/{token}` must be treated as a blocked
frontend handoff rather than a working guest page. The token is never moved into
query parameters, browser storage, or an authenticated API contract while that
shell boundary is unresolved.
Draft saves never issue public tokens or enqueue invitation delivery, even when
`create_participant_invitations` is left at its compatibility default. A
collecting request may explicitly issue invitations; authenticated in-module
responses lazily create a gateway-bound invitation and discard its token. The
draft-to-open transition therefore supports authenticated lazy responses, but
does not make a guest link available from the current UI. The product decision
still open is the explicit organizer workflow for issuing or reissuing public
links after a draft is opened and, when Mail is installed, whether that action
should also enqueue delivery or return links for separate distribution. Until
that workflow is agreed, opening a draft does not silently send anything.
## FieldLabel omission register
Scheduling uses the shared `FieldLabel` for form controls that need explanatory
inline help. The only intentional omissions are:
- Candidate-slot and participant DataGrid cells: column headers supply the
visible label and each interactive cell has an accessible name.
- Per-slot availability selects: the enclosing visible slot/date text is the
native label for that individual choice.
There are no other authorized Scheduling omissions. Inline help remains
controlled by the user's shared interface setting; hiding it does not remove
accessible labels.

View File

@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-scheduling"
version = "0.1.9"
version = "0.1.10"
description = "GovOPlaN meeting scheduling and Terminfindung module seed."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.9",
"govoplan-poll>=0.1.9",
"govoplan-core>=0.1.10",
"govoplan-poll>=0.1.10",
]
[tool.setuptools.packages.find]

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.9"
__version__ = "0.1.10"

View File

@@ -37,6 +37,14 @@ class SchedulingRequest(Base, TimestampMixin):
allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
participant_visibility: Mapped[str] = mapped_column(String(32), default="aggregates_only", nullable=False)
notify_on_answers: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
single_choice: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
max_participants_per_option: Mapped[int | None] = mapped_column(Integer, nullable=True)
allow_maybe: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_comments: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
participant_email_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
anonymous_password_protection_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
anonymous_password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True)
calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
@@ -106,8 +114,10 @@ class SchedulingParticipant(Base, TimestampMixin):
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
status: Mapped[str] = mapped_column(String(40), default="invited", nullable=False, index=True)
poll_invitation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
participation_gateway: Mapped[str | None] = mapped_column(String(40), nullable=True)
last_invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
response_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)

View File

@@ -20,11 +20,12 @@ from govoplan_core.core.modules import (
from govoplan_core.db.base import Base
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
MODULE_ID = "scheduling"
MODULE_NAME = "Scheduling"
MODULE_VERSION = "0.1.9"
MODULE_VERSION = "0.1.10"
READ_SCOPE = "scheduling:schedule:read"
WRITE_SCOPE = "scheduling:schedule:write"
ADMIN_SCOPE = "scheduling:schedule:admin"
@@ -47,8 +48,8 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = (
_permission(READ_SCOPE, "View scheduling", "Read scheduling polls, proposals, participant state, and selected outcomes."),
_permission(WRITE_SCOPE, "Manage scheduling", "Create and update scheduling polls, candidate slots, reminders, and decision handoff."),
_permission(ADMIN_SCOPE, "Administer scheduling", "Configure tenant-level scheduling policies, external participation, and retention defaults."),
_permission(WRITE_SCOPE, "Manage own scheduling", "Create scheduling polls and manage requests for which the account is the organizer."),
_permission(ADMIN_SCOPE, "Administer scheduling", "Manage every tenant scheduling request and configure scheduling policies, external participation, and retention defaults."),
_permission(RESPOND_SCOPE, "Respond to scheduling polls", "Submit and update own scheduling availability responses."),
)
@@ -56,7 +57,7 @@ ROLE_TEMPLATES = (
RoleTemplate(
slug="scheduling_manager",
name="Scheduling manager",
description="Create scheduling polls, manage candidate slots, and decide outcomes.",
description="Create scheduling polls and manage candidate slots and outcomes for requests the account organizes.",
permissions=(READ_SCOPE, WRITE_SCOPE, RESPOND_SCOPE),
),
RoleTemplate(
@@ -137,16 +138,19 @@ manifest = ModuleManifest(
CAPABILITY_CALENDAR_SCHEDULING,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
),
required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
required_capabilities=(
CAPABILITY_POLL_SCHEDULING,
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
),
provides_interfaces=(
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.9"),
ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.9"),
ModuleInterfaceProvider(name="scheduling.candidate_slots", version=MODULE_VERSION),
ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION),
),
requires_interfaces=(
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.9", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.9", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.9", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),

View File

@@ -0,0 +1,168 @@
"""v0.1.10 scheduling response settings
Revision ID: ad7e3c9b2f10
Revises: 9c2f4a7d1e6b
Create Date: 2026-07-21 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad7e3c9b2f10"
down_revision = "9c2f4a7d1e6b"
branch_labels = None
depends_on = None
_REQUEST_COLUMNS = (
"notify_on_answers",
"single_choice",
"max_participants_per_option",
"allow_maybe",
"allow_comments",
"participant_email_required",
"anonymous_password_protection_enabled",
"anonymous_password_hash",
)
def _adopted_columns(inspector: sa.Inspector, table_name: str, names: tuple[str, ...]) -> bool:
columns = {item["name"]: item for item in inspector.get_columns(table_name)}
present = [name for name in names if name in columns]
if not present:
return False
if len(present) != len(names):
missing = sorted(set(names) - set(present))
raise RuntimeError(
f"Cannot adopt partial {table_name} scheduling response settings; missing columns: "
+ ", ".join(missing)
)
return True
def _require_compatible_adopted_schema(inspector: sa.Inspector) -> None:
request_columns = {
item["name"]: item
for item in inspector.get_columns("scheduling_requests")
}
for name in (
"notify_on_answers",
"single_choice",
"allow_maybe",
"allow_comments",
"participant_email_required",
"anonymous_password_protection_enabled",
):
column = request_columns[name]
if column.get("nullable") or not isinstance(column["type"], sa.Boolean):
raise RuntimeError(
f"Cannot adopt scheduling_requests.{name} because its schema is unexpected"
)
capacity = request_columns["max_participants_per_option"]
if not capacity.get("nullable") or not isinstance(capacity["type"], sa.Integer):
raise RuntimeError(
"Cannot adopt scheduling_requests.max_participants_per_option because its schema is unexpected"
)
password_hash = request_columns["anonymous_password_hash"]
if (
not password_hash.get("nullable")
or not isinstance(password_hash["type"], sa.String)
or password_hash["type"].length != 500
):
raise RuntimeError(
"Cannot adopt scheduling_requests.anonymous_password_hash because its schema is unexpected"
)
participant_columns = {
item["name"]: item
for item in inspector.get_columns("scheduling_participants")
}
comment = participant_columns["response_comment"]
if not comment.get("nullable") or not isinstance(comment["type"], sa.Text):
raise RuntimeError(
"Cannot adopt scheduling_participants.response_comment because its schema is unexpected"
)
gateway = participant_columns["participation_gateway"]
if (
not gateway.get("nullable")
or not isinstance(gateway["type"], sa.String)
or gateway["type"].length != 40
):
raise RuntimeError(
"Cannot adopt scheduling_participants.participation_gateway because its schema is unexpected"
)
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
request_columns_present = _adopted_columns(
inspector,
"scheduling_requests",
_REQUEST_COLUMNS,
)
participant_columns_present = _adopted_columns(
inspector,
"scheduling_participants",
("response_comment", "participation_gateway"),
)
if request_columns_present != participant_columns_present:
raise RuntimeError(
"Cannot adopt partial scheduling response settings across request and participant tables"
)
if request_columns_present:
_require_compatible_adopted_schema(inspector)
return
op.add_column(
"scheduling_requests",
sa.Column("notify_on_answers", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"scheduling_requests",
sa.Column("single_choice", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"scheduling_requests",
sa.Column("max_participants_per_option", sa.Integer(), nullable=True),
)
op.add_column(
"scheduling_requests",
sa.Column("allow_maybe", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"scheduling_requests",
sa.Column("allow_comments", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"scheduling_requests",
sa.Column("participant_email_required", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"scheduling_requests",
sa.Column(
"anonymous_password_protection_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.add_column(
"scheduling_requests",
sa.Column("anonymous_password_hash", sa.String(length=500), nullable=True),
)
op.add_column(
"scheduling_participants",
sa.Column("response_comment", sa.Text(), nullable=True),
)
op.add_column(
"scheduling_participants",
sa.Column("participation_gateway", sa.String(length=40), nullable=True),
)
def downgrade() -> None:
op.drop_column("scheduling_participants", "participation_gateway")
op.drop_column("scheduling_participants", "response_comment")
for name in reversed(_REQUEST_COLUMNS):
op.drop_column("scheduling_requests", name)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import dataclasses
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
@@ -26,6 +26,9 @@ from govoplan_scheduling.backend.schemas import (
SchedulingRequestResponse,
SchedulingRequestUpdateRequest,
SchedulingPollSummaryResponse,
SchedulingPublicParticipationAccessRequest,
SchedulingPublicParticipationResponse,
SchedulingPublicParticipationSubmitRequest,
SchedulingStatusResponse,
SchedulingSummaryResponse,
)
@@ -34,6 +37,7 @@ from govoplan_scheduling.backend.service import (
SchedulingConflictError,
SchedulingError,
SchedulingPermissionError,
SchedulingPublicParticipationError,
cancel_scheduling_request,
close_scheduling_request,
create_final_calendar_event,
@@ -42,7 +46,9 @@ from govoplan_scheduling.backend.service import (
create_tentative_calendar_holds,
decide_scheduling_request,
evaluate_calendar_freebusy,
get_scheduling_request,
get_scheduling_availability_response,
get_public_scheduling_participation,
get_visible_scheduling_request,
list_visible_scheduling_notifications,
list_visible_scheduling_requests,
@@ -53,8 +59,9 @@ from govoplan_scheduling.backend.service import (
scheduling_request_response,
scheduling_request_summary,
submit_scheduling_availability,
submit_public_scheduling_participation,
update_scheduling_candidate_slot,
update_scheduling_request,
update_scheduling_request_with_invitation_tokens,
)
@@ -112,7 +119,40 @@ def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE)
return has_scope(principal, ADMIN_SCOPE)
def _require_scheduling_writer(principal: ApiPrincipal) -> None:
if has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing scope: {WRITE_SCOPE} or {ADMIN_SCOPE}",
)
def _require_request_editor(
session: Session,
*,
principal: ApiPrincipal,
request_id: str,
) -> None:
_require_scheduling_writer(principal)
if has_scope(principal, ADMIN_SCOPE):
return
try:
request = get_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
if request.organizer_user_id not in _principal_actor_ids(principal):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the organizer or a scheduling administrator can edit this request",
)
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
@@ -127,6 +167,25 @@ def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _public_participation_http_error(
exc: SchedulingPublicParticipationError,
) -> HTTPException:
if exc.retry_after_seconds:
return HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(exc),
headers={"Retry-After": str(exc.retry_after_seconds)},
)
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
def _client_address(request: Request) -> str | None:
return request.client.host if request.client is not None else None
def _request_response(
request,
*,
@@ -144,6 +203,58 @@ def _request_response(
)
@router.post(
"/public/{request_id}/{token}",
response_model=SchedulingPublicParticipationResponse,
)
def api_get_public_scheduling_participation(
request_id: str,
token: str,
payload: SchedulingPublicParticipationAccessRequest,
request: Request,
session: Session = Depends(get_session),
) -> SchedulingPublicParticipationResponse:
try:
response = get_public_scheduling_participation(
session,
request_id=request_id,
token=token,
payload=payload,
client_address=_client_address(request),
)
except SchedulingPublicParticipationError as exc:
raise _public_participation_http_error(exc) from exc
return SchedulingPublicParticipationResponse.model_validate(response)
@router.post(
"/public/{request_id}/{token}/responses",
response_model=SchedulingPublicParticipationResponse,
)
def api_submit_public_scheduling_participation(
request_id: str,
token: str,
payload: SchedulingPublicParticipationSubmitRequest,
request: Request,
session: Session = Depends(get_session),
) -> SchedulingPublicParticipationResponse:
try:
response = submit_public_scheduling_participation(
session,
request_id=request_id,
token=token,
payload=payload,
client_address=_client_address(request),
)
except SchedulingPublicParticipationError as exc:
raise _public_participation_http_error(exc) from exc
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
validated = SchedulingPublicParticipationResponse.model_validate(response)
session.commit()
return validated
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
def api_lookup_scheduling_addresses(
query: str = Query(min_length=1),
@@ -151,7 +262,7 @@ def api_lookup_scheduling_addresses(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingAddressLookupResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scheduling_writer(principal)
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
if capability is None or not hasattr(capability, "lookup"):
return SchedulingAddressLookupResponse(available=False, candidates=[])
@@ -198,7 +309,7 @@ def api_create_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scheduling_writer(principal)
try:
request, invitation_tokens = create_scheduling_request(
session,
@@ -294,9 +405,13 @@ def api_update_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(
session,
principal=principal,
request_id=request_id,
)
try:
request = update_scheduling_request(
request, invitation_tokens = update_scheduling_request_with_invitation_tokens(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
@@ -304,7 +419,11 @@ def api_update_scheduling_request(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal)
response = _request_response(
request,
principal=principal,
invitation_tokens=invitation_tokens,
)
session.commit()
return response
@@ -317,7 +436,11 @@ def api_update_scheduling_candidate_slot(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(
session,
principal=principal,
request_id=request_id,
)
try:
request = update_scheduling_candidate_slot(
session,
@@ -339,7 +462,7 @@ def api_open_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
@@ -355,7 +478,7 @@ def api_close_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
@@ -372,7 +495,7 @@ def api_decide_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = decide_scheduling_request(
session,
@@ -398,7 +521,7 @@ def api_evaluate_calendar_freebusy(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try:
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
@@ -419,7 +542,7 @@ def api_create_tentative_calendar_holds(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
request, created_event_ids, warnings = create_tentative_calendar_holds(
@@ -446,7 +569,7 @@ def api_create_final_calendar_event(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
request, event_id, warnings = create_final_calendar_event(
@@ -472,7 +595,7 @@ def api_cancel_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
@@ -547,7 +670,7 @@ def api_create_scheduling_notifications(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingNotificationListResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
notifications = create_scheduling_notification_jobs(
session,

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
@@ -25,6 +25,24 @@ def _known_timezone(value: str | None) -> str | None:
return value
def _participant_email(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().casefold()
if not normalized:
return None
local, separator, domain = normalized.partition("@")
if (
separator != "@"
or not local
or not domain
or "@" in domain
or any(character.isspace() for character in normalized)
):
raise ValueError("participant_email must be a valid email address")
return normalized
class SchedulingCalendarPreferences(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -91,6 +109,30 @@ class SchedulingParticipantInput(BaseModel):
required: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
_validate_email = field_validator("email")(_participant_email)
class SchedulingCandidateSlotReconcileInput(SchedulingCandidateSlotInput):
id: str | None = Field(default=None, max_length=36)
revision: str | None = Field(
default=None,
min_length=64,
max_length=64,
pattern=r"^[0-9a-f]{64}$",
)
@model_validator(mode="after")
def validate_existing_revision(self) -> "SchedulingCandidateSlotReconcileInput":
if self.id is not None and self.revision is None:
raise ValueError("revision is required for an existing scheduling slot")
if self.id is None and self.revision is not None:
raise ValueError("revision can only be supplied for an existing scheduling slot")
return self
class SchedulingParticipantReconcileInput(SchedulingParticipantInput):
id: str | None = Field(default=None, max_length=36)
class SchedulingRequestCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -105,6 +147,14 @@ class SchedulingRequestCreateRequest(BaseModel):
allow_participant_updates: bool = True
result_visibility: SchedulingResultVisibility = "after_close"
participant_visibility: SchedulingParticipantVisibility = "aggregates_only"
notify_on_answers: bool = True
single_choice: bool = False
max_participants_per_option: int | None = Field(default=None, ge=1)
allow_maybe: bool = True
allow_comments: bool = False
participant_email_required: bool = False
anonymous_password_protection_enabled: bool = False
anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024)
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
@@ -113,6 +163,14 @@ class SchedulingRequestCreateRequest(BaseModel):
_validate_timezone = field_validator("timezone")(_known_timezone)
@model_validator(mode="after")
def validate_anonymous_password(self) -> "SchedulingRequestCreateRequest":
if self.anonymous_password_protection_enabled and self.anonymous_password is None:
raise ValueError("anonymous_password is required when password protection is enabled")
if not self.anonymous_password_protection_enabled and self.anonymous_password is not None:
raise ValueError("anonymous_password requires password protection to be enabled")
return self
class SchedulingRequestUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -125,9 +183,40 @@ class SchedulingRequestUpdateRequest(BaseModel):
allow_participant_updates: bool | None = None
result_visibility: SchedulingResultVisibility | None = None
participant_visibility: SchedulingParticipantVisibility | None = None
notify_on_answers: bool | None = None
single_choice: bool | None = None
max_participants_per_option: int | None = Field(default=None, ge=1)
allow_maybe: bool | None = None
allow_comments: bool | None = None
participant_email_required: bool | None = None
anonymous_password_protection_enabled: bool | None = None
anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024)
calendar: SchedulingCalendarPreferences | None = None
slots: list[SchedulingCandidateSlotReconcileInput] | None = Field(
default=None,
min_length=1,
)
participants: list[SchedulingParticipantReconcileInput] | None = None
create_participant_invitations: bool = True
metadata: dict[str, Any] | None = None
@model_validator(mode="after")
def validate_anonymous_password(self) -> "SchedulingRequestUpdateRequest":
if self.anonymous_password_protection_enabled is False and self.anonymous_password is not None:
raise ValueError("anonymous_password cannot be set while password protection is disabled")
return self
@model_validator(mode="after")
def validate_reconciliation_ids(self) -> "SchedulingRequestUpdateRequest":
for field_name in ("slots", "participants"):
values = getattr(self, field_name)
if values is None:
continue
ids = [value.id for value in values if value.id is not None]
if len(ids) != len(set(ids)):
raise ValueError(f"Duplicate ids are not allowed in {field_name}")
return self
class SchedulingCandidateSlotResponse(BaseModel):
id: str
@@ -149,11 +238,12 @@ class SchedulingCandidateSlotResponse(BaseModel):
class SchedulingParticipantResponse(BaseModel):
id: str
is_current_participant: bool = False
respondent_id: str | None = None
display_name: str | None = None
email: str | None = None
participant_type: str
required: bool
participant_type: str | None = None
required: bool | None = None
status: str
poll_invitation_id: str | None = None
invitation_token: str | None = None
@@ -178,7 +268,7 @@ class SchedulingParticipantVisibilityDecisionResponse(BaseModel):
class SchedulingRequestResponse(BaseModel):
id: str
tenant_id: str
tenant_id: str | None = None
title: str
description: str | None = None
location: str | None = None
@@ -192,14 +282,23 @@ class SchedulingRequestResponse(BaseModel):
allow_participant_updates: bool
result_visibility: str
participant_visibility: SchedulingParticipantVisibility
notify_on_answers: bool
single_choice: bool
max_participants_per_option: int | None = None
allow_maybe: bool
allow_comments: bool
participant_email_required: bool
anonymous_password_protection_enabled: bool
public_participation_policy_enforcement_available: bool | None = None
public_participation_policy_enforcement_reason: str | None = None
effective_participant_visibility: SchedulingParticipantVisibility
participant_aggregate: SchedulingParticipantAggregateResponse
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
calendar_integration_enabled: bool
calendar_integration_enabled: bool | None = None
calendar_id: str | None = None
calendar_freebusy_enabled: bool
calendar_hold_enabled: bool
create_calendar_event_on_decision: bool
calendar_freebusy_enabled: bool | None = None
calendar_hold_enabled: bool | None = None
create_calendar_event_on_decision: bool | None = None
calendar_event_id: str | None = None
handed_off_at: datetime | None = None
cancelled_at: datetime | None = None
@@ -238,6 +337,7 @@ class SchedulingAvailabilityResponseRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
comment: str | None = Field(default=None, max_length=4000)
@model_validator(mode="after")
def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest":
@@ -258,6 +358,70 @@ class SchedulingAvailabilityResponse(BaseModel):
has_response: bool = False
submitted_at: datetime | None = None
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
comment: str | None = None
class SchedulingPublicParticipationAccessRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
participant_email: str | None = Field(default=None, max_length=320)
password: SecretStr | None = Field(default=None, max_length=1024)
_validate_participant_email = field_validator("participant_email")(_participant_email)
class SchedulingPublicParticipationSubmitRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
participant_email: str | None = Field(default=None, max_length=320)
password: SecretStr | None = Field(default=None, max_length=1024)
comment: str | None = Field(default=None, max_length=4000)
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
_validate_participant_email = field_validator("participant_email")(_participant_email)
@model_validator(mode="after")
def validate_unique_slots(self) -> "SchedulingPublicParticipationSubmitRequest":
slot_ids = [answer.slot_id for answer in self.answers]
if len(slot_ids) != len(set(slot_ids)):
raise ValueError("Each scheduling slot can be answered only once")
return self
class SchedulingPublicCandidateSlotResponse(BaseModel):
id: str
label: str
description: str | None = None
start_at: datetime
end_at: datetime
timezone: str
location: str | None = None
position: int
revision: str
class SchedulingPublicParticipationResponse(BaseModel):
request_id: str
title: str
description: str | None = None
location: str | None = None
timezone: str
status: str
deadline_at: datetime | None = None
participant_email_required: bool
anonymous_password_required: bool
single_choice: bool
max_participants_per_option: int | None = None
allow_maybe: bool
allow_comments: bool
allow_participant_updates: bool
has_response: bool = False
submitted_at: datetime | None = None
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
comment: str | None = None
replayed: bool = False
slots: list[SchedulingPublicCandidateSlotResponse] = Field(default_factory=list)
class SchedulingPollOptionResultResponse(BaseModel):

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
_ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16
def hash_participant_password(
password: str,
*,
iterations: int = _DEFAULT_ITERATIONS,
) -> str:
"""Hash a public-participant access password for durable storage."""
salt = os.urandom(_SALT_BYTES)
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
)
return "$".join(
(
_ALGORITHM,
str(iterations),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
)
)
def verify_participant_password(password: str, encoded: str | None) -> bool:
"""Verify a participant password without exposing the stored hash."""
if not encoded:
return False
try:
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
if algorithm != _ALGORITHM:
return False
iterations = int(iterations_text)
salt = base64.b64decode(salt_b64.encode("ascii"), validate=True)
expected = base64.b64decode(digest_b64.encode("ascii"), validate=True)
except (TypeError, ValueError):
return False
actual = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
)
return hmac.compare_digest(actual, expected)
__all__ = ["hash_participant_password", "verify_participant_password"]

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,10 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("access", manifest.optional_dependencies)
self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("policy", manifest.optional_dependencies)
self.assertEqual(("poll.scheduling",), manifest.required_capabilities)
self.assertEqual(
("poll.scheduling", "poll.participation_gateway"),
manifest.required_capabilities,
)
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
@@ -29,14 +32,18 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.response_collection", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.governed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces}
self.assertEqual("0.1.9", required_interfaces["poll.availability_matrix"].version_min)
self.assertEqual("0.1.9", required_interfaces["poll.response_collection"].version_min)
self.assertEqual("0.1.9", required_interfaces["poll.workflow_context"].version_min)
for interface_name in (
"poll.availability_matrix",
"poll.response_collection",
"poll.workflow_context",
"poll.governed_participation",
):
self.assertEqual("0.1.10", required_interfaces[interface_name].version_min)
if __name__ == "__main__":

View File

@@ -20,7 +20,7 @@ from govoplan_scheduling.backend.db.models import (
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
_SCHEDULING_HEAD = "9c2f4a7d1e6b"
_SCHEDULING_HEAD = "ad7e3c9b2f10"
_ENABLED_MODULES = ("poll", "scheduling")
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
@@ -110,12 +110,27 @@ class SchedulingMigrationTests(unittest.TestCase):
"scheduling_requests"
)
}
participant_columns = {
item["name"]
for item in inspect(connection).get_columns(
"scheduling_participants"
)
}
visibility = connection.execute(
text(
"SELECT participant_visibility FROM scheduling_requests "
"WHERE id = 'request-1'"
)
).scalar_one()
response_defaults = connection.execute(
text(
"SELECT notify_on_answers, single_choice, "
"max_participants_per_option, allow_maybe, allow_comments, "
"participant_email_required, anonymous_password_protection_enabled, "
"anonymous_password_hash FROM scheduling_requests "
"WHERE id = 'request-1'"
)
).one()
counts = {
model.__tablename__: connection.execute(
select(func.count()).select_from(model)
@@ -130,7 +145,11 @@ class SchedulingMigrationTests(unittest.TestCase):
self.assertIn(_SCHEDULING_HEAD, heads)
self.assertIn("participant_visibility", columns)
self.assertIn("max_participants_per_option", columns)
self.assertIn("response_comment", participant_columns)
self.assertIn("participation_gateway", participant_columns)
self.assertEqual(visibility, "aggregates_only")
self.assertEqual(tuple(response_defaults), (1, 0, None, 1, 0, 0, 0, None))
self.assertEqual(set(counts.values()), {1})
finally:
engine.dispose()
@@ -219,6 +238,74 @@ class SchedulingMigrationTests(unittest.TestCase):
finally:
engine.dispose()
def test_rejects_partial_existing_response_settings(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-scheduling-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
self._migrate(url)
engine = create_engine(url)
try:
with engine.begin() as connection:
self._remove_scheduling_revision(
connection,
remove_privacy_column=False,
)
connection.execute(
text(
"ALTER TABLE scheduling_requests "
"DROP COLUMN allow_comments"
)
)
with self.assertRaisesRegex(
RuntimeError,
"partial scheduling_requests scheduling response settings",
):
self._migrate(url)
with engine.connect() as connection:
heads = set(
MigrationContext.configure(connection).get_current_heads()
)
self.assertNotIn(_SCHEDULING_HEAD, heads)
finally:
engine.dispose()
def test_rejects_partial_existing_participant_response_settings(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-scheduling-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
self._migrate(url)
engine = create_engine(url)
try:
with engine.begin() as connection:
self._remove_scheduling_revision(
connection,
remove_privacy_column=False,
)
connection.execute(
text(
"ALTER TABLE scheduling_participants "
"DROP COLUMN participation_gateway"
)
)
with self.assertRaisesRegex(
RuntimeError,
"partial scheduling_participants scheduling response settings",
):
self._migrate(url)
with engine.connect() as connection:
heads = set(
MigrationContext.configure(connection).get_current_heads()
)
self.assertNotIn(_SCHEDULING_HEAD, heads)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -89,11 +90,46 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
"tenant_id": "tenant-1",
"title": "Privacy review",
"status": "collecting",
"poll_id": "poll-internal",
"organizer_user_id": "organizer-1",
"calendar_integration_enabled": True,
"calendar_id": "calendar-internal",
"calendar_freebusy_enabled": True,
"calendar_hold_enabled": True,
"create_calendar_event_on_decision": True,
"calendar_event_id": "event-internal",
"metadata_": {"connector_secret_ref": "secret-internal"},
}
if participant_visibility is not None:
values["participant_visibility"] = participant_visibility
request = SchedulingRequest(**values)
request.slots = [
SchedulingCandidateSlot(
tenant_id="tenant-1",
poll_option_id="poll-option-internal",
label="Monday morning",
start_at=datetime(2026, 7, 27, 9, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 27, 10, tzinfo=timezone.utc),
timezone="Europe/Berlin",
position=0,
freebusy_checked_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc),
freebusy_status="busy",
freebusy_conflicts=[
{
"calendar_id": "calendar-internal",
"event_id": "event-internal",
"uid": "connector-uid-internal",
"recurrence_id": "recurrence-internal",
"start_at": "2026-07-27T09:30:00+00:00",
"end_at": "2026-07-27T09:45:00+00:00",
"status": "CONFIRMED",
},
{"error": "connector-internal failure"},
],
tentative_hold_event_id="hold-internal",
metadata_={"provider_ref": "provider-internal"},
)
]
request.participants = [
SchedulingParticipant(
tenant_id="tenant-1",
@@ -102,6 +138,9 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
email="alice@example.test",
participant_type="internal",
status="responded",
poll_invitation_id="invitation-alice-internal",
last_invited_at=datetime(2026, 7, 20, 12, tzinfo=timezone.utc),
responded_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc),
metadata_={"private": "alice"},
),
SchedulingParticipant(
@@ -152,10 +191,43 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
self.assertEqual(response.participant_visibility, "aggregates_only")
self.assertEqual(response.effective_participant_visibility, "aggregates_only")
self.assertEqual([participant.display_name for participant in response.participants], ["Alice"])
self.assertEqual(response.participants[0].email, "alice@example.test")
own = response.participants[0]
self.assertTrue(own.is_current_participant)
self.assertEqual(own.email, "alice@example.test")
self.assertIsNone(own.respondent_id)
self.assertIsNone(own.participant_type)
self.assertIsNone(own.required)
self.assertIsNone(own.poll_invitation_id)
self.assertEqual(own.metadata, {})
self.assertEqual(response.participant_aggregate.total, 2)
self.assertEqual(response.participant_aggregate.status_counts["responded"], 1)
self.assertEqual(response.participant_aggregate.status_counts["invited"], 1)
self.assertIsNone(response.tenant_id)
self.assertIsNone(response.poll_id)
self.assertIsNone(response.organizer_user_id)
self.assertIsNone(response.calendar_integration_enabled)
self.assertIsNone(response.calendar_id)
self.assertIsNone(response.calendar_freebusy_enabled)
self.assertIsNone(response.calendar_hold_enabled)
self.assertIsNone(response.create_calendar_event_on_decision)
self.assertIsNone(response.calendar_event_id)
self.assertIsNone(response.public_participation_policy_enforcement_available)
self.assertEqual(response.metadata, {})
slot = response.slots[0]
self.assertIsNone(slot.poll_option_id)
self.assertEqual(slot.freebusy_status, "busy")
self.assertEqual(
slot.freebusy_conflicts,
[
{
"start_at": "2026-07-27T09:30:00+00:00",
"end_at": "2026-07-27T09:45:00+00:00",
"status": "CONFIRMED",
}
],
)
self.assertIsNone(slot.tentative_hold_event_id)
self.assertEqual(slot.metadata, {})
def test_configured_roster_returns_other_names_and_statuses_with_sensitive_fields_redacted(self) -> None:
request = self._request(participant_visibility="names_and_statuses")
@@ -165,11 +237,15 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
self.assertEqual(response.effective_participant_visibility, "names_and_statuses")
own = next(participant for participant in response.participants if participant.display_name == "Alice")
other = next(participant for participant in response.participants if participant.display_name == "Bob")
self.assertEqual(own.respondent_id, "alice-id")
self.assertTrue(own.is_current_participant)
self.assertIsNone(own.respondent_id)
self.assertEqual(own.email, "alice@example.test")
self.assertEqual(other.status, "invited")
self.assertFalse(other.is_current_participant)
self.assertIsNone(other.respondent_id)
self.assertIsNone(other.email)
self.assertIsNone(other.participant_type)
self.assertIsNone(other.required)
self.assertIsNone(other.poll_invitation_id)
self.assertEqual(other.metadata, {})
@@ -199,6 +275,18 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
"bob@example.test",
})
self.assertTrue(response.participant_visibility_decision.details["management_access"])
self.assertEqual(response.tenant_id, "tenant-1")
self.assertEqual(response.poll_id, "poll-internal")
self.assertEqual(response.organizer_user_id, "organizer-1")
self.assertEqual(response.calendar_id, "calendar-internal")
self.assertEqual(response.calendar_event_id, "event-internal")
self.assertEqual(response.metadata["connector_secret_ref"], "secret-internal")
self.assertEqual(response.slots[0].poll_option_id, "poll-option-internal")
self.assertEqual(
response.slots[0].freebusy_conflicts[0]["uid"],
"connector-uid-internal",
)
self.assertEqual(response.slots[0].tentative_hold_event_id, "hold-internal")
def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
restricting_policy = _PrivacyPolicy("aggregates_only")
@@ -211,8 +299,8 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
self.assertEqual([participant.display_name for participant in reduced.participants], ["Alice"])
self.assertTrue(reduced.participant_visibility_decision.policy_applied)
self.assertEqual(reduced.participant_visibility_decision.reason, "Tenant participant privacy policy")
self.assertEqual(reduced.participant_visibility_decision.source_path[0]["path"], "tenant:tenant-1")
self.assertTrue(reduced.participant_visibility_decision.details["provider_session_available"])
self.assertEqual(reduced.participant_visibility_decision.source_path, [])
self.assertEqual(reduced.participant_visibility_decision.details, {})
self.assertEqual(restricting_policy.requests[0].actor_user_id, "alice-account")
widening_policy = _PrivacyPolicy("names_and_statuses")

File diff suppressed because it is too large Load Diff

View File

@@ -19,13 +19,21 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_access.backend.db.models import Account, User
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollOption,
PollParticipationSubmission,
PollResponse,
)
from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest
from govoplan_poll.backend.router import api_get_poll, api_list_polls, api_submit_poll_response
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
from govoplan_poll.backend.service import get_poll, submit_poll_response_with_token
from govoplan_poll.backend.service import get_poll
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.manifest import READ_SCOPE as SCHEDULING_READ_SCOPE
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE as SCHEDULING_ADMIN_SCOPE
from govoplan_scheduling.backend.manifest import RESPOND_SCOPE as SCHEDULING_RESPOND_SCOPE
from govoplan_scheduling.backend.manifest import WRITE_SCOPE as SCHEDULING_WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
@@ -35,6 +43,7 @@ from govoplan_scheduling.backend.schemas import (
SchedulingCandidateSlotInput,
SchedulingDecisionRequest,
SchedulingParticipantInput,
SchedulingPublicParticipationSubmitRequest,
SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest,
)
@@ -68,6 +77,7 @@ from govoplan_scheduling.backend.service import (
require_visible_scheduling_results,
scheduling_request_summary,
scheduling_slot_revision,
submit_public_scheduling_participation,
update_scheduling_request,
)
from govoplan_scheduling.backend.runtime import configure_runtime
@@ -88,6 +98,7 @@ class SchedulingServiceTests(unittest.TestCase):
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
PollParticipationSubmission.__table__,
PollLifecycleTransition.__table__,
CalendarCollection.__table__,
CalendarEvent.__table__,
@@ -121,6 +132,7 @@ class SchedulingServiceTests(unittest.TestCase):
CalendarSyncSource.__table__,
CalendarEvent.__table__,
CalendarCollection.__table__,
PollParticipationSubmission.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
PollResponse.__table__,
@@ -215,6 +227,82 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual(len(tokens), 2)
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants))
def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None:
class RejectingNotificationProvider:
def enqueue_notification(self, *_args, **_kwargs):
raise AssertionError("A draft must not enqueue invitation delivery")
with patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=RejectingNotificationProvider(),
):
request, tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(update={"status": "draft"}),
)
self.assertEqual(tokens, {})
self.assertEqual(
self.session.query(PollInvitation).filter(
PollInvitation.poll_id == request.poll_id
).count(),
0,
)
self.assertTrue(
all(
participant.status == "draft"
and participant.poll_invitation_id is None
for participant in request.participants
)
)
self.assertEqual(
self.session.query(SchedulingNotification).filter(
SchedulingNotification.request_id == request.id
).count(),
0,
)
open_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
)
api_submit_scheduling_availability(
request.id,
SchedulingAvailabilityResponseRequest(
answers=[
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
)
]
),
session=self.session,
principal=self._principal(
"alice-account",
email="alice@example.test",
scopes={SCHEDULING_RESPOND_SCOPE},
),
)
alice = next(
participant
for participant in request.participants
if participant.email == "alice@example.test"
)
self.assertEqual(alice.status, "responded")
self.assertEqual(alice.participation_gateway, "scheduling")
self.assertIsNotNone(alice.poll_invitation_id)
self.assertEqual(
self.session.query(SchedulingNotification).filter(
SchedulingNotification.request_id == request.id,
SchedulingNotification.event_kind == "invitation",
).count(),
0,
)
def test_create_route_persists_after_request_session_closes(self) -> None:
response = api_create_scheduling_request(
self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()}),
@@ -244,15 +332,25 @@ class SchedulingServiceTests(unittest.TestCase):
first_slot = request.slots[0]
second_slot = request.slots[1]
submit_poll_response_with_token(
submit_public_scheduling_participation(
self.session,
request_id=request.id,
token=tokens[first_participant.id],
payload=PollSubmitResponseRequest(
payload=SchedulingPublicParticipationSubmitRequest(
answers=[
PollAnswerInput(option_id=first_slot.poll_option_id, value="available"),
PollAnswerInput(option_id=second_slot.poll_option_id, value="maybe"),
SchedulingAvailabilityAnswerInput(
slot_id=first_slot.id,
value="available",
option_revision=scheduling_slot_revision(first_slot),
),
SchedulingAvailabilityAnswerInput(
slot_id=second_slot.id,
value="maybe",
option_revision=scheduling_slot_revision(second_slot),
),
]
),
client_address="127.0.0.1",
)
summary = scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)
@@ -550,6 +648,142 @@ class SchedulingServiceTests(unittest.TestCase):
).id,
)
ordinary_writer = self._principal(
"unrelated-writer",
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE},
)
administrator = self._principal(
"scheduling-admin",
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_ADMIN_SCOPE},
)
self.assertEqual(
[],
api_list_scheduling_requests(
status_filter=None,
session=self.session,
principal=ordinary_writer,
).requests,
)
self.assertEqual(
[request.id],
[
item.id
for item in api_list_scheduling_requests(
status_filter=None,
session=self.session,
principal=administrator,
).requests
],
)
def test_participant_list_and_get_redact_management_and_connector_internals(self) -> None:
request, _tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
)
request.calendar_event_id = "calendar-event-internal"
request.metadata_ = {"connector_ref": "connector-internal"}
slot = request.slots[0]
slot.freebusy_status = "busy"
slot.freebusy_conflicts = [
{
"calendar_id": "calendar-1",
"event_id": "busy-event-internal",
"uid": "busy-uid-internal",
"recurrence_id": "busy-recurrence-internal",
"start_at": "2026-07-20T09:15:00+00:00",
"end_at": "2026-07-20T09:45:00+00:00",
"status": "CONFIRMED",
}
]
slot.tentative_hold_event_id = "hold-event-internal"
slot.metadata_ = {"provider_ref": "provider-internal"}
participant = request.participants[0]
participant.respondent_id = "alice-identity-internal"
participant.metadata_ = {"directory_ref": "directory-internal"}
self.session.flush()
principal = self._principal(
"alice-account",
email="alice@example.test",
scopes={SCHEDULING_READ_SCOPE},
)
listed = api_list_scheduling_requests(
status_filter=None,
session=self.session,
principal=principal,
).requests
fetched = api_get_scheduling_request(
request.id,
session=self.session,
principal=principal,
)
self.assertEqual(len(listed), 1)
for response in (listed[0], fetched):
self.assertIsNone(response.tenant_id)
self.assertIsNone(response.poll_id)
self.assertIsNone(response.organizer_user_id)
self.assertIsNone(response.calendar_integration_enabled)
self.assertIsNone(response.calendar_id)
self.assertIsNone(response.calendar_freebusy_enabled)
self.assertIsNone(response.calendar_hold_enabled)
self.assertIsNone(response.create_calendar_event_on_decision)
self.assertIsNone(response.calendar_event_id)
self.assertIsNone(
response.public_participation_policy_enforcement_available
)
self.assertEqual(response.metadata, {})
self.assertEqual(response.participant_aggregate.total, 2)
self.assertEqual(len(response.participants), 1)
own = response.participants[0]
self.assertTrue(own.is_current_participant)
self.assertEqual(own.email, "alice@example.test")
self.assertIsNone(own.respondent_id)
self.assertIsNone(own.poll_invitation_id)
self.assertEqual(own.metadata, {})
projected_slot = response.slots[0]
self.assertIsNone(projected_slot.poll_option_id)
self.assertEqual(projected_slot.freebusy_status, "busy")
self.assertEqual(
projected_slot.freebusy_conflicts,
[
{
"start_at": "2026-07-20T09:15:00+00:00",
"end_at": "2026-07-20T09:45:00+00:00",
"status": "CONFIRMED",
}
],
)
self.assertIsNone(projected_slot.tentative_hold_event_id)
self.assertEqual(projected_slot.metadata, {})
organizer = self._principal(
"user-1",
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE},
)
management = api_get_scheduling_request(
request.id,
session=self.session,
principal=organizer,
)
self.assertEqual(management.tenant_id, "tenant-1")
self.assertEqual(management.poll_id, request.poll_id)
self.assertEqual(management.calendar_id, "calendar-1")
self.assertEqual(management.calendar_event_id, "calendar-event-internal")
self.assertEqual(management.metadata["connector_ref"], "connector-internal")
self.assertEqual(management.slots[0].poll_option_id, slot.poll_option_id)
self.assertEqual(
management.slots[0].freebusy_conflicts[0]["uid"],
"busy-uid-internal",
)
self.assertEqual(
management.slots[0].tentative_hold_event_id,
"hold-event-internal",
)
def test_result_visibility_follows_existing_after_close_policy(self) -> None:
request, _tokens = create_scheduling_request(
self.session,
@@ -800,7 +1034,7 @@ class SchedulingServiceTests(unittest.TestCase):
organizer = self._principal("user-1", scopes={"poll:poll:read"})
self.assertEqual(api_get_poll(request.poll_id, session=self.session, principal=organizer).id, request.poll_id)
def test_authenticated_response_reconciles_only_the_current_participant(self) -> None:
def test_module_owned_poll_rejects_direct_response_and_uses_current_participant(self) -> None:
participants = [
SchedulingParticipantInput(
respondent_id="alice-membership",
@@ -835,10 +1069,16 @@ class SchedulingServiceTests(unittest.TestCase):
},
)
response = api_submit_poll_response(
with self.assertRaises(HTTPException) as direct_response:
api_submit_poll_response(
request.poll_id,
PollSubmitResponseRequest(
answers=[PollAnswerInput(option_id=request.slots[0].poll_option_id, value="available")],
answers=[
PollAnswerInput(
option_id=request.slots[0].poll_option_id,
value="available",
)
],
metadata={"invitation_id": target.poll_invitation_id},
),
session=self.session,
@@ -855,8 +1095,29 @@ class SchedulingServiceTests(unittest.TestCase):
principal=attacker,
)
self.assertNotIn("invitation_id", response.metadata)
self.assertEqual(direct_response.exception.status_code, 400)
self.assertEqual([request.id], [item.id for item in listed.requests])
self.assertFalse(current.has_response)
api_submit_scheduling_availability(
request.id,
SchedulingAvailabilityResponseRequest(
answers=[
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
)
]
),
session=self.session,
principal=attacker,
)
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=attacker,
)
self.assertTrue(current.has_response)
self.assertEqual(
[(answer.slot_id, answer.value) for answer in current.answers],
@@ -964,17 +1225,20 @@ class SchedulingServiceTests(unittest.TestCase):
payload=payload,
)
participant = request.participants[0]
submit_poll_response_with_token(
submit_public_scheduling_participation(
self.session,
request_id=request.id,
token=tokens[participant.id],
payload=PollSubmitResponseRequest(
payload=SchedulingPublicParticipationSubmitRequest(
answers=[
PollAnswerInput(
option_id=request.slots[0].poll_option_id,
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
)
]
),
client_address="127.0.0.1",
)
alice = self._principal(
"alice-account",
@@ -1010,7 +1274,7 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual(len(responses), 1)
self.assertEqual(
responses[0].respondent_id,
f"invitation:{participant.poll_invitation_id}",
participant.respondent_id,
)
self.assertEqual(
responses[0].metadata_["invitation_id"],
@@ -1071,13 +1335,17 @@ class SchedulingServiceTests(unittest.TestCase):
for response in responses:
own = next(item for item in response.participants if item.display_name == "Alice")
other = next(item for item in response.participants if item.display_name == "Bob")
self.assertEqual(own.respondent_id, "alice-id")
self.assertTrue(own.is_current_participant)
self.assertIsNone(own.respondent_id)
self.assertEqual(own.email, "alice@example.test")
self.assertEqual(own.metadata, {"private": "alice"})
self.assertIsNotNone(own.poll_invitation_id)
self.assertEqual(own.metadata, {})
self.assertIsNone(own.poll_invitation_id)
self.assertIsNone(own.invitation_token)
self.assertFalse(other.is_current_participant)
self.assertIsNone(other.respondent_id)
self.assertIsNone(other.email)
self.assertIsNone(other.participant_type)
self.assertIsNone(other.required)
self.assertIsNone(other.poll_invitation_id)
self.assertIsNone(other.last_invited_at)
self.assertIsNone(other.responded_at)
@@ -1114,13 +1382,13 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertIsNone(request.selected_slot_id)
availability_reader = self._principal(
"reader",
"user-1",
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_AVAILABILITY_READ_SCOPE},
)
freebusy = api_evaluate_calendar_freebusy(request.id, session=self.session, principal=availability_reader)
self.assertTrue(freebusy.updated_slot_ids)
event_writer = self._principal(
"event-writer",
"user-1",
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_EVENT_WRITE_SCOPE},
)
holds = api_create_tentative_calendar_holds(request.id, session=self.session, principal=event_writer)
@@ -1139,7 +1407,7 @@ class SchedulingServiceTests(unittest.TestCase):
self.session.flush()
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
scheduling_writer = self._principal(
"writer",
"user-1",
scopes={SCHEDULING_WRITE_SCOPE},
)
@@ -1209,7 +1477,10 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"})
self.assertEqual(
{item.action_url for item in provider.requests},
{f"/poll/public/{token}" for token in tokens.values()},
{
f"/scheduling/public/{request.id}/{token}"
for token in tokens.values()
},
)
local_notifications = list_scheduling_notifications(
self.session,

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/scheduling-webui",
"version": "0.1.9",
"version": "0.1.10",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.9",
"@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -11,42 +11,60 @@ assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calend
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*ModuleSubnav,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
assert.match(page, /className="scheduling-full-editor"/);
assert.match(page, /className="scheduling-page-actions"/);
const editorActions = page.slice(
page.indexOf('<div className="scheduling-page-actions">'),
page.indexOf('</div>', page.indexOf('<div className="scheduling-page-actions">'))
);
assert.ok(editorActions.indexOf("I18N.discard") < editorActions.indexOf("I18N.save"));
assert.match(editorActions, /form="scheduling-create-form"/);
const createBranchStart = page.indexOf(" if (creating) {");
const createReturnStart = page.indexOf("\n return (", createBranchStart);
const browseBranchStart = page.indexOf("\n return (", createReturnStart + 1);
const createBranch = page.slice(createBranchStart, browseBranchStart);
const browseBranch = page.slice(browseBranchStart);
assert.match(createBranch, /<ModuleSubnav/);
assert.match(createBranch, /className="scheduling-create-subnav"/);
assert.doesNotMatch(browseBranch, /scheduling-sidebar|<aside/);
assert.match(browseBranch, /<h1>\{I18N\.requests\}<\/h1>/);
assert.match(browseBranch, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/);
assert.match(page, /className="scheduling-workspace-layout"/);
assert.match(page, /<aside className="scheduling-request-sidebar">/);
assert.match(page, /<section className="scheduling-main-panel">/);
assert.match(page, /title=\{I18N\.requests\}[\s\S]*<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/);
assert.match(page, /title=\{I18N\.myRequests\}/);
assert.match(page, /title=\{I18N\.invitedRequests\}/);
assert.ok(page.indexOf("title={I18N.myRequests}") < page.indexOf("title={I18N.invitedRequests}"));
assert.match(page, /<SelectionList label=\{title\} className="scheduling-request-list">/);
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selectedId === request\.id\}[\s\S]*className="scheduling-list-item"/);
assert.doesNotMatch(page, /<button[\s\S]{0,160}scheduling-list-item/);
assert.match(createBranch, /<Card title=\{I18N\.basicInformation\}>/);
assert.match(createBranch, /<Card title=\{I18N\.calendarIntegration\}>/);
assert.match(page, /editorMode \? \(/);
assert.match(page, /id="scheduling-editor-form"/);
assert.doesNotMatch(page, /ModuleSubnav|scheduling-create-subnav|scheduling-full-editor/);
const editorStart = page.indexOf('<div className="scheduling-editor-surface">');
const editorEnd = page.indexOf('</form>', editorStart);
const editor = page.slice(editorStart, editorEnd);
assert.ok(editor.indexOf("I18N.discard") < editor.indexOf("I18N.save"));
assert.match(editor, /form="scheduling-editor-form"/);
assert.match(editor, /<Card title=\{I18N\.basicInformation\}>/);
assert.match(editor, /<Card title=\{I18N\.calendarIntegration\}>/);
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
assert.match(page, /<Card title=\{I18N\.participants\}>/);
assert.match(createBranch, /<ToggleSwitch[\s\S]*checked=\{calendarEnabled\}/);
assert.match(page, /<ToggleSwitch[\s\S]*checked=\{participantRosterVisible\}/);
assert.match(page, /participant_visibility: participantRosterVisible \? "names_and_statuses" : "aggregates_only"/);
assert.match(page, /<Card title=\{I18N\.generalSettings\}>/);
assert.match(page, /<Card title=\{I18N\.participantPrivacy\}>/);
assert.match(editor, /<FormField label=\{I18N\.title\}>/);
assert.match(page, /useUnsavedDraftGuard\(/);
assert.match(page, /requestDiscard\(exitEditor\)/);
assert.match(page, /dirty: responseDirty,[\s\S]*onSave: persistAvailability,[\s\S]*onDiscard: resetResponseDraft/);
assert.doesNotMatch(page, /window\.(?:alert|confirm)\(/);
for (const setting of [
"participantRosterVisible",
"notifyOnAnswers",
"singleChoice",
"participantLimitEnabled",
"allowMaybe",
"allowComments",
"participantEmailRequired",
"anonymousPasswordProtectionEnabled"
]) {
assert.match(page, new RegExp(`checked=\\{${setting}\\}`));
}
assert.match(page, /<PasswordField[\s\S]*minLength=\{8\}/);
assert.match(page, /type="number"[\s\S]*min=\{1\}/);
assert.match(page, /min=\{addLocalMinutes\(slot\.start_at, 1\)\}/);
assert.match(page, /create_participant_invitations: true/);
assert.doesNotMatch(page, /usesGatewayPolicy|updateSchedulingCandidateSlot/);
assert.match(page, /public_participation_policy_enforcement_available/);
assert.match(page, /const canCreateOrWrite = canWrite \|\| canAdminister/);
assert.match(page, /policyLocked=\{participationPolicyLocked\}/);
assert.match(page, /id="scheduling-create-candidate-slots-grid"/);
assert.match(page, /id="scheduling-create-participants-grid"/);
assert.match(page, /id="scheduling-candidate-slots-grid"/);
@@ -55,21 +73,43 @@ assert.match(page, /<DataGridRowActions/);
assert.match(page, /<DataGridEmptyAction/);
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/);
assert.doesNotMatch(page, /header: I18N\.required|<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /<TableActionGroup[\s\S]*label: i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
assert.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
assert.match(page, /showDecisionAction=\{canManageSelected\}/);
assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/);
assert.doesNotMatch(page, /AdminIconButton/);
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
assert.match(page, /option_revision: slot\.revision/);
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
assert.match(page, /setAvailability\(Object\.fromEntries\(response\.answers\.map/);
assert.match(page, /selected\.participant_aggregate\.total/);
assert.match(page, /const answers = Object\.fromEntries\(response\.answers\.map/);
assert.match(page, /setSavedAvailability\(answers\)/);
assert.match(page, /const comment = response\.comment \?\? ""/);
assert.match(page, /setSavedAvailabilityComment\(comment\)/);
assert.match(page, /<ParticipationStats/);
assert.match(page, /selected\.effective_participant_visibility === "names_and_statuses"/);
assert.match(api, /participant_visibility: SchedulingParticipantVisibility/);
assert.match(api, /participant_aggregate: SchedulingParticipantAggregate/);
assert.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/);
assert.match(page, /className="scheduling-selected-calendar"/);
assert.match(page, /showPlanningCalendarActions/);
assert.match(page, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/);
for (const field of [
"notify_on_answers",
"single_choice",
"max_participants_per_option",
"allow_maybe",
"allow_comments",
"participant_email_required",
"anonymous_password_protection_enabled",
"public_participation_policy_enforcement_available"
]) {
assert.match(api, new RegExp(`${field}:`));
}
assert.match(api, /method: "PATCH"/);
assert.match(page, /slots: slots\.map\(\(slot\) => \(\{/);
assert.match(page, /participants: participants[\s\S]*create_participant_invitations: true/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
assert.match(api, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/);
assert.match(page, /useSearchParams\(\)/);
assert.match(page, /Promise\.allSettled/);
console.log("Scheduling page structure satisfies the focused list/create/respond contract.");
console.log("Scheduling page structure satisfies the two-pane editor, response, and policy contract.");

View File

@@ -23,11 +23,12 @@ export type SchedulingCandidateSlot = {
export type SchedulingParticipant = {
id: string;
is_current_participant: boolean;
respondent_id?: string | null;
display_name?: string | null;
email?: string | null;
participant_type: string;
required: boolean;
participant_type: string | null;
required: boolean | null;
status: string;
poll_invitation_id?: string | null;
invitation_token?: string | null;
@@ -52,7 +53,7 @@ export type SchedulingParticipantVisibilityDecision = {
export type SchedulingRequest = {
id: string;
tenant_id: string;
tenant_id: string | null;
title: string;
description?: string | null;
location?: string | null;
@@ -69,11 +70,20 @@ export type SchedulingRequest = {
effective_participant_visibility: SchedulingParticipantVisibility;
participant_aggregate: SchedulingParticipantAggregate;
participant_visibility_decision: SchedulingParticipantVisibilityDecision;
calendar_integration_enabled: boolean;
notify_on_answers: boolean;
single_choice: boolean;
max_participants_per_option: number | null;
allow_maybe: boolean;
allow_comments: boolean;
participant_email_required: boolean;
anonymous_password_protection_enabled: boolean;
public_participation_policy_enforcement_available: boolean | null;
public_participation_policy_enforcement_reason?: string | null;
calendar_integration_enabled: boolean | null;
calendar_id?: string | null;
calendar_freebusy_enabled: boolean;
calendar_hold_enabled: boolean;
create_calendar_event_on_decision: boolean;
calendar_freebusy_enabled: boolean | null;
calendar_hold_enabled: boolean | null;
create_calendar_event_on_decision: boolean | null;
calendar_event_id?: string | null;
handed_off_at?: string | null;
cancelled_at?: string | null;
@@ -117,6 +127,14 @@ export type SchedulingRequestCreatePayload = {
allow_participant_updates?: boolean;
result_visibility?: "organizer" | "after_response" | "after_close" | "public";
participant_visibility?: SchedulingParticipantVisibility;
notify_on_answers?: boolean;
single_choice?: boolean;
max_participants_per_option?: number | null;
allow_maybe?: boolean;
allow_comments?: boolean;
participant_email_required?: boolean;
anonymous_password_protection_enabled?: boolean;
anonymous_password?: string;
calendar?: {
enabled?: boolean;
calendar_id?: string | null;
@@ -130,6 +148,33 @@ export type SchedulingRequestCreatePayload = {
metadata?: Record<string, unknown>;
};
export type SchedulingRequestUpdatePayload = Omit<
SchedulingRequestCreatePayload,
"timezone" | "status" | "slots" | "participants"
> & {
slots?: SchedulingCandidateSlotReconcilePayload[];
participants?: SchedulingParticipantReconcilePayload[];
};
export type SchedulingCandidateSlotReconcilePayload = SchedulingCandidateSlotPayload & {
id?: string;
revision?: string;
};
export type SchedulingParticipantReconcilePayload = SchedulingParticipantPayload & {
id?: string;
};
export type SchedulingCandidateSlotUpdatePayload = {
label?: string | null;
description?: string | null;
start_at?: string | null;
end_at?: string | null;
timezone?: string | null;
location?: string | null;
metadata?: Record<string, unknown> | null;
};
export type SchedulingDecisionPayload = {
slot_id?: string | null;
poll_option_id?: string | null;
@@ -144,6 +189,7 @@ export type SchedulingAvailabilityPayload = {
value: SchedulingAvailabilityValue;
option_revision: string;
}>;
comment?: string | null;
};
export type SchedulingAvailabilityResponse = {
@@ -151,12 +197,62 @@ export type SchedulingAvailabilityResponse = {
participant_id: string;
has_response: boolean;
submitted_at?: string | null;
comment?: string | null;
answers: Array<{
slot_id: string;
value: SchedulingAvailabilityValue;
}>;
};
export type SchedulingPublicCandidateSlot = {
id: string;
label: string;
description?: string | null;
start_at: string;
end_at: string;
timezone: string;
location?: string | null;
position: number;
revision: string;
};
export type SchedulingPublicParticipationResponse = {
request_id: string;
title: string;
description?: string | null;
location?: string | null;
timezone: string;
status: SchedulingStatus;
deadline_at?: string | null;
participant_email_required: boolean;
anonymous_password_required: boolean;
single_choice: boolean;
max_participants_per_option: number | null;
allow_maybe: boolean;
allow_comments: boolean;
allow_participant_updates: boolean;
has_response: boolean;
submitted_at?: string | null;
answers: Array<{
slot_id: string;
value: SchedulingAvailabilityValue;
}>;
comment?: string | null;
replayed: boolean;
slots: SchedulingPublicCandidateSlot[];
};
export type SchedulingPublicParticipationAccessPayload = {
participant_email?: string | null;
password?: string | null;
};
export type SchedulingPublicParticipationSubmitPayload = SchedulingPublicParticipationAccessPayload & {
answers: SchedulingAvailabilityPayload["answers"];
comment?: string | null;
idempotency_key?: string;
};
export type SchedulingCalendarActionResponse = {
request: SchedulingRequest;
created_event_ids: string[];
@@ -239,6 +335,29 @@ export function createSchedulingRequest(settings: ApiSettings, payload: Scheduli
return apiFetch<SchedulingRequest>(settings, "/api/v1/scheduling/requests", json(payload));
}
export function updateSchedulingRequest(
settings: ApiSettings,
requestId: string,
payload: SchedulingRequestUpdatePayload
): Promise<SchedulingRequest> {
return apiFetch<SchedulingRequest>(settings, `/api/v1/scheduling/requests/${requestId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateSchedulingCandidateSlot(
settings: ApiSettings,
requestId: string,
slotId: string,
payload: SchedulingCandidateSlotUpdatePayload
): Promise<SchedulingRequest> {
return apiFetch<SchedulingRequest>(settings, `/api/v1/scheduling/requests/${requestId}/slots/${slotId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function submitSchedulingAvailability(
settings: ApiSettings,
requestId: string,
@@ -261,6 +380,32 @@ export function getSchedulingAvailabilityResponse(
);
}
export function getPublicSchedulingParticipation(
settings: ApiSettings,
requestId: string,
token: string,
payload: SchedulingPublicParticipationAccessPayload
): Promise<SchedulingPublicParticipationResponse> {
return apiFetch<SchedulingPublicParticipationResponse>(
settings,
`/api/v1/scheduling/public/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}`,
json(payload)
);
}
export function submitPublicSchedulingParticipation(
settings: ApiSettings,
requestId: string,
token: string,
payload: SchedulingPublicParticipationSubmitPayload
): Promise<SchedulingPublicParticipationResponse> {
return apiFetch<SchedulingPublicParticipationResponse>(
settings,
`/api/v1/scheduling/public/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}/responses`,
json(payload)
);
}
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,8 @@
import type { SchedulingParticipant, SchedulingRequest } from "../../api/scheduling";
import type {
SchedulingAvailabilityValue,
SchedulingParticipant,
SchedulingRequest
} from "../../api/scheduling";
export type SchedulingActor = {
accountId?: string | null;
@@ -28,20 +32,27 @@ export function schedulingActorIds(actor: SchedulingActor): string[] {
actor.membershipId,
actor.identityId,
actor.email
].filter((value): value is string => Boolean(value))));
].filter((value): value is string => Boolean(value)).flatMap((value) =>
value.includes("@") ? [value, value.trim().toLowerCase()] : [value]
)));
}
export function schedulingParticipantForActor(
request: SchedulingRequest,
actor: SchedulingActor
): SchedulingParticipant | null {
const projected = request.participants.find(
(participant) => participant.is_current_participant
);
if (projected) return projected;
const ids = new Set(schedulingActorIds(actor));
return request.participants.find((participant) =>
Boolean(
return request.participants.find((participant) => {
const email = participant.email?.trim().toLowerCase();
return Boolean(
(participant.respondent_id && ids.has(participant.respondent_id)) ||
(participant.email && ids.has(participant.email))
)
) ?? null;
(email && ids.has(email))
);
}) ?? null;
}
export function schedulingRequestIsOwned(
@@ -137,6 +148,26 @@ export function schedulingRequestIsPast(
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
}
export function applySchedulingAvailabilityChoice(
slotIds: string[],
current: Record<string, SchedulingAvailabilityValue | "">,
slotId: string,
value: SchedulingAvailabilityValue | "",
singleChoice: boolean
): Record<string, SchedulingAvailabilityValue | ""> {
if (!singleChoice || !["available", "maybe"].includes(value)) {
return { ...current, [slotId]: value };
}
return Object.fromEntries(slotIds.map((candidateId) => [
candidateId,
candidateId === slotId
? value
: current[candidateId] && current[candidateId] !== "unavailable"
? "unavailable"
: current[candidateId] ?? ""
]));
}
const SORT_PHASE_ORDER: Record<SchedulingSortPhase, number> = {
unanswered: 0,
answered: 1,

View File

@@ -1,5 +1,38 @@
export const generatedTranslations = {
en: {
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Save or discard your unsent availability changes before leaving.",
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "A slot can be selected after the request is closed.",
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Add Maybe between Available and Unavailable for each candidate slot.",
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Allow comments",
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Awaiting",
"i18n:govoplan-scheduling.capacity.d3c375f8": "Capacity",
"i18n:govoplan-scheduling.comment.d03495b1": "Comment",
"i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8": "Create an organizer notification whenever a response is submitted or updated.",
"i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465": "Each participant can select Available or Maybe for at most one candidate slot.",
"i18n:govoplan-scheduling.edit_scheduling_request.7e749c19": "Edit scheduling request",
"i18n:govoplan-scheduling.invited_participant_identity_is_locked_remove_and_add_the_participant_to_change_it.34e1201a": "An invited participant's name and email are locked. Remove and add the participant to change them.",
"i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0": "Guest links are not issued while the configured participation controls cannot be enforced by the public response gateway. Signed-in participants can still respond here.",
"i18n:govoplan-scheduling.guest_password.94545e82": "Guest password",
"i18n:govoplan-scheduling.let_participants_add_a_comment_to_their_response.9dce8d17": "Let participants add a comment to their response.",
"i18n:govoplan-scheduling.limit_participants_per_option.1e9aa51d": "Limit participants per option",
"i18n:govoplan-scheduling.maximum_participants_per_option.5abdfb27": "Maximum participants per option",
"i18n:govoplan-scheduling.notify_me_about_each_answer.505749d6": "Notify me about each answer",
"i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c": "Participants can choose only one option",
"i18n:govoplan-scheduling.participation.9ad70cc4": "Participation",
"i18n:govoplan-scheduling.participation_rate.46bc5504": "Participation rate",
"i18n:govoplan-scheduling.participation_settings.8dc6f62c": "Participation settings",
"i18n:govoplan-scheduling.participant_privacy.108c470f": "Participant privacy",
"i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740": "Participation controls are locked after invitation links are issued. Participant visibility and answer notifications can still be changed.",
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Password-protect guest access",
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "People responding without an account must provide an email address.",
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "People who are not signed in must enter this password before viewing the request.",
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Provide a Maybe option",
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "Require an email address from guests",
"i18n:govoplan-scheduling.responses.3427e3ab": "Responses",
"i18n:govoplan-scheduling.response_results_are_not_available_for_this_view.1e82db18": "Response results are not available for this view.",
"i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3": "Notifications could not be loaded.",
"i18n:govoplan-scheduling.stop_accepting_yes_responses_for_an_option_when_its_limit.79af21db": "Stop accepting Available responses for an option when its limit is reached.",
"i18n:govoplan-scheduling.use_at_least_8_characters_the_password_is_never_displayed.035708a2": "Use at least 8 characters. The password is never displayed after it is saved.",
"i18n:govoplan-scheduling.add_participant.6cff957d": "Add participant",
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Add scheduling request",
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Add slot",
@@ -21,7 +54,6 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.close_poll.a6a18916": "Close poll",
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Close stops accepting new availability responses.",
"i18n:govoplan-scheduling.closed.88d86b77": "Closed",
"i18n:govoplan-scheduling.create_and_open_scheduling_request.1dbc9345": "Create and open scheduling request",
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Create calendar event",
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Create tentative holds",
"i18n:govoplan-scheduling.cancellation.319aaae4": "Cancellation",
@@ -108,6 +140,39 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
},
de: {
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Speichern oder verwerfen Sie Ihre noch nicht gesendeten Verfügbarkeitsänderungen, bevor Sie die Ansicht verlassen.",
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "Ein Terminvorschlag kann ausgewählt werden, nachdem die Anfrage geschlossen wurde.",
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Für jeden Terminvorschlag Vielleicht zwischen Verfügbar und Nicht verfügbar anbieten.",
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Kommentare erlauben",
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Ausstehend",
"i18n:govoplan-scheduling.capacity.d3c375f8": "Kapazität",
"i18n:govoplan-scheduling.comment.d03495b1": "Kommentar",
"i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8": "Bei jeder neuen oder geänderten Antwort eine Benachrichtigung für die organisierende Person erstellen.",
"i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465": "Jede teilnehmende Person kann höchstens einen Terminvorschlag als Verfügbar oder Vielleicht markieren.",
"i18n:govoplan-scheduling.edit_scheduling_request.7e749c19": "Terminanfrage bearbeiten",
"i18n:govoplan-scheduling.invited_participant_identity_is_locked_remove_and_add_the_participant_to_change_it.34e1201a": "Name und E-Mail-Adresse einer eingeladenen Person sind gesperrt. Entfernen Sie die Person und fügen Sie sie erneut hinzu, um diese Angaben zu ändern.",
"i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0": "Gastlinks werden nicht ausgegeben, solange die konfigurierten Teilnahmeregeln am öffentlichen Antwortzugang nicht durchgesetzt werden können. Angemeldete Teilnehmende können weiterhin hier antworten.",
"i18n:govoplan-scheduling.guest_password.94545e82": "Gastpasswort",
"i18n:govoplan-scheduling.let_participants_add_a_comment_to_their_response.9dce8d17": "Teilnehmende können ihrer Antwort einen Kommentar hinzufügen.",
"i18n:govoplan-scheduling.limit_participants_per_option.1e9aa51d": "Teilnehmende je Terminvorschlag begrenzen",
"i18n:govoplan-scheduling.maximum_participants_per_option.5abdfb27": "Maximale Teilnehmendenzahl je Terminvorschlag",
"i18n:govoplan-scheduling.notify_me_about_each_answer.505749d6": "Über jede Antwort benachrichtigen",
"i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c": "Teilnehmende können nur einen Terminvorschlag wählen",
"i18n:govoplan-scheduling.participation.9ad70cc4": "Teilnahme",
"i18n:govoplan-scheduling.participation_rate.46bc5504": "Teilnahmequote",
"i18n:govoplan-scheduling.participation_settings.8dc6f62c": "Teilnahmeeinstellungen",
"i18n:govoplan-scheduling.participant_privacy.108c470f": "Datenschutz für Teilnehmende",
"i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740": "Teilnahmeregeln sind gesperrt, nachdem Einladungslinks erstellt wurden. Sichtbarkeit und Antwortbenachrichtigungen können weiterhin geändert werden.",
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Gastzugang mit Passwort schützen",
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "Personen ohne Konto müssen für ihre Antwort eine E-Mail-Adresse angeben.",
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "Nicht angemeldete Personen müssen dieses Passwort eingeben, bevor sie die Anfrage sehen können.",
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Antwort Vielleicht anbieten",
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "E-Mail-Adresse von Gästen verlangen",
"i18n:govoplan-scheduling.responses.3427e3ab": "Antworten",
"i18n:govoplan-scheduling.response_results_are_not_available_for_this_view.1e82db18": "Antwortergebnisse sind für diese Ansicht nicht verfügbar.",
"i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3": "Benachrichtigungen konnten nicht geladen werden.",
"i18n:govoplan-scheduling.stop_accepting_yes_responses_for_an_option_when_its_limit.79af21db": "Keine weiteren Verfügbar-Antworten annehmen, sobald die Begrenzung eines Terminvorschlags erreicht ist.",
"i18n:govoplan-scheduling.use_at_least_8_characters_the_password_is_never_displayed.035708a2": "Verwenden Sie mindestens 8 Zeichen. Das Passwort wird nach dem Speichern nicht mehr angezeigt.",
"i18n:govoplan-scheduling.add_participant.6cff957d": "Teilnehmende Person hinzufügen",
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Terminanfrage hinzufügen",
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Terminvorschlag hinzufügen",
@@ -129,7 +194,6 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.close_poll.a6a18916": "Abstimmung schließen",
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Schließen beendet die Annahme neuer Verfügbarkeitsantworten.",
"i18n:govoplan-scheduling.closed.88d86b77": "Geschlossen",
"i18n:govoplan-scheduling.create_and_open_scheduling_request.1dbc9345": "Terminanfrage erstellen und öffnen",
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Kalendertermin erstellen",
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Vorläufige Reservierungen erstellen",
"i18n:govoplan-scheduling.cancellation.319aaae4": "Stornierung",

View File

@@ -10,7 +10,7 @@ const scheduleRead = ["scheduling:schedule:read"];
export const schedulingModule: PlatformWebModule = {
id: "scheduling",
label: "Scheduling",
version: "1.0.0",
version: "0.1.10",
dependencies: ["poll"],
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations,

View File

@@ -13,8 +13,7 @@
box-sizing: border-box;
}
.scheduling-workspace,
.scheduling-full-editor {
.scheduling-workspace {
width: 100%;
height: 100%;
min-width: 0;
@@ -26,6 +25,72 @@
background: var(--bg);
}
.scheduling-workspace-layout {
min-width: 0;
min-height: 0;
flex: 1 1 auto;
display: grid;
grid-template-columns: minmax(310px, 370px) minmax(0, 1fr);
overflow: hidden;
}
.scheduling-request-sidebar,
.scheduling-main-panel,
.scheduling-editor-surface {
min-width: 0;
min-height: 0;
}
.scheduling-request-sidebar {
padding: 12px;
border-right: var(--border-line);
background: var(--panel-soft);
}
.scheduling-request-sidebar > .card {
height: 100%;
display: flex;
flex-direction: column;
}
.scheduling-request-sidebar > .card > .card-body {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
padding: 10px 12px;
}
.scheduling-sidebar-actions {
display: flex;
align-items: center;
gap: 6px;
}
.scheduling-sidebar-actions .btn {
display: inline-flex;
align-items: center;
gap: 5px;
}
.scheduling-main-panel {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.scheduling-main-panel > .alert {
flex: 0 0 auto;
margin: 12px 14px 0;
}
.scheduling-editor-surface {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scheduling-page-header {
min-height: 58px;
flex: 0 0 auto;
@@ -89,28 +154,9 @@
gap: 6px;
}
.scheduling-alert,
.scheduling-success {
margin: 10px 14px 0;
padding: 9px 11px;
border: var(--border-line);
border-radius: 7px;
}
.scheduling-alert {
border-color: var(--danger-border-strong);
color: var(--danger-text-strong);
background: var(--danger-soft);
}
.scheduling-success {
border-color: var(--success);
color: var(--success);
background: color-mix(in srgb, var(--success) 10%, var(--panel));
}
.scheduling-detail,
.scheduling-editor-scroll {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: auto;
@@ -124,15 +170,21 @@
gap: 12px;
}
.scheduling-request-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
.scheduling-request-group + .scheduling-request-group {
margin-top: 14px;
padding-top: 14px;
border-top: var(--border-line);
}
.scheduling-request-group h2 {
margin: 0 0 8px;
color: var(--text-strong);
font-size: 13px;
}
.scheduling-list-group {
min-height: 52px;
max-height: 270px;
max-height: min(31vh, 320px);
overflow: auto;
}
@@ -171,14 +223,6 @@
font-size: 12px;
}
.scheduling-list-status {
max-width: 120px;
padding: 3px 7px;
border-radius: 999px;
background: var(--panel-soft);
font-weight: 700;
}
.scheduling-list-empty {
margin: 3px 0 8px;
}
@@ -187,46 +231,12 @@
flex-wrap: wrap;
}
.scheduling-status {
padding: 4px 8px;
border-radius: 999px;
color: var(--text-strong);
background: var(--panel-soft);
font-size: 12px;
font-weight: 800;
}
.scheduling-status-collecting,
.scheduling-status-handed_off {
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
.scheduling-slot-title {
min-width: 0;
display: grid;
justify-items: start;
}
.scheduling-freebusy {
display: inline-block;
margin-top: 3px;
padding: 2px 6px;
border-radius: 5px;
color: var(--muted);
background: var(--panel-soft);
font-size: 11px;
font-weight: 700;
}
.scheduling-freebusy.free {
color: var(--success);
}
.scheduling-freebusy.busy,
.scheduling-freebusy.error {
color: var(--danger-text-strong);
}
.scheduling-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -284,20 +294,6 @@
color: var(--muted);
}
.scheduling-response-grid select,
.scheduling-field input,
.scheduling-field textarea,
.scheduling-grid-input {
width: 100%;
min-height: 36px;
padding: 7px 9px;
border: var(--border-line);
border-radius: 6px;
color: var(--text);
background: var(--control-bg);
font: inherit;
}
.scheduling-action-help {
margin: 0;
}
@@ -308,47 +304,14 @@
text-align: center;
}
.scheduling-editor-page {
background: var(--panel);
}
.scheduling-editor-layout {
.scheduling-setting-with-field {
min-width: 0;
min-height: 0;
flex: 1 1 auto;
display: grid;
grid-template-columns: 198px minmax(0, 1fr);
overflow: hidden;
gap: 10px;
}
.scheduling-create-subnav {
min-height: 0;
}
.scheduling-create-section {
min-width: 0;
scroll-margin-top: 14px;
}
.scheduling-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.scheduling-field-wide {
grid-column: 1 / -1;
}
.scheduling-field {
display: grid;
gap: 5px;
}
.scheduling-field > span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
.scheduling-setting-with-field .form-field {
padding-left: 48px;
}
.scheduling-capability-note {
@@ -362,9 +325,10 @@
}
@media (max-width: 900px) {
.scheduling-editor-layout {
grid-template-columns: minmax(0, 1fr);
.scheduling-workspace-layout {
grid-template-columns: minmax(270px, 320px) minmax(0, 1fr);
}
}
@media (max-width: 820px) {
@@ -375,16 +339,27 @@
}
.scheduling-workspace,
.scheduling-full-editor,
.scheduling-editor-layout,
.scheduling-workspace-layout,
.scheduling-main-panel,
.scheduling-editor-surface,
.scheduling-detail,
.scheduling-editor-scroll {
height: auto;
overflow: visible;
}
.scheduling-columns,
.scheduling-form-grid {
.scheduling-workspace-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.scheduling-request-sidebar {
max-height: 48vh;
border-right: 0;
border-bottom: var(--border-line);
}
.scheduling-columns {
grid-template-columns: minmax(0, 1fr);
}

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { SchedulingRequest } from "../src/api/scheduling.ts";
import {
applySchedulingAvailabilityChoice,
groupSchedulingRequests,
schedulingSortPhase,
type SchedulingActor
@@ -47,6 +48,15 @@ function request(
source_path: [],
details: {}
},
notify_on_answers: true,
single_choice: false,
max_participants_per_option: null,
allow_maybe: true,
allow_comments: false,
participant_email_required: false,
anonymous_password_protection_enabled: false,
public_participation_policy_enforcement_available: false,
public_participation_policy_enforcement_reason: "Public participation gateway not installed",
calendar_integration_enabled: false,
calendar_freebusy_enabled: false,
calendar_hold_enabled: false,
@@ -61,11 +71,13 @@ function request(
end_at: options.end ?? "2026-07-21T10:00:00Z",
timezone: "UTC",
position: 0,
revision: "0".repeat(64),
freebusy_conflicts: [],
metadata: {}
}],
participants: options.participantStatus ? [{
id: `participant-${id}`,
is_current_participant: true,
respondent_id: "membership-1",
email: "person@example.test",
participant_type: "internal",
@@ -88,6 +100,29 @@ test("groups owned, invited, and administrator-only requests without hiding any"
assert.deepEqual(groups.other.map((item) => item.id), ["managed"]);
});
test("matches email-only invitations without case sensitivity", () => {
const invited = request("email-case", { participantStatus: "invited" });
invited.participants[0].is_current_participant = false;
invited.participants[0].respondent_id = null;
invited.participants[0].email = "Person@Example.Test";
const groups = groupSchedulingRequests([invited], actor, now);
assert.deepEqual(groups.invited.map((item) => item.id), ["email-case"]);
assert.deepEqual(groups.other, []);
});
test("recognizes the current participant after identity bindings are redacted", () => {
const invited = request("safe-projection", { participantStatus: "invited" });
invited.participants[0].respondent_id = null;
invited.participants[0].email = null;
const groups = groupSchedulingRequests([invited], actor, now);
assert.deepEqual(groups.invited.map((item) => item.id), ["safe-projection"]);
assert.deepEqual(groups.other, []);
});
test("keeps an organizer's active request in the open phase even if they also responded", () => {
const owned = request("mine-and-invited", {
organizer: "account-1",
@@ -122,3 +157,31 @@ test("orders unanswered by nearest slot before answered, closed, determined, and
assert.equal(schedulingSortPhase(groups.invited[0], actor, now), "unanswered");
assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past");
});
test("single-choice availability keeps one positive choice while preserving explicit no answers", () => {
const next = applySchedulingAvailabilityChoice(
["slot-a", "slot-b", "slot-c"],
{ "slot-a": "available", "slot-b": "maybe", "slot-c": "unavailable" },
"slot-b",
"available",
true
);
assert.deepEqual(next, {
"slot-a": "unavailable",
"slot-b": "available",
"slot-c": "unavailable"
});
});
test("multi-choice availability changes only the selected slot", () => {
const next = applySchedulingAvailabilityChoice(
["slot-a", "slot-b"],
{ "slot-a": "available" },
"slot-b",
"maybe",
false
);
assert.deepEqual(next, { "slot-a": "available", "slot-b": "maybe" });
});