diff --git a/README.md b/README.md index 05337e3..8d7233c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/govoplan_scheduling/backend/db/__init__.py b/src/govoplan_scheduling/backend/db/__init__.py index 03f504d..df70210 100644 --- a/src/govoplan_scheduling/backend/db/__init__.py +++ b/src/govoplan_scheduling/backend/db/__init__.py @@ -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"] diff --git a/src/govoplan_scheduling/backend/db/models.py b/src/govoplan_scheduling/backend/db/models.py index ac03ae8..8142f14 100644 --- a/src/govoplan_scheduling/backend/db/models.py +++ b/src/govoplan_scheduling/backend/db/models.py @@ -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", +] diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index 53a2dd4..38f7069 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -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", ), ), diff --git a/src/govoplan_scheduling/backend/migrations/versions/4d5e6f7a8b9c_v018_scheduling_calendar_notifications.py b/src/govoplan_scheduling/backend/migrations/versions/4d5e6f7a8b9c_v018_scheduling_calendar_notifications.py new file mode 100644 index 0000000..53928e6 --- /dev/null +++ b/src/govoplan_scheduling/backend/migrations/versions/4d5e6f7a8b9c_v018_scheduling_calendar_notifications.py @@ -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") diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 58a2629..38a73a5 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -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 + ] + ) diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index ff60d4d..16ea404 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -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) diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 04ef9cc..b46b07c 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -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 {}, + } diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 8e45387..14b3085 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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}) diff --git a/tests/test_service.py b/tests/test_service.py index 25d429e..ae301de 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -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}) diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..7a64c66 --- /dev/null +++ b/webui/package.json @@ -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 + } + } +} diff --git a/webui/src/api/scheduling.ts b/webui/src/api/scheduling.ts new file mode 100644 index 0000000..45f6659 --- /dev/null +++ b/webui/src/api/scheduling.ts @@ -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[]; + tentative_hold_event_id?: string | null; + metadata?: Record; +}; + +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; +}; + +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; + 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; +}; + +export type SchedulingParticipantPayload = { + respondent_id?: string | null; + display_name?: string | null; + email?: string | null; + participant_type?: "internal" | "external" | "resource"; + required?: boolean; + metadata?: Record; +}; + +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; +}; + +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; + error?: string | null; + sent_at?: string | null; + created_at: string; + updated_at: string; + metadata?: Record; +}; + +export type SchedulingNotificationListResponse = { notifications: SchedulingNotification[] }; + +export type SchedulingPollOptionResult = { + option_id: string; + option_key: string; + label: string; + count: number; + score: number; + values: Record; +}; + +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 { + const query = status ? `?status=${encodeURIComponent(status)}` : ""; + return apiFetch(settings, `/api/v1/scheduling/requests${query}`); +} + +export function createSchedulingRequest(settings: ApiSettings, payload: SchedulingRequestCreatePayload): Promise { + return apiFetch(settings, "/api/v1/scheduling/requests", json(payload)); +} + +export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({})); +} + +export function closeSchedulingRequest(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/close`, json({})); +} + +export function decideSchedulingRequest(settings: ApiSettings, requestId: string, payload: SchedulingDecisionPayload): Promise { + return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/decide`, json(payload)); +} + +export function evaluateSchedulingFreeBusy(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/calendar/freebusy`, json({})); +} + +export function createSchedulingHolds(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/calendar/holds`, json({})); +} + +export function createSchedulingCalendarEvent(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/calendar/event`, json({})); +} + +export function schedulingSummary(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/summary`); +} + +export function createSchedulingNotifications(settings: ApiSettings, requestId: string, eventKind: string): Promise { + return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/notifications`, json({ event_kind: eventKind })); +} + +export function listSchedulingNotifications(settings: ApiSettings, requestId?: string): Promise { + const query = requestId ? `?request_id=${encodeURIComponent(requestId)}` : ""; + return apiFetch(settings, `/api/v1/scheduling/notifications${query}`); +} diff --git a/webui/src/features/scheduling/SchedulingPage.tsx b/webui/src/features/scheduling/SchedulingPage.tsx new file mode 100644 index 0000000..a659a9a --- /dev/null +++ b/webui/src/features/scheduling/SchedulingPage.tsx @@ -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([]); + const [selectedId, setSelectedId] = useState(""); + const [summary, setSummary] = useState(null); + const [notifications, setNotifications] = useState([]); + 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([ + { label: "Option 1", start_at: localValue(defaultStart), end_at: localValue(defaultEnd) } + ]); + const [participants, setParticipants] = useState([{ 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(); + 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 ( +
+
+ +
+
+
+ + {selected?.title || "Scheduling"} +
+
+ + + + + + +
+
+ + {error ?
{error}
: null} + +
+
void createRequest(event)}> +
+ + New poll +
+ + + + {calendarEnabled ? ( + + ) : null} +
+
+ Slots + +
+ {slots.map((slot, index) => ( +
+ updateSlot(index, "label", event.target.value)} /> + updateSlot(index, "start_at", event.target.value)} /> + updateSlot(index, "end_at", event.target.value)} /> +
+ ))} +
+
+
+ Participants + +
+ {participants.map((participant, index) => ( +
+ updateParticipant(index, "display_name", event.target.value)} /> + updateParticipant(index, "email", event.target.value)} /> +
+ ))} +
+ +
+ +
+ {selected ? ( + <> +
+ {selected.status} + {selected.participants.length} participants + {summary?.poll_summary.response_count || 0} responses +
+
+ {selected.slots.map((slot) => { + const result = slot.poll_option_id ? optionResultById.get(slot.poll_option_id) : null; + return ( +
+
+ {slot.label} + {formatRange(slot.start_at, slot.end_at)} +
+ {slot.freebusy_status || "unchecked"} + {result?.values.available || 0} yes + {result?.values.maybe || 0} maybe + {result?.values.unavailable || 0} no + +
+ ); + })} +
+
+
+
Participants
+ {selected.participants.map((participant) => ( +
+ {participant.display_name || participant.email || participant.id} + {participant.status} +
+ ))} +
+
+
Outbox
+ {notifications.map((notification) => ( +
+ {notification.event_kind} + {notification.status} +
+ ))} +
+
+ + ) : ( +
No scheduling request
+ )} +
+
+
+
+
+ ); + + 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"; +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts new file mode 100644 index 0000000..b2d1925 --- /dev/null +++ b/webui/src/i18n/generatedTranslations.ts @@ -0,0 +1,4 @@ +export const generatedTranslations = { + en: {}, + de: {} +}; diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..ad206b7 --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,2 @@ +export { schedulingModule as default, schedulingModule } from "./module"; +export * from "./api/scheduling"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..d5028c3 --- /dev/null +++ b/webui/src/module.ts @@ -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; diff --git a/webui/src/styles/scheduling.css b/webui/src/styles/scheduling.css new file mode 100644 index 0000000..6f83d14 --- /dev/null +++ b/webui/src/styles/scheduling.css @@ -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; + } +} diff --git a/webui/src/vite-env.d.ts b/webui/src/vite-env.d.ts new file mode 100644 index 0000000..1398537 --- /dev/null +++ b/webui/src/vite-env.d.ts @@ -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; +} diff --git a/webui/tsconfig.json b/webui/tsconfig.json new file mode 100644 index 0000000..30cfa96 --- /dev/null +++ b/webui/tsconfig.json @@ -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"] +}