Add scheduling calendar integration and WebUI

This commit is contained in:
2026-07-12 19:00:54 +02:00
parent ace32a2a3d
commit c1afce7bdb
19 changed files with 1702 additions and 25 deletions

View File

@@ -108,10 +108,13 @@ poll-backed scheduling requests:
- signed poll invitations for internal or external participants
- request lifecycle APIs: draft, collecting, closed, decided, handed off, cancelled
- result summaries sourced from Poll response aggregation
- calendar integration preferences and handoff metadata, without hard-coupling
Scheduling to Calendar internals
- optional Calendar free/busy checks, tentative holds, and final event creation
- notification outbox jobs for invitations, reminders, decisions, and cancellations
- a first Scheduling WebUI package with request creation, slot matrix, Calendar
actions, decisions, and notification-job creation
The next slices should add the WebUI, notification delivery, real Calendar
free/busy checks, and Calendar/Appointments event creation after decision.
The next slices should add real notification delivery workers, richer public
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.

View File

@@ -1,5 +1,5 @@
from __future__ import annotations
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
__all__ = ["SchedulingCandidateSlot", "SchedulingParticipant", "SchedulingRequest"]
__all__ = ["SchedulingCandidateSlot", "SchedulingNotification", "SchedulingParticipant", "SchedulingRequest"]

View File

