Add scheduling calendar integration and WebUI
This commit is contained in:
11
README.md
11
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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {},
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
31
webui/package.json
Normal 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
210
webui/src/api/scheduling.ts
Normal 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}`);
|
||||
}
|
||||
349
webui/src/features/scheduling/SchedulingPage.tsx
Normal file
349
webui/src/features/scheduling/SchedulingPage.tsx
Normal 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";
|
||||
}
|
||||
4
webui/src/i18n/generatedTranslations.ts
Normal file
4
webui/src/i18n/generatedTranslations.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const generatedTranslations = {
|
||||
en: {},
|
||||
de: {}
|
||||
};
|
||||
2
webui/src/index.ts
Normal file
2
webui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { schedulingModule as default, schedulingModule } from "./module";
|
||||
export * from "./api/scheduling";
|
||||
23
webui/src/module.ts
Normal file
23
webui/src/module.ts
Normal 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;
|
||||
308
webui/src/styles/scheduling.css
Normal file
308
webui/src/styles/scheduling.css
Normal 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
13
webui/src/vite-env.d.ts
vendored
Normal 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
31
webui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user