✨(freebusy) add availability management (#35)
Adds organization-level default calendar sharing controls, "Find a Time" scheduling UI with a Free/Busy timeline showing attendee availability and conflicts, Working hours editor in Settings to manage and save availability, Autocomplete attendee search with debounced, partial name/email matching and timezone display. Fixes #26. Fixes #25. Fixes #24.
This commit is contained in:
18
README.md
18
README.md
@@ -82,12 +82,20 @@ Your Docker services should now be up and running! 🎉
|
||||
|
||||
You can access the project by going to <http://localhost:8930>.
|
||||
|
||||
You will be prompted to log in. The default credentials are:
|
||||
You will be prompted to log in. The following test users are
|
||||
pre-configured in Keycloak (password = username prefix):
|
||||
|
||||
```
|
||||
username: calendars
|
||||
password: calendars
|
||||
```
|
||||
| Email | Password | Org domain |
|
||||
|---|---|---|
|
||||
| `user1@example.local` | `user1` | `example.local` |
|
||||
| `user2@example.local` | `user2` | `example.local` |
|
||||
| `user3@example.local` | `user3` | `example.local` |
|
||||
| `user1.2@example2.local` | `user1.2` | `example2.local` |
|
||||
| `user2.2@example2.local` | `user2.2` | `example2.local` |
|
||||
|
||||
Users sharing the same domain are placed in the same organization
|
||||
automatically on first login. Use users from different domains
|
||||
(`example.local` vs `example2.local`) to test cross-org isolation.
|
||||
|
||||
Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ services:
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
volumes:
|
||||
- ./docker/postgresql/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh:ro
|
||||
env_file:
|
||||
- env.d/development/postgresql.defaults
|
||||
- env.d/development/postgresql.local
|
||||
|
||||
@@ -51,15 +51,71 @@
|
||||
"failureFactor": 30,
|
||||
"users": [
|
||||
{
|
||||
"username": "calendars",
|
||||
"email": "calendars@calendars.world",
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"username": "user1",
|
||||
"email": "user1@example.local",
|
||||
"firstName": "User",
|
||||
"lastName": "One",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "calendars"
|
||||
"value": "user1"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user2",
|
||||
"email": "user2@example.local",
|
||||
"firstName": "User",
|
||||
"lastName": "Two",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "user2"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user3",
|
||||
"email": "user3@example.local",
|
||||
"firstName": "User",
|
||||
"lastName": "Three",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "user3"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user1.2",
|
||||
"email": "user1.2@example2.local",
|
||||
"firstName": "User",
|
||||
"lastName": "One-Bis",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "user1.2"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user2.2",
|
||||
"email": "user2.2@example2.local",
|
||||
"firstName": "User",
|
||||
"lastName": "Two-Bis",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "user2.2"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
|
||||
@@ -83,6 +83,15 @@ class Base(Configuration):
|
||||
CALDAV_INTERNAL_API_KEY = SecretFileValue(
|
||||
None, environ_name="CALDAV_INTERNAL_API_KEY", environ_prefix=None
|
||||
)
|
||||
|
||||
# Default calendar sharing level for new organizations.
|
||||
# Controls what colleagues in the same org can see by default.
|
||||
# Values: "none", "freebusy", "read", "write"
|
||||
ORG_DEFAULT_SHARING_LEVEL = values.Value(
|
||||
"freebusy",
|
||||
environ_name="ORG_DEFAULT_SHARING_LEVEL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Salt for django-fernet-encrypted-fields (Channel tokens, etc.)
|
||||
# Used with SECRET_KEY to derive Fernet encryption keys via PBKDF2
|
||||
SALT_KEY = values.Value(
|
||||
@@ -931,8 +940,8 @@ class Development(Base):
|
||||
EMAIL_PORT = 1025
|
||||
EMAIL_USE_TLS = False
|
||||
EMAIL_USE_SSL = False
|
||||
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
|
||||
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
|
||||
DEFAULT_FROM_EMAIL = "noreply@example.local"
|
||||
CALENDAR_INVITATION_FROM_EMAIL = "noreply@example.local"
|
||||
APP_NAME = "Calendars (dev)"
|
||||
APP_URL = "http://localhost:8931"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
|
||||
from rest_framework import serializers
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
@@ -13,10 +14,16 @@ from core.models import uuid_to_urlsafe
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize organizations."""
|
||||
|
||||
sharing_level = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
fields = ["id", "name"]
|
||||
read_only_fields = ["id", "name"]
|
||||
fields = ["id", "name", "sharing_level"]
|
||||
read_only_fields = ["id", "name", "sharing_level"]
|
||||
|
||||
def get_sharing_level(self, org) -> str:
|
||||
"""Return the effective sharing level (org override or server default)."""
|
||||
return org.effective_sharing_level
|
||||
|
||||
|
||||
class UserLiteSerializer(serializers.ModelSerializer):
|
||||
@@ -32,6 +39,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
email = serializers.SerializerMethodField(read_only=True)
|
||||
timezone = TimeZoneSerializerField(use_pytz=False)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
@@ -40,6 +48,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"email",
|
||||
"full_name",
|
||||
"language",
|
||||
"timezone",
|
||||
]
|
||||
read_only_fields = ["id", "email", "full_name"]
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.utils.text import slugify
|
||||
|
||||
from rest_framework import mixins, pagination, response, status, views, viewsets
|
||||
@@ -98,9 +99,9 @@ class UserViewSet(
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Limit listed users by querying the email field.
|
||||
Limit listed users by querying email or full_name.
|
||||
Scoped to the requesting user's organization.
|
||||
If query contains "@", search exactly. Otherwise return empty.
|
||||
Minimum 3 characters required.
|
||||
"""
|
||||
queryset = self.queryset
|
||||
|
||||
@@ -112,15 +113,13 @@ class UserViewSet(
|
||||
return queryset.none()
|
||||
queryset = queryset.filter(organization_id=self.request.user.organization_id)
|
||||
|
||||
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
|
||||
if not (query := self.request.query_params.get("q", "")) or len(query) < 3:
|
||||
return queryset.none()
|
||||
|
||||
# For emails, match exactly
|
||||
if "@" in query:
|
||||
return queryset.filter(email__iexact=query).order_by("email")
|
||||
|
||||
# For non-email queries, return empty (no fuzzy search)
|
||||
return queryset.none()
|
||||
# Search by email (partial, case-insensitive) or full name
|
||||
return queryset.filter(
|
||||
Q(email__icontains=query) | Q(full_name__icontains=query)
|
||||
).order_by("full_name", "email")[:50]
|
||||
|
||||
@action(
|
||||
detail=False,
|
||||
@@ -210,6 +209,57 @@ class ConfigView(views.APIView):
|
||||
return theme_customization
|
||||
|
||||
|
||||
class OrganizationSettingsViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for organization settings (sharing level, etc.).
|
||||
|
||||
Only org admins can update settings; all org members can read them.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def retrieve(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""GET /api/v1.0/organization-settings/current/"""
|
||||
org = request.user.organization
|
||||
if not org:
|
||||
return response.Response(
|
||||
{"detail": "User has no organization."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return response.Response(serializers.OrganizationSerializer(org).data)
|
||||
|
||||
def partial_update(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""PATCH /api/v1.0/organization-settings/current/"""
|
||||
if not request.user.organization:
|
||||
return response.Response(
|
||||
{"detail": "User has no organization."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Check admin permission
|
||||
perm = permissions.IsOrgAdmin()
|
||||
if not perm.has_permission(request, self):
|
||||
return response.Response(
|
||||
{"detail": "Only org admins can update settings."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
org = request.user.organization
|
||||
sharing_level = request.data.get("default_sharing_level")
|
||||
if sharing_level is not None:
|
||||
valid = {c[0] for c in models.SharingLevel.choices}
|
||||
if sharing_level not in valid:
|
||||
return response.Response(
|
||||
{
|
||||
"detail": f"Invalid sharing level. Must be one of: {', '.join(valid)}"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
org.default_sharing_level = sharing_level
|
||||
org.save(update_fields=["default_sharing_level", "updated_at"])
|
||||
|
||||
return response.Response(serializers.OrganizationSerializer(org).data)
|
||||
|
||||
|
||||
class CalendarViewSet(viewsets.GenericViewSet):
|
||||
"""ViewSet for calendar operations.
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.9 on 2026-03-09 08:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='default_sharing_level',
|
||||
field=models.CharField(blank=True, choices=[('none', 'No sharing'), ('freebusy', 'Free/Busy only'), ('read', 'Read access'), ('write', 'Read/Write access')], help_text='Default calendar sharing level for org members. Null means use the server-wide default (ORG_DEFAULT_SHARING_LEVEL).', max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
@@ -66,6 +66,15 @@ class BaseModel(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class SharingLevel(models.TextChoices):
|
||||
"""Calendar sharing visibility levels within an organization."""
|
||||
|
||||
NONE = "none", "No sharing"
|
||||
FREEBUSY = "freebusy", "Free/Busy only"
|
||||
READ = "read", "Read access"
|
||||
WRITE = "write", "Read/Write access"
|
||||
|
||||
|
||||
class Organization(BaseModel):
|
||||
"""Organization model, populated from OIDC claims and entitlements.
|
||||
|
||||
@@ -81,6 +90,16 @@ class Organization(BaseModel):
|
||||
db_index=True,
|
||||
help_text="Organization identifier from OIDC claim or email domain.",
|
||||
)
|
||||
default_sharing_level = models.CharField(
|
||||
max_length=10,
|
||||
choices=SharingLevel.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Default calendar sharing level for org members. "
|
||||
"Null means use the server-wide default (ORG_DEFAULT_SHARING_LEVEL)."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "calendars_organization"
|
||||
@@ -90,6 +109,13 @@ class Organization(BaseModel):
|
||||
def __str__(self):
|
||||
return self.name or self.external_id
|
||||
|
||||
@property
|
||||
def effective_sharing_level(self):
|
||||
"""Return the effective sharing level, falling back to server default."""
|
||||
if self.default_sharing_level:
|
||||
return self.default_sharing_level
|
||||
return settings.ORG_DEFAULT_SHARING_LEVEL
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Delete org after cleaning up members' CalDAV data.
|
||||
|
||||
|
||||
@@ -58,11 +58,15 @@ class CalDAVHTTPClient:
|
||||
"""
|
||||
if not user.email:
|
||||
raise ValueError("User has no email address")
|
||||
return {
|
||||
headers = {
|
||||
"X-Api-Key": cls.get_api_key(),
|
||||
"X-Forwarded-User": user.email,
|
||||
"X-CalDAV-Organization": str(user.organization_id),
|
||||
}
|
||||
org = getattr(user, "organization", None)
|
||||
if org and hasattr(org, "effective_sharing_level"):
|
||||
headers["X-CalDAV-Sharing-Level"] = org.effective_sharing_level
|
||||
return headers
|
||||
|
||||
def build_url(self, path: str, query: str = "") -> str:
|
||||
"""Build a full CalDAV URL from a resource path.
|
||||
|
||||
@@ -73,61 +73,38 @@ def test_api_users_list_query_inactive():
|
||||
|
||||
|
||||
def test_api_users_list_query_short_queries():
|
||||
"""
|
||||
Queries shorter than 5 characters should return an empty result set.
|
||||
"""
|
||||
|
||||
"""Queries shorter than 3 characters should return an empty result set."""
|
||||
user = factories.UserFactory()
|
||||
org = user.organization
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory(email="john.doe@example.com")
|
||||
factories.UserFactory(email="john.lennon@example.com")
|
||||
factories.UserFactory(email="john.doe@example.com", organization=org)
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=jo")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["results"] == []
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=john")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["results"] == []
|
||||
|
||||
# Non-email queries (without @) return empty
|
||||
response = client.get("/api/v1.0/users/?q=john.")
|
||||
response = client.get("/api/v1.0/users/?q=j")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["results"] == []
|
||||
|
||||
|
||||
def test_api_users_list_limit(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users and the number of results
|
||||
should be limited to 10.
|
||||
"""
|
||||
def test_api_users_list_limit(settings): # pylint: disable=unused-argument
|
||||
"""Results should be bounded even with many matching users."""
|
||||
user = factories.UserFactory()
|
||||
org = user.organization
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Use a base name with a length equal 5 to test that the limit is applied
|
||||
base_name = "alice"
|
||||
for i in range(15):
|
||||
factories.UserFactory(email=f"{base_name}.{i}@example.com", organization=org)
|
||||
for i in range(55):
|
||||
factories.UserFactory(email=f"alice.{i}@example.com", organization=org)
|
||||
|
||||
# Non-email queries (without @) return empty
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice",
|
||||
)
|
||||
# Partial match returns results (capped at 50)
|
||||
response = client.get("/api/v1.0/users/?q=alice")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["results"] == []
|
||||
|
||||
# Email queries require exact match
|
||||
settings.API_USERS_LIST_LIMIT = 100
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice.0@example.com",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["results"]) == 1
|
||||
assert len(response.json()["results"]) == 50
|
||||
|
||||
|
||||
def test_api_users_list_throttling_authenticated(settings):
|
||||
@@ -154,8 +131,7 @@ def test_api_users_list_throttling_authenticated(settings):
|
||||
|
||||
def test_api_users_list_query_email(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users and filter by email.
|
||||
Only exact email matches are returned (case-insensitive).
|
||||
Authenticated users should be able to search users by partial email.
|
||||
"""
|
||||
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "9999/minute"
|
||||
@@ -167,42 +143,35 @@ def test_api_users_list_query_email(settings):
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory(email="david.bowman@work.com", organization=org)
|
||||
factories.UserFactory(email="nicole.bowman@work.com", organization=org)
|
||||
nicole = factories.UserFactory(email="nicole.bowman@work.com", organization=org)
|
||||
|
||||
# Exact match works
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
response = client.get("/api/v1.0/users/?q=david.bowman@work.com")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
user_ids = [u["id"] for u in response.json()["results"]]
|
||||
assert str(dave.id) in user_ids
|
||||
|
||||
# Partial email match works
|
||||
response = client.get("/api/v1.0/users/?q=bowman@work")
|
||||
assert response.status_code == 200
|
||||
user_ids = [u["id"] for u in response.json()["results"]]
|
||||
assert str(dave.id) in user_ids
|
||||
assert str(nicole.id) in user_ids
|
||||
|
||||
# Case-insensitive match works
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=David.Bowman@Work.COM",
|
||||
)
|
||||
response = client.get("/api/v1.0/users/?q=David.Bowman@Work.COM")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
user_ids = [u["id"] for u in response.json()["results"]]
|
||||
assert str(dave.id) in user_ids
|
||||
|
||||
# Typos don't match (exact match only)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=davig.bovman@worm.com",
|
||||
)
|
||||
# Typos don't match
|
||||
response = client.get("/api/v1.0/users/?q=davig.bovman@worm.com")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == []
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=davig.bovman@worm.cop",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == []
|
||||
assert response.json()["results"] == []
|
||||
|
||||
|
||||
def test_api_users_list_query_email_matching():
|
||||
"""Email queries return exact matches only (case-insensitive)."""
|
||||
def test_api_users_list_query_email_partial_matching():
|
||||
"""Partial email queries return matching users."""
|
||||
user = factories.UserFactory()
|
||||
org = user.organization
|
||||
|
||||
@@ -212,29 +181,111 @@ def test_api_users_list_query_email_matching():
|
||||
user1 = factories.UserFactory(
|
||||
email="alice.johnson@example.gouv.fr", organization=org
|
||||
)
|
||||
factories.UserFactory(email="alice.johnnson@example.gouv.fr", organization=org)
|
||||
user2 = factories.UserFactory(
|
||||
email="alice.johnnson@example.gouv.fr", organization=org
|
||||
)
|
||||
factories.UserFactory(email="alice.kohlson@example.gouv.fr", organization=org)
|
||||
user4 = factories.UserFactory(
|
||||
email="alicia.johnnson@example.gouv.fr", organization=org
|
||||
)
|
||||
factories.UserFactory(email="alicia.johnnson@example.gov.uk", organization=org)
|
||||
# Different org user should not appear
|
||||
other_org_user = factories.UserFactory(email="alice.johnnson@example.gov.uk")
|
||||
factories.UserFactory(email="alice.thomson@example.gouv.fr", organization=org)
|
||||
|
||||
# Exact match returns only that user
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
|
||||
)
|
||||
# Partial match on "alice.john" returns alice.johnson and alice.johnnson
|
||||
response = client.get("/api/v1.0/users/?q=alice.john")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(user1.id)]
|
||||
user_ids = [u["id"] for u in response.json()["results"]]
|
||||
assert str(user1.id) in user_ids
|
||||
assert str(user2.id) in user_ids
|
||||
assert str(other_org_user.id) not in user_ids
|
||||
|
||||
# Different email returns different user
|
||||
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
|
||||
# Partial match on "alicia" returns alicia.johnnson (same org only)
|
||||
response = client.get("/api/v1.0/users/?q=alicia")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [u["id"] for u in response.json()["results"]]
|
||||
assert user_ids == [str(user4.id)]
|
||||
|
||||
|
||||
def test_api_users_list_query_by_name():
|
||||
"""Users should be searchable by full name (partial, case-insensitive)."""
|
||||
user = factories.UserFactory()
|
||||
org = user.organization
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
alice = factories.UserFactory(
|
||||
email="alice@example.com", full_name="Alice Johnson", organization=org
|
||||
)
|
||||
bob = factories.UserFactory(
|
||||
email="bob@example.com", full_name="Bob Smith", organization=org
|
||||
)
|
||||
factories.UserFactory(
|
||||
email="charlie@example.com", full_name="Charlie Johnson", organization=org
|
||||
)
|
||||
|
||||
# Search by first name
|
||||
response = client.get("/api/v1.0/users/?q=Alice")
|
||||
assert response.status_code == 200
|
||||
user_ids = [u["id"] for u in response.json()["results"]]
|
||||
assert str(alice.id) in user_ids
|
||||
assert str(bob.id) not in user_ids
|
||||
|
||||
# Search by last name matches multiple users
|
||||
response = client.get("/api/v1.0/users/?q=Johnson")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["results"]) == 2
|
||||
|
||||
# Case-insensitive
|
||||
response = client.get("/api/v1.0/users/?q=bob")
|
||||
assert response.status_code == 200
|
||||
user_ids = [u["id"] for u in response.json()["results"]]
|
||||
assert str(bob.id) in user_ids
|
||||
|
||||
|
||||
def test_api_users_list_cross_org_isolation():
|
||||
"""Users from different organizations should not see each other."""
|
||||
org1 = factories.OrganizationFactory(name="Org One")
|
||||
org2 = factories.OrganizationFactory(name="Org Two")
|
||||
|
||||
user1 = factories.UserFactory(
|
||||
email="user1@org1.com", full_name="Shared Name", organization=org1
|
||||
)
|
||||
factories.UserFactory(
|
||||
email="user2@org2.com", full_name="Shared Name", organization=org2
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user1)
|
||||
|
||||
# Search by shared name - should only return same-org user
|
||||
response = client.get("/api/v1.0/users/?q=Shared")
|
||||
assert response.status_code == 200
|
||||
user_ids = [u["id"] for u in response.json()["results"]]
|
||||
assert str(user1.id) in user_ids
|
||||
assert len(user_ids) == 1
|
||||
|
||||
# Search by cross-org email - should return nothing
|
||||
response = client.get("/api/v1.0/users/?q=user2@org2.com")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["results"] == []
|
||||
|
||||
|
||||
def test_api_users_list_includes_self():
|
||||
"""Search should include the requesting user if they match."""
|
||||
user = factories.UserFactory(email="alice@example.com", full_name="Alice Test")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# User should find themselves
|
||||
response = client.get("/api/v1.0/users/?q=alice")
|
||||
assert response.status_code == 200
|
||||
user_ids = [u["id"] for u in response.json()["results"]]
|
||||
assert str(user.id) in user_ids
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_anonymous():
|
||||
"""Anonymous users should not be allowed to list users."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
@@ -271,11 +322,13 @@ def test_api_users_retrieve_me_authenticated():
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"can_access": True,
|
||||
"can_admin": True,
|
||||
"organization": {
|
||||
"id": str(user.organization.id),
|
||||
"name": user.organization.name,
|
||||
"sharing_level": "freebusy",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -440,8 +493,8 @@ def test_api_users_update_anonymous():
|
||||
|
||||
def test_api_users_update_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to update their own user but only "language"
|
||||
and "timezone" fields.
|
||||
Authenticated users should be able to update their own user but only "language",
|
||||
"timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -528,8 +581,8 @@ def test_api_users_patch_anonymous():
|
||||
|
||||
def test_api_users_patch_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to patch their own user but only "language"
|
||||
and "timezone" fields.
|
||||
Authenticated users should be able to patch their own user but only "language",
|
||||
"timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
|
||||
@@ -335,6 +335,232 @@ class TestCalDAVProxy:
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCalDAVFreeBusy:
|
||||
"""Tests for free/busy queries via CalDAV outbox POST."""
|
||||
|
||||
FREEBUSY_REQUEST = (
|
||||
"BEGIN:VCALENDAR\r\n"
|
||||
"VERSION:2.0\r\n"
|
||||
"PRODID:-//Test//EN\r\n"
|
||||
"METHOD:REQUEST\r\n"
|
||||
"BEGIN:VFREEBUSY\r\n"
|
||||
"DTSTART:20260309T000000Z\r\n"
|
||||
"DTEND:20260310T000000Z\r\n"
|
||||
"ORGANIZER:mailto:{organizer}\r\n"
|
||||
"ATTENDEE:mailto:{attendee}\r\n"
|
||||
"END:VFREEBUSY\r\n"
|
||||
"END:VCALENDAR"
|
||||
)
|
||||
|
||||
FREEBUSY_RESPONSE = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||
'<cal:schedule-response xmlns:d="DAV:" '
|
||||
'xmlns:cal="urn:ietf:params:xml:ns:caldav">\n'
|
||||
" <cal:response>\n"
|
||||
" <cal:recipient><d:href>mailto:{attendee}</d:href></cal:recipient>\n"
|
||||
" <cal:request-status>2.0;Success</cal:request-status>\n"
|
||||
" <cal:calendar-data>"
|
||||
"BEGIN:VCALENDAR\r\n"
|
||||
"VERSION:2.0\r\n"
|
||||
"PRODID:-//SabreDAV//EN\r\n"
|
||||
"BEGIN:VFREEBUSY\r\n"
|
||||
"DTSTART:20260309T000000Z\r\n"
|
||||
"DTEND:20260310T000000Z\r\n"
|
||||
"FREEBUSY:20260309T100000Z/20260309T110000Z\r\n"
|
||||
"END:VFREEBUSY\r\n"
|
||||
"END:VCALENDAR"
|
||||
"</cal:calendar-data>\n"
|
||||
" </cal:response>\n"
|
||||
"</cal:schedule-response>"
|
||||
)
|
||||
|
||||
@responses.activate
|
||||
def test_freebusy_post_forwarded_with_correct_content_type(self):
|
||||
"""POST to outbox should forward text/calendar content-type to CalDAV."""
|
||||
user = factories.UserFactory(email="alice@example.com")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
caldav_url = settings.CALDAV_URL
|
||||
outbox_path = f"calendars/users/{user.email}/outbox/"
|
||||
responses.add(
|
||||
responses.Response(
|
||||
method="POST",
|
||||
url=f"{caldav_url}/caldav/{outbox_path}",
|
||||
status=HTTP_200_OK,
|
||||
body=self.FREEBUSY_RESPONSE.format(attendee="bob@example.com"),
|
||||
headers={"Content-Type": "application/xml"},
|
||||
)
|
||||
)
|
||||
|
||||
body = self.FREEBUSY_REQUEST.format(
|
||||
organizer=user.email, attendee="bob@example.com"
|
||||
)
|
||||
response = client.generic(
|
||||
"POST",
|
||||
f"/caldav/{outbox_path}",
|
||||
data=body,
|
||||
content_type="text/calendar; charset=utf-8",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
# Verify content-type is forwarded (not overwritten to application/xml)
|
||||
forwarded = responses.calls[0].request
|
||||
assert "text/calendar" in forwarded.headers["Content-Type"]
|
||||
|
||||
@responses.activate
|
||||
def test_freebusy_post_forwards_body(self):
|
||||
"""POST to outbox should forward the iCalendar body unchanged."""
|
||||
user = factories.UserFactory(email="alice@example.com")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
caldav_url = settings.CALDAV_URL
|
||||
outbox_path = f"calendars/users/{user.email}/outbox/"
|
||||
responses.add(
|
||||
responses.Response(
|
||||
method="POST",
|
||||
url=f"{caldav_url}/caldav/{outbox_path}",
|
||||
status=HTTP_200_OK,
|
||||
body=self.FREEBUSY_RESPONSE.format(attendee="bob@example.com"),
|
||||
headers={"Content-Type": "application/xml"},
|
||||
)
|
||||
)
|
||||
|
||||
body = self.FREEBUSY_REQUEST.format(
|
||||
organizer=user.email, attendee="bob@example.com"
|
||||
)
|
||||
client.generic(
|
||||
"POST",
|
||||
f"/caldav/{outbox_path}",
|
||||
data=body,
|
||||
content_type="text/calendar; charset=utf-8",
|
||||
)
|
||||
|
||||
# Verify the body was forwarded
|
||||
forwarded = responses.calls[0].request
|
||||
assert b"BEGIN:VCALENDAR" in forwarded.body
|
||||
assert b"VFREEBUSY" in forwarded.body
|
||||
assert b"bob@example.com" in forwarded.body
|
||||
|
||||
@responses.activate
|
||||
def test_freebusy_post_forwards_auth_headers(self):
|
||||
"""POST to outbox should include X-Forwarded-User and X-Api-Key."""
|
||||
user = factories.UserFactory(email="alice@example.com")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
caldav_url = settings.CALDAV_URL
|
||||
outbox_path = f"calendars/users/{user.email}/outbox/"
|
||||
responses.add(
|
||||
responses.Response(
|
||||
method="POST",
|
||||
url=f"{caldav_url}/caldav/{outbox_path}",
|
||||
status=HTTP_200_OK,
|
||||
body=self.FREEBUSY_RESPONSE.format(attendee="bob@example.com"),
|
||||
headers={"Content-Type": "application/xml"},
|
||||
)
|
||||
)
|
||||
|
||||
body = self.FREEBUSY_REQUEST.format(
|
||||
organizer=user.email, attendee="bob@example.com"
|
||||
)
|
||||
client.generic(
|
||||
"POST",
|
||||
f"/caldav/{outbox_path}",
|
||||
data=body,
|
||||
content_type="text/calendar; charset=utf-8",
|
||||
)
|
||||
|
||||
forwarded = responses.calls[0].request
|
||||
assert forwarded.headers["X-Forwarded-User"] == user.email
|
||||
assert forwarded.headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY
|
||||
|
||||
@responses.activate
|
||||
def test_freebusy_post_returns_schedule_response(self):
|
||||
"""POST to outbox should return the CalDAV schedule-response XML."""
|
||||
user = factories.UserFactory(email="alice@example.com")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
caldav_url = settings.CALDAV_URL
|
||||
outbox_path = f"calendars/users/{user.email}/outbox/"
|
||||
response_body = self.FREEBUSY_RESPONSE.format(attendee="bob@example.com")
|
||||
responses.add(
|
||||
responses.Response(
|
||||
method="POST",
|
||||
url=f"{caldav_url}/caldav/{outbox_path}",
|
||||
status=HTTP_200_OK,
|
||||
body=response_body,
|
||||
headers={"Content-Type": "application/xml"},
|
||||
)
|
||||
)
|
||||
|
||||
body = self.FREEBUSY_REQUEST.format(
|
||||
organizer=user.email, attendee="bob@example.com"
|
||||
)
|
||||
response = client.generic(
|
||||
"POST",
|
||||
f"/caldav/{outbox_path}",
|
||||
data=body,
|
||||
content_type="text/calendar; charset=utf-8",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
# Verify the schedule-response is returned to the client
|
||||
root = ET.fromstring(response.content)
|
||||
ns = {"cal": "urn:ietf:params:xml:ns:caldav", "d": "DAV:"}
|
||||
status = root.find(".//cal:request-status", ns)
|
||||
assert status is not None
|
||||
assert "2.0" in status.text
|
||||
|
||||
def test_freebusy_post_requires_authentication(self):
|
||||
"""POST to outbox should require authentication."""
|
||||
client = APIClient()
|
||||
response = client.generic(
|
||||
"POST",
|
||||
"/caldav/calendars/users/alice@example.com/outbox/",
|
||||
data="BEGIN:VCALENDAR\r\nEND:VCALENDAR",
|
||||
content_type="text/calendar",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
|
||||
@responses.activate
|
||||
def test_freebusy_post_includes_organization_header(self):
|
||||
"""POST to outbox should include X-CalDAV-Organization header."""
|
||||
user = factories.UserFactory(email="alice@example.com")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
caldav_url = settings.CALDAV_URL
|
||||
outbox_path = f"calendars/users/{user.email}/outbox/"
|
||||
responses.add(
|
||||
responses.Response(
|
||||
method="POST",
|
||||
url=f"{caldav_url}/caldav/{outbox_path}",
|
||||
status=HTTP_200_OK,
|
||||
body=self.FREEBUSY_RESPONSE.format(attendee="bob@example.com"),
|
||||
headers={"Content-Type": "application/xml"},
|
||||
)
|
||||
)
|
||||
|
||||
body = self.FREEBUSY_REQUEST.format(
|
||||
organizer=user.email, attendee="bob@example.com"
|
||||
)
|
||||
client.generic(
|
||||
"POST",
|
||||
f"/caldav/{outbox_path}",
|
||||
data=body,
|
||||
content_type="text/calendar; charset=utf-8",
|
||||
)
|
||||
|
||||
forwarded = responses.calls[0].request
|
||||
assert forwarded.headers["X-CalDAV-Organization"] == str(user.organization_id)
|
||||
|
||||
|
||||
class TestValidateCaldavProxyPath:
|
||||
"""Tests for validate_caldav_proxy_path utility."""
|
||||
|
||||
|
||||
@@ -194,3 +194,100 @@ def test_user_list_same_org_visible():
|
||||
assert len(data) == 1
|
||||
assert data[0]["email"] == "carol@example.com"
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
|
||||
# -- Sharing level --
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_effective_sharing_level_defaults_to_server_setting():
|
||||
"""Organization without override returns server-wide default."""
|
||||
org = factories.OrganizationFactory(default_sharing_level=None)
|
||||
assert org.effective_sharing_level == "freebusy"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ORG_DEFAULT_SHARING_LEVEL="none")
|
||||
def test_effective_sharing_level_follows_server_override():
|
||||
"""Organization without override returns overridden server default."""
|
||||
org = factories.OrganizationFactory(default_sharing_level=None)
|
||||
assert org.effective_sharing_level == "none"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_effective_sharing_level_org_override():
|
||||
"""Organization with explicit level ignores server default."""
|
||||
org = factories.OrganizationFactory(default_sharing_level="read")
|
||||
assert org.effective_sharing_level == "read"
|
||||
|
||||
|
||||
# -- Organization settings API --
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(
|
||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||
)
|
||||
def test_org_settings_retrieve():
|
||||
"""GET /organization-settings/current/ returns org sharing level."""
|
||||
get_entitlements_backend.cache_clear()
|
||||
org = factories.OrganizationFactory(external_id="test-org")
|
||||
user = factories.UserFactory(organization=org)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
response = client.get("/api/v1.0/organization-settings/current/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["sharing_level"] == "freebusy"
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(
|
||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||
)
|
||||
def test_org_settings_update_sharing_level():
|
||||
"""PATCH /organization-settings/current/ updates sharing level."""
|
||||
get_entitlements_backend.cache_clear()
|
||||
org = factories.OrganizationFactory(external_id="test-org")
|
||||
user = factories.UserFactory(organization=org)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
response = client.patch(
|
||||
"/api/v1.0/organization-settings/current/",
|
||||
{"default_sharing_level": "none"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
org.refresh_from_db()
|
||||
assert org.default_sharing_level == "none"
|
||||
assert response.json()["sharing_level"] == "none"
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(
|
||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||
)
|
||||
def test_org_settings_update_rejects_invalid_level():
|
||||
"""PATCH with invalid sharing level returns 400."""
|
||||
get_entitlements_backend.cache_clear()
|
||||
org = factories.OrganizationFactory(external_id="test-org")
|
||||
user = factories.UserFactory(organization=org)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
response = client.patch(
|
||||
"/api/v1.0/organization-settings/current/",
|
||||
{"default_sharing_level": "superadmin"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
@@ -272,6 +272,34 @@ class TestCalDAVProxyOrgHeader:
|
||||
assert request.headers["X-CalDAV-Organization"] == str(org.id)
|
||||
assert request.headers["X-CalDAV-Organization"] != "spoofed-org-id"
|
||||
|
||||
@responses.activate
|
||||
def test_proxy_sends_sharing_level_header(self):
|
||||
"""CalDAV proxy sends X-CalDAV-Sharing-Level from org's effective level."""
|
||||
org = factories.OrganizationFactory(
|
||||
external_id="org-fb", default_sharing_level="none"
|
||||
)
|
||||
user = factories.UserFactory(email="alice@example.com", organization=org)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
caldav_url = settings.CALDAV_URL
|
||||
responses.add(
|
||||
responses.Response(
|
||||
method="PROPFIND",
|
||||
url=f"{caldav_url}/caldav/principals/resources/",
|
||||
status=HTTP_207_MULTI_STATUS,
|
||||
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
|
||||
headers={"Content-Type": "application/xml"},
|
||||
)
|
||||
)
|
||||
|
||||
client.generic("PROPFIND", "/caldav/principals/resources/")
|
||||
|
||||
assert len(responses.calls) == 1
|
||||
request = responses.calls[0].request
|
||||
assert request.headers["X-CalDAV-Sharing-Level"] == "none"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IsEntitledToAccess permission — fail-closed
|
||||
|
||||
@@ -20,6 +20,11 @@ router.register("users", viewsets.UserViewSet, basename="users")
|
||||
router.register("calendars", viewsets.CalendarViewSet, basename="calendars")
|
||||
router.register("resources", viewsets.ResourceViewSet, basename="resources")
|
||||
router.register("channels", ChannelViewSet, basename="channels")
|
||||
router.register(
|
||||
"organization-settings",
|
||||
viewsets.OrganizationSettingsViewSet,
|
||||
basename="organization-settings",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
@@ -17,6 +17,8 @@ use Calendars\SabreDav\AttendeeNormalizerPlugin;
|
||||
use Calendars\SabreDav\InternalApiPlugin;
|
||||
use Calendars\SabreDav\ResourceAutoSchedulePlugin;
|
||||
use Calendars\SabreDav\ResourceMkCalendarBlockPlugin;
|
||||
use Calendars\SabreDav\FreeBusyOrgScopePlugin;
|
||||
use Calendars\SabreDav\AvailabilityPlugin;
|
||||
use Calendars\SabreDav\CalendarsRoot;
|
||||
use Calendars\SabreDav\CustomCalDAVPlugin;
|
||||
use Calendars\SabreDav\PrincipalsRoot;
|
||||
@@ -88,7 +90,12 @@ $principalBackend->setServer($server);
|
||||
$server->addPlugin($authPlugin);
|
||||
$server->addPlugin(new CustomCalDAVPlugin());
|
||||
$server->addPlugin(new CardDAV\Plugin());
|
||||
$server->addPlugin(new DAVACL\Plugin());
|
||||
// PrincipalsRoot is a plain DAV\Collection (not IPrincipalCollection), so the
|
||||
// default principalCollectionSet ['principals'] would skip it during principal
|
||||
// search. Point directly to the child IPrincipalCollection nodes instead.
|
||||
$aclPlugin = new DAVACL\Plugin();
|
||||
$aclPlugin->principalCollectionSet = ['principals/users', 'principals/resources'];
|
||||
$server->addPlugin($aclPlugin);
|
||||
$server->addPlugin(new DAV\Browser\Plugin());
|
||||
|
||||
// Add ICS export plugin for iCal subscription URLs
|
||||
@@ -169,6 +176,10 @@ if ($defaultCallbackUrl) {
|
||||
$imipPlugin = new HttpCallbackIMipPlugin($callbackApiKey, $defaultCallbackUrl);
|
||||
$server->addPlugin($imipPlugin);
|
||||
|
||||
// Enforce org-level freebusy sharing settings
|
||||
// Blocks VFREEBUSY queries when X-CalDAV-Sharing-Level is "none"
|
||||
$server->addPlugin(new FreeBusyOrgScopePlugin());
|
||||
|
||||
// Add CalDAV scheduling support
|
||||
// See https://sabre.io/dav/scheduling/
|
||||
// The Schedule\Plugin will automatically find and use the IMipPlugin we just added
|
||||
@@ -183,6 +194,10 @@ $server->addPlugin(new ResourceAutoSchedulePlugin($pdo, $caldavBackend));
|
||||
// Block MKCALENDAR on resource principals (each resource has exactly one calendar)
|
||||
$server->addPlugin(new ResourceMkCalendarBlockPlugin());
|
||||
|
||||
// Add availability integration for freebusy responses
|
||||
// Reads calendar-availability property and adds BUSY-UNAVAILABLE periods
|
||||
$server->addPlugin(new AvailabilityPlugin());
|
||||
|
||||
// Add property storage plugin for custom properties (resource metadata, etc.)
|
||||
$server->addPlugin(new DAV\PropertyStorage\Plugin(
|
||||
new DAV\PropertyStorage\Backend\PDO($pdo)
|
||||
|
||||
513
src/caldav/src/AvailabilityPlugin.php
Normal file
513
src/caldav/src/AvailabilityPlugin.php
Normal file
@@ -0,0 +1,513 @@
|
||||
<?php
|
||||
/**
|
||||
* AvailabilityPlugin - Integrates VAVAILABILITY (RFC 7953) into freebusy responses.
|
||||
*
|
||||
* When a freebusy query is made via the scheduling outbox, this plugin
|
||||
* post-processes the response to add BUSY-UNAVAILABLE periods based on
|
||||
* each recipient's calendar-availability property.
|
||||
*
|
||||
* The calendar-availability property is stored on the user's calendar home
|
||||
* via the PropertyStorage plugin and contains a VCALENDAR with VAVAILABILITY
|
||||
* and AVAILABLE components that define working hours.
|
||||
*
|
||||
* Runs after Schedule\Plugin (priority 200 on afterMethod:POST).
|
||||
*/
|
||||
|
||||
namespace Calendars\SabreDav;
|
||||
|
||||
use Sabre\DAV;
|
||||
use Sabre\DAV\Server;
|
||||
use Sabre\DAV\ServerPlugin;
|
||||
use Sabre\HTTP\RequestInterface;
|
||||
use Sabre\HTTP\ResponseInterface;
|
||||
use Sabre\VObject\Reader;
|
||||
|
||||
class AvailabilityPlugin extends ServerPlugin
|
||||
{
|
||||
/** @var Server */
|
||||
protected $server;
|
||||
|
||||
/** CalDAV namespace */
|
||||
private const CALDAV_NS = '{urn:ietf:params:xml:ns:caldav}';
|
||||
|
||||
/** calendar-availability property name */
|
||||
private const AVAILABILITY_PROP = '{urn:ietf:params:xml:ns:caldav}calendar-availability';
|
||||
|
||||
public function initialize(Server $server)
|
||||
{
|
||||
$this->server = $server;
|
||||
// Priority 200: runs after Schedule\Plugin (110) has built the response
|
||||
$server->on('afterMethod:POST', [$this, 'afterPost'], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process scheduling outbox responses to inject BUSY-UNAVAILABLE periods.
|
||||
*/
|
||||
public function afterPost(RequestInterface $request, ResponseInterface $response)
|
||||
{
|
||||
// Only process successful responses
|
||||
if ($response->getStatus() !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process outbox requests
|
||||
$path = $request->getPath();
|
||||
if (strpos($path, 'outbox') === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process XML responses
|
||||
$contentType = $response->getHeader('Content-Type');
|
||||
if (!$contentType || strpos($contentType, 'application/xml') === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = $response->getBodyAsString();
|
||||
if (!$body) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$modified = $this->processScheduleResponse($body);
|
||||
if ($modified !== null) {
|
||||
$response->setBody($modified);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log("[AvailabilityPlugin] Error processing response: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the schedule-response XML and inject BUSY-UNAVAILABLE periods
|
||||
* for each recipient that has a calendar-availability property.
|
||||
*
|
||||
* @param string $xml The original XML response body
|
||||
* @return string|null Modified XML or null if no changes
|
||||
*/
|
||||
private function processScheduleResponse($xml)
|
||||
{
|
||||
$dom = new \DOMDocument();
|
||||
$dom->preserveWhiteSpace = true;
|
||||
$dom->formatOutput = false;
|
||||
|
||||
if (!@$dom->loadXML($xml)) {
|
||||
error_log("[AvailabilityPlugin] Failed to parse XML response");
|
||||
return null;
|
||||
}
|
||||
|
||||
$xpath = new \DOMXPath($dom);
|
||||
$xpath->registerNamespace('D', 'DAV:');
|
||||
$xpath->registerNamespace('C', 'urn:ietf:params:xml:ns:caldav');
|
||||
|
||||
// Find all schedule-response/response elements
|
||||
$responses = $xpath->query('//C:schedule-response/C:response');
|
||||
if (!$responses || $responses->length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modified = false;
|
||||
|
||||
foreach ($responses as $responseNode) {
|
||||
// Extract recipient email
|
||||
$recipientNodes = $xpath->query('.//C:recipient/D:href', $responseNode);
|
||||
if (!$recipientNodes || $recipientNodes->length === 0) {
|
||||
continue;
|
||||
}
|
||||
$recipientHref = $recipientNodes->item(0)->textContent;
|
||||
$email = $this->extractEmail($recipientHref);
|
||||
if (!$email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find calendar-data element
|
||||
$calDataNodes = $xpath->query('.//C:calendar-data', $responseNode);
|
||||
if (!$calDataNodes || $calDataNodes->length === 0) {
|
||||
continue;
|
||||
}
|
||||
$calDataNode = $calDataNodes->item(0);
|
||||
$icsData = $calDataNode->textContent;
|
||||
|
||||
if (strpos($icsData, 'VFREEBUSY') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the user's availability property
|
||||
$availability = $this->getCalendarAvailability($email);
|
||||
if (!$availability) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse available windows from the VAVAILABILITY
|
||||
$availableWindows = $this->parseAvailableWindows($availability);
|
||||
if (empty($availableWindows)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the freebusy query range from the VFREEBUSY component
|
||||
$queryRange = $this->extractFreebusyRange($icsData);
|
||||
if (!$queryRange) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute BUSY-UNAVAILABLE periods
|
||||
$busyPeriods = $this->computeBusyUnavailable(
|
||||
$queryRange['start'],
|
||||
$queryRange['end'],
|
||||
$availableWindows
|
||||
);
|
||||
|
||||
if (empty($busyPeriods)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inject BUSY-UNAVAILABLE lines into the ICS data
|
||||
$modifiedIcs = $this->injectBusyUnavailable($icsData, $busyPeriods);
|
||||
if ($modifiedIcs !== null) {
|
||||
$calDataNode->textContent = '';
|
||||
$calDataNode->appendChild($dom->createTextNode($modifiedIcs));
|
||||
$modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($modified) {
|
||||
return $dom->saveXML();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract email from a mailto: URI.
|
||||
*
|
||||
* @param string $uri
|
||||
* @return string|null
|
||||
*/
|
||||
private function extractEmail($uri)
|
||||
{
|
||||
if (stripos($uri, 'mailto:') === 0) {
|
||||
return strtolower(substr($uri, 7));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the calendar-availability property for a user.
|
||||
*
|
||||
* Resolves the calendar home path from the principal URI via the
|
||||
* CalDAV plugin rather than hardcoding the path structure.
|
||||
*
|
||||
* @param string $email
|
||||
* @return string|null The VCALENDAR string or null
|
||||
*/
|
||||
private function getCalendarAvailability($email)
|
||||
{
|
||||
$caldavPlugin = $this->server->getPlugin('caldav');
|
||||
if (!$caldavPlugin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$principalUri = 'principals/users/' . $email;
|
||||
$calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUri);
|
||||
if (!$calendarHomePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$properties = $this->server->getProperties(
|
||||
$calendarHomePath,
|
||||
[self::AVAILABILITY_PROP]
|
||||
);
|
||||
|
||||
if (isset($properties[self::AVAILABILITY_PROP])) {
|
||||
return $properties[self::AVAILABILITY_PROP];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log("[AvailabilityPlugin] Failed to get availability for user: "
|
||||
. $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse VAVAILABILITY/AVAILABLE components to extract available windows.
|
||||
*
|
||||
* Returns an array of available window definitions, each with:
|
||||
* - 'startTime': time string "HH:MM:SS"
|
||||
* - 'endTime': time string "HH:MM:SS"
|
||||
* - 'days': array of day-of-week integers (1=Monday .. 7=Sunday, ISO-8601)
|
||||
* - 'specificDate': string "Y-m-d" if this is a specific-date window (no RRULE)
|
||||
*
|
||||
* @param string $vcalendarStr
|
||||
* @return array
|
||||
*/
|
||||
private function parseAvailableWindows($vcalendarStr)
|
||||
{
|
||||
$windows = [];
|
||||
|
||||
try {
|
||||
$vcalendar = Reader::read($vcalendarStr);
|
||||
} catch (\Exception $e) {
|
||||
error_log("[AvailabilityPlugin] Failed to parse VAVAILABILITY: " . $e->getMessage());
|
||||
return $windows;
|
||||
}
|
||||
|
||||
if (!isset($vcalendar->VAVAILABILITY)) {
|
||||
return $windows;
|
||||
}
|
||||
|
||||
foreach ($vcalendar->VAVAILABILITY as $vavailability) {
|
||||
if (!isset($vavailability->AVAILABLE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($vavailability->AVAILABLE as $available) {
|
||||
if (!isset($available->DTSTART) || !isset($available->DTEND)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dtstart = $available->DTSTART->getDateTime();
|
||||
$dtend = $available->DTEND->getDateTime();
|
||||
|
||||
$startTime = $dtstart->format('H:i:s');
|
||||
$endTime = $dtend->format('H:i:s');
|
||||
|
||||
// Parse RRULE to get BYDAY
|
||||
$days = [];
|
||||
$specificDate = null;
|
||||
if (isset($available->RRULE)) {
|
||||
$rrule = (string)$available->RRULE;
|
||||
if (preg_match('/BYDAY=([A-Z,]+)/', $rrule, $matches)) {
|
||||
$dayMap = [
|
||||
'MO' => 1,
|
||||
'TU' => 2,
|
||||
'WE' => 3,
|
||||
'TH' => 4,
|
||||
'FR' => 5,
|
||||
'SA' => 6,
|
||||
'SU' => 7,
|
||||
];
|
||||
foreach (explode(',', $matches[1]) as $day) {
|
||||
if (isset($dayMap[$day])) {
|
||||
$days[] = $dayMap[$day];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No RRULE: specific-date availability, scoped to DTSTART date
|
||||
$specificDate = $dtstart->format('Y-m-d');
|
||||
$days = [(int)$dtstart->format('N')];
|
||||
}
|
||||
|
||||
$windows[] = [
|
||||
'startTime' => $startTime,
|
||||
'endTime' => $endTime,
|
||||
'days' => $days,
|
||||
'specificDate' => $specificDate,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $windows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the DTSTART and DTEND range from a VFREEBUSY component.
|
||||
*
|
||||
* @param string $icsData
|
||||
* @return array|null ['start' => DateTimeImmutable, 'end' => DateTimeImmutable]
|
||||
*/
|
||||
private function extractFreebusyRange($icsData)
|
||||
{
|
||||
try {
|
||||
$vcalendar = Reader::read($icsData);
|
||||
} catch (\Exception $e) {
|
||||
error_log("[AvailabilityPlugin] Failed to parse VFREEBUSY ICS: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isset($vcalendar->VFREEBUSY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vfreebusy = $vcalendar->VFREEBUSY;
|
||||
|
||||
if (!isset($vfreebusy->DTSTART) || !isset($vfreebusy->DTEND)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'start' => $vfreebusy->DTSTART->getDateTime(),
|
||||
'end' => $vfreebusy->DTEND->getDateTime(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute BUSY-UNAVAILABLE periods for times outside available windows.
|
||||
*
|
||||
* TODO: The available times in DTSTART/DTEND of AVAILABLE are treated as
|
||||
* UTC for now. Proper timezone handling would require resolving the TZID
|
||||
* from the VAVAILABILITY component and converting accordingly.
|
||||
*
|
||||
* @param \DateTimeInterface $rangeStart
|
||||
* @param \DateTimeInterface $rangeEnd
|
||||
* @param array $windows Available windows from parseAvailableWindows()
|
||||
* @return array Array of ['start' => DateTimeImmutable, 'end' => DateTimeImmutable]
|
||||
*/
|
||||
private function computeBusyUnavailable(
|
||||
\DateTimeInterface $rangeStart,
|
||||
\DateTimeInterface $rangeEnd,
|
||||
array $windows
|
||||
) {
|
||||
$utc = new \DateTimeZone('UTC');
|
||||
$busyPeriods = [];
|
||||
|
||||
// Iterate day by day through the range
|
||||
$currentDay = new \DateTimeImmutable(
|
||||
$rangeStart->format('Y-m-d'),
|
||||
$utc
|
||||
);
|
||||
$endDay = new \DateTimeImmutable(
|
||||
$rangeEnd->format('Y-m-d'),
|
||||
$utc
|
||||
);
|
||||
|
||||
while ($currentDay <= $endDay) {
|
||||
$dayOfWeek = (int)$currentDay->format('N'); // 1=Monday .. 7=Sunday
|
||||
$dayStart = $currentDay;
|
||||
$dayEnd = $currentDay->modify('+1 day');
|
||||
|
||||
// Clamp to the query range
|
||||
$effectiveDayStart = $dayStart < $rangeStart
|
||||
? new \DateTimeImmutable($rangeStart->format('Y-m-d\TH:i:s'), $utc)
|
||||
: $dayStart;
|
||||
$effectiveDayEnd = $dayEnd > $rangeEnd
|
||||
? new \DateTimeImmutable($rangeEnd->format('Y-m-d\TH:i:s'), $utc)
|
||||
: $dayEnd;
|
||||
|
||||
if ($effectiveDayStart >= $effectiveDayEnd) {
|
||||
$currentDay = $currentDay->modify('+1 day');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect available slots for this day of the week
|
||||
$availableSlots = [];
|
||||
$dateStr = $currentDay->format('Y-m-d');
|
||||
foreach ($windows as $window) {
|
||||
// Skip specific-date windows that don't match this day
|
||||
if ($window['specificDate'] !== null && $window['specificDate'] !== $dateStr) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($dayOfWeek, $window['days'], true)) {
|
||||
$slotStart = new \DateTimeImmutable(
|
||||
$currentDay->format('Y-m-d') . 'T' . $window['startTime'],
|
||||
$utc
|
||||
);
|
||||
$slotEnd = new \DateTimeImmutable(
|
||||
$currentDay->format('Y-m-d') . 'T' . $window['endTime'],
|
||||
$utc
|
||||
);
|
||||
|
||||
// Clamp to effective day range
|
||||
if ($slotStart < $effectiveDayStart) {
|
||||
$slotStart = $effectiveDayStart;
|
||||
}
|
||||
if ($slotEnd > $effectiveDayEnd) {
|
||||
$slotEnd = $effectiveDayEnd;
|
||||
}
|
||||
|
||||
if ($slotStart < $slotEnd) {
|
||||
$availableSlots[] = [
|
||||
'start' => $slotStart,
|
||||
'end' => $slotEnd,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort available slots by start time
|
||||
usort($availableSlots, function ($a, $b) {
|
||||
return $a['start'] <=> $b['start'];
|
||||
});
|
||||
|
||||
// Merge overlapping slots
|
||||
$mergedSlots = [];
|
||||
foreach ($availableSlots as $slot) {
|
||||
if (empty($mergedSlots)) {
|
||||
$mergedSlots[] = $slot;
|
||||
} else {
|
||||
$last = &$mergedSlots[count($mergedSlots) - 1];
|
||||
if ($slot['start'] <= $last['end']) {
|
||||
if ($slot['end'] > $last['end']) {
|
||||
$last['end'] = $slot['end'];
|
||||
}
|
||||
} else {
|
||||
$mergedSlots[] = $slot;
|
||||
}
|
||||
unset($last);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute gaps (BUSY-UNAVAILABLE periods)
|
||||
$cursor = $effectiveDayStart;
|
||||
foreach ($mergedSlots as $slot) {
|
||||
if ($cursor < $slot['start']) {
|
||||
$busyPeriods[] = [
|
||||
'start' => $cursor,
|
||||
'end' => $slot['start'],
|
||||
];
|
||||
}
|
||||
$cursor = $slot['end'];
|
||||
}
|
||||
if ($cursor < $effectiveDayEnd) {
|
||||
$busyPeriods[] = [
|
||||
'start' => $cursor,
|
||||
'end' => $effectiveDayEnd,
|
||||
];
|
||||
}
|
||||
|
||||
$currentDay = $currentDay->modify('+1 day');
|
||||
}
|
||||
|
||||
return $busyPeriods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject FREEBUSY;FBTYPE=BUSY-UNAVAILABLE lines into a VFREEBUSY ICS string.
|
||||
*
|
||||
* @param string $icsData
|
||||
* @param array $busyPeriods
|
||||
* @return string|null Modified ICS data or null if injection failed
|
||||
*/
|
||||
private function injectBusyUnavailable($icsData, array $busyPeriods)
|
||||
{
|
||||
// Build FREEBUSY lines
|
||||
$lines = '';
|
||||
foreach ($busyPeriods as $period) {
|
||||
$start = $period['start']->format('Ymd\THis\Z');
|
||||
$end = $period['end']->format('Ymd\THis\Z');
|
||||
$lines .= "FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:{$start}/{$end}\r\n";
|
||||
}
|
||||
|
||||
// Insert before END:VFREEBUSY
|
||||
$pos = strpos($icsData, "END:VFREEBUSY");
|
||||
if ($pos === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr($icsData, 0, $pos) . $lines . substr($icsData, $pos);
|
||||
}
|
||||
|
||||
public function getPluginName()
|
||||
{
|
||||
return 'availability';
|
||||
}
|
||||
|
||||
public function getPluginInfo()
|
||||
{
|
||||
return [
|
||||
'name' => $this->getPluginName(),
|
||||
'description' => 'Integrates VAVAILABILITY (RFC 7953) into freebusy responses',
|
||||
];
|
||||
}
|
||||
}
|
||||
69
src/caldav/src/FreeBusyOrgScopePlugin.php
Normal file
69
src/caldav/src/FreeBusyOrgScopePlugin.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Calendars\SabreDav;
|
||||
|
||||
use Sabre\DAV;
|
||||
use Sabre\HTTP\RequestInterface;
|
||||
|
||||
/**
|
||||
* Blocks VFREEBUSY queries when the organization sharing level is "none".
|
||||
*
|
||||
* The X-CalDAV-Sharing-Level header is set by the Django proxy based on
|
||||
* the organization's effective sharing level setting.
|
||||
*
|
||||
* Regular scheduling requests (invitations) are not affected.
|
||||
*/
|
||||
class FreeBusyOrgScopePlugin extends DAV\ServerPlugin
|
||||
{
|
||||
protected $server;
|
||||
|
||||
public function initialize(DAV\Server $server)
|
||||
{
|
||||
$this->server = $server;
|
||||
// Priority 99: before Schedule\Plugin (110) processes freebusy
|
||||
$server->on('beforeMethod:POST', [$this, 'beforePost'], 99);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept POST to scheduling outbox and block VFREEBUSY if sharing is "none".
|
||||
*/
|
||||
public function beforePost(RequestInterface $request)
|
||||
{
|
||||
$path = $request->getPath();
|
||||
|
||||
// Only intercept outbox requests (where freebusy queries are sent)
|
||||
if (strpos($path, '/outbox') === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sharingLevel = $request->getHeader('X-CalDAV-Sharing-Level');
|
||||
|
||||
// Only block when sharing is explicitly disabled
|
||||
if ($sharingLevel !== 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read body to check if this is a VFREEBUSY request
|
||||
$body = $request->getBodyAsString();
|
||||
$request->setBody($body); // Reset stream for subsequent reads
|
||||
|
||||
if (stripos($body, 'VFREEBUSY') !== false) {
|
||||
throw new DAV\Exception\Forbidden(
|
||||
'Free/busy queries are not allowed when organization sharing is disabled'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getPluginName()
|
||||
{
|
||||
return 'freebusy-org-scope';
|
||||
}
|
||||
|
||||
public function getPluginInfo()
|
||||
{
|
||||
return [
|
||||
'name' => $this->getPluginName(),
|
||||
'description' => 'Enforces organization-level freebusy sharing settings',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -77,29 +77,46 @@ class NamedPrincipalCollection extends CalDAV\Principal\Collection
|
||||
{
|
||||
return $this->nodeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return SchedulablePrincipal nodes that allow authenticated users to
|
||||
* read principal properties (required for CalDAV scheduling / freebusy).
|
||||
*/
|
||||
public function getChildForPrincipal(array $principal)
|
||||
{
|
||||
return new SchedulablePrincipal($this->principalBackend, $principal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal collection for resources that returns ResourcePrincipal nodes.
|
||||
* Principal collection for resources.
|
||||
*
|
||||
* Resource principals have no DAV owner, so the default ACL (which only
|
||||
* grants {DAV:}all to {DAV:}owner) blocks all property reads with 403.
|
||||
* This collection returns ResourcePrincipal nodes that additionally grant
|
||||
* {DAV:}read to {DAV:}authenticated, allowing any logged-in user to
|
||||
* discover resource names, types, and emails via PROPFIND.
|
||||
* This collection returns SchedulablePrincipal nodes that additionally grant
|
||||
* {DAV:}read to {DAV:}authenticated.
|
||||
*/
|
||||
class ResourcePrincipalCollection extends NamedPrincipalCollection
|
||||
{
|
||||
public function getChildForPrincipal(array $principal)
|
||||
{
|
||||
return new ResourcePrincipal($this->principalBackend, $principal);
|
||||
return new SchedulablePrincipal($this->principalBackend, $principal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A principal node with a permissive read ACL for resource discovery.
|
||||
* A principal node with read ACL for authenticated users.
|
||||
*
|
||||
* Required for CalDAV scheduling: the Schedule\Plugin looks up other users'
|
||||
* calendar-home-set and schedule-inbox-URL via principalSearch(), which
|
||||
* triggers a propFind that is subject to ACL. Without read access, the
|
||||
* properties return 403 and freebusy queries fail with "Could not find
|
||||
* calendar-home-set".
|
||||
*
|
||||
* Also used for resource discovery (any logged-in user can discover resource
|
||||
* names, types, and emails via PROPFIND).
|
||||
*/
|
||||
class ResourcePrincipal extends CalDAV\Principal\User
|
||||
class SchedulablePrincipal extends CalDAV\Principal\User
|
||||
{
|
||||
public function getACL()
|
||||
{
|
||||
|
||||
@@ -140,12 +140,8 @@ interface ImportTaskResponse {
|
||||
*/
|
||||
export interface TaskStatus {
|
||||
status: "PENDING" | "PROGRESS" | "SUCCESS" | "FAILURE";
|
||||
result: {
|
||||
status: string;
|
||||
result: ImportEventsResult | null;
|
||||
error: string | null;
|
||||
} | null;
|
||||
error: string | null;
|
||||
progress?: number;
|
||||
message?: string;
|
||||
}
|
||||
@@ -202,14 +198,14 @@ export const pollImportTask = async (
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === "SUCCESS" && status.result?.result) {
|
||||
resolve(status.result.result);
|
||||
if (status.status === "SUCCESS" && status.result) {
|
||||
resolve(status.result);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
new Error(
|
||||
status.result?.error ?? status.error ?? "Import failed",
|
||||
status.error ?? "Import failed",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Input,
|
||||
Modal,
|
||||
ModalSize,
|
||||
TextArea,
|
||||
} from "@gouvfr-lasuite/cunningham-react";
|
||||
|
||||
import { DEFAULT_COLORS } from "./constants";
|
||||
@@ -26,7 +25,6 @@ export const CalendarModal = ({
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState(DEFAULT_COLORS[0]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -36,11 +34,9 @@ export const CalendarModal = ({
|
||||
if (mode === "edit" && calendar) {
|
||||
setName(calendar.displayName || "");
|
||||
setColor(calendar.color || DEFAULT_COLORS[0]);
|
||||
setDescription(calendar.description || "");
|
||||
} else {
|
||||
setName("");
|
||||
setColor(DEFAULT_COLORS[0]);
|
||||
setDescription("");
|
||||
}
|
||||
setError(null);
|
||||
}
|
||||
@@ -55,7 +51,7 @@ export const CalendarModal = ({
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(name.trim(), color, description.trim() || undefined);
|
||||
await onSave(name.trim(), color);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('api.error.unexpected'));
|
||||
@@ -67,7 +63,6 @@ export const CalendarModal = ({
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setColor(DEFAULT_COLORS[0]);
|
||||
setDescription("");
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
@@ -133,13 +128,6 @@ export const CalendarModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
label={t('calendar.createCalendar.description')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Wraps the UI Kit ShareModal for managing calendar sharing via CalDAV.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShareModal } from "@gouvfr-lasuite/ui-kit";
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ToasterItem,
|
||||
} from "../../../ui/components/toaster/Toaster";
|
||||
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
||||
import { fetchAPI } from "@/features/api/fetchApi";
|
||||
|
||||
interface CalendarShareModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -100,16 +101,67 @@ export const CalendarShareModal = ({
|
||||
}
|
||||
}, [isOpen, calendar, fetchSharees]);
|
||||
|
||||
const handleSearchUsers = useCallback((query: string) => {
|
||||
if (EMAIL_REGEX.test(query.trim())) {
|
||||
const email = query.trim();
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const handleSearchUsers = useCallback(
|
||||
(query: string) => {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
const trimmed = query.trim();
|
||||
|
||||
if (trimmed.length < 3) {
|
||||
// For very short queries, fall back to email-only matching
|
||||
if (EMAIL_REGEX.test(trimmed)) {
|
||||
setSearchResults([
|
||||
{ id: email, email, full_name: email },
|
||||
{ id: trimmed, email: trimmed, full_name: trimmed },
|
||||
]);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, []);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce the API call
|
||||
searchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetchAPI("users/", {
|
||||
params: { q: trimmed },
|
||||
});
|
||||
const data = await response.json();
|
||||
const results: ShareUser[] = (data.results ?? []).map(
|
||||
(u: { id: string; email: string; full_name: string }) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
full_name: u.full_name || u.email,
|
||||
}),
|
||||
);
|
||||
// Always allow raw email entry too
|
||||
if (
|
||||
EMAIL_REGEX.test(trimmed) &&
|
||||
!results.some(
|
||||
(r) => r.email.toLowerCase() === trimmed.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
results.push({
|
||||
id: trimmed,
|
||||
email: trimmed,
|
||||
full_name: trimmed,
|
||||
});
|
||||
}
|
||||
setSearchResults(results);
|
||||
} catch {
|
||||
// Fallback to email-only on API error
|
||||
if (EMAIL_REGEX.test(trimmed)) {
|
||||
setSearchResults([
|
||||
{ id: trimmed, email: trimmed, full_name: trimmed },
|
||||
]);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleInviteUser = useCallback(
|
||||
async (users: ShareUser[]) => {
|
||||
|
||||
@@ -76,12 +76,11 @@ export const useCalendarListState = ({
|
||||
}, []);
|
||||
|
||||
const handleSaveCalendar = useCallback(
|
||||
async (name: string, color: string, description?: string) => {
|
||||
async (name: string, color: string) => {
|
||||
if (modalState.mode === "create") {
|
||||
const result = await createCalendar({
|
||||
displayName: name,
|
||||
color,
|
||||
description,
|
||||
components: ['VEVENT'],
|
||||
});
|
||||
if (!result.success) {
|
||||
@@ -91,7 +90,6 @@ export const useCalendarListState = ({
|
||||
const result = await updateCalendar(modalState.calendar.url, {
|
||||
displayName: name,
|
||||
color,
|
||||
description,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface CalendarModalProps {
|
||||
mode: "create" | "edit";
|
||||
calendar?: CalDavCalendar | null;
|
||||
onClose: () => void;
|
||||
onSave: (name: string, color: string, description?: string) => Promise<void>;
|
||||
onSave: (name: string, color: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
@@ -37,6 +38,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
list-style: none;
|
||||
background: var(--c--theme--colors--greyscale-000, #fff);
|
||||
border: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__suggestion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
|
||||
&:hover,
|
||||
&--highlighted {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
}
|
||||
|
||||
&--empty {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
font-style: italic;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__suggestion-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--c--theme--colors--greyscale-900);
|
||||
}
|
||||
|
||||
&__suggestion-email {
|
||||
font-size: 0.75rem;
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
}
|
||||
|
||||
&__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { useState, useCallback, type KeyboardEvent } from "react";
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { Input } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { Badge } from "@gouvfr-lasuite/ui-kit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { IcsAttendee, IcsOrganizer } from "ts-ics";
|
||||
import {
|
||||
useUserSearch,
|
||||
type UserSearchResult,
|
||||
} from "@/features/users/hooks/useUserSearch";
|
||||
import { filterSuggestions, isValidEmail } from "./attendees-utils";
|
||||
|
||||
interface AttendeesInputProps {
|
||||
attendees: IcsAttendee[];
|
||||
@@ -11,11 +22,6 @@ interface AttendeesInputProps {
|
||||
organizer?: IcsOrganizer;
|
||||
}
|
||||
|
||||
const isValidEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
type BadgeType =
|
||||
| "accent"
|
||||
| "neutral"
|
||||
@@ -59,40 +65,86 @@ export function AttendeesInput({
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const addAttendee = useCallback(() => {
|
||||
const email = inputValue.trim().toLowerCase();
|
||||
const { data: searchResults, isLoading: isSearching } =
|
||||
useUserSearch(inputValue);
|
||||
|
||||
if (!email) {
|
||||
return;
|
||||
// Filter out already-added attendees and the organizer
|
||||
const suggestions = filterSuggestions(
|
||||
searchResults ?? [],
|
||||
attendees,
|
||||
organizerEmail,
|
||||
);
|
||||
|
||||
// Show suggestions when we have results or are searching with enough chars
|
||||
const trimmedInput = inputValue.trim();
|
||||
const shouldShowDropdown =
|
||||
showSuggestions && trimmedInput.length >= 3 && !isSearching;
|
||||
const hasNoResults =
|
||||
shouldShowDropdown &&
|
||||
suggestions.length === 0 &&
|
||||
searchResults !== undefined;
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
const addAttendeeByEmail = useCallback(
|
||||
(email: string, fullName?: string) => {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
if (!normalized) return;
|
||||
|
||||
if (!isValidEmail(normalized)) {
|
||||
setError(t("calendar.attendees.invalidEmail"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (attendees.some((a) => a.email.toLowerCase() === email)) {
|
||||
if (attendees.some((a) => a.email.toLowerCase() === normalized)) {
|
||||
setError(t("calendar.attendees.alreadyAdded"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizerEmail && email === organizerEmail.toLowerCase()) {
|
||||
if (organizerEmail && normalized === organizerEmail.toLowerCase()) {
|
||||
setError(t("calendar.attendees.cannotAddOrganizer"));
|
||||
return;
|
||||
}
|
||||
|
||||
const newAttendee: IcsAttendee = {
|
||||
email,
|
||||
email: normalized,
|
||||
partstat: "NEEDS-ACTION",
|
||||
rsvp: true,
|
||||
role: "REQ-PARTICIPANT",
|
||||
...(fullName && { cn: fullName }),
|
||||
};
|
||||
|
||||
onChange([...attendees, newAttendee]);
|
||||
setInputValue("");
|
||||
setError(null);
|
||||
}, [inputValue, attendees, onChange, organizerEmail, t]);
|
||||
setShowSuggestions(false);
|
||||
setHighlightedIndex(-1);
|
||||
},
|
||||
[attendees, onChange, organizerEmail, t],
|
||||
);
|
||||
|
||||
const selectSuggestion = useCallback(
|
||||
(user: UserSearchResult) => {
|
||||
addAttendeeByEmail(user.email, user.full_name);
|
||||
},
|
||||
[addAttendeeByEmail],
|
||||
);
|
||||
|
||||
const removeAttendee = useCallback(
|
||||
(emailToRemove: string) => {
|
||||
@@ -103,16 +155,52 @@ export function AttendeesInput({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
setShowSuggestions(false);
|
||||
setHighlightedIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldShowDropdown &&
|
||||
suggestions.length > 0 &&
|
||||
(e.key === "ArrowDown" || e.key === "ArrowUp")
|
||||
) {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
return prev < suggestions.length - 1 ? prev + 1 : 0;
|
||||
}
|
||||
return prev > 0 ? prev - 1 : suggestions.length - 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addAttendee();
|
||||
if (
|
||||
shouldShowDropdown &&
|
||||
highlightedIndex >= 0 &&
|
||||
highlightedIndex < suggestions.length
|
||||
) {
|
||||
selectSuggestion(suggestions[highlightedIndex]);
|
||||
} else {
|
||||
addAttendeeByEmail(inputValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[addAttendee],
|
||||
[
|
||||
shouldShowDropdown,
|
||||
suggestions,
|
||||
highlightedIndex,
|
||||
selectSuggestion,
|
||||
addAttendeeByEmail,
|
||||
inputValue,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="attendees-input">
|
||||
<div className="attendees-input" ref={containerRef}>
|
||||
<div className="attendees-input__field">
|
||||
<Input
|
||||
label={t("calendar.attendees.label")}
|
||||
@@ -123,12 +211,49 @@ export function AttendeesInput({
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
setHighlightedIndex(-1);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
state={error ? "error" : "default"}
|
||||
text={error || undefined}
|
||||
/>
|
||||
|
||||
{(shouldShowDropdown && suggestions.length > 0) || hasNoResults ? (
|
||||
<ul className="attendees-input__suggestions" role="listbox">
|
||||
{suggestions.map((user, index) => (
|
||||
<li
|
||||
key={user.id}
|
||||
role="option"
|
||||
aria-selected={index === highlightedIndex}
|
||||
className={`attendees-input__suggestion${
|
||||
index === highlightedIndex
|
||||
? " attendees-input__suggestion--highlighted"
|
||||
: ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
selectSuggestion(user);
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<span className="attendees-input__suggestion-name">
|
||||
{user.full_name}
|
||||
</span>
|
||||
<span className="attendees-input__suggestion-email">
|
||||
{user.email}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{hasNoResults && (
|
||||
<li className="attendees-input__suggestion attendees-input__suggestion--empty">
|
||||
{t("calendar.attendees.noResults")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="attendees-input__pills">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AttendeesSection } from "./event-modal-sections/AttendeesSection";
|
||||
import { ResourcesSection } from "./event-modal-sections/ResourcesSection";
|
||||
import { DescriptionSection } from "./event-modal-sections/DescriptionSection";
|
||||
import { InvitationResponseSection } from "./event-modal-sections/InvitationResponseSection";
|
||||
import { FreeBusySection } from "./event-modal-sections/FreeBusySection";
|
||||
import { SectionPills } from "./event-modal-sections/SectionPills";
|
||||
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
|
||||
import type { EventModalProps, RecurringDeleteOption } from "./types";
|
||||
@@ -176,6 +177,11 @@ export const EventModal = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "scheduling" as const,
|
||||
icon: "event_available",
|
||||
label: t("scheduling.findATime"),
|
||||
},
|
||||
],
|
||||
[t, visioBaseUrl, availableResources.length],
|
||||
);
|
||||
@@ -305,6 +311,25 @@ export const EventModal = ({
|
||||
alwaysOpen
|
||||
/>
|
||||
)}
|
||||
{form.isSectionExpanded("scheduling") && (
|
||||
<FreeBusySection
|
||||
attendees={form.attendees}
|
||||
resourceEmails={form.resources
|
||||
.map((r) => r.email)
|
||||
.filter((e): e is string => !!e)}
|
||||
resourceNames={Object.fromEntries(
|
||||
form.resources
|
||||
.filter((r) => r.email)
|
||||
.map((r) => [r.email!.toLowerCase(), r.name]),
|
||||
)}
|
||||
organizerEmail={user?.email}
|
||||
startDateTime={form.startDateTime}
|
||||
endDateTime={form.endDateTime}
|
||||
onStartChange={form.setStartDateTime}
|
||||
onEndChange={form.setEndDateTime}
|
||||
alwaysOpen
|
||||
/>
|
||||
)}
|
||||
{availableResources.length > 0 &&
|
||||
form.isSectionExpanded("resources") && (
|
||||
<ResourcesSection
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
.freebusy-timeline {
|
||||
padding: 0.25rem 0;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #5f6368;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: #f1f3f4;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #202124;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
font-size: 14px;
|
||||
color: #5f6368;
|
||||
animation: freebusy-spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 0.6875rem;
|
||||
margin-left: auto;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 8px;
|
||||
|
||||
&--ok {
|
||||
color: #137333;
|
||||
background-color: #e6f4ea;
|
||||
}
|
||||
|
||||
&--conflict {
|
||||
color: #c5221f;
|
||||
background-color: #fce8e6;
|
||||
}
|
||||
}
|
||||
|
||||
&__calendar {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
// Hide the day name row (e.g. "Monday") — redundant with our nav header
|
||||
.ec-col-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Shrink the resource label column
|
||||
.ec-sidebar {
|
||||
width: 90px !important;
|
||||
min-width: 90px !important;
|
||||
max-width: 90px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ec-resource {
|
||||
font-size: 0.625rem;
|
||||
padding: 0 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Compact row heights, no row separators
|
||||
.ec-body .ec-resource {
|
||||
height: 24px !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
// Smaller time header labels
|
||||
.ec-header .ec-time {
|
||||
font-size: 0.5625rem;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.ec-header {
|
||||
height: auto !important;
|
||||
min-height: unset !important;
|
||||
}
|
||||
|
||||
// Single overlay for proposed meeting time across all rows
|
||||
.freebusy-proposed-overlay {
|
||||
border-left: 2px solid #137333;
|
||||
border-right: 2px solid #137333;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(19, 115, 51, 0.35),
|
||||
rgba(19, 115, 51, 0.35) 1px,
|
||||
transparent 1px,
|
||||
transparent 7px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: #5f6368;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes freebusy-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createCalendar, destroyCalendar, ResourceTimeline } from "@event-calendar/core";
|
||||
import type { FreeBusyResponse } from "../../services/dav/types/caldav-service";
|
||||
|
||||
interface FreeBusyTimelineProps {
|
||||
date: Date;
|
||||
attendees: FreeBusyResponse[];
|
||||
displayNames?: Record<string, string>;
|
||||
eventStart: Date;
|
||||
eventEnd: Date;
|
||||
isLoading: boolean;
|
||||
onDateChange: (date: Date) => void;
|
||||
onTimeSelect: (start: Date, end: Date) => void;
|
||||
}
|
||||
|
||||
/** Map freebusy type to a background color. */
|
||||
const BUSY_COLORS: Record<string, string> = {
|
||||
BUSY: "rgba(26, 115, 232, 0.4)",
|
||||
"BUSY-UNAVAILABLE": "rgba(95, 99, 104, 0.5)",
|
||||
"BUSY-TENTATIVE": "rgba(26, 115, 232, 0.2)",
|
||||
};
|
||||
|
||||
export const FreeBusyTimeline = ({
|
||||
date,
|
||||
attendees,
|
||||
displayNames,
|
||||
eventStart,
|
||||
eventEnd,
|
||||
isLoading,
|
||||
onDateChange,
|
||||
onTimeSelect,
|
||||
}: FreeBusyTimelineProps) => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const calendarRef = useRef<ReturnType<typeof createCalendar> | null>(null);
|
||||
const handlersRef = useRef({ onTimeSelect, onDateChange });
|
||||
|
||||
useEffect(() => {
|
||||
handlersRef.current = { onTimeSelect, onDateChange };
|
||||
});
|
||||
|
||||
const eventDurationMs = eventEnd.getTime() - eventStart.getTime();
|
||||
const eventDurationRef = useRef(eventDurationMs);
|
||||
useEffect(() => {
|
||||
eventDurationRef.current = eventDurationMs;
|
||||
}, [eventDurationMs]);
|
||||
|
||||
const dateStr = date.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
// Map attendees → resources, using displayNames for friendly labels
|
||||
const resources = useMemo(
|
||||
() =>
|
||||
attendees.map((a) => ({
|
||||
id: a.attendee,
|
||||
title:
|
||||
displayNames?.[a.attendee] ??
|
||||
displayNames?.[a.attendee.toLowerCase()] ??
|
||||
a.attendee.split("@")[0],
|
||||
})),
|
||||
[attendees, displayNames],
|
||||
);
|
||||
|
||||
// Map busy periods → background events + proposed event overlay
|
||||
const events = useMemo(() => {
|
||||
const allEvents: {
|
||||
id: string;
|
||||
resourceIds: string[];
|
||||
start: Date;
|
||||
end: Date;
|
||||
display?: string;
|
||||
backgroundColor?: string;
|
||||
title?: string;
|
||||
}[] = attendees.flatMap((a) =>
|
||||
a.periods.map((period, idx) => ({
|
||||
id: `busy-${a.attendee}-${idx}`,
|
||||
resourceIds: [a.attendee],
|
||||
start: period.start,
|
||||
end: period.end,
|
||||
display: "background",
|
||||
backgroundColor: BUSY_COLORS[period.type] ?? BUSY_COLORS.BUSY,
|
||||
})),
|
||||
);
|
||||
|
||||
return allEvents;
|
||||
}, [attendees, date, resources]);
|
||||
|
||||
// Count conflicts
|
||||
const eventOnThisDay =
|
||||
eventStart.toDateString() === date.toDateString() ||
|
||||
eventEnd.toDateString() === date.toDateString() ||
|
||||
(eventStart < date && eventEnd > date);
|
||||
const conflictCount = eventOnThisDay
|
||||
? attendees.filter((a) =>
|
||||
a.periods.some((p) => p.start < eventEnd && p.end > eventStart),
|
||||
).length
|
||||
: 0;
|
||||
|
||||
// Create / destroy calendar instance
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const ec = createCalendar(containerRef.current, [ResourceTimeline], {
|
||||
view: "resourceTimelineDay",
|
||||
headerToolbar: false,
|
||||
slotMinTime: "07:00",
|
||||
slotMaxTime: "24:00",
|
||||
slotDuration: "01:00",
|
||||
height: "auto",
|
||||
date,
|
||||
resources,
|
||||
events,
|
||||
editable: false,
|
||||
selectable: false,
|
||||
dateClick: (info: { date: Date; resource?: { id: string } }) => {
|
||||
const clickedDate = info.date;
|
||||
const dur = eventDurationRef.current;
|
||||
const newEnd = new Date(clickedDate.getTime() + dur);
|
||||
handlersRef.current.onTimeSelect(clickedDate, newEnd);
|
||||
},
|
||||
});
|
||||
|
||||
calendarRef.current = ec;
|
||||
|
||||
// Make the time grid areas scrollable while keeping sidebar fixed
|
||||
const GRID_WIDTH = 1100;
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef.current) return;
|
||||
// Structure: .ec-main > .ec-header, .ec-main > .ec-body
|
||||
const header = containerRef.current.querySelector(
|
||||
".ec-header",
|
||||
) as HTMLElement | null;
|
||||
const body = containerRef.current.querySelector(
|
||||
".ec-body",
|
||||
) as HTMLElement | null;
|
||||
if (!header || !body) return;
|
||||
|
||||
// The grid inside header/body holds the time slots
|
||||
const headerGrid = header.querySelector(".ec-grid") as HTMLElement | null;
|
||||
const bodyGrid = body.querySelector(".ec-grid") as HTMLElement | null;
|
||||
if (!headerGrid || !bodyGrid) return;
|
||||
|
||||
headerGrid.style.minWidth = `${GRID_WIDTH}px`;
|
||||
bodyGrid.style.minWidth = `${GRID_WIDTH}px`;
|
||||
|
||||
// Make the body scrollable, hide header scrollbar
|
||||
body.style.overflowX = "auto";
|
||||
header.style.overflowX = "hidden";
|
||||
|
||||
// Sync header scroll when body scrolls
|
||||
const onBodyScroll = () => {
|
||||
header.scrollLeft = body.scrollLeft;
|
||||
};
|
||||
body.addEventListener("scroll", onBodyScroll);
|
||||
|
||||
// Scroll to ~8 AM (1 hour into the 7:00-24:00 range)
|
||||
const scrollPos = (1 / 17) * GRID_WIDTH;
|
||||
body.scrollLeft = scrollPos;
|
||||
header.scrollLeft = scrollPos;
|
||||
|
||||
// Inject a single proposed-time overlay spanning all rows
|
||||
const startsOnDay = eventStart.toDateString() === date.toDateString();
|
||||
const endsOnDay = eventEnd.toDateString() === date.toDateString();
|
||||
const spansDay = eventStart < date && eventEnd > date;
|
||||
if ((startsOnDay || endsOnDay || spansDay) && resources.length > 0) {
|
||||
const SLOT_MIN = 7; // slotMinTime hours
|
||||
const SLOT_MAX = 24; // slotMaxTime hours
|
||||
const totalHours = SLOT_MAX - SLOT_MIN;
|
||||
const startH = startsOnDay
|
||||
? eventStart.getHours() + eventStart.getMinutes() / 60
|
||||
: SLOT_MIN;
|
||||
const endH = endsOnDay
|
||||
? eventEnd.getHours() + eventEnd.getMinutes() / 60
|
||||
: SLOT_MAX;
|
||||
const leftPct = ((Math.max(startH, SLOT_MIN) - SLOT_MIN) / totalHours) * 100;
|
||||
const rightPct = ((Math.min(endH, SLOT_MAX) - SLOT_MIN) / totalHours) * 100;
|
||||
|
||||
if (rightPct > leftPct) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "freebusy-proposed-overlay";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.bottom = "0";
|
||||
overlay.style.left = `${leftPct}%`;
|
||||
overlay.style.width = `${rightPct - leftPct}%`;
|
||||
overlay.style.pointerEvents = "none";
|
||||
overlay.style.zIndex = "5";
|
||||
|
||||
bodyGrid.style.position = "relative";
|
||||
bodyGrid.appendChild(overlay);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (calendarRef.current) {
|
||||
destroyCalendar(calendarRef.current);
|
||||
calendarRef.current = null;
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
};
|
||||
// Recreate when data changes
|
||||
}, [date, resources, events, eventStart, eventEnd]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
const prev = new Date(date);
|
||||
prev.setDate(prev.getDate() - 1);
|
||||
onDateChange(prev);
|
||||
}, [date, onDateChange]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + 1);
|
||||
onDateChange(next);
|
||||
}, [date, onDateChange]);
|
||||
|
||||
return (
|
||||
<div className="freebusy-timeline">
|
||||
<div className="freebusy-timeline__header">
|
||||
<button
|
||||
type="button"
|
||||
className="freebusy-timeline__nav"
|
||||
onClick={handlePrev}
|
||||
aria-label={t("scheduling.previousDay")}
|
||||
>
|
||||
<span className="material-icons">chevron_left</span>
|
||||
</button>
|
||||
<span className="freebusy-timeline__date">{dateStr}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="freebusy-timeline__nav"
|
||||
onClick={handleNext}
|
||||
aria-label={t("scheduling.nextDay")}
|
||||
>
|
||||
<span className="material-icons">chevron_right</span>
|
||||
</button>
|
||||
{isLoading && (
|
||||
<span className="freebusy-timeline__loading">
|
||||
<span className="material-icons freebusy-timeline__spinner">
|
||||
sync
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{!isLoading && eventOnThisDay && (
|
||||
<span
|
||||
className={`freebusy-timeline__status ${
|
||||
conflictCount > 0
|
||||
? "freebusy-timeline__status--conflict"
|
||||
: "freebusy-timeline__status--ok"
|
||||
}`}
|
||||
>
|
||||
{conflictCount > 0
|
||||
? t("scheduling.conflicts", { count: conflictCount })
|
||||
: t("scheduling.noConflicts")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{attendees.length === 0 && !isLoading ? (
|
||||
<div className="freebusy-timeline__empty">
|
||||
{t("scheduling.addAttendeesToSeeAvailability")}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef} className="freebusy-timeline__calendar" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { filterSuggestions, isValidEmail } from "../attendees-utils";
|
||||
import type { UserSearchResult } from "@/features/users/hooks/useUserSearch";
|
||||
import type { IcsAttendee } from "ts-ics";
|
||||
|
||||
describe("attendees-utils", () => {
|
||||
describe("isValidEmail", () => {
|
||||
it("accepts a valid email", () => {
|
||||
expect(isValidEmail("user@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty string", () => {
|
||||
expect(isValidEmail("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a string without @", () => {
|
||||
expect(isValidEmail("not-an-email")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a string without domain", () => {
|
||||
expect(isValidEmail("user@")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a string with spaces", () => {
|
||||
expect(isValidEmail("user @example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterSuggestions", () => {
|
||||
const makeUser = (
|
||||
email: string,
|
||||
name = "Test User",
|
||||
): UserSearchResult => ({
|
||||
id: email,
|
||||
email,
|
||||
full_name: name,
|
||||
});
|
||||
|
||||
const makeAttendee = (email: string): IcsAttendee => ({
|
||||
email,
|
||||
partstat: "NEEDS-ACTION",
|
||||
rsvp: true,
|
||||
role: "REQ-PARTICIPANT",
|
||||
});
|
||||
|
||||
it("returns all suggestions when no attendees or organizer", () => {
|
||||
const results = [
|
||||
makeUser("alice@org.com", "Alice"),
|
||||
makeUser("bob@org.com", "Bob"),
|
||||
];
|
||||
expect(filterSuggestions(results, [])).toEqual(results);
|
||||
});
|
||||
|
||||
it("filters out already-added attendees", () => {
|
||||
const results = [
|
||||
makeUser("alice@org.com"),
|
||||
makeUser("bob@org.com"),
|
||||
makeUser("carol@org.com"),
|
||||
];
|
||||
const attendees = [makeAttendee("alice@org.com")];
|
||||
const filtered = filterSuggestions(results, attendees);
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.map((u) => u.email)).toEqual([
|
||||
"bob@org.com",
|
||||
"carol@org.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters case-insensitively", () => {
|
||||
const results = [makeUser("Alice@Org.com")];
|
||||
const attendees = [makeAttendee("alice@org.com")];
|
||||
expect(filterSuggestions(results, attendees)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters out the organizer email", () => {
|
||||
const results = [
|
||||
makeUser("organizer@org.com"),
|
||||
makeUser("other@org.com"),
|
||||
];
|
||||
const filtered = filterSuggestions(
|
||||
results,
|
||||
[],
|
||||
"organizer@org.com",
|
||||
);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].email).toBe("other@org.com");
|
||||
});
|
||||
|
||||
it("filters organizer case-insensitively", () => {
|
||||
const results = [makeUser("Organizer@Org.COM")];
|
||||
const filtered = filterSuggestions(
|
||||
results,
|
||||
[],
|
||||
"organizer@org.com",
|
||||
);
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters both attendees and organizer together", () => {
|
||||
const results = [
|
||||
makeUser("organizer@org.com"),
|
||||
makeUser("attendee@org.com"),
|
||||
makeUser("available@org.com"),
|
||||
];
|
||||
const attendees = [makeAttendee("attendee@org.com")];
|
||||
const filtered = filterSuggestions(
|
||||
results,
|
||||
attendees,
|
||||
"organizer@org.com",
|
||||
);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].email).toBe("available@org.com");
|
||||
});
|
||||
|
||||
it("filters out users with no email", () => {
|
||||
const results = [{ id: "1", email: "", full_name: "No Email" }];
|
||||
expect(filterSuggestions(results, [])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array for empty results", () => {
|
||||
expect(filterSuggestions([], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IcsAttendee } from "ts-ics";
|
||||
import type { UserSearchResult } from "@/features/users/hooks/useUserSearch";
|
||||
|
||||
/**
|
||||
* Filter search results to exclude already-added attendees
|
||||
* and the organizer email.
|
||||
*/
|
||||
export function filterSuggestions(
|
||||
searchResults: UserSearchResult[],
|
||||
attendees: IcsAttendee[],
|
||||
organizerEmail?: string,
|
||||
): UserSearchResult[] {
|
||||
return searchResults.filter((user) => {
|
||||
const email = user.email?.toLowerCase();
|
||||
if (!email) return false;
|
||||
if (attendees.some((a) => a.email.toLowerCase() === email)) return false;
|
||||
if (organizerEmail && email === organizerEmail.toLowerCase()) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const isValidEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { IcsAttendee } from "ts-ics";
|
||||
import { useCalendarContext } from "../../../contexts/CalendarContext";
|
||||
import { useFreeBusy } from "../hooks/useFreeBusy";
|
||||
import { FreeBusyTimeline } from "../FreeBusyTimeline";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
import {
|
||||
formatDateTimeLocal,
|
||||
parseDateTimeLocal,
|
||||
} from "../utils/dateFormatters";
|
||||
|
||||
interface FreeBusySectionProps {
|
||||
attendees: IcsAttendee[];
|
||||
resourceEmails?: string[];
|
||||
resourceNames?: Record<string, string>;
|
||||
organizerEmail?: string;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
onStartChange: (value: string) => void;
|
||||
onEndChange: (value: string) => void;
|
||||
alwaysOpen?: boolean;
|
||||
}
|
||||
|
||||
export const FreeBusySection = ({
|
||||
attendees,
|
||||
resourceEmails,
|
||||
resourceNames,
|
||||
organizerEmail,
|
||||
startDateTime,
|
||||
endDateTime,
|
||||
onStartChange,
|
||||
onEndChange,
|
||||
alwaysOpen,
|
||||
}: FreeBusySectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { caldavService } = useCalendarContext();
|
||||
|
||||
const eventStart = useMemo(
|
||||
() => parseDateTimeLocal(startDateTime),
|
||||
[startDateTime],
|
||||
);
|
||||
const eventEnd = useMemo(
|
||||
() => parseDateTimeLocal(endDateTime),
|
||||
[endDateTime],
|
||||
);
|
||||
|
||||
const allEmails = useMemo(
|
||||
() => [
|
||||
...attendees.map((a) => a.email.toLowerCase()),
|
||||
...(resourceEmails ?? []).map((e) => e.toLowerCase()),
|
||||
],
|
||||
[attendees, resourceEmails],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useFreeBusy({
|
||||
caldavService,
|
||||
attendees: allEmails,
|
||||
organizerEmail,
|
||||
date: eventStart,
|
||||
enabled: allEmails.length > 0,
|
||||
});
|
||||
|
||||
const handleDateChange = (date: Date) => {
|
||||
const newStart = new Date(eventStart);
|
||||
newStart.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const newEnd = new Date(eventEnd);
|
||||
newEnd.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
onStartChange(formatDateTimeLocal(newStart));
|
||||
onEndChange(formatDateTimeLocal(newEnd));
|
||||
};
|
||||
|
||||
const handleTimeSelect = (start: Date, end: Date) => {
|
||||
onStartChange(formatDateTimeLocal(start));
|
||||
onEndChange(formatDateTimeLocal(end));
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
icon="event_available"
|
||||
label={t("scheduling.findATime")}
|
||||
alwaysOpen={alwaysOpen}
|
||||
iconAlign="flex-start"
|
||||
>
|
||||
<FreeBusyTimeline
|
||||
date={eventStart}
|
||||
attendees={data}
|
||||
displayNames={resourceNames}
|
||||
eventStart={eventStart}
|
||||
eventEnd={eventEnd}
|
||||
isLoading={isLoading}
|
||||
onDateChange={handleDateChange}
|
||||
onTimeSelect={handleTimeSelect}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
@@ -426,6 +426,7 @@ export const useEventForm = ({
|
||||
location,
|
||||
setLocation,
|
||||
startDateTime,
|
||||
setStartDateTime,
|
||||
endDateTime,
|
||||
setEndDateTime,
|
||||
selectedCalendarUrl,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { FreeBusyResponse } from "../../../services/dav/types/caldav-service";
|
||||
import type { CalDavService } from "../../../services/dav/CalDavService";
|
||||
|
||||
interface UseFreeBusyOptions {
|
||||
caldavService: CalDavService;
|
||||
attendees: string[];
|
||||
organizerEmail?: string;
|
||||
date: Date;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseFreeBusyResult {
|
||||
data: FreeBusyResponse[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to query freebusy data for a list of attendees on a given date.
|
||||
* Automatically re-queries when attendees or date change.
|
||||
*/
|
||||
export function useFreeBusy({
|
||||
caldavService,
|
||||
attendees,
|
||||
organizerEmail,
|
||||
date,
|
||||
enabled,
|
||||
}: UseFreeBusyOptions): UseFreeBusyResult {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Stable key: sort attendees so order doesn't trigger refetch
|
||||
const attendeesKey = useMemo(
|
||||
() => [...attendees].sort().join(","),
|
||||
[attendees],
|
||||
);
|
||||
const dateKey = date.toISOString().slice(0, 10);
|
||||
|
||||
const queryKey = ["freebusy", attendeesKey, organizerEmail, dateKey];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async (): Promise<FreeBusyResponse[]> => {
|
||||
const start = new Date(date);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(date);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
const result = await caldavService.queryFreeBusy({
|
||||
attendees,
|
||||
timeRange: { start, end },
|
||||
organizer: organizerEmail
|
||||
? { email: organizerEmail, name: organizerEmail.split("@")[0] }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error ?? "Failed to query availability");
|
||||
},
|
||||
enabled: enabled && attendees.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
return {
|
||||
data: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error?.message ?? null,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useRef, MutableRefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createCalendar,
|
||||
destroyCalendar,
|
||||
TimeGrid,
|
||||
DayGrid,
|
||||
List,
|
||||
@@ -252,13 +253,9 @@ export const useSchedulerInit = ({
|
||||
calendarRef.current = ec as unknown as CalendarApi;
|
||||
|
||||
return () => {
|
||||
// @event-calendar/core is Svelte-based and uses $destroy
|
||||
// Always call $destroy before clearing the container to avoid memory leaks
|
||||
if (calendarRef.current) {
|
||||
const calendar = calendarRef.current as CalendarApi;
|
||||
if (typeof calendar.$destroy === 'function') {
|
||||
calendar.$destroy();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
destroyCalendar(calendarRef.current as any);
|
||||
calendarRef.current = null;
|
||||
}
|
||||
// Clear the container only after calendar is destroyed
|
||||
|
||||
@@ -83,7 +83,8 @@ export type EventFormSectionId =
|
||||
| "recurrence"
|
||||
| "attendees"
|
||||
| "resources"
|
||||
| "videoConference";
|
||||
| "videoConference"
|
||||
| "scheduling";
|
||||
|
||||
/**
|
||||
* Attachment metadata (UI only, no actual file upload).
|
||||
@@ -130,7 +131,6 @@ export interface CalendarApi {
|
||||
addEvent: (event: unknown) => void;
|
||||
unselect: () => void;
|
||||
refetchEvents: () => void;
|
||||
$destroy?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
buildSyncCollectionXml,
|
||||
buildPrincipalSearchXml,
|
||||
executeDavRequest,
|
||||
escapeXml,
|
||||
CALENDAR_PROPS,
|
||||
parseCalendarComponents,
|
||||
parseSharePrivilege,
|
||||
@@ -793,20 +794,22 @@ export class CalDavService {
|
||||
throw new Error('Scheduling outbox not found')
|
||||
}
|
||||
|
||||
// Construct full URL - outboxUrl is relative to serverUrl
|
||||
// Construct full URL - outboxUrl from PROPFIND is an absolute path (e.g. /caldav/calendars/...)
|
||||
// so we only need to prepend the origin, not the full serverUrl (which already has /caldav/)
|
||||
const fullOutboxUrl = outboxUrl.startsWith('http')
|
||||
? outboxUrl
|
||||
: `${this._account!.serverUrl}${outboxUrl.startsWith('/') ? outboxUrl.slice(1) : outboxUrl}`
|
||||
: `${new URL(this._account!.serverUrl).origin}${outboxUrl}`
|
||||
|
||||
// Use fetch directly to avoid davRequest URL construction issues in dev mode
|
||||
// Note: fetchOptions is spread first so its headers don't override our Content-Type
|
||||
const response = await fetch(fullOutboxUrl, {
|
||||
...this._account!.fetchOptions,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8; method=' + request.method,
|
||||
...this._account!.headers,
|
||||
'Content-Type': 'text/calendar; charset=utf-8; method=' + request.method,
|
||||
},
|
||||
body: iCalString,
|
||||
...this._account!.fetchOptions,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -910,31 +913,91 @@ ${attendeeLines}
|
||||
END:VFREEBUSY
|
||||
END:VCALENDAR`
|
||||
|
||||
const responses = await davRequest({
|
||||
url: outboxUrl,
|
||||
init: {
|
||||
// Construct full URL - outboxUrl from PROPFIND is an absolute path (e.g. /caldav/calendars/...)
|
||||
// so we only need to prepend the origin, not the full serverUrl (which already has /caldav/)
|
||||
const fullOutboxUrl = outboxUrl.startsWith('http')
|
||||
? outboxUrl
|
||||
: `${new URL(this._account!.serverUrl).origin}${outboxUrl}`
|
||||
|
||||
// Note: fetchOptions is spread first so its headers don't override our Content-Type
|
||||
const response = await fetch(fullOutboxUrl, {
|
||||
...this._account!.fetchOptions,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
...this._account!.headers,
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
},
|
||||
body: fbRequest,
|
||||
},
|
||||
fetchOptions: this._account!.fetchOptions,
|
||||
})
|
||||
|
||||
const response = responses[0]
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Failed to query free/busy: ${response?.status}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to query free/busy: ${response.status}`)
|
||||
}
|
||||
|
||||
return request.attendees.map((email) => ({
|
||||
attendee: email,
|
||||
periods: [],
|
||||
}))
|
||||
const xmlText = await response.text()
|
||||
return parseScheduleFreeBusyResponse(xmlText)
|
||||
}, 'Failed to query free/busy')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Availability (Working Hours)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the user's calendar availability (working hours).
|
||||
* Reads the {urn:ietf:params:xml:ns:caldav}calendar-availability property
|
||||
* from the calendar home via PROPFIND.
|
||||
*/
|
||||
async getAvailability(): Promise<CalDavResponse<string | null>> {
|
||||
if (!this._account?.homeUrl) {
|
||||
return { success: false, error: 'Not connected' }
|
||||
}
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const response = await propfind({
|
||||
url: this._account!.homeUrl!,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-availability`]: {},
|
||||
},
|
||||
headers: this._account!.headers,
|
||||
fetchOptions: this._account!.fetchOptions,
|
||||
depth: '0',
|
||||
})
|
||||
|
||||
return response[0]?.props?.['calendarAvailability'] ?? null
|
||||
}, 'Failed to get availability')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the user's calendar availability (working hours).
|
||||
* Stores a VCALENDAR with VAVAILABILITY/AVAILABLE components
|
||||
* on the calendar home via PROPPATCH.
|
||||
*/
|
||||
async setAvailability(
|
||||
vcalendarText: string,
|
||||
): Promise<CalDavResponse> {
|
||||
if (!this._account?.homeUrl) {
|
||||
return { success: false, error: 'Not connected' }
|
||||
}
|
||||
|
||||
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<C:calendar-availability>${escapeXml(vcalendarText)}</C:calendar-availability>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>`
|
||||
|
||||
return executeDavRequest({
|
||||
url: this._account.homeUrl,
|
||||
method: 'PROPPATCH',
|
||||
body,
|
||||
headers: this._account.headers,
|
||||
fetchOptions: this._account.fetchOptions,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sync Operations
|
||||
// ============================================================================
|
||||
@@ -1189,3 +1252,127 @@ END:VCALENDAR`
|
||||
export function createCalDavService(): CalDavService {
|
||||
return new CalDavService()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FreeBusy Response Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse a CalDAV schedule-response XML containing VFREEBUSY data.
|
||||
* Response format defined in RFC 6638.
|
||||
*/
|
||||
function parseScheduleFreeBusyResponse(
|
||||
xmlText: string,
|
||||
): FreeBusyResponse[] {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xmlText, 'application/xml')
|
||||
|
||||
const results: FreeBusyResponse[] = []
|
||||
const CAL_NS = 'urn:ietf:params:xml:ns:caldav'
|
||||
const DAV_NS = 'DAV:'
|
||||
|
||||
const responseElements = doc.getElementsByTagNameNS(CAL_NS, 'response')
|
||||
|
||||
for (let i = 0; i < responseElements.length; i++) {
|
||||
const responseEl = responseElements[i]
|
||||
|
||||
// Check request-status — skip unknown users (3.x = error)
|
||||
const statusEl = responseEl.getElementsByTagNameNS(CAL_NS, 'request-status')[0]
|
||||
const status = statusEl?.textContent ?? ''
|
||||
if (status.startsWith('3.')) continue
|
||||
|
||||
// Extract recipient email
|
||||
const recipientEl = responseEl.getElementsByTagNameNS(CAL_NS, 'recipient')[0]
|
||||
const hrefEl = recipientEl?.getElementsByTagNameNS(DAV_NS, 'href')[0]
|
||||
const href = hrefEl?.textContent ?? ''
|
||||
const email = href.replace(/^mailto:/i, '').toLowerCase()
|
||||
|
||||
// Extract calendar-data (ICS text containing VFREEBUSY)
|
||||
const calDataEl = responseEl.getElementsByTagNameNS(CAL_NS, 'calendar-data')[0]
|
||||
const icsText = calDataEl?.textContent ?? ''
|
||||
|
||||
const periods = parseFreeBusyPeriods(icsText)
|
||||
results.push({ attendee: email, periods })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse FREEBUSY lines from a VFREEBUSY ICS component.
|
||||
* Format: FREEBUSY;FBTYPE=BUSY:20260310T090000Z/20260310T100000Z
|
||||
*/
|
||||
function parseFreeBusyPeriods(icsText: string): FreeBusyResponse['periods'] {
|
||||
const periods: FreeBusyResponse['periods'] = []
|
||||
const lines = icsText.split(/\r?\n/)
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('FREEBUSY')) continue
|
||||
|
||||
// Split into params and value: FREEBUSY;FBTYPE=BUSY:start/end
|
||||
const colonIdx = line.indexOf(':')
|
||||
if (colonIdx === -1) continue
|
||||
|
||||
const params = line.substring(0, colonIdx)
|
||||
const value = line.substring(colonIdx + 1)
|
||||
|
||||
// Parse FBTYPE (default BUSY)
|
||||
let fbType: 'BUSY' | 'BUSY-UNAVAILABLE' | 'BUSY-TENTATIVE' | 'FREE' = 'BUSY'
|
||||
const typeMatch = params.match(/FBTYPE=([A-Z-]+)/i)
|
||||
if (typeMatch) {
|
||||
fbType = typeMatch[1].toUpperCase() as typeof fbType
|
||||
}
|
||||
|
||||
// Parse comma-separated periods
|
||||
const periodStrs = value.split(',')
|
||||
for (const periodStr of periodStrs) {
|
||||
const [startStr, endStr] = periodStr.split('/')
|
||||
if (!startStr || !endStr) continue
|
||||
|
||||
const start = parseIcsDateTime(startStr)
|
||||
if (!start) continue
|
||||
|
||||
let end: Date | null
|
||||
if (endStr.startsWith('P')) {
|
||||
// Duration format (e.g., PT1H)
|
||||
end = addIsoDuration(start, endStr)
|
||||
} else {
|
||||
end = parseIcsDateTime(endStr)
|
||||
}
|
||||
if (!end) continue
|
||||
|
||||
periods.push({ start, end, type: fbType })
|
||||
}
|
||||
}
|
||||
|
||||
return periods
|
||||
}
|
||||
|
||||
/** Parse an ICS datetime string like 20260310T090000Z into a Date. */
|
||||
function parseIcsDateTime(str: string): Date | null {
|
||||
const m = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/)
|
||||
if (!m) return null
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
parseInt(m[1]),
|
||||
parseInt(m[2]) - 1,
|
||||
parseInt(m[3]),
|
||||
parseInt(m[4]),
|
||||
parseInt(m[5]),
|
||||
parseInt(m[6]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Add an ISO 8601 duration (e.g., PT1H30M) to a Date. */
|
||||
function addIsoDuration(date: Date, duration: string): Date {
|
||||
const m = duration.match(/P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/)
|
||||
if (!m) return date
|
||||
const ms =
|
||||
(parseInt(m[1] || '0') * 86400 +
|
||||
parseInt(m[2] || '0') * 3600 +
|
||||
parseInt(m[3] || '0') * 60 +
|
||||
parseInt(m[4] || '0')) *
|
||||
1000
|
||||
return new Date(date.getTime() + ms)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,28 @@
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
"my_account": "My Account",
|
||||
"settings": "Settings",
|
||||
"settings": {
|
||||
"label": "Settings",
|
||||
"workingHours": {
|
||||
"title": "Working Hours",
|
||||
"description": "Set your available hours for scheduling",
|
||||
"when": "When",
|
||||
"everyMonday": "Every Monday",
|
||||
"everyTuesday": "Every Tuesday",
|
||||
"everyWednesday": "Every Wednesday",
|
||||
"everyThursday": "Every Thursday",
|
||||
"everyFriday": "Every Friday",
|
||||
"everySaturday": "Every Saturday",
|
||||
"everySunday": "Every Sunday",
|
||||
"specificDate": "Specific date...",
|
||||
"start": "Start",
|
||||
"end": "End",
|
||||
"addAvailability": "Add availability",
|
||||
"removeAvailability": "Remove",
|
||||
"save": "Save",
|
||||
"saved": "Working hours saved"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"error": {
|
||||
"unexpected": "An unexpected error occurred."
|
||||
@@ -331,7 +352,8 @@
|
||||
"participants": "Participants",
|
||||
"organizer": "Organizer",
|
||||
"viewProfile": "View profile",
|
||||
"cannotRemoveOrganizer": "Cannot remove organizer"
|
||||
"cannotRemoveOrganizer": "Cannot remove organizer",
|
||||
"noResults": "No users found"
|
||||
},
|
||||
"resources": {
|
||||
"placeholder": "Select a resource...",
|
||||
@@ -514,6 +536,14 @@
|
||||
"copy": "Copy token",
|
||||
"copied": "Token copied to clipboard."
|
||||
}
|
||||
},
|
||||
"scheduling": {
|
||||
"findATime": "Find a time",
|
||||
"previousDay": "Previous day",
|
||||
"nextDay": "Next day",
|
||||
"noConflicts": "No conflicts",
|
||||
"conflicts": "{{count}} conflict(s)",
|
||||
"addAttendeesToSeeAvailability": "Add attendees to see availability"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -585,7 +615,28 @@
|
||||
"logout": "Déconnexion",
|
||||
"login": "Connexion",
|
||||
"my_account": "Mon Compte",
|
||||
"settings": "Paramètres",
|
||||
"settings": {
|
||||
"label": "Paramètres",
|
||||
"workingHours": {
|
||||
"title": "Heures de travail",
|
||||
"description": "Définissez vos heures de disponibilité pour la planification",
|
||||
"when": "Quand",
|
||||
"everyMonday": "Chaque lundi",
|
||||
"everyTuesday": "Chaque mardi",
|
||||
"everyWednesday": "Chaque mercredi",
|
||||
"everyThursday": "Chaque jeudi",
|
||||
"everyFriday": "Chaque vendredi",
|
||||
"everySaturday": "Chaque samedi",
|
||||
"everySunday": "Chaque dimanche",
|
||||
"specificDate": "Date spécifique...",
|
||||
"start": "Début",
|
||||
"end": "Fin",
|
||||
"addAvailability": "Ajouter une disponibilité",
|
||||
"removeAvailability": "Supprimer",
|
||||
"save": "Enregistrer",
|
||||
"saved": "Heures de travail enregistrées"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"error": {
|
||||
"unexpected": "Une erreur inattendue est survenue."
|
||||
@@ -1152,7 +1203,8 @@
|
||||
"participants": "Participants",
|
||||
"organizer": "Organisateur",
|
||||
"viewProfile": "Voir le profil",
|
||||
"cannotRemoveOrganizer": "Impossible de retirer l'organisateur"
|
||||
"cannotRemoveOrganizer": "Impossible de retirer l'organisateur",
|
||||
"noResults": "Aucun utilisateur trouvé"
|
||||
},
|
||||
"resources": {
|
||||
"placeholder": "Sélectionner une ressource...",
|
||||
@@ -1335,6 +1387,14 @@
|
||||
"copy": "Copier le jeton",
|
||||
"copied": "Jeton copié dans le presse-papiers."
|
||||
}
|
||||
},
|
||||
"scheduling": {
|
||||
"findATime": "Trouver un créneau",
|
||||
"previousDay": "Jour précédent",
|
||||
"nextDay": "Jour suivant",
|
||||
"noConflicts": "Aucun conflit",
|
||||
"conflicts": "{{count}} conflit(s)",
|
||||
"addAttendeesToSeeAvailability": "Ajoutez des participants pour voir les disponibilités"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1401,7 +1461,28 @@
|
||||
"logout": "Uitloggen",
|
||||
"login": "Inloggen",
|
||||
"my_account": "Mijn account",
|
||||
"settings": "Instellingen",
|
||||
"settings": {
|
||||
"label": "Instellingen",
|
||||
"workingHours": {
|
||||
"title": "Werkuren",
|
||||
"description": "Stel uw beschikbare uren in voor planning",
|
||||
"when": "Wanneer",
|
||||
"everyMonday": "Elke maandag",
|
||||
"everyTuesday": "Elke dinsdag",
|
||||
"everyWednesday": "Elke woensdag",
|
||||
"everyThursday": "Elke donderdag",
|
||||
"everyFriday": "Elke vrijdag",
|
||||
"everySaturday": "Elke zaterdag",
|
||||
"everySunday": "Elke zondag",
|
||||
"specificDate": "Specifieke datum...",
|
||||
"start": "Start",
|
||||
"end": "Einde",
|
||||
"addAvailability": "Beschikbaarheid toevoegen",
|
||||
"removeAvailability": "Verwijderen",
|
||||
"save": "Opslaan",
|
||||
"saved": "Werkuren opgeslagen"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"error": {
|
||||
"unexpected": "Er is een onverwachte fout opgetreden."
|
||||
@@ -1715,7 +1796,8 @@
|
||||
"participants": "Deelnemers",
|
||||
"organizer": "Organisator",
|
||||
"viewProfile": "Profiel bekijken",
|
||||
"cannotRemoveOrganizer": "Kan organisator niet verwijderen"
|
||||
"cannotRemoveOrganizer": "Kan organisator niet verwijderen",
|
||||
"noResults": "Geen gebruikers gevonden"
|
||||
},
|
||||
"resources": {
|
||||
"placeholder": "Selecteer een middel...",
|
||||
@@ -1898,6 +1980,14 @@
|
||||
"copy": "Token kopiëren",
|
||||
"copied": "Token gekopieerd naar klembord."
|
||||
}
|
||||
},
|
||||
"scheduling": {
|
||||
"findATime": "Zoek een tijdstip",
|
||||
"previousDay": "Vorige dag",
|
||||
"nextDay": "Volgende dag",
|
||||
"noConflicts": "Geen conflicten",
|
||||
"conflicts": "{{count}} conflict(en)",
|
||||
"addAttendeesToSeeAvailability": "Voeg deelnemers toe om beschikbaarheid te zien"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,13 +43,10 @@ const ApplicationMenu = () => {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user?.can_admin) return null;
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
options={[
|
||||
const adminOptions = user.can_admin
|
||||
? [
|
||||
{
|
||||
label: t("resources.title"),
|
||||
icon: <Icon name="meeting_room" type={IconType.OUTLINED} />,
|
||||
@@ -60,12 +57,26 @@ const ApplicationMenu = () => {
|
||||
icon: <Icon name="integration_instructions" type={IconType.OUTLINED} />,
|
||||
callback: () => void router.push("/integrations"),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
options={[
|
||||
{
|
||||
label: t("settings.workingHours.title"),
|
||||
icon: <Icon name="schedule" type={IconType.OUTLINED} />,
|
||||
callback: () => void router.push("/settings"),
|
||||
},
|
||||
...adminOptions,
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
icon={<Icon name="settings" type={IconType.OUTLINED} />}
|
||||
aria-label={t("settings")}
|
||||
aria-label={t("settings.label")}
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
slotsToVCalendar,
|
||||
vCalendarToSlots,
|
||||
} from "../availability-ics";
|
||||
import type { AvailabilitySlots } from "../types";
|
||||
|
||||
describe("availability-ics", () => {
|
||||
describe("slotsToVCalendar", () => {
|
||||
it("converts recurring weekday slots to VCALENDAR", () => {
|
||||
const slots: AvailabilitySlots = [
|
||||
{ id: "1", when: { type: "recurring", day: "monday" }, start: "09:00", end: "17:00" },
|
||||
{ id: "2", when: { type: "recurring", day: "friday" }, start: "09:00", end: "17:00" },
|
||||
];
|
||||
const result = slotsToVCalendar(slots);
|
||||
expect(result).toContain("BEGIN:VCALENDAR");
|
||||
expect(result).toContain("BEGIN:VAVAILABILITY");
|
||||
expect(result).toContain("RRULE:FREQ=WEEKLY;BYDAY=MO,FR");
|
||||
expect(result).toContain("T090000");
|
||||
expect(result).toContain("T170000");
|
||||
});
|
||||
|
||||
it("creates separate blocks for different time ranges", () => {
|
||||
const slots: AvailabilitySlots = [
|
||||
{ id: "1", when: { type: "recurring", day: "monday" }, start: "09:00", end: "12:00" },
|
||||
{ id: "2", when: { type: "recurring", day: "monday" }, start: "14:00", end: "18:00" },
|
||||
];
|
||||
const result = slotsToVCalendar(slots);
|
||||
const count = (result.match(/BEGIN:AVAILABLE/g) || []).length;
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("handles specific-date slots without RRULE", () => {
|
||||
const slots: AvailabilitySlots = [
|
||||
{ id: "1", when: { type: "specific", date: "2026-03-15" }, start: "10:00", end: "14:00" },
|
||||
];
|
||||
const result = slotsToVCalendar(slots);
|
||||
expect(result).toContain("DTSTART:20260315T100000");
|
||||
expect(result).toContain("DTEND:20260315T140000");
|
||||
expect(result).not.toContain("RRULE");
|
||||
});
|
||||
|
||||
it("handles mixed recurring and specific-date slots", () => {
|
||||
const slots: AvailabilitySlots = [
|
||||
{ id: "1", when: { type: "recurring", day: "tuesday" }, start: "09:00", end: "17:00" },
|
||||
{ id: "2", when: { type: "specific", date: "2026-04-01" }, start: "10:00", end: "15:00" },
|
||||
];
|
||||
const result = slotsToVCalendar(slots);
|
||||
expect(result).toContain("RRULE:FREQ=WEEKLY;BYDAY=TU");
|
||||
expect(result).toContain("DTSTART:20260401T100000");
|
||||
expect(result).not.toMatch(/RRULE.*\n.*20260401/);
|
||||
});
|
||||
|
||||
it("handles empty slots array", () => {
|
||||
const result = slotsToVCalendar([]);
|
||||
expect(result).toContain("BEGIN:VAVAILABILITY");
|
||||
expect(result).not.toContain("BEGIN:AVAILABLE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("vCalendarToSlots", () => {
|
||||
it("parses recurring AVAILABLE blocks into slots", () => {
|
||||
const vcal = `BEGIN:VCALENDAR
|
||||
BEGIN:VAVAILABILITY
|
||||
BEGIN:AVAILABLE
|
||||
DTSTART:20260105T090000
|
||||
DTEND:20260105T170000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
|
||||
END:AVAILABLE
|
||||
END:VAVAILABILITY
|
||||
END:VCALENDAR`;
|
||||
const result = vCalendarToSlots(vcal);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((s) => (s.when as { day: string }).day)).toEqual([
|
||||
"monday", "wednesday", "friday",
|
||||
]);
|
||||
expect(result[0].start).toBe("09:00");
|
||||
expect(result[0].end).toBe("17:00");
|
||||
});
|
||||
|
||||
it("parses specific-date AVAILABLE blocks", () => {
|
||||
const vcal = `BEGIN:VCALENDAR
|
||||
BEGIN:VAVAILABILITY
|
||||
BEGIN:AVAILABLE
|
||||
DTSTART:20260315T100000
|
||||
DTEND:20260315T140000
|
||||
END:AVAILABLE
|
||||
END:VAVAILABILITY
|
||||
END:VCALENDAR`;
|
||||
const result = vCalendarToSlots(vcal);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].when).toEqual({ type: "specific", date: "2026-03-15" });
|
||||
expect(result[0].start).toBe("10:00");
|
||||
expect(result[0].end).toBe("14:00");
|
||||
});
|
||||
|
||||
it("round-trips recurring slots", () => {
|
||||
const original: AvailabilitySlots = [
|
||||
{ id: "1", when: { type: "recurring", day: "monday" }, start: "08:00", end: "12:00" },
|
||||
{ id: "2", when: { type: "recurring", day: "monday" }, start: "14:00", end: "18:00" },
|
||||
{ id: "3", when: { type: "recurring", day: "wednesday" }, start: "09:00", end: "17:00" },
|
||||
];
|
||||
const vcal = slotsToVCalendar(original);
|
||||
const parsed = vCalendarToSlots(vcal);
|
||||
|
||||
expect(parsed).toHaveLength(3);
|
||||
// Check monday slots
|
||||
const monSlots = parsed.filter(
|
||||
(s) => s.when.type === "recurring" && s.when.day === "monday",
|
||||
);
|
||||
expect(monSlots).toHaveLength(2);
|
||||
expect(monSlots.map((s) => s.start).sort()).toEqual(["08:00", "14:00"]);
|
||||
});
|
||||
|
||||
it("round-trips specific-date slots", () => {
|
||||
const original: AvailabilitySlots = [
|
||||
{ id: "1", when: { type: "specific", date: "2026-12-25" }, start: "10:00", end: "14:00" },
|
||||
];
|
||||
const vcal = slotsToVCalendar(original);
|
||||
const parsed = vCalendarToSlots(vcal);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].when).toEqual({ type: "specific", date: "2026-12-25" });
|
||||
expect(parsed[0].start).toBe("10:00");
|
||||
expect(parsed[0].end).toBe("14:00");
|
||||
});
|
||||
|
||||
it("returns empty array for invalid input", () => {
|
||||
expect(vCalendarToSlots("not valid ics")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for VCALENDAR with no AVAILABLE", () => {
|
||||
const vcal = `BEGIN:VCALENDAR\nBEGIN:VAVAILABILITY\nEND:VAVAILABILITY\nEND:VCALENDAR`;
|
||||
expect(vCalendarToSlots(vcal)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out past specific dates on parse", () => {
|
||||
const vcal = `BEGIN:VCALENDAR
|
||||
BEGIN:VAVAILABILITY
|
||||
BEGIN:AVAILABLE
|
||||
DTSTART:20200101T090000
|
||||
DTEND:20200101T170000
|
||||
END:AVAILABLE
|
||||
END:VAVAILABILITY
|
||||
END:VCALENDAR`;
|
||||
expect(vCalendarToSlots(vcal)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out past specific dates on save", () => {
|
||||
const slots: AvailabilitySlots = [
|
||||
{ id: "1", when: { type: "specific", date: "2020-01-01" }, start: "09:00", end: "17:00" },
|
||||
{ id: "2", when: { type: "recurring", day: "monday" }, start: "09:00", end: "17:00" },
|
||||
];
|
||||
const result = slotsToVCalendar(slots);
|
||||
expect(result).not.toContain("20200101");
|
||||
expect(result).toContain("BYDAY=MO");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { CalDavService } from "@/features/calendar/services/dav/CalDavService";
|
||||
import {
|
||||
caldavServerUrl,
|
||||
headers,
|
||||
fetchOptions,
|
||||
} from "@/features/calendar/utils/DavClient";
|
||||
import { useAuth } from "@/features/auth/Auth";
|
||||
|
||||
import {
|
||||
DEFAULT_AVAILABILITY,
|
||||
type AvailabilitySlots,
|
||||
} from "../types";
|
||||
import {
|
||||
slotsToVCalendar,
|
||||
vCalendarToSlots,
|
||||
} from "../availability-ics";
|
||||
|
||||
const WORKING_HOURS_KEY = ["working-hours"];
|
||||
|
||||
export const useWorkingHours = () => {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const caldavService = useMemo(() => new CalDavService(), []);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
let cancelled = false;
|
||||
|
||||
caldavService
|
||||
.connect({ serverUrl: caldavServerUrl, headers, fetchOptions })
|
||||
.then((result) => {
|
||||
if (!cancelled && result.success) {
|
||||
setIsConnected(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to connect to CalDAV:", err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [caldavService, user]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: WORKING_HOURS_KEY,
|
||||
queryFn: async (): Promise<AvailabilitySlots> => {
|
||||
const result = await caldavService.getAvailability();
|
||||
if (
|
||||
!result.success ||
|
||||
typeof result.data !== "string" ||
|
||||
!result.data.includes("VAVAILABILITY")
|
||||
) {
|
||||
return DEFAULT_AVAILABILITY;
|
||||
}
|
||||
const slots = vCalendarToSlots(result.data);
|
||||
return slots.length > 0 ? slots : DEFAULT_AVAILABILITY;
|
||||
},
|
||||
enabled: isConnected,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (slots: AvailabilitySlots) => {
|
||||
const vcalendar = slotsToVCalendar(slots);
|
||||
const result = await caldavService.setAvailability(vcalendar);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
result.error ?? "Failed to save availability",
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: WORKING_HOURS_KEY,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
slots: query.data ?? DEFAULT_AVAILABILITY,
|
||||
isLoading: !isConnected || query.isLoading,
|
||||
save: mutation.mutateAsync,
|
||||
isSaving: mutation.isPending,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { DayOfWeek, AvailabilitySlots } from "./types";
|
||||
import { generateSlotId } from "./types";
|
||||
|
||||
/** Map day names to iCal BYDAY abbreviations */
|
||||
const DAY_TO_ICAL: Record<DayOfWeek, string> = {
|
||||
monday: "MO",
|
||||
tuesday: "TU",
|
||||
wednesday: "WE",
|
||||
thursday: "TH",
|
||||
friday: "FR",
|
||||
saturday: "SA",
|
||||
sunday: "SU",
|
||||
};
|
||||
|
||||
/** Reverse map: iCal abbreviation to day name */
|
||||
const ICAL_TO_DAY: Record<string, DayOfWeek> = Object.fromEntries(
|
||||
Object.entries(DAY_TO_ICAL).map(([k, v]) => [v, k as DayOfWeek]),
|
||||
) as Record<string, DayOfWeek>;
|
||||
|
||||
/**
|
||||
* Anchor dates for each weekday (week of 2026-01-05, a Monday).
|
||||
* Used as DTSTART reference for recurring AVAILABLE components.
|
||||
*/
|
||||
const DAY_ANCHORS: Record<DayOfWeek, string> = {
|
||||
monday: "20260105",
|
||||
tuesday: "20260106",
|
||||
wednesday: "20260107",
|
||||
thursday: "20260108",
|
||||
friday: "20260109",
|
||||
saturday: "20260110",
|
||||
sunday: "20260111",
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert AvailabilitySlots to a VCALENDAR string with VAVAILABILITY.
|
||||
*
|
||||
* Groups recurring slots with identical time ranges into single
|
||||
* AVAILABLE components with multi-day BYDAY rules.
|
||||
* Specific-date slots each get their own AVAILABLE block without RRULE.
|
||||
*/
|
||||
export function slotsToVCalendar(slots: AvailabilitySlots): string {
|
||||
// Separate recurring vs specific
|
||||
const recurringSlots = slots.filter(
|
||||
(s) => s.when.type === "recurring",
|
||||
);
|
||||
const specificSlots = slots.filter(
|
||||
(s) => s.when.type === "specific",
|
||||
);
|
||||
|
||||
// Group recurring by time range
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ days: DayOfWeek[]; start: string; end: string }
|
||||
>();
|
||||
for (const slot of recurringSlots) {
|
||||
if (slot.when.type !== "recurring") continue;
|
||||
const key = `${slot.start}-${slot.end}`;
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.days.push(slot.when.day);
|
||||
} else {
|
||||
groups.set(key, {
|
||||
days: [slot.when.day],
|
||||
start: slot.start,
|
||||
end: slot.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
|
||||
// Recurring blocks
|
||||
for (const { days, start, end } of groups.values()) {
|
||||
const byDay = days.map((d) => DAY_TO_ICAL[d]).join(",");
|
||||
const anchor = DAY_ANCHORS[days[0]];
|
||||
const startTime = start.replace(":", "");
|
||||
const endTime = end.replace(":", "");
|
||||
blocks.push(`BEGIN:AVAILABLE
|
||||
DTSTART:${anchor}T${startTime}00
|
||||
DTEND:${anchor}T${endTime}00
|
||||
RRULE:FREQ=WEEKLY;BYDAY=${byDay}
|
||||
END:AVAILABLE`);
|
||||
}
|
||||
|
||||
// Specific-date blocks (skip past dates)
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
for (const slot of specificSlots) {
|
||||
if (slot.when.type !== "specific") continue;
|
||||
if (slot.when.date < today) continue;
|
||||
const dateStr = slot.when.date.replace(/-/g, "");
|
||||
const startTime = slot.start.replace(":", "");
|
||||
const endTime = slot.end.replace(":", "");
|
||||
blocks.push(`BEGIN:AVAILABLE
|
||||
DTSTART:${dateStr}T${startTime}00
|
||||
DTEND:${dateStr}T${endTime}00
|
||||
END:AVAILABLE`);
|
||||
}
|
||||
|
||||
const inner = blocks.length > 0 ? "\n" + blocks.join("\n") + "\n" : "\n";
|
||||
|
||||
return `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Calendars//Working Hours//EN
|
||||
BEGIN:VAVAILABILITY${inner}END:VAVAILABILITY
|
||||
END:VCALENDAR`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a VCALENDAR with VAVAILABILITY back into AvailabilitySlots.
|
||||
*/
|
||||
export function vCalendarToSlots(vcalendar: string): AvailabilitySlots {
|
||||
const slots: AvailabilitySlots = [];
|
||||
|
||||
const availableRegex = /BEGIN:AVAILABLE[\s\S]*?END:AVAILABLE/g;
|
||||
let match;
|
||||
|
||||
while ((match = availableRegex.exec(vcalendar)) !== null) {
|
||||
const block = match[0];
|
||||
|
||||
const startMatch = block.match(
|
||||
/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})\d{2}/,
|
||||
);
|
||||
const endMatch = block.match(
|
||||
/DTEND:\d{8}T(\d{2})(\d{2})\d{2}/,
|
||||
);
|
||||
|
||||
if (!startMatch || !endMatch) continue;
|
||||
|
||||
const start = `${startMatch[4]}:${startMatch[5]}`;
|
||||
const end = `${endMatch[1]}:${endMatch[2]}`;
|
||||
const dateStr = `${startMatch[1]}-${startMatch[2]}-${startMatch[3]}`;
|
||||
|
||||
const byDayMatch = block.match(/BYDAY=([A-Z,]+)/);
|
||||
|
||||
if (byDayMatch) {
|
||||
// Recurring: create one slot per day in the BYDAY list
|
||||
const days = byDayMatch[1].split(",");
|
||||
for (const icalDay of days) {
|
||||
const dayName = ICAL_TO_DAY[icalDay];
|
||||
if (dayName) {
|
||||
slots.push({
|
||||
id: generateSlotId(),
|
||||
when: { type: "recurring", day: dayName },
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Specific date — skip past dates
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (dateStr < today) continue;
|
||||
|
||||
slots.push({
|
||||
id: generateSlotId(),
|
||||
when: { type: "specific", date: dateStr },
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
DAYS_OF_WEEK,
|
||||
type AvailabilitySlot,
|
||||
type AvailabilityWhen,
|
||||
type DayOfWeek,
|
||||
} from "../types";
|
||||
|
||||
interface AvailabilityRowProps {
|
||||
slot: AvailabilitySlot;
|
||||
onChange: (id: string, updates: Partial<AvailabilitySlot>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const AvailabilityRow = ({
|
||||
slot,
|
||||
onChange,
|
||||
onDelete,
|
||||
}: AvailabilityRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const selectValue =
|
||||
slot.when.type === "recurring" ? slot.when.day : "specific";
|
||||
|
||||
const handleWhenChange = useCallback(
|
||||
(value: string) => {
|
||||
let when: AvailabilityWhen;
|
||||
if (value === "specific") {
|
||||
// Default to today's date
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
when = { type: "specific", date: today };
|
||||
} else {
|
||||
when = { type: "recurring", day: value as DayOfWeek };
|
||||
}
|
||||
onChange(slot.id, { when });
|
||||
},
|
||||
[onChange, slot.id],
|
||||
);
|
||||
|
||||
const handleDateChange = useCallback(
|
||||
(date: string) => {
|
||||
onChange(slot.id, {
|
||||
when: { type: "specific", date },
|
||||
});
|
||||
},
|
||||
[onChange, slot.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="working-hours__row">
|
||||
<div className="working-hours__row-when">
|
||||
<select
|
||||
className="working-hours__select"
|
||||
value={selectValue}
|
||||
onChange={(e) => handleWhenChange(e.target.value)}
|
||||
>
|
||||
{DAYS_OF_WEEK.map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{t(`settings.workingHours.every${day.charAt(0).toUpperCase() + day.slice(1)}`)}
|
||||
</option>
|
||||
))}
|
||||
<option value="specific">
|
||||
{t("settings.workingHours.specificDate")}
|
||||
</option>
|
||||
</select>
|
||||
{slot.when.type === "specific" && (
|
||||
<input
|
||||
type="date"
|
||||
className="working-hours__date-input"
|
||||
value={slot.when.date}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="time"
|
||||
className="working-hours__row-time"
|
||||
value={slot.start}
|
||||
onChange={(e) => onChange(slot.id, { start: e.target.value })}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="time"
|
||||
className="working-hours__row-time"
|
||||
value={slot.end}
|
||||
onChange={(e) => onChange(slot.id, { end: e.target.value })}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="working-hours__row-delete"
|
||||
onClick={() => onDelete(slot.id)}
|
||||
aria-label={t("settings.workingHours.removeAvailability")}
|
||||
>
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
.settings-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: var(--c--globals--spacings--base);
|
||||
}
|
||||
|
||||
.working-hours {
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--c--globals--spacings--xs);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
margin-bottom: var(--c--globals--spacings--base);
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--c--globals--spacings--2xs);
|
||||
}
|
||||
|
||||
&__grid-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 120px 36px;
|
||||
gap: var(--c--globals--spacings--sm);
|
||||
padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 120px 36px;
|
||||
gap: var(--c--globals--spacings--sm);
|
||||
align-items: center;
|
||||
padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm);
|
||||
border: 1px solid var(--c--contextuals--border--semantic--neutral--secondary);
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__row-when {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--c--globals--spacings--2xs) var(--c--globals--spacings--xs);
|
||||
border: 1px solid var(--c--contextuals--border--semantic--neutral--secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--c--contextuals--background--surface--primary);
|
||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__date-input {
|
||||
width: 140px;
|
||||
padding: var(--c--globals--spacings--2xs) var(--c--globals--spacings--xs);
|
||||
border: 1px solid var(--c--contextuals--border--semantic--neutral--secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--c--contextuals--background--surface--primary);
|
||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
}
|
||||
|
||||
&__row-time {
|
||||
padding: var(--c--globals--spacings--2xs) var(--c--globals--spacings--xs);
|
||||
border: 1px solid var(--c--contextuals--border--semantic--neutral--secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--c--contextuals--background--surface--primary);
|
||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
}
|
||||
|
||||
&__row-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--c--contextuals--background--surface--secondary);
|
||||
color: var(--c--contextuals--content--semantic--danger--primary);
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-row {
|
||||
margin-top: var(--c--globals--spacings--xs);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--c--globals--spacings--base);
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--c--globals--spacings--xl) 0;
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
|
||||
.material-icons {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--c--globals--spacings--sm);
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
animation: spin 1.5s linear infinite;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import {
|
||||
addToast,
|
||||
ToasterItem,
|
||||
} from "@/features/ui/components/toaster/Toaster";
|
||||
|
||||
import { useWorkingHours } from "../api/useWorkingHours";
|
||||
import {
|
||||
type AvailabilitySlot,
|
||||
type AvailabilitySlots,
|
||||
generateSlotId,
|
||||
} from "../types";
|
||||
import { AvailabilityRow } from "./AvailabilityRow";
|
||||
|
||||
interface AvailabilityFormProps {
|
||||
initialSlots: AvailabilitySlots;
|
||||
onSave: (slots: AvailabilitySlots) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const AvailabilityForm = ({
|
||||
initialSlots,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: AvailabilityFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const [slots, setSlots] = useState<AvailabilitySlots>(initialSlots);
|
||||
|
||||
const addSlot = useCallback(() => {
|
||||
setSlots((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: generateSlotId(),
|
||||
when: { type: "recurring", day: "monday" },
|
||||
start: "09:00",
|
||||
end: "18:00",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const removeSlot = useCallback((id: string) => {
|
||||
setSlots((prev) => prev.filter((s) => s.id !== id));
|
||||
}, []);
|
||||
|
||||
const updateSlot = useCallback(
|
||||
(id: string, updates: Partial<AvailabilitySlot>) => {
|
||||
setSlots((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, ...updates } : s)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSave(slots);
|
||||
addToast(
|
||||
<ToasterItem type="info">
|
||||
<span>{t("settings.workingHours.saved")}</span>
|
||||
</ToasterItem>,
|
||||
);
|
||||
} catch {
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
<span>{t("api.error.unexpected")}</span>
|
||||
</ToasterItem>,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="working-hours">
|
||||
<div className="working-hours__header">
|
||||
<div className="working-hours__title-row">
|
||||
<Button
|
||||
color="neutral"
|
||||
size="small"
|
||||
icon={
|
||||
<span className="material-icons">arrow_back</span>
|
||||
}
|
||||
onClick={() => void router.push("/calendar")}
|
||||
aria-label={t("app_title")}
|
||||
/>
|
||||
<h2>{t("settings.workingHours.title")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="working-hours__description">
|
||||
{t("settings.workingHours.description")}
|
||||
</p>
|
||||
|
||||
<div className="working-hours__grid">
|
||||
<div className="working-hours__grid-header">
|
||||
<span>{t("settings.workingHours.when")}</span>
|
||||
<span>{t("settings.workingHours.start")}</span>
|
||||
<span>{t("settings.workingHours.end")}</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{slots.map((slot) => (
|
||||
<AvailabilityRow
|
||||
key={slot.id}
|
||||
slot={slot}
|
||||
onChange={updateSlot}
|
||||
onDelete={removeSlot}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="working-hours__add-row">
|
||||
<Button
|
||||
color="neutral"
|
||||
size="small"
|
||||
icon={<span className="material-icons">add</span>}
|
||||
onClick={addSlot}
|
||||
>
|
||||
{t("settings.workingHours.addAvailability")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="working-hours__actions">
|
||||
<Button
|
||||
color="brand"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t("settings.workingHours.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkingHoursSettings = () => {
|
||||
const { slots, isLoading, save, isSaving } = useWorkingHours();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="working-hours__loading">
|
||||
<span className="material-icons working-hours__spinner">
|
||||
hourglass_empty
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AvailabilityForm
|
||||
key={JSON.stringify(slots)}
|
||||
initialSlots={slots}
|
||||
onSave={save}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
);
|
||||
};
|
||||
46
src/frontend/apps/calendars/src/features/settings/types.ts
Normal file
46
src/frontend/apps/calendars/src/features/settings/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface DaySchedule {
|
||||
start: string; // "HH:MM" format
|
||||
end: string; // "HH:MM" format
|
||||
}
|
||||
|
||||
export type DayOfWeek =
|
||||
| "monday"
|
||||
| "tuesday"
|
||||
| "wednesday"
|
||||
| "thursday"
|
||||
| "friday"
|
||||
| "saturday"
|
||||
| "sunday";
|
||||
|
||||
export const DAYS_OF_WEEK: DayOfWeek[] = [
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
];
|
||||
|
||||
export type AvailabilityWhen =
|
||||
| { type: "recurring"; day: DayOfWeek }
|
||||
| { type: "specific"; date: string }; // "YYYY-MM-DD"
|
||||
|
||||
export interface AvailabilitySlot {
|
||||
id: string;
|
||||
when: AvailabilityWhen;
|
||||
start: string; // "HH:MM"
|
||||
end: string; // "HH:MM"
|
||||
}
|
||||
|
||||
export type AvailabilitySlots = AvailabilitySlot[];
|
||||
|
||||
export const generateSlotId = (): string => crypto.randomUUID();
|
||||
|
||||
export const DEFAULT_AVAILABILITY: AvailabilitySlots = [
|
||||
{ id: "d-mon", when: { type: "recurring", day: "monday" }, start: "09:00", end: "18:00" },
|
||||
{ id: "d-tue", when: { type: "recurring", day: "tuesday" }, start: "09:00", end: "18:00" },
|
||||
{ id: "d-wed", when: { type: "recurring", day: "wednesday" }, start: "09:00", end: "18:00" },
|
||||
{ id: "d-thu", when: { type: "recurring", day: "thursday" }, start: "09:00", end: "18:00" },
|
||||
{ id: "d-fri", when: { type: "recurring", day: "friday" }, start: "09:00", end: "18:00" },
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchAPI } from "@/features/api/fetchApi";
|
||||
|
||||
export interface UserSearchResult {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: UserSearchResult[];
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
const MIN_QUERY_LENGTH = 3;
|
||||
|
||||
function useDebouncedValue(value: string, delay: number): string {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export const useUserSearch = (query: string) => {
|
||||
const debouncedQuery = useDebouncedValue(query.trim(), DEBOUNCE_MS);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["users", "search", debouncedQuery],
|
||||
queryFn: async () => {
|
||||
const response = await fetchAPI("users/", {
|
||||
params: { q: debouncedQuery },
|
||||
});
|
||||
const data = (await response.json()) as PaginatedResponse;
|
||||
return data.results;
|
||||
},
|
||||
enabled: debouncedQuery.length >= MIN_QUERY_LENGTH,
|
||||
});
|
||||
};
|
||||
79
src/frontend/apps/calendars/src/pages/settings.tsx
Normal file
79
src/frontend/apps/calendars/src/pages/settings.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { MainLayout } from "@gouvfr-lasuite/ui-kit";
|
||||
import Head from "next/head";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { login, useAuth } from "@/features/auth/Auth";
|
||||
import { GlobalLayout } from "@/features/layouts/components/global/GlobalLayout";
|
||||
import {
|
||||
HeaderIcon,
|
||||
HeaderRight,
|
||||
} from "@/features/layouts/components/header/Header";
|
||||
import { SpinnerPage } from "@/features/ui/components/spinner/SpinnerPage";
|
||||
import { Toaster } from "@/features/ui/components/toaster/Toaster";
|
||||
import { WorkingHoursSettings } from "@/features/settings/components/WorkingHoursSettings";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
login(window.location.href);
|
||||
} else if (user.can_access === false) {
|
||||
void router.push("/no-access");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
if (!user || user.can_access === false) {
|
||||
return <SpinnerPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{t("settings.workingHours.title")} -{" "}
|
||||
{t("app_title")}
|
||||
</title>
|
||||
<meta
|
||||
name="description"
|
||||
content={t(
|
||||
"settings.workingHours.description",
|
||||
)}
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
</Head>
|
||||
|
||||
<div className="settings-page">
|
||||
<WorkingHoursSettings />
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
SettingsPage.getLayout = function getLayout(
|
||||
page: React.ReactElement,
|
||||
) {
|
||||
return (
|
||||
<GlobalLayout>
|
||||
<MainLayout
|
||||
enableResize={false}
|
||||
hideLeftPanelOnDesktop={true}
|
||||
icon={<HeaderIcon />}
|
||||
rightHeaderContent={<HeaderRight />}
|
||||
>
|
||||
{page}
|
||||
</MainLayout>
|
||||
</GlobalLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +1,31 @@
|
||||
@use "@gouvfr-lasuite/ui-kit/sass/fonts";
|
||||
@use "@gouvfr-lasuite/ui-kit/style";
|
||||
@use "./cunningham-tokens.css";
|
||||
@use "./../features/layouts/components/global/GlobalLayout.scss";
|
||||
@use "./../features/feedback/Feedback.scss";
|
||||
@use "./../features/layouts/components/header/index.scss";
|
||||
@use "./../features/layouts/components/global/GlobalLayout";
|
||||
@use "./../features/feedback/Feedback";
|
||||
@use "./../features/layouts/components/header/index";
|
||||
@use "./../features/ui/components/toaster";
|
||||
@use "./../features/ui/components/generic-disclaimer/GenericDisclaimer.scss";
|
||||
@use "./../features/ui/components/spinner/SpinnerPage.scss";
|
||||
@use "./../features/ui/components/logo/DynamicCalendarLogo.scss";
|
||||
@use "./../features/layouts/components/left-panel/LeftPanelMobile.scss";
|
||||
@use "./../features/calendar/components/left-panel/MiniCalendar.scss";
|
||||
@use "./../features/calendar/components/calendar-list/CalendarList.scss";
|
||||
@use "./../features/calendar/components/left-panel/LeftPanel.scss";
|
||||
@use "./../features/calendar/components/scheduler/EventModal.scss";
|
||||
@use "./../features/calendar/components/scheduler/RecurrenceEditor.scss";
|
||||
@use "./../features/calendar/components/scheduler/AttendeesInput.scss";
|
||||
@use "./../features/calendar/components/scheduler/Scheduler.scss";
|
||||
@use "./../features/calendar/components/scheduler/scheduler-theme.scss";
|
||||
@use "./../features/calendar/components/scheduler/SchedulerToolbar.scss";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionRow.scss";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection.scss";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill.scss";
|
||||
@use "./../features/ui/components/generic-disclaimer/GenericDisclaimer";
|
||||
@use "./../features/ui/components/spinner/SpinnerPage";
|
||||
@use "./../features/ui/components/logo/DynamicCalendarLogo";
|
||||
@use "./../features/layouts/components/left-panel/LeftPanelMobile";
|
||||
@use "./../features/calendar/components/left-panel/MiniCalendar";
|
||||
@use "./../features/calendar/components/calendar-list/CalendarList";
|
||||
@use "./../features/calendar/components/left-panel/LeftPanel";
|
||||
@use "./../features/calendar/components/scheduler/EventModal";
|
||||
@use "./../features/calendar/components/scheduler/RecurrenceEditor";
|
||||
@use "./../features/calendar/components/scheduler/AttendeesInput";
|
||||
@use "./../features/calendar/components/scheduler/Scheduler";
|
||||
@use "./../features/calendar/components/scheduler/scheduler-theme";
|
||||
@use "./../features/calendar/components/scheduler/SchedulerToolbar";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionRow";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill";
|
||||
@use "./../features/calendar/components/scheduler/FreeBusyTimeline";
|
||||
@use "./../features/resources/components/Resources";
|
||||
@use "./../pages/index.scss" as *;
|
||||
@use "./../pages/calendar.scss" as *;
|
||||
@use "./../features/settings/components/WorkingHoursSettings";
|
||||
@use "./../pages/index" as *;
|
||||
@use "./../pages/calendar" as *;
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
3
src/frontend/apps/calendars/types.d.ts
vendored
3
src/frontend/apps/calendars/types.d.ts
vendored
@@ -25,7 +25,6 @@ declare module "@event-calendar/core" {
|
||||
updateEvent: (event: unknown) => void;
|
||||
removeEventById: (id: string) => void;
|
||||
unselect: () => void;
|
||||
$destroy?: () => void;
|
||||
}
|
||||
|
||||
export function createCalendar(
|
||||
@@ -34,6 +33,8 @@ declare module "@event-calendar/core" {
|
||||
options: Record<string, unknown>
|
||||
): Calendar;
|
||||
|
||||
export function destroyCalendar(calendar: Calendar): void;
|
||||
|
||||
export const TimeGrid: unknown;
|
||||
export const DayGrid: unknown;
|
||||
export const List: unknown;
|
||||
|
||||
Reference in New Issue
Block a user