@@ -77,6 +77,10 @@ class SchedulingCandidateSlot(Base, TimestampMixin):
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
location: Mapped[str | None] = mapped_column(String(500))
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
freebusy_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
freebusy_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
freebusy_conflicts: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
tentative_hold_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=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)
@@ -109,4 +113,31 @@ class SchedulingParticipant(Base, TimestampMixin):
request: Mapped[SchedulingRequest] = relationship(back_populates="participants")
__all__ = ["SchedulingCandidateSlot", "SchedulingParticipant", "SchedulingRequest", "new_uuid"]
class SchedulingNotification(Base, TimestampMixin):
__tablename__ = "scheduling_notifications"
__table_args__ = (
Index("ix_scheduling_notifications_request_status", "request_id", "status"),
Index("ix_scheduling_notifications_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_id: Mapped[str] = mapped_column(ForeignKey("scheduling_requests.id", ondelete="CASCADE"), nullable=False, index=True)
participant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
event_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
channel: Mapped[str] = mapped_column(String(40), default="mail", nullable=False, index=True)
recipient: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(40), default="pending", nullable=False, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
error: Mapped[str | None] = mapped_column(Text)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
__all__ = [
"SchedulingCandidateSlot",
"SchedulingNotification",
"SchedulingParticipant",
"SchedulingRequest",
"new_uuid",
]

View File

@@ -6,11 +6,13 @@ from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPA
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
@@ -86,7 +88,7 @@ DOCUMENTATION = (
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
return {
"scheduling_requests": (
@@ -104,6 +106,11 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
.filter(SchedulingParticipant.tenant_id == tenant_id, SchedulingParticipant.deleted_at.is_(None))
.count()
),
"scheduling_notifications": (
session.query(SchedulingNotification)
.filter(SchedulingNotification.tenant_id == tenant_id, SchedulingNotification.status == "pending")
.count()
),
}
@@ -132,6 +139,12 @@ manifest = ModuleManifest(
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/scheduling-webui",
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
),
route_factory=_scheduling_router,
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
@@ -143,6 +156,7 @@ manifest = ModuleManifest(
scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant,
scheduling_models.SchedulingNotification,
label="Scheduling",
),
retirement_notes="Destructive retirement drops scheduling-owned database tables after the installer captures a database snapshot.",
@@ -152,6 +166,7 @@ manifest = ModuleManifest(
scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant,
scheduling_models.SchedulingNotification,
label="Scheduling",
),
),

View File

@@ -0,0 +1,74 @@
"""v0.1.8 scheduling calendar integration and notification outbox
Revision ID: 4d5e6f7a8b9c
Revises: 3c4d5e6f7a8b
Create Date: 2026-07-12 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4d5e6f7a8b9c"
down_revision = "3c4d5e6f7a8b"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_checked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_status", sa.String(length=40), nullable=True))
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("scheduling_candidate_slots", sa.Column("tentative_hold_event_id", sa.String(length=36), nullable=True))
op.create_index(op.f("ix_scheduling_candidate_slots_freebusy_status"), "scheduling_candidate_slots", ["freebusy_status"], unique=False)
op.create_index(
op.f("ix_scheduling_candidate_slots_tentative_hold_event_id"),
"scheduling_candidate_slots",
["tentative_hold_event_id"],
unique=False,
)
op.create_table(
"scheduling_notifications",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("participant_id", sa.String(length=36), nullable=True),
sa.Column("event_kind", sa.String(length=40), nullable=False),
sa.Column("channel", sa.String(length=40), nullable=False),
sa.Column("recipient", sa.String(length=500), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["request_id"],
["scheduling_requests.id"],
name=op.f("fk_scheduling_notifications_request_id_scheduling_requests"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_scheduling_notifications")),
)
op.create_index(op.f("ix_scheduling_notifications_channel"), "scheduling_notifications", ["channel"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_event_kind"), "scheduling_notifications", ["event_kind"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_participant_id"), "scheduling_notifications", ["participant_id"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_recipient"), "scheduling_notifications", ["recipient"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_request_id"), "scheduling_notifications", ["request_id"], unique=False)
op.create_index("ix_scheduling_notifications_request_status", "scheduling_notifications", ["request_id", "status"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_status"), "scheduling_notifications", ["status"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_tenant_id"), "scheduling_notifications", ["tenant_id"], unique=False)
op.create_index("ix_scheduling_notifications_tenant_status", "scheduling_notifications", ["tenant_id", "status"], unique=False)
def downgrade() -> None:
op.drop_table("scheduling_notifications")
op.drop_index(op.f("ix_scheduling_candidate_slots_tentative_hold_event_id"), table_name="scheduling_candidate_slots")
op.drop_index(op.f("ix_scheduling_candidate_slots_freebusy_status"), table_name="scheduling_candidate_slots")
op.drop_column("scheduling_candidate_slots", "tentative_hold_event_id")
op.drop_column("scheduling_candidate_slots", "freebusy_conflicts")
op.drop_column("scheduling_candidate_slots", "freebusy_status")
op.drop_column("scheduling_candidate_slots", "freebusy_checked_at")

View File

@@ -8,7 +8,11 @@ from govoplan_core.db.session import get_session
from govoplan_poll.backend.schemas import PollResultSummaryResponse
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
SchedulingCalendarActionResponse,
SchedulingDecisionRequest,
SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse,
SchedulingNotificationResponse,
SchedulingRequestCreateRequest,
SchedulingRequestListResponse,
SchedulingRequestResponse,
@@ -20,11 +24,17 @@ from govoplan_scheduling.backend.service import (
SchedulingError,
cancel_scheduling_request,
close_scheduling_request,
create_final_calendar_event,
create_scheduling_notification_jobs,
create_scheduling_request,
create_tentative_calendar_holds,
decide_scheduling_request,
evaluate_calendar_freebusy,
get_scheduling_request,
list_scheduling_notifications,
list_scheduling_requests,
open_scheduling_request,
scheduling_notification_response,
scheduling_request_response,
scheduling_request_summary,
update_scheduling_request,
@@ -147,12 +157,83 @@ def api_decide_scheduling_request(
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request = decide_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
request = decide_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
payload=payload,
user_id=principal.account_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
@router.post("/requests/{request_id}/calendar/freebusy", response_model=SchedulingCalendarActionResponse)
def api_evaluate_calendar_freebusy(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
warnings=warnings,
)
@router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse)
def api_create_tentative_calendar_holds(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request, created_event_ids, warnings = create_tentative_calendar_holds(
session,
tenant_id=principal.tenant_id,
user_id=principal.account_id,
request_id=request_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
created_event_ids=created_event_ids,
updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids],
warnings=warnings,
)
@router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse)
def api_create_final_calendar_event(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request, event_id, warnings = create_final_calendar_event(
session,
tenant_id=principal.tenant_id,
user_id=principal.account_id,
request_id=request_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
created_event_ids=[event_id] if event_id else [],
warnings=warnings,
)
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
def api_cancel_scheduling_request(
request_id: str,
@@ -183,3 +264,52 @@ def api_scheduling_summary(
request=_request_response(request),
poll_summary=PollResultSummaryResponse.model_validate(summary),
)
@router.get("/notifications", response_model=SchedulingNotificationListResponse)
def api_list_scheduling_notifications(
request_id: str | None = None,
status_filter: str | None = Query(default=None, alias="status"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingNotificationListResponse:
_require_scope(principal, READ_SCOPE)
notifications = list_scheduling_notifications(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
status=status_filter,
)
return SchedulingNotificationListResponse(
notifications=[
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
for notification in notifications
]
)
@router.post("/requests/{request_id}/notifications", response_model=SchedulingNotificationListResponse)
def api_create_scheduling_notifications(
request_id: str,
payload: SchedulingNotificationCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingNotificationListResponse:
_require_scope(principal, WRITE_SCOPE)
try:
notifications = create_scheduling_notification_jobs(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
event_kind=payload.event_kind,
channel=payload.channel,
metadata=payload.metadata,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingNotificationListResponse(
notifications=[
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
for notification in notifications
]
)

View File

@@ -96,6 +96,10 @@ class SchedulingCandidateSlotResponse(BaseModel):
timezone: str
location: str | None = None
position: int
freebusy_checked_at: datetime | None = None
freebusy_status: str | None = None
freebusy_conflicts: list[dict[str, Any]] = Field(default_factory=list)
tentative_hold_event_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
@@ -163,3 +167,38 @@ class SchedulingDecisionRequest(BaseModel):
class SchedulingSummaryResponse(BaseModel):
request: SchedulingRequestResponse
poll_summary: PollResultSummaryResponse
class SchedulingCalendarActionResponse(BaseModel):
request: SchedulingRequestResponse
created_event_ids: list[str] = Field(default_factory=list)
updated_slot_ids: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
class SchedulingNotificationResponse(BaseModel):
id: str
request_id: str
participant_id: str | None = None
event_kind: str
channel: str
recipient: str | None = None
status: str
payload: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
sent_at: datetime | None = None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingNotificationListResponse(BaseModel):
notifications: list[SchedulingNotificationResponse] = Field(default_factory=list)
class SchedulingNotificationCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
event_kind: Literal["invitation", "reminder", "decision", "cancellation"]
channel: str = Field(default="mail", max_length=40)
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -18,7 +18,7 @@ from govoplan_poll.backend.service import (
open_poll,
poll_result_summary_by_id,
)
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.schemas import (
SchedulingDecisionRequest,
SchedulingRequestCreateRequest,
@@ -36,6 +36,7 @@ WORKFLOW_CLOSED = "availability_closed"
WORKFLOW_DECIDED = "slot_decided"
WORKFLOW_HANDED_OFF = "handed_off"
WORKFLOW_CANCELLED = "cancelled"
NOTIFICATION_KINDS = {"invitation", "reminder", "decision", "cancellation"}
def response_datetime(value: datetime | None) -> datetime | None:
@@ -46,6 +47,17 @@ def response_datetime(value: datetime | None) -> datetime | None:
return value.astimezone(timezone.utc)
def _jsonable(value: Any) -> Any:
if isinstance(value, datetime):
normalized = response_datetime(value)
return normalized.isoformat() if normalized else None
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, list):
return [_jsonable(item) for item in value]
return value
def _now() -> datetime:
return utcnow()
@@ -148,6 +160,238 @@ def _set_calendar_preferences(request: SchedulingRequest, payload) -> None:
request.create_calendar_event_on_decision = False
def _calendar_api():
try:
from govoplan_calendar.backend.schemas import CalendarEventCreateRequest
from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy
except ModuleNotFoundError as exc:
raise SchedulingError("Calendar module is not installed") from exc
return CalendarEventCreateRequest, CalendarError, create_event, list_freebusy
def _require_calendar_enabled(request: SchedulingRequest) -> None:
if not request.calendar_integration_enabled:
raise SchedulingError("Calendar integration is not enabled for this scheduling request")
if not request.calendar_id:
raise SchedulingError("Scheduling request has no target calendar")
def _attendees_for_calendar_event(request: SchedulingRequest) -> list[dict[str, Any]]:
attendees: list[dict[str, Any]] = []
for participant in _active_participants(request):
if not participant.email and not participant.display_name:
continue
attendees.append(
{
"email": participant.email,
"name": participant.display_name,
"required": participant.required,
"participant_id": participant.id,
"participant_type": participant.participant_type,
}
)
return attendees
def _calendar_event_payload(
request: SchedulingRequest,
slot: SchedulingCandidateSlot,
*,
status: str,
summary_prefix: str | None = None,
) -> Any:
CalendarEventCreateRequest, _CalendarError, _create_event, _list_freebusy = _calendar_api()
summary = f"{summary_prefix}: {request.title}" if summary_prefix else request.title
return CalendarEventCreateRequest(
calendar_id=request.calendar_id,
summary=summary,
description=request.description,
location=slot.location or request.location,
status=status,
transparency="OPAQUE",
classification="PRIVATE" if status == "TENTATIVE" else "PUBLIC",
start_at=slot.start_at,
end_at=slot.end_at,
timezone=slot.timezone,
attendees=_attendees_for_calendar_event(request),
categories=["GovOPlaN", "Scheduling"],
related_to=[{"reltype": "SIBLING", "uid": request.poll_id, "type": "poll"}] if request.poll_id else [],
metadata={
"scheduling_request_id": request.id,
"scheduling_slot_id": slot.id,
"poll_id": request.poll_id,
"kind": "tentative_hold" if status == "TENTATIVE" else "scheduled_meeting",
},
)
def evaluate_calendar_freebusy(
session: Session,
*,
tenant_id: str,
request_id: str,
) -> tuple[SchedulingRequest, list[str]]:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
_require_calendar_enabled(request)
if not request.calendar_freebusy_enabled:
raise SchedulingError("Calendar free/busy checks are not enabled for this scheduling request")
_CalendarEventCreateRequest, CalendarError, _create_event, list_freebusy = _calendar_api()
warnings: list[str] = []
for slot in _active_slots(request):
try:
conflicts = list_freebusy(
session,
tenant_id=tenant_id,
start_at=slot.start_at,
end_at=slot.end_at,
calendar_ids=[request.calendar_id] if request.calendar_id else None,
)
except CalendarError as exc:
slot.freebusy_checked_at = _now()
slot.freebusy_status = "error"
slot.freebusy_conflicts = [{"error": str(exc)}]
warnings.append(str(exc))
continue
slot.freebusy_checked_at = _now()
slot.freebusy_conflicts = _jsonable(conflicts)
slot.freebusy_status = "busy" if conflicts else "free"
session.flush()
return request, warnings
def create_tentative_calendar_holds(
session: Session,
*,
tenant_id: str,
user_id: str | None,
request_id: str,
) -> tuple[SchedulingRequest, list[str], list[str]]:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
_require_calendar_enabled(request)
if not request.calendar_hold_enabled:
raise SchedulingError("Tentative calendar holds are not enabled for this scheduling request")
_CalendarEventCreateRequest, CalendarError, create_event, _list_freebusy = _calendar_api()
created_event_ids: list[str] = []
warnings: list[str] = []
for slot in _active_slots(request):
if slot.tentative_hold_event_id:
continue
try:
event = create_event(
session,
tenant_id=tenant_id,
user_id=user_id,
payload=_calendar_event_payload(request, slot, status="TENTATIVE", summary_prefix="Tentative hold"),
)
except CalendarError as exc:
warnings.append(str(exc))
continue
slot.tentative_hold_event_id = event.id
created_event_ids.append(event.id)
session.flush()
return request, created_event_ids, warnings
def create_final_calendar_event(
session: Session,
*,
tenant_id: str,
user_id: str | None,
request_id: str,
) -> tuple[SchedulingRequest, str | None, list[str]]:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
_require_calendar_enabled(request)
if not request.create_calendar_event_on_decision:
raise SchedulingError("Final calendar event creation is not enabled for this scheduling request")
if request.calendar_event_id:
return request, request.calendar_event_id, []
if not request.selected_slot_id:
raise SchedulingError("Scheduling request has no selected slot")
slot = _selected_slot(request, slot_id=request.selected_slot_id)
_CalendarEventCreateRequest, CalendarError, create_event, _list_freebusy = _calendar_api()
try:
event = create_event(
session,
tenant_id=tenant_id,
user_id=user_id,
payload=_calendar_event_payload(request, slot, status="CONFIRMED"),
)
except CalendarError as exc:
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {"status": "error", "error": str(exc), "slot_id": slot.id, "calendar_id": request.calendar_id},
}
session.flush()
return request, None, [str(exc)]
request.calendar_event_id = event.id
request.handed_off_at = _now()
request.status = "handed_off"
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {"status": "created", "event_id": event.id, "slot_id": slot.id, "calendar_id": request.calendar_id},
}
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request, event.id, []
def create_scheduling_notification_jobs(
session: Session,
*,
tenant_id: str,
request_id: str,
event_kind: str,
channel: str = "mail",
metadata: dict[str, Any] | None = None,
) -> list[SchedulingNotification]:
if event_kind not in NOTIFICATION_KINDS:
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
notifications: list[SchedulingNotification] = []
for participant in _active_participants(request):
recipient = participant.email or participant.respondent_id
status = "pending" if recipient else "skipped"
notification = SchedulingNotification(
tenant_id=tenant_id,
request_id=request.id,
participant_id=participant.id,
event_kind=event_kind,
channel=channel,
recipient=recipient,
status=status,
payload={
"title": request.title,
"request_id": request.id,
"poll_id": request.poll_id,
"participant_id": participant.id,
"selected_slot_id": request.selected_slot_id,
"calendar_event_id": request.calendar_event_id,
},
metadata_=metadata or {},
)
if status == "skipped":
notification.error = "Participant has no deliverable recipient address or id"
session.add(notification)
notifications.append(notification)
session.flush()
return notifications
def list_scheduling_notifications(
session: Session,
*,
tenant_id: str,
request_id: str | None = None,
status: str | None = None,
) -> list[SchedulingNotification]:
query = session.query(SchedulingNotification).filter(SchedulingNotification.tenant_id == tenant_id)
if request_id:
query = query.filter(SchedulingNotification.request_id == request_id)
if status:
query = query.filter(SchedulingNotification.status == status)
return query.order_by(SchedulingNotification.created_at.desc(), SchedulingNotification.id.asc()).all()
def refresh_participant_response_state(session: Session, *, request: SchedulingRequest) -> None:
if request.poll_id is None:
return
@@ -290,6 +534,13 @@ def create_scheduling_request(
participant.last_invited_at = _now()
participant.status = "invited"
invitation_tokens[participant.id] = token
create_scheduling_notification_jobs(
session,
tenant_id=tenant_id,
request_id=request.id,
event_kind="invitation",
metadata={"created_with_request": True},
)
session.flush()
return request, invitation_tokens
@@ -368,6 +619,7 @@ def decide_scheduling_request(
tenant_id: str,
request_id: str,
payload: SchedulingDecisionRequest,
user_id: str | None = None,
) -> SchedulingRequest:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.poll_id is None:
@@ -384,20 +636,25 @@ def decide_scheduling_request(
raise SchedulingError(str(exc)) from exc
request.selected_slot_id = slot.id
request.status = "decided"
if payload.handoff_to_calendar or (
should_handoff = payload.handoff_to_calendar or (
payload.handoff_to_calendar is None and request.create_calendar_event_on_decision
):
request.handed_off_at = _now()
request.status = "handed_off"
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {
"status": "pending",
"reason": "Calendar event creation is an optional integration step.",
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
)
if should_handoff:
if request.calendar_integration_enabled and request.create_calendar_event_on_decision:
create_final_calendar_event(session, tenant_id=tenant_id, user_id=user_id, request_id=request.id)
else:
request.handed_off_at = _now()
request.status = "handed_off"
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {
"status": "pending",
"reason": "Calendar event creation is not enabled or Calendar is unavailable.",
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="decision")
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request
@@ -415,6 +672,7 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
except PollError as exc:
raise SchedulingError(str(exc)) from exc
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="cancellation")
session.flush()
return request
@@ -456,6 +714,10 @@ def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]:
"timezone": slot.timezone,
"location": slot.location,
"position": slot.position,
"freebusy_checked_at": response_datetime(slot.freebusy_checked_at),
"freebusy_status": slot.freebusy_status,
"freebusy_conflicts": slot.freebusy_conflicts or [],
"tentative_hold_event_id": slot.tentative_hold_event_id,
"metadata": slot.metadata_ or {},
}
@@ -511,3 +773,21 @@ def scheduling_request_response(request: SchedulingRequest, *, invitation_tokens
for participant in _active_participants(request)
],
}
def scheduling_notification_response(notification: SchedulingNotification) -> dict[str, Any]:
return {
"id": notification.id,
"request_id": notification.request_id,
"participant_id": notification.participant_id,
"event_kind": notification.event_kind,
"channel": notification.channel,
"recipient": notification.recipient,
"status": notification.status,
"payload": notification.payload or {},
"error": notification.error,
"sent_at": response_datetime(notification.sent_at),
"created_at": response_datetime(notification.created_at),
"updated_at": response_datetime(notification.updated_at),
"metadata": notification.metadata_ or {},
}

View File

@@ -20,6 +20,7 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertIn("poll.availability_matrix", {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})

View File

@@ -7,10 +7,13 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.base import Base
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_access.backend.db.models import Account, User
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncSource
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
from govoplan_poll.backend.service import get_poll, submit_poll_response_with_token
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.schemas import (
SchedulingCalendarPreferences,
SchedulingCandidateSlotInput,
@@ -21,8 +24,13 @@ from govoplan_scheduling.backend.schemas import (
from govoplan_scheduling.backend.service import (
SchedulingError,
close_scheduling_request,
create_final_calendar_event,
create_scheduling_notification_jobs,
create_scheduling_request,
create_tentative_calendar_holds,
decide_scheduling_request,
evaluate_calendar_freebusy,
list_scheduling_notifications,
scheduling_request_summary,
)
@@ -37,9 +45,16 @@ class SchedulingServiceTests(unittest.TestCase):
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
CalendarCollection.__table__,
CalendarEvent.__table__,
CalendarSyncSource.__table__,
ChangeSequenceEntry.__table__,
Account.__table__,
User.__table__,
SchedulingRequest.__table__,
SchedulingCandidateSlot.__table__,
SchedulingParticipant.__table__,
SchedulingNotification.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
@@ -51,8 +66,15 @@ class SchedulingServiceTests(unittest.TestCase):
self.engine,
tables=[
SchedulingParticipant.__table__,
SchedulingNotification.__table__,
SchedulingCandidateSlot.__table__,
SchedulingRequest.__table__,
User.__table__,
Account.__table__,
ChangeSequenceEntry.__table__,
CalendarSyncSource.__table__,
CalendarEvent.__table__,
CalendarCollection.__table__,
PollInvitation.__table__,
PollResponse.__table__,
PollOption.__table__,
@@ -61,6 +83,22 @@ class SchedulingServiceTests(unittest.TestCase):
)
self.engine.dispose()
def _calendar(self) -> CalendarCollection:
calendar = CalendarCollection(
id="calendar-1",
tenant_id="tenant-1",
slug="team",
name="Team",
timezone="Europe/Berlin",
owner_type="tenant",
visibility="tenant",
is_default=True,
metadata_={},
)
self.session.add(calendar)
self.session.flush()
return calendar
def _payload(self) -> SchedulingRequestCreateRequest:
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
return SchedulingRequestCreateRequest(
@@ -74,6 +112,7 @@ class SchedulingServiceTests(unittest.TestCase):
enabled=True,
calendar_id="calendar-1",
freebusy_enabled=True,
tentative_holds_enabled=True,
create_event_on_decision=True,
),
slots=[
@@ -107,11 +146,12 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants))
def test_signed_response_summary_and_decision_handoff(self) -> None:
payload = self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()})
request, tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
payload=payload,
)
first_participant = request.participants[0]
first_slot = request.slots[0]
@@ -151,6 +191,99 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual(poll.status, "decided")
self.assertEqual(poll.workflow_state, "handed_off")
def test_calendar_freebusy_holds_and_final_event_creation(self) -> None:
self._calendar()
payload = self._payload()
request, _tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=payload,
)
first_slot = request.slots[0]
self.session.add(
CalendarEvent(
tenant_id="tenant-1",
calendar_id="calendar-1",
uid="busy@example.test",
sequence=0,
summary="Busy",
status="CONFIRMED",
transparency="OPAQUE",
classification="PUBLIC",
start_at=first_slot.start_at,
end_at=first_slot.end_at,
all_day=False,
attendees=[],
categories=[],
rdate=[],
exdate=[],
reminders=[],
attachments=[],
related_to=[],
source_kind="local",
icalendar={},
metadata_={},
)
)
self.session.flush()
request, warnings = evaluate_calendar_freebusy(self.session, tenant_id="tenant-1", request_id=request.id)
self.assertEqual(warnings, [])
self.assertEqual(request.slots[0].freebusy_status, "busy")
self.assertEqual(request.slots[1].freebusy_status, "free")
request, hold_event_ids, warnings = create_tentative_calendar_holds(
self.session,
tenant_id="tenant-1",
user_id="user-1",
request_id=request.id,
)
self.assertEqual(warnings, [])
self.assertEqual(len(hold_event_ids), 2)
self.assertTrue(all(slot.tentative_hold_event_id for slot in request.slots))
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
decided = decide_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
payload=SchedulingDecisionRequest(slot_id=request.slots[1].id, handoff_to_calendar=False),
user_id="user-1",
)
decided, event_id, warnings = create_final_calendar_event(
self.session,
tenant_id="tenant-1",
user_id="user-1",
request_id=decided.id,
)
self.assertEqual(warnings, [])
self.assertIsNotNone(event_id)
self.assertEqual(decided.status, "handed_off")
self.assertEqual(decided.calendar_event_id, event_id)
self.assertEqual(decided.metadata_["calendar_handoff"]["status"], "created")
def test_notification_outbox_jobs_are_created_and_listed(self) -> None:
request, _tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
)
reminder_jobs = create_scheduling_notification_jobs(
self.session,
tenant_id="tenant-1",
request_id=request.id,
event_kind="reminder",
)
all_jobs = list_scheduling_notifications(self.session, tenant_id="tenant-1", request_id=request.id)
self.assertEqual(len(reminder_jobs), 2)
self.assertGreaterEqual(len(all_jobs), 4)
self.assertTrue(all(job.status == "pending" for job in reminder_jobs))
def test_external_participants_can_be_rejected(self) -> None:
payload = self._payload().model_copy(update={"allow_external_participants": False})

31
webui/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@govoplan/scheduling-webui",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/scheduling.css": "./src/styles/scheduling.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

210
webui/src/api/scheduling.ts Normal file
View File

@@ -0,0 +1,210 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
export type SchedulingCandidateSlot = {
id: string;
poll_option_id?: string | null;
label: string;
description?: string | null;
start_at: string;
end_at: string;
timezone: string;
location?: string | null;
position: number;
freebusy_checked_at?: string | null;
freebusy_status?: string | null;
freebusy_conflicts: Record<string, unknown>[];
tentative_hold_event_id?: string | null;
metadata?: Record<string, unknown>;
};
export type SchedulingParticipant = {
id: string;
respondent_id?: string | null;
display_name?: string | null;
email?: string | null;
participant_type: string;
required: boolean;
status: string;
poll_invitation_id?: string | null;
invitation_token?: string | null;
last_invited_at?: string | null;
responded_at?: string | null;
metadata?: Record<string, unknown>;
};
export type SchedulingRequest = {
id: string;
tenant_id: string;
title: string;
description?: string | null;
location?: string | null;
timezone: string;
status: SchedulingStatus;
poll_id?: string | null;
selected_slot_id?: string | null;
organizer_user_id?: string | null;
deadline_at?: string | null;
allow_external_participants: boolean;
allow_participant_updates: boolean;
result_visibility: string;
calendar_integration_enabled: boolean;
calendar_id?: string | null;
calendar_freebusy_enabled: boolean;
calendar_hold_enabled: boolean;
create_calendar_event_on_decision: boolean;
calendar_event_id?: string | null;
handed_off_at?: string | null;
cancelled_at?: string | null;
created_at: string;
updated_at: string;
metadata?: Record<string, unknown>;
slots: SchedulingCandidateSlot[];
participants: SchedulingParticipant[];
};
export type SchedulingRequestListResponse = { requests: SchedulingRequest[] };
export type SchedulingStatusResponse = { request: SchedulingRequest };
export type SchedulingCandidateSlotPayload = {
label?: string | null;
description?: string | null;
start_at: string;
end_at: string;
timezone?: string | null;
location?: string | null;
metadata?: Record<string, unknown>;
};
export type SchedulingParticipantPayload = {
respondent_id?: string | null;
display_name?: string | null;
email?: string | null;
participant_type?: "internal" | "external" | "resource";
required?: boolean;
metadata?: Record<string, unknown>;
};
export type SchedulingRequestCreatePayload = {
title: string;
description?: string | null;
location?: string | null;
timezone?: string;
status?: "draft" | "collecting";
deadline_at?: string | null;
allow_external_participants?: boolean;
allow_participant_updates?: boolean;
result_visibility?: "organizer" | "after_response" | "after_close" | "public";
calendar?: {
enabled?: boolean;
calendar_id?: string | null;
freebusy_enabled?: boolean;
tentative_holds_enabled?: boolean;
create_event_on_decision?: boolean;
};
slots: SchedulingCandidateSlotPayload[];
participants?: SchedulingParticipantPayload[];
create_participant_invitations?: boolean;
metadata?: Record<string, unknown>;
};
export type SchedulingDecisionPayload = {
slot_id?: string | null;
poll_option_id?: string | null;
handoff_to_calendar?: boolean | null;
};
export type SchedulingCalendarActionResponse = {
request: SchedulingRequest;
created_event_ids: string[];
updated_slot_ids: string[];
warnings: string[];
};
export type SchedulingNotification = {
id: string;
request_id: string;
participant_id?: string | null;
event_kind: string;
channel: string;
recipient?: string | null;
status: string;
payload: Record<string, unknown>;
error?: string | null;
sent_at?: string | null;
created_at: string;
updated_at: string;
metadata?: Record<string, unknown>;
};
export type SchedulingNotificationListResponse = { notifications: SchedulingNotification[] };
export type SchedulingPollOptionResult = {
option_id: string;
option_key: string;
label: string;
count: number;
score: number;
values: Record<string, number>;
};
export type SchedulingSummaryResponse = {
request: SchedulingRequest;
poll_summary: {
poll_id: string;
kind: string;
status: string;
response_count: number;
option_results: SchedulingPollOptionResult[];
leading_option_ids: string[];
};
};
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) });
export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> {
const query = status ? `?status=${encodeURIComponent(status)}` : "";
return apiFetch<SchedulingRequestListResponse>(settings, `/api/v1/scheduling/requests${query}`);
}
export function createSchedulingRequest(settings: ApiSettings, payload: SchedulingRequestCreatePayload): Promise<SchedulingRequest> {
return apiFetch<SchedulingRequest>(settings, "/api/v1/scheduling/requests", json(payload));
}
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
}
export function closeSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/close`, json({}));
}
export function decideSchedulingRequest(settings: ApiSettings, requestId: string, payload: SchedulingDecisionPayload): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/decide`, json(payload));
}
export function evaluateSchedulingFreeBusy(settings: ApiSettings, requestId: string): Promise<SchedulingCalendarActionResponse> {
return apiFetch<SchedulingCalendarActionResponse>(settings, `/api/v1/scheduling/requests/${requestId}/calendar/freebusy`, json({}));
}
export function createSchedulingHolds(settings: ApiSettings, requestId: string): Promise<SchedulingCalendarActionResponse> {
return apiFetch<SchedulingCalendarActionResponse>(settings, `/api/v1/scheduling/requests/${requestId}/calendar/holds`, json({}));
}
export function createSchedulingCalendarEvent(settings: ApiSettings, requestId: string): Promise<SchedulingCalendarActionResponse> {
return apiFetch<SchedulingCalendarActionResponse>(settings, `/api/v1/scheduling/requests/${requestId}/calendar/event`, json({}));
}
export function schedulingSummary(settings: ApiSettings, requestId: string): Promise<SchedulingSummaryResponse> {
return apiFetch<SchedulingSummaryResponse>(settings, `/api/v1/scheduling/requests/${requestId}/summary`);
}
export function createSchedulingNotifications(settings: ApiSettings, requestId: string, eventKind: string): Promise<SchedulingNotificationListResponse> {
return apiFetch<SchedulingNotificationListResponse>(settings, `/api/v1/scheduling/requests/${requestId}/notifications`, json({ event_kind: eventKind }));
}
export function listSchedulingNotifications(settings: ApiSettings, requestId?: string): Promise<SchedulingNotificationListResponse> {
const query = requestId ? `?request_id=${encodeURIComponent(requestId)}` : "";
return apiFetch<SchedulingNotificationListResponse>(settings, `/api/v1/scheduling/notifications${query}`);
}

View File

@@ -0,0 +1,349 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Bell, CalendarCheck, Check, Clock, Plus, RefreshCw, Send, XCircle } from "lucide-react";
import { Button, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
import {
closeSchedulingRequest,
createSchedulingCalendarEvent,
createSchedulingHolds,
createSchedulingNotifications,
createSchedulingRequest,
decideSchedulingRequest,
evaluateSchedulingFreeBusy,
listSchedulingNotifications,
listSchedulingRequests,
openSchedulingRequest,
schedulingSummary,
type SchedulingNotification,
type SchedulingRequest,
type SchedulingSummaryResponse
} from "../../api/scheduling";
type SlotDraft = { label: string; start_at: string; end_at: string };
type ParticipantDraft = { display_name: string; email: string; required: boolean };
const now = new Date();
const defaultStart = new Date(now.getTime() + 24 * 60 * 60 * 1000);
defaultStart.setMinutes(0, 0, 0);
const defaultEnd = new Date(defaultStart.getTime() + 60 * 60 * 1000);
function localValue(date: Date): string {
const pad = (value: number) => value.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function isoFromLocal(value: string): string {
return new Date(value).toISOString();
}
export default function SchedulingPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [requests, setRequests] = useState<SchedulingRequest[]>([]);
const [selectedId, setSelectedId] = useState("");
const [summary, setSummary] = useState<SchedulingSummaryResponse | null>(null);
const [notifications, setNotifications] = useState<SchedulingNotification[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [title, setTitle] = useState("Scheduling poll");
const [location, setLocation] = useState("");
const [calendarId, setCalendarId] = useState("");
const [calendarEnabled, setCalendarEnabled] = useState(false);
const [slots, setSlots] = useState<SlotDraft[]>([
{ label: "Option 1", start_at: localValue(defaultStart), end_at: localValue(defaultEnd) }
]);
const [participants, setParticipants] = useState<ParticipantDraft[]>([{ display_name: "", email: "", required: true }]);
const canWrite = hasScope(auth, "scheduling:schedule:write");
const selected = useMemo(() => requests.find((item) => item.id === selectedId) || requests[0] || null, [requests, selectedId]);
const optionResultById = useMemo(() => {
const map = new Map<string, SchedulingSummaryResponse["poll_summary"]["option_results"][number]>();
for (const result of summary?.poll_summary.option_results || []) map.set(result.option_id, result);
return map;
}, [summary]);
useEffect(() => {
void loadRequests();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!selected?.id) {
setSummary(null);
setNotifications([]);
return;
}
void loadDetails(selected.id);
}, [selected?.id]);
async function loadRequests() {
setLoading(true);
setError("");
try {
const response = await listSchedulingRequests(settings);
setRequests(response.requests);
if (!selectedId && response.requests[0]) setSelectedId(response.requests[0].id);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
async function loadDetails(requestId: string) {
try {
const [summaryResponse, notificationResponse] = await Promise.all([
schedulingSummary(settings, requestId).catch(() => null),
listSchedulingNotifications(settings, requestId).catch(() => ({ notifications: [] }))
]);
setSummary(summaryResponse);
setNotifications(notificationResponse.notifications);
} catch (err) {
setError(errorMessage(err));
}
}
async function createRequest(event: FormEvent) {
event.preventDefault();
if (!canWrite) return;
setSaving(true);
setError("");
try {
const request = await createSchedulingRequest(settings, {
title,
location: location || null,
status: "collecting",
calendar: {
enabled: calendarEnabled,
calendar_id: calendarId || null,
freebusy_enabled: calendarEnabled,
tentative_holds_enabled: calendarEnabled,
create_event_on_decision: calendarEnabled
},
slots: slots.map((slot) => ({
label: slot.label || null,
start_at: isoFromLocal(slot.start_at),
end_at: isoFromLocal(slot.end_at),
location: location || null
})),
participants: participants
.filter((participant) => participant.email || participant.display_name)
.map((participant) => ({ ...participant, participant_type: "external" }))
});
setRequests((items) => [request, ...items]);
setSelectedId(request.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function runAction(action: () => Promise<{ request?: SchedulingRequest } | SchedulingSummaryResponse | unknown>) {
if (!selected || !canWrite) return;
setSaving(true);
setError("");
try {
const response = await action();
const updated = isRequestEnvelope(response) ? response.request : null;
if (updated) {
setRequests((items) => items.map((item) => (item.id === updated.id ? updated : item)));
}
await loadDetails(selected.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
return (
<main className="scheduling-page">
<div className="scheduling-shell">
<aside className="scheduling-sidebar">
<div className="scheduling-sidebar-bar">
<strong>Scheduling</strong>
<Button className="scheduling-icon-button" onClick={() => void loadRequests()} title="Refresh">
<RefreshCw size={16} />
</Button>
</div>
<div className="scheduling-list">
{loading ? <div className="scheduling-note">Loading</div> : null}
{requests.map((request) => (
<button
key={request.id}
className={`scheduling-list-item ${selected?.id === request.id ? "is-selected" : ""}`}
type="button"
onClick={() => setSelectedId(request.id)}
>
<span>{request.title}</span>
<small>{request.status}</small>
</button>
))}
</div>
</aside>
<section className="scheduling-workspace">
<div className="scheduling-topbar">
<div className="scheduling-title-line">
<CalendarCheck size={18} />
<strong>{selected?.title || "Scheduling"}</strong>
</div>
<div className="scheduling-actions">
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => openSchedulingRequest(settings, selected!.id))}>
<Send size={16} /> Open
</Button>
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => closeSchedulingRequest(settings, selected!.id))}>
<XCircle size={16} /> Close
</Button>
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => evaluateSchedulingFreeBusy(settings, selected!.id))}>
<RefreshCw size={16} /> Free/busy
</Button>
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => createSchedulingHolds(settings, selected!.id))}>
<Clock size={16} /> Holds
</Button>
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => createSchedulingCalendarEvent(settings, selected!.id))}>
<CalendarCheck size={16} /> Event
</Button>
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => createSchedulingNotifications(settings, selected!.id, "reminder"))}>
<Bell size={16} /> Reminder
</Button>
</div>
</div>
{error ? <div className="scheduling-alert">{error}</div> : null}
<div className="scheduling-content">
<form className="scheduling-create-panel" onSubmit={(event) => void createRequest(event)}>
<div className="scheduling-panel-heading">
<Plus size={16} />
<strong>New poll</strong>
</div>
<label>
<span>Title</span>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
</label>
<label>
<span>Location</span>
<input value={location} onChange={(event) => setLocation(event.target.value)} />
</label>
<label className="scheduling-checkbox">
<input checked={calendarEnabled} type="checkbox" onChange={(event) => setCalendarEnabled(event.target.checked)} />
<span>Calendar</span>
</label>
{calendarEnabled ? (
<label>
<span>Calendar ID</span>
<input value={calendarId} onChange={(event) => setCalendarId(event.target.value)} />
</label>
) : null}
<div className="scheduling-form-section">
<div className="scheduling-mini-heading">
<span>Slots</span>
<Button type="button" onClick={() => setSlots((items) => [...items, { label: `Option ${items.length + 1}`, start_at: localValue(defaultStart), end_at: localValue(defaultEnd) }])}>
<Plus size={14} />
</Button>
</div>
{slots.map((slot, index) => (
<div className="scheduling-slot-draft" key={index}>
<input value={slot.label} onChange={(event) => updateSlot(index, "label", event.target.value)} />
<input type="datetime-local" value={slot.start_at} onChange={(event) => updateSlot(index, "start_at", event.target.value)} />
<input type="datetime-local" value={slot.end_at} onChange={(event) => updateSlot(index, "end_at", event.target.value)} />
</div>
))}
</div>
<div className="scheduling-form-section">
<div className="scheduling-mini-heading">
<span>Participants</span>
<Button type="button" onClick={() => setParticipants((items) => [...items, { display_name: "", email: "", required: true }])}>
<Plus size={14} />
</Button>
</div>
{participants.map((participant, index) => (
<div className="scheduling-participant-draft" key={index}>
<input placeholder="Name" value={participant.display_name} onChange={(event) => updateParticipant(index, "display_name", event.target.value)} />
<input placeholder="Email" value={participant.email} onChange={(event) => updateParticipant(index, "email", event.target.value)} />
</div>
))}
</div>
<Button disabled={saving || !canWrite} type="submit" variant="primary">
<Plus size={16} /> Create
</Button>
</form>
<div className="scheduling-detail">
{selected ? (
<>
<div className="scheduling-status-row">
<span className={`scheduling-status scheduling-status-${selected.status}`}>{selected.status}</span>
<span>{selected.participants.length} participants</span>
<span>{summary?.poll_summary.response_count || 0} responses</span>
</div>
<div className="scheduling-slot-table">
{selected.slots.map((slot) => {
const result = slot.poll_option_id ? optionResultById.get(slot.poll_option_id) : null;
return (
<div className="scheduling-slot-row" key={slot.id}>
<div>
<strong>{slot.label}</strong>
<small>{formatRange(slot.start_at, slot.end_at)}</small>
</div>
<span className={`scheduling-freebusy ${slot.freebusy_status || "unknown"}`}>{slot.freebusy_status || "unchecked"}</span>
<span>{result?.values.available || 0} yes</span>
<span>{result?.values.maybe || 0} maybe</span>
<span>{result?.values.unavailable || 0} no</span>
<Button disabled={saving || !canWrite} onClick={() => void runAction(() => decideSchedulingRequest(settings, selected.id, { slot_id: slot.id, handoff_to_calendar: selected.create_calendar_event_on_decision }))}>
<Check size={16} /> Decide
</Button>
</div>
);
})}
</div>
<div className="scheduling-columns">
<div>
<div className="scheduling-panel-heading"><strong>Participants</strong></div>
{selected.participants.map((participant) => (
<div className="scheduling-compact-row" key={participant.id}>
<span>{participant.display_name || participant.email || participant.id}</span>
<small>{participant.status}</small>
</div>
))}
</div>
<div>
<div className="scheduling-panel-heading"><strong>Outbox</strong></div>
{notifications.map((notification) => (
<div className="scheduling-compact-row" key={notification.id}>
<span>{notification.event_kind}</span>
<small>{notification.status}</small>
</div>
))}
</div>
</div>
</>
) : (
<div className="scheduling-empty">No scheduling request</div>
)}
</div>
</div>
</section>
</div>
</main>
);
function updateSlot(index: number, field: keyof SlotDraft, value: string) {
setSlots((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)));
}
function updateParticipant(index: number, field: keyof ParticipantDraft, value: string) {
setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)));
}
}
function isRequestEnvelope(value: unknown): value is { request: SchedulingRequest } {
return Boolean(value && typeof value === "object" && "request" in value);
}
function formatRange(start: string, end: string): string {
const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" });
return `${formatter.format(new Date(start))} - ${formatter.format(new Date(end))}`;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Request failed";
}

View File

@@ -0,0 +1,4 @@
export const generatedTranslations = {
en: {},
de: {}
};

2
webui/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { schedulingModule as default, schedulingModule } from "./module";
export * from "./api/scheduling";

23
webui/src/module.ts Normal file
View File

@@ -0,0 +1,23 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/scheduling.css";
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
const scheduleRead = ["scheduling:schedule:read"];
export const schedulingModule: PlatformWebModule = {
id: "scheduling",
label: "Scheduling",
version: "1.0.0",
dependencies: ["poll"],
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments"],
translations: generatedTranslations,
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar", anyOf: scheduleRead, order: 56 }],
routes: [
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
]
};
export default schedulingModule;

View File

@@ -0,0 +1,308 @@
.scheduling-page {
box-sizing: border-box;
height: calc(100vh - 115px);
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.scheduling-page *,
.scheduling-page *::before,
.scheduling-page *::after {
box-sizing: border-box;
}
.scheduling-shell {
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: minmax(250px, 310px) minmax(0, 1fr);
border: var(--border-line);
background: var(--panel);
overflow: hidden;
}
.scheduling-sidebar {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border-right: var(--border-line);
background: var(--panel-soft);
}
.scheduling-sidebar-bar,
.scheduling-topbar,
.scheduling-panel-heading,
.scheduling-mini-heading,
.scheduling-status-row,
.scheduling-actions,
.scheduling-title-line {
display: flex;
align-items: center;
gap: 8px;
}
.scheduling-sidebar-bar {
justify-content: space-between;
min-height: 54px;
padding: 10px 12px;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.scheduling-icon-button.btn {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
justify-content: center;
}
.scheduling-list {
min-height: 0;
overflow: auto;
padding: 8px;
}
.scheduling-list-item {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 40px;
margin: 0 0 4px;
padding: 8px 9px;
border: 0;
border-radius: 6px;
color: var(--text);
background: transparent;
text-align: left;
cursor: pointer;
}
.scheduling-list-item:hover,
.scheduling-list-item.is-selected {
background: var(--hover-bg);
}
.scheduling-list-item span,
.scheduling-slot-row strong,
.scheduling-compact-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scheduling-list-item small,
.scheduling-slot-row small,
.scheduling-compact-row small,
.scheduling-note {
color: var(--muted);
font-size: 12px;
}
.scheduling-workspace {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scheduling-topbar {
justify-content: space-between;
min-height: 54px;
padding: 9px 12px;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.scheduling-title-line {
min-width: 180px;
}
.scheduling-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.scheduling-actions .btn,
.scheduling-create-panel .btn,
.scheduling-slot-row .btn {
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.scheduling-alert {
margin: 8px 12px 0;
padding: 8px 10px;
border: var(--border-line);
border-color: var(--danger);
border-radius: 6px;
color: var(--danger);
background: var(--danger-soft);
}
.scheduling-content {
min-height: 0;
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 0;
overflow: hidden;
}
.scheduling-create-panel,
.scheduling-detail {
min-height: 0;
overflow: auto;
padding: 12px;
}
.scheduling-create-panel {
display: grid;
align-content: start;
gap: 10px;
border-right: var(--border-line);
background: var(--panel-soft);
}
.scheduling-create-panel label,
.scheduling-form-section {
display: grid;
gap: 5px;
}
.scheduling-create-panel label span,
.scheduling-mini-heading span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.scheduling-create-panel input {
width: 100%;
min-height: 34px;
padding: 6px 8px;
border: var(--border-line);
border-radius: 6px;
color: var(--text);
background: var(--input-bg);
}
.scheduling-checkbox {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
}
.scheduling-slot-draft,
.scheduling-participant-draft {
display: grid;
gap: 6px;
}
.scheduling-slot-draft {
grid-template-columns: minmax(0, 1fr);
}
.scheduling-status-row {
flex-wrap: wrap;
margin-bottom: 10px;
}
.scheduling-status {
padding: 4px 8px;
border-radius: 999px;
color: var(--text-strong);
background: var(--panel-soft);
font-size: 12px;
font-weight: 700;
}
.scheduling-status-collecting,
.scheduling-status-handed_off {
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
.scheduling-slot-table {
display: grid;
gap: 6px;
}
.scheduling-slot-row {
display: grid;
grid-template-columns: minmax(180px, 1fr) auto auto auto auto auto;
gap: 8px;
align-items: center;
min-height: 46px;
padding: 8px;
border-bottom: var(--border-line);
}
.scheduling-slot-row:hover {
background: var(--hover-bg);
}
.scheduling-freebusy {
min-width: 74px;
padding: 3px 7px;
border-radius: 6px;
text-align: center;
font-size: 12px;
font-weight: 700;
background: var(--panel-soft);
}
.scheduling-freebusy.free {
color: var(--success);
}
.scheduling-freebusy.busy,
.scheduling-freebusy.error {
color: var(--danger);
}
.scheduling-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.scheduling-compact-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
min-height: 34px;
align-items: center;
border-bottom: var(--border-line);
}
.scheduling-empty {
padding: 20px;
color: var(--muted);
}
@media (max-width: 980px) {
.scheduling-shell,
.scheduling-content,
.scheduling-columns {
grid-template-columns: minmax(0, 1fr);
}
.scheduling-sidebar,
.scheduling-create-panel {
border-right: 0;
border-bottom: var(--border-line);
}
.scheduling-slot-row {
grid-template-columns: minmax(0, 1fr) auto;
}
}

13
webui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_CSRF_COOKIE_NAME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:govoplan-installed-modules" {
const modules: any[];
export default modules;
}

31
webui/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
}
},
"include": ["src"]
}