✨(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 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):
|
||||||
|
|
||||||
```
|
| Email | Password | Org domain |
|
||||||
username: calendars
|
|---|---|---|
|
||||||
password: calendars
|
| `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:
|
Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ services:
|
|||||||
interval: 1s
|
interval: 1s
|
||||||
timeout: 2s
|
timeout: 2s
|
||||||
retries: 300
|
retries: 300
|
||||||
|
volumes:
|
||||||
|
- ./docker/postgresql/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh:ro
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/postgresql.defaults
|
- env.d/development/postgresql.defaults
|
||||||
- env.d/development/postgresql.local
|
- env.d/development/postgresql.local
|
||||||
|
|||||||
@@ -51,15 +51,71 @@
|
|||||||
"failureFactor": 30,
|
"failureFactor": 30,
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"username": "calendars",
|
"username": "user1",
|
||||||
"email": "calendars@calendars.world",
|
"email": "user1@example.local",
|
||||||
"firstName": "John",
|
"firstName": "User",
|
||||||
"lastName": "Doe",
|
"lastName": "One",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"credentials": [
|
"credentials": [
|
||||||
{
|
{
|
||||||
"type": "password",
|
"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"]
|
"realmRoles": ["user"]
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ class Base(Configuration):
|
|||||||
CALDAV_INTERNAL_API_KEY = SecretFileValue(
|
CALDAV_INTERNAL_API_KEY = SecretFileValue(
|
||||||
None, environ_name="CALDAV_INTERNAL_API_KEY", environ_prefix=None
|
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.)
|
# Salt for django-fernet-encrypted-fields (Channel tokens, etc.)
|
||||||
# Used with SECRET_KEY to derive Fernet encryption keys via PBKDF2
|
# Used with SECRET_KEY to derive Fernet encryption keys via PBKDF2
|
||||||
SALT_KEY = values.Value(
|
SALT_KEY = values.Value(
|
||||||
@@ -931,8 +940,8 @@ class Development(Base):
|
|||||||
EMAIL_PORT = 1025
|
EMAIL_PORT = 1025
|
||||||
EMAIL_USE_TLS = False
|
EMAIL_USE_TLS = False
|
||||||
EMAIL_USE_SSL = False
|
EMAIL_USE_SSL = False
|
||||||
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
|
DEFAULT_FROM_EMAIL = "noreply@example.local"
|
||||||
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
|
CALENDAR_INVITATION_FROM_EMAIL = "noreply@example.local"
|
||||||
APP_NAME = "Calendars (dev)"
|
APP_NAME = "Calendars (dev)"
|
||||||
APP_URL = "http://localhost:8931"
|
APP_URL = "http://localhost:8931"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from django.conf import settings
|
|||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
@@ -13,10 +14,16 @@ from core.models import uuid_to_urlsafe
|
|||||||
class OrganizationSerializer(serializers.ModelSerializer):
|
class OrganizationSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize organizations."""
|
"""Serialize organizations."""
|
||||||
|
|
||||||
|
sharing_level = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Organization
|
model = models.Organization
|
||||||
fields = ["id", "name"]
|
fields = ["id", "name", "sharing_level"]
|
||||||
read_only_fields = ["id", "name"]
|
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):
|
class UserLiteSerializer(serializers.ModelSerializer):
|
||||||
@@ -32,6 +39,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"""Serialize users."""
|
"""Serialize users."""
|
||||||
|
|
||||||
email = serializers.SerializerMethodField(read_only=True)
|
email = serializers.SerializerMethodField(read_only=True)
|
||||||
|
timezone = TimeZoneSerializerField(use_pytz=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
@@ -40,6 +48,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"email",
|
"email",
|
||||||
"full_name",
|
"full_name",
|
||||||
"language",
|
"language",
|
||||||
|
"timezone",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "email", "full_name"]
|
read_only_fields = ["id", "email", "full_name"]
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from rest_framework import mixins, pagination, response, status, views, viewsets
|
from rest_framework import mixins, pagination, response, status, views, viewsets
|
||||||
@@ -98,9 +99,9 @@ class UserViewSet(
|
|||||||
|
|
||||||
def get_queryset(self):
|
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.
|
Scoped to the requesting user's organization.
|
||||||
If query contains "@", search exactly. Otherwise return empty.
|
Minimum 3 characters required.
|
||||||
"""
|
"""
|
||||||
queryset = self.queryset
|
queryset = self.queryset
|
||||||
|
|
||||||
@@ -112,15 +113,13 @@ class UserViewSet(
|
|||||||
return queryset.none()
|
return queryset.none()
|
||||||
queryset = queryset.filter(organization_id=self.request.user.organization_id)
|
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()
|
return queryset.none()
|
||||||
|
|
||||||
# For emails, match exactly
|
# Search by email (partial, case-insensitive) or full name
|
||||||
if "@" in query:
|
return queryset.filter(
|
||||||
return queryset.filter(email__iexact=query).order_by("email")
|
Q(email__icontains=query) | Q(full_name__icontains=query)
|
||||||
|
).order_by("full_name", "email")[:50]
|
||||||
# For non-email queries, return empty (no fuzzy search)
|
|
||||||
return queryset.none()
|
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
detail=False,
|
detail=False,
|
||||||
@@ -210,6 +209,57 @@ class ConfigView(views.APIView):
|
|||||||
return theme_customization
|
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):
|
class CalendarViewSet(viewsets.GenericViewSet):
|
||||||
"""ViewSet for calendar operations.
|
"""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)
|
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):
|
class Organization(BaseModel):
|
||||||
"""Organization model, populated from OIDC claims and entitlements.
|
"""Organization model, populated from OIDC claims and entitlements.
|
||||||
|
|
||||||
@@ -81,6 +90,16 @@ class Organization(BaseModel):
|
|||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Organization identifier from OIDC claim or email domain.",
|
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:
|
class Meta:
|
||||||
db_table = "calendars_organization"
|
db_table = "calendars_organization"
|
||||||
@@ -90,6 +109,13 @@ class Organization(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name or self.external_id
|
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):
|
def delete(self, *args, **kwargs):
|
||||||
"""Delete org after cleaning up members' CalDAV data.
|
"""Delete org after cleaning up members' CalDAV data.
|
||||||
|
|
||||||
|
|||||||
@@ -58,11 +58,15 @@ class CalDAVHTTPClient:
|
|||||||
"""
|
"""
|
||||||
if not user.email:
|
if not user.email:
|
||||||
raise ValueError("User has no email address")
|
raise ValueError("User has no email address")
|
||||||
return {
|
headers = {
|
||||||
"X-Api-Key": cls.get_api_key(),
|
"X-Api-Key": cls.get_api_key(),
|
||||||
"X-Forwarded-User": user.email,
|
"X-Forwarded-User": user.email,
|
||||||
"X-CalDAV-Organization": str(user.organization_id),
|
"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:
|
def build_url(self, path: str, query: str = "") -> str:
|
||||||
"""Build a full CalDAV URL from a resource path.
|
"""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():
|
def test_api_users_list_query_short_queries():
|
||||||
"""
|
"""Queries shorter than 3 characters should return an empty result set."""
|
||||||
Queries shorter than 5 characters should return an empty result set.
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
org = user.organization
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
factories.UserFactory(email="john.doe@example.com")
|
factories.UserFactory(email="john.doe@example.com", organization=org)
|
||||||
factories.UserFactory(email="john.lennon@example.com")
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/?q=jo")
|
response = client.get("/api/v1.0/users/?q=jo")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["results"] == []
|
assert response.json()["results"] == []
|
||||||
|
|
||||||
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"] == []
|
|
||||||
|
|
||||||
# Non-email queries (without @) return empty
|
|
||||||
response = client.get("/api/v1.0/users/?q=john.")
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["results"] == []
|
assert response.json()["results"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_list_limit(settings):
|
def test_api_users_list_limit(settings): # pylint: disable=unused-argument
|
||||||
"""
|
"""Results should be bounded even with many matching users."""
|
||||||
Authenticated users should be able to list users and the number of results
|
|
||||||
should be limited to 10.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
org = user.organization
|
org = user.organization
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
# Use a base name with a length equal 5 to test that the limit is applied
|
for i in range(55):
|
||||||
base_name = "alice"
|
factories.UserFactory(email=f"alice.{i}@example.com", organization=org)
|
||||||
for i in range(15):
|
|
||||||
factories.UserFactory(email=f"{base_name}.{i}@example.com", organization=org)
|
|
||||||
|
|
||||||
# Non-email queries (without @) return empty
|
# Partial match returns results (capped at 50)
|
||||||
response = client.get(
|
response = client.get("/api/v1.0/users/?q=alice")
|
||||||
"/api/v1.0/users/?q=alice",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["results"] == []
|
assert len(response.json()["results"]) == 50
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_list_throttling_authenticated(settings):
|
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):
|
def test_api_users_list_query_email(settings):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to list users and filter by email.
|
Authenticated users should be able to search users by partial email.
|
||||||
Only exact email matches are returned (case-insensitive).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "9999/minute"
|
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)
|
client.force_login(user)
|
||||||
|
|
||||||
dave = factories.UserFactory(email="david.bowman@work.com", organization=org)
|
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
|
# Exact match works
|
||||||
response = client.get(
|
response = client.get("/api/v1.0/users/?q=david.bowman@work.com")
|
||||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
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(dave.id)]
|
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
|
# Case-insensitive match works
|
||||||
response = client.get(
|
response = client.get("/api/v1.0/users/?q=David.Bowman@Work.COM")
|
||||||
"/api/v1.0/users/?q=David.Bowman@Work.COM",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
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(dave.id)]
|
assert str(dave.id) in user_ids
|
||||||
|
|
||||||
# Typos don't match (exact match only)
|
# Typos don't match
|
||||||
response = client.get(
|
response = client.get("/api/v1.0/users/?q=davig.bovman@worm.com")
|
||||||
"/api/v1.0/users/?q=davig.bovman@worm.com",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user_ids = [user["id"] for user in response.json()["results"]]
|
assert 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 == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_list_query_email_matching():
|
def test_api_users_list_query_email_partial_matching():
|
||||||
"""Email queries return exact matches only (case-insensitive)."""
|
"""Partial email queries return matching users."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
org = user.organization
|
org = user.organization
|
||||||
|
|
||||||
@@ -212,29 +181,111 @@ def test_api_users_list_query_email_matching():
|
|||||||
user1 = factories.UserFactory(
|
user1 = factories.UserFactory(
|
||||||
email="alice.johnson@example.gouv.fr", organization=org
|
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)
|
factories.UserFactory(email="alice.kohlson@example.gouv.fr", organization=org)
|
||||||
user4 = factories.UserFactory(
|
user4 = factories.UserFactory(
|
||||||
email="alicia.johnnson@example.gouv.fr", organization=org
|
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)
|
factories.UserFactory(email="alice.thomson@example.gouv.fr", organization=org)
|
||||||
|
|
||||||
# Exact match returns only that user
|
# Partial match on "alice.john" returns alice.johnson and alice.johnnson
|
||||||
response = client.get(
|
response = client.get("/api/v1.0/users/?q=alice.john")
|
||||||
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
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(user1.id)]
|
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
|
# Partial match on "alicia" returns alicia.johnnson (same org only)
|
||||||
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
|
response = client.get("/api/v1.0/users/?q=alicia")
|
||||||
assert response.status_code == 200
|
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)]
|
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():
|
def test_api_users_retrieve_me_anonymous():
|
||||||
"""Anonymous users should not be allowed to list users."""
|
"""Anonymous users should not be allowed to list users."""
|
||||||
factories.UserFactory.create_batch(2)
|
factories.UserFactory.create_batch(2)
|
||||||
@@ -271,11 +322,13 @@ def test_api_users_retrieve_me_authenticated():
|
|||||||
"email": user.email,
|
"email": user.email,
|
||||||
"full_name": user.full_name,
|
"full_name": user.full_name,
|
||||||
"language": user.language,
|
"language": user.language,
|
||||||
|
"timezone": str(user.timezone),
|
||||||
"can_access": True,
|
"can_access": True,
|
||||||
"can_admin": True,
|
"can_admin": True,
|
||||||
"organization": {
|
"organization": {
|
||||||
"id": str(user.organization.id),
|
"id": str(user.organization.id),
|
||||||
"name": user.organization.name,
|
"name": user.organization.name,
|
||||||
|
"sharing_level": "freebusy",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,8 +493,8 @@ def test_api_users_update_anonymous():
|
|||||||
|
|
||||||
def test_api_users_update_authenticated_self():
|
def test_api_users_update_authenticated_self():
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to update their own user but only "language"
|
Authenticated users should be able to update their own user but only "language",
|
||||||
and "timezone" fields.
|
"timezone" fields.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
@@ -528,8 +581,8 @@ def test_api_users_patch_anonymous():
|
|||||||
|
|
||||||
def test_api_users_patch_authenticated_self():
|
def test_api_users_patch_authenticated_self():
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to patch their own user but only "language"
|
Authenticated users should be able to patch their own user but only "language",
|
||||||
and "timezone" fields.
|
"timezone" fields.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
|||||||
@@ -335,6 +335,232 @@ class TestCalDAVProxy:
|
|||||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
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:
|
class TestValidateCaldavProxyPath:
|
||||||
"""Tests for validate_caldav_proxy_path utility."""
|
"""Tests for validate_caldav_proxy_path utility."""
|
||||||
|
|
||||||
|
|||||||
@@ -194,3 +194,100 @@ def test_user_list_same_org_visible():
|
|||||||
assert len(data) == 1
|
assert len(data) == 1
|
||||||
assert data[0]["email"] == "carol@example.com"
|
assert data[0]["email"] == "carol@example.com"
|
||||||
get_entitlements_backend.cache_clear()
|
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"] == str(org.id)
|
||||||
assert request.headers["X-CalDAV-Organization"] != "spoofed-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
|
# IsEntitledToAccess permission — fail-closed
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ router.register("users", viewsets.UserViewSet, basename="users")
|
|||||||
router.register("calendars", viewsets.CalendarViewSet, basename="calendars")
|
router.register("calendars", viewsets.CalendarViewSet, basename="calendars")
|
||||||
router.register("resources", viewsets.ResourceViewSet, basename="resources")
|
router.register("resources", viewsets.ResourceViewSet, basename="resources")
|
||||||
router.register("channels", ChannelViewSet, basename="channels")
|
router.register("channels", ChannelViewSet, basename="channels")
|
||||||
|
router.register(
|
||||||
|
"organization-settings",
|
||||||
|
viewsets.OrganizationSettingsViewSet,
|
||||||
|
basename="organization-settings",
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ use Calendars\SabreDav\AttendeeNormalizerPlugin;
|
|||||||
use Calendars\SabreDav\InternalApiPlugin;
|
use Calendars\SabreDav\InternalApiPlugin;
|
||||||
use Calendars\SabreDav\ResourceAutoSchedulePlugin;
|
use Calendars\SabreDav\ResourceAutoSchedulePlugin;
|
||||||
use Calendars\SabreDav\ResourceMkCalendarBlockPlugin;
|
use Calendars\SabreDav\ResourceMkCalendarBlockPlugin;
|
||||||
|
use Calendars\SabreDav\FreeBusyOrgScopePlugin;
|
||||||
|
use Calendars\SabreDav\AvailabilityPlugin;
|
||||||
use Calendars\SabreDav\CalendarsRoot;
|
use Calendars\SabreDav\CalendarsRoot;
|
||||||
use Calendars\SabreDav\CustomCalDAVPlugin;
|
use Calendars\SabreDav\CustomCalDAVPlugin;
|
||||||
use Calendars\SabreDav\PrincipalsRoot;
|
use Calendars\SabreDav\PrincipalsRoot;
|
||||||
@@ -88,7 +90,12 @@ $principalBackend->setServer($server);
|
|||||||
$server->addPlugin($authPlugin);
|
$server->addPlugin($authPlugin);
|
||||||
$server->addPlugin(new CustomCalDAVPlugin());
|
$server->addPlugin(new CustomCalDAVPlugin());
|
||||||
$server->addPlugin(new CardDAV\Plugin());
|
$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());
|
$server->addPlugin(new DAV\Browser\Plugin());
|
||||||
|
|
||||||
// Add ICS export plugin for iCal subscription URLs
|
// Add ICS export plugin for iCal subscription URLs
|
||||||
@@ -169,6 +176,10 @@ if ($defaultCallbackUrl) {
|
|||||||
$imipPlugin = new HttpCallbackIMipPlugin($callbackApiKey, $defaultCallbackUrl);
|
$imipPlugin = new HttpCallbackIMipPlugin($callbackApiKey, $defaultCallbackUrl);
|
||||||
$server->addPlugin($imipPlugin);
|
$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
|
// Add CalDAV scheduling support
|
||||||
// See https://sabre.io/dav/scheduling/
|
// See https://sabre.io/dav/scheduling/
|
||||||
// The Schedule\Plugin will automatically find and use the IMipPlugin we just added
|
// 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)
|
// Block MKCALENDAR on resource principals (each resource has exactly one calendar)
|
||||||
$server->addPlugin(new ResourceMkCalendarBlockPlugin());
|
$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.)
|
// Add property storage plugin for custom properties (resource metadata, etc.)
|
||||||
$server->addPlugin(new DAV\PropertyStorage\Plugin(
|
$server->addPlugin(new DAV\PropertyStorage\Plugin(
|
||||||
new DAV\PropertyStorage\Backend\PDO($pdo)
|
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 $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
|
* Resource principals have no DAV owner, so the default ACL (which only
|
||||||
* grants {DAV:}all to {DAV:}owner) blocks all property reads with 403.
|
* grants {DAV:}all to {DAV:}owner) blocks all property reads with 403.
|
||||||
* This collection returns ResourcePrincipal nodes that additionally grant
|
* This collection returns SchedulablePrincipal nodes that additionally grant
|
||||||
* {DAV:}read to {DAV:}authenticated, allowing any logged-in user to
|
* {DAV:}read to {DAV:}authenticated.
|
||||||
* discover resource names, types, and emails via PROPFIND.
|
|
||||||
*/
|
*/
|
||||||
class ResourcePrincipalCollection extends NamedPrincipalCollection
|
class ResourcePrincipalCollection extends NamedPrincipalCollection
|
||||||
{
|
{
|
||||||
public function getChildForPrincipal(array $principal)
|
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()
|
public function getACL()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -140,12 +140,8 @@ interface ImportTaskResponse {
|
|||||||
*/
|
*/
|
||||||
export interface TaskStatus {
|
export interface TaskStatus {
|
||||||
status: "PENDING" | "PROGRESS" | "SUCCESS" | "FAILURE";
|
status: "PENDING" | "PROGRESS" | "SUCCESS" | "FAILURE";
|
||||||
result: {
|
|
||||||
status: string;
|
|
||||||
result: ImportEventsResult | null;
|
result: ImportEventsResult | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
} | null;
|
|
||||||
error: string | null;
|
|
||||||
progress?: number;
|
progress?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
@@ -202,14 +198,14 @@ export const pollImportTask = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.status === "SUCCESS" && status.result?.result) {
|
if (status.status === "SUCCESS" && status.result) {
|
||||||
resolve(status.result.result);
|
resolve(status.result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
status.result?.error ?? status.error ?? "Import failed",
|
status.error ?? "Import failed",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
ModalSize,
|
ModalSize,
|
||||||
TextArea,
|
|
||||||
} from "@gouvfr-lasuite/cunningham-react";
|
} from "@gouvfr-lasuite/cunningham-react";
|
||||||
|
|
||||||
import { DEFAULT_COLORS } from "./constants";
|
import { DEFAULT_COLORS } from "./constants";
|
||||||
@@ -26,7 +25,6 @@ export const CalendarModal = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [color, setColor] = useState(DEFAULT_COLORS[0]);
|
const [color, setColor] = useState(DEFAULT_COLORS[0]);
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -36,11 +34,9 @@ export const CalendarModal = ({
|
|||||||
if (mode === "edit" && calendar) {
|
if (mode === "edit" && calendar) {
|
||||||
setName(calendar.displayName || "");
|
setName(calendar.displayName || "");
|
||||||
setColor(calendar.color || DEFAULT_COLORS[0]);
|
setColor(calendar.color || DEFAULT_COLORS[0]);
|
||||||
setDescription(calendar.description || "");
|
|
||||||
} else {
|
} else {
|
||||||
setName("");
|
setName("");
|
||||||
setColor(DEFAULT_COLORS[0]);
|
setColor(DEFAULT_COLORS[0]);
|
||||||
setDescription("");
|
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
@@ -55,7 +51,7 @@ export const CalendarModal = ({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await onSave(name.trim(), color, description.trim() || undefined);
|
await onSave(name.trim(), color);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t('api.error.unexpected'));
|
setError(err instanceof Error ? err.message : t('api.error.unexpected'));
|
||||||
@@ -67,7 +63,6 @@ export const CalendarModal = ({
|
|||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setName("");
|
setName("");
|
||||||
setColor(DEFAULT_COLORS[0]);
|
setColor(DEFAULT_COLORS[0]);
|
||||||
setDescription("");
|
|
||||||
setError(null);
|
setError(null);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -133,13 +128,6 @@ export const CalendarModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextArea
|
|
||||||
label={t('calendar.createCalendar.description')}
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Wraps the UI Kit ShareModal for managing calendar sharing via CalDAV.
|
* 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 { useTranslation } from "react-i18next";
|
||||||
import { ShareModal } from "@gouvfr-lasuite/ui-kit";
|
import { ShareModal } from "@gouvfr-lasuite/ui-kit";
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ToasterItem,
|
ToasterItem,
|
||||||
} from "../../../ui/components/toaster/Toaster";
|
} from "../../../ui/components/toaster/Toaster";
|
||||||
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
||||||
|
import { fetchAPI } from "@/features/api/fetchApi";
|
||||||
|
|
||||||
interface CalendarShareModalProps {
|
interface CalendarShareModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -100,16 +101,67 @@ export const CalendarShareModal = ({
|
|||||||
}
|
}
|
||||||
}, [isOpen, calendar, fetchSharees]);
|
}, [isOpen, calendar, fetchSharees]);
|
||||||
|
|
||||||
const handleSearchUsers = useCallback((query: string) => {
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
if (EMAIL_REGEX.test(query.trim())) {
|
|
||||||
const email = query.trim();
|
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([
|
setSearchResults([
|
||||||
{ id: email, email, full_name: email },
|
{ id: trimmed, email: trimmed, full_name: trimmed },
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
setSearchResults([]);
|
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(
|
const handleInviteUser = useCallback(
|
||||||
async (users: ShareUser[]) => {
|
async (users: ShareUser[]) => {
|
||||||
|
|||||||
@@ -76,12 +76,11 @@ export const useCalendarListState = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveCalendar = useCallback(
|
const handleSaveCalendar = useCallback(
|
||||||
async (name: string, color: string, description?: string) => {
|
async (name: string, color: string) => {
|
||||||
if (modalState.mode === "create") {
|
if (modalState.mode === "create") {
|
||||||
const result = await createCalendar({
|
const result = await createCalendar({
|
||||||
displayName: name,
|
displayName: name,
|
||||||
color,
|
color,
|
||||||
description,
|
|
||||||
components: ['VEVENT'],
|
components: ['VEVENT'],
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -91,7 +90,6 @@ export const useCalendarListState = ({
|
|||||||
const result = await updateCalendar(modalState.calendar.url, {
|
const result = await updateCalendar(modalState.calendar.url, {
|
||||||
displayName: name,
|
displayName: name,
|
||||||
color,
|
color,
|
||||||
description,
|
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface CalendarModalProps {
|
|||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
calendar?: CalDavCalendar | null;
|
calendar?: CalDavCalendar | null;
|
||||||
onClose: () => void;
|
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;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__add-btn {
|
&__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 {
|
&__pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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 { Input } from "@gouvfr-lasuite/cunningham-react";
|
||||||
import { Badge } from "@gouvfr-lasuite/ui-kit";
|
import { Badge } from "@gouvfr-lasuite/ui-kit";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { IcsAttendee, IcsOrganizer } from "ts-ics";
|
import type { IcsAttendee, IcsOrganizer } from "ts-ics";
|
||||||
|
import {
|
||||||
|
useUserSearch,
|
||||||
|
type UserSearchResult,
|
||||||
|
} from "@/features/users/hooks/useUserSearch";
|
||||||
|
import { filterSuggestions, isValidEmail } from "./attendees-utils";
|
||||||
|
|
||||||
interface AttendeesInputProps {
|
interface AttendeesInputProps {
|
||||||
attendees: IcsAttendee[];
|
attendees: IcsAttendee[];
|
||||||
@@ -11,11 +22,6 @@ interface AttendeesInputProps {
|
|||||||
organizer?: IcsOrganizer;
|
organizer?: IcsOrganizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidEmail = (email: string): boolean => {
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
return emailRegex.test(email);
|
|
||||||
};
|
|
||||||
|
|
||||||
type BadgeType =
|
type BadgeType =
|
||||||
| "accent"
|
| "accent"
|
||||||
| "neutral"
|
| "neutral"
|
||||||
@@ -59,40 +65,86 @@ export function AttendeesInput({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 { data: searchResults, isLoading: isSearching } =
|
||||||
const email = inputValue.trim().toLowerCase();
|
useUserSearch(inputValue);
|
||||||
|
|
||||||
if (!email) {
|
// Filter out already-added attendees and the organizer
|
||||||
return;
|
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"));
|
setError(t("calendar.attendees.invalidEmail"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attendees.some((a) => a.email.toLowerCase() === email)) {
|
if (attendees.some((a) => a.email.toLowerCase() === normalized)) {
|
||||||
setError(t("calendar.attendees.alreadyAdded"));
|
setError(t("calendar.attendees.alreadyAdded"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organizerEmail && email === organizerEmail.toLowerCase()) {
|
if (organizerEmail && normalized === organizerEmail.toLowerCase()) {
|
||||||
setError(t("calendar.attendees.cannotAddOrganizer"));
|
setError(t("calendar.attendees.cannotAddOrganizer"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAttendee: IcsAttendee = {
|
const newAttendee: IcsAttendee = {
|
||||||
email,
|
email: normalized,
|
||||||
partstat: "NEEDS-ACTION",
|
partstat: "NEEDS-ACTION",
|
||||||
rsvp: true,
|
rsvp: true,
|
||||||
role: "REQ-PARTICIPANT",
|
role: "REQ-PARTICIPANT",
|
||||||
|
...(fullName && { cn: fullName }),
|
||||||
};
|
};
|
||||||
|
|
||||||
onChange([...attendees, newAttendee]);
|
onChange([...attendees, newAttendee]);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
setError(null);
|
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(
|
const removeAttendee = useCallback(
|
||||||
(emailToRemove: string) => {
|
(emailToRemove: string) => {
|
||||||
@@ -103,16 +155,52 @@ export function AttendeesInput({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
(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") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addAttendee();
|
if (
|
||||||
|
shouldShowDropdown &&
|
||||||
|
highlightedIndex >= 0 &&
|
||||||
|
highlightedIndex < suggestions.length
|
||||||
|
) {
|
||||||
|
selectSuggestion(suggestions[highlightedIndex]);
|
||||||
|
} else {
|
||||||
|
addAttendeeByEmail(inputValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addAttendee],
|
[
|
||||||
|
shouldShowDropdown,
|
||||||
|
suggestions,
|
||||||
|
highlightedIndex,
|
||||||
|
selectSuggestion,
|
||||||
|
addAttendeeByEmail,
|
||||||
|
inputValue,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="attendees-input">
|
<div className="attendees-input" ref={containerRef}>
|
||||||
<div className="attendees-input__field">
|
<div className="attendees-input__field">
|
||||||
<Input
|
<Input
|
||||||
label={t("calendar.attendees.label")}
|
label={t("calendar.attendees.label")}
|
||||||
@@ -123,12 +211,49 @@ export function AttendeesInput({
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInputValue(e.target.value);
|
setInputValue(e.target.value);
|
||||||
|
setShowSuggestions(true);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
if (error) setError(null);
|
if (error) setError(null);
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setShowSuggestions(true)}
|
||||||
state={error ? "error" : "default"}
|
state={error ? "error" : "default"}
|
||||||
text={error || undefined}
|
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>
|
||||||
|
|
||||||
<div className="attendees-input__pills">
|
<div className="attendees-input__pills">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { AttendeesSection } from "./event-modal-sections/AttendeesSection";
|
|||||||
import { ResourcesSection } from "./event-modal-sections/ResourcesSection";
|
import { ResourcesSection } from "./event-modal-sections/ResourcesSection";
|
||||||
import { DescriptionSection } from "./event-modal-sections/DescriptionSection";
|
import { DescriptionSection } from "./event-modal-sections/DescriptionSection";
|
||||||
import { InvitationResponseSection } from "./event-modal-sections/InvitationResponseSection";
|
import { InvitationResponseSection } from "./event-modal-sections/InvitationResponseSection";
|
||||||
|
import { FreeBusySection } from "./event-modal-sections/FreeBusySection";
|
||||||
import { SectionPills } from "./event-modal-sections/SectionPills";
|
import { SectionPills } from "./event-modal-sections/SectionPills";
|
||||||
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
|
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
|
||||||
import type { EventModalProps, RecurringDeleteOption } from "./types";
|
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],
|
[t, visioBaseUrl, availableResources.length],
|
||||||
);
|
);
|
||||||
@@ -305,6 +311,25 @@ export const EventModal = ({
|
|||||||
alwaysOpen
|
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 &&
|
{availableResources.length > 0 &&
|
||||||
form.isSectionExpanded("resources") && (
|
form.isSectionExpanded("resources") && (
|
||||||
<ResourcesSection
|
<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,
|
location,
|
||||||
setLocation,
|
setLocation,
|
||||||
startDateTime,
|
startDateTime,
|
||||||
|
setStartDateTime,
|
||||||
endDateTime,
|
endDateTime,
|
||||||
setEndDateTime,
|
setEndDateTime,
|
||||||
selectedCalendarUrl,
|
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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
createCalendar,
|
createCalendar,
|
||||||
|
destroyCalendar,
|
||||||
TimeGrid,
|
TimeGrid,
|
||||||
DayGrid,
|
DayGrid,
|
||||||
List,
|
List,
|
||||||
@@ -252,13 +253,9 @@ export const useSchedulerInit = ({
|
|||||||
calendarRef.current = ec as unknown as CalendarApi;
|
calendarRef.current = ec as unknown as CalendarApi;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// @event-calendar/core is Svelte-based and uses $destroy
|
|
||||||
// Always call $destroy before clearing the container to avoid memory leaks
|
|
||||||
if (calendarRef.current) {
|
if (calendarRef.current) {
|
||||||
const calendar = calendarRef.current as CalendarApi;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (typeof calendar.$destroy === 'function') {
|
destroyCalendar(calendarRef.current as any);
|
||||||
calendar.$destroy();
|
|
||||||
}
|
|
||||||
calendarRef.current = null;
|
calendarRef.current = null;
|
||||||
}
|
}
|
||||||
// Clear the container only after calendar is destroyed
|
// Clear the container only after calendar is destroyed
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ export type EventFormSectionId =
|
|||||||
| "recurrence"
|
| "recurrence"
|
||||||
| "attendees"
|
| "attendees"
|
||||||
| "resources"
|
| "resources"
|
||||||
| "videoConference";
|
| "videoConference"
|
||||||
|
| "scheduling";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attachment metadata (UI only, no actual file upload).
|
* Attachment metadata (UI only, no actual file upload).
|
||||||
@@ -130,7 +131,6 @@ export interface CalendarApi {
|
|||||||
addEvent: (event: unknown) => void;
|
addEvent: (event: unknown) => void;
|
||||||
unselect: () => void;
|
unselect: () => void;
|
||||||
refetchEvents: () => void;
|
refetchEvents: () => void;
|
||||||
$destroy?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
buildSyncCollectionXml,
|
buildSyncCollectionXml,
|
||||||
buildPrincipalSearchXml,
|
buildPrincipalSearchXml,
|
||||||
executeDavRequest,
|
executeDavRequest,
|
||||||
|
escapeXml,
|
||||||
CALENDAR_PROPS,
|
CALENDAR_PROPS,
|
||||||
parseCalendarComponents,
|
parseCalendarComponents,
|
||||||
parseSharePrivilege,
|
parseSharePrivilege,
|
||||||
@@ -793,20 +794,22 @@ export class CalDavService {
|
|||||||
throw new Error('Scheduling outbox not found')
|
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')
|
const fullOutboxUrl = outboxUrl.startsWith('http')
|
||||||
? outboxUrl
|
? 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
|
// 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, {
|
const response = await fetch(fullOutboxUrl, {
|
||||||
|
...this._account!.fetchOptions,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/calendar; charset=utf-8; method=' + request.method,
|
|
||||||
...this._account!.headers,
|
...this._account!.headers,
|
||||||
|
'Content-Type': 'text/calendar; charset=utf-8; method=' + request.method,
|
||||||
},
|
},
|
||||||
body: iCalString,
|
body: iCalString,
|
||||||
...this._account!.fetchOptions,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -910,31 +913,91 @@ ${attendeeLines}
|
|||||||
END:VFREEBUSY
|
END:VFREEBUSY
|
||||||
END:VCALENDAR`
|
END:VCALENDAR`
|
||||||
|
|
||||||
const responses = await davRequest({
|
// Construct full URL - outboxUrl from PROPFIND is an absolute path (e.g. /caldav/calendars/...)
|
||||||
url: outboxUrl,
|
// so we only need to prepend the origin, not the full serverUrl (which already has /caldav/)
|
||||||
init: {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/calendar; charset=utf-8',
|
|
||||||
...this._account!.headers,
|
...this._account!.headers,
|
||||||
|
'Content-Type': 'text/calendar; charset=utf-8',
|
||||||
},
|
},
|
||||||
body: fbRequest,
|
body: fbRequest,
|
||||||
},
|
|
||||||
fetchOptions: this._account!.fetchOptions,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = responses[0]
|
if (!response.ok) {
|
||||||
if (!response?.ok) {
|
throw new Error(`Failed to query free/busy: ${response.status}`)
|
||||||
throw new Error(`Failed to query free/busy: ${response?.status}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return request.attendees.map((email) => ({
|
const xmlText = await response.text()
|
||||||
attendee: email,
|
return parseScheduleFreeBusyResponse(xmlText)
|
||||||
periods: [],
|
|
||||||
}))
|
|
||||||
}, 'Failed to query free/busy')
|
}, '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
|
// Sync Operations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1189,3 +1252,127 @@ END:VCALENDAR`
|
|||||||
export function createCalDavService(): CalDavService {
|
export function createCalDavService(): CalDavService {
|
||||||
return new 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",
|
"logout": "Logout",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"my_account": "My Account",
|
"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": {
|
"api": {
|
||||||
"error": {
|
"error": {
|
||||||
"unexpected": "An unexpected error occurred."
|
"unexpected": "An unexpected error occurred."
|
||||||
@@ -331,7 +352,8 @@
|
|||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
"organizer": "Organizer",
|
"organizer": "Organizer",
|
||||||
"viewProfile": "View profile",
|
"viewProfile": "View profile",
|
||||||
"cannotRemoveOrganizer": "Cannot remove organizer"
|
"cannotRemoveOrganizer": "Cannot remove organizer",
|
||||||
|
"noResults": "No users found"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"placeholder": "Select a resource...",
|
"placeholder": "Select a resource...",
|
||||||
@@ -514,6 +536,14 @@
|
|||||||
"copy": "Copy token",
|
"copy": "Copy token",
|
||||||
"copied": "Token copied to clipboard."
|
"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",
|
"logout": "Déconnexion",
|
||||||
"login": "Connexion",
|
"login": "Connexion",
|
||||||
"my_account": "Mon Compte",
|
"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": {
|
"api": {
|
||||||
"error": {
|
"error": {
|
||||||
"unexpected": "Une erreur inattendue est survenue."
|
"unexpected": "Une erreur inattendue est survenue."
|
||||||
@@ -1152,7 +1203,8 @@
|
|||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
"organizer": "Organisateur",
|
"organizer": "Organisateur",
|
||||||
"viewProfile": "Voir le profil",
|
"viewProfile": "Voir le profil",
|
||||||
"cannotRemoveOrganizer": "Impossible de retirer l'organisateur"
|
"cannotRemoveOrganizer": "Impossible de retirer l'organisateur",
|
||||||
|
"noResults": "Aucun utilisateur trouvé"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"placeholder": "Sélectionner une ressource...",
|
"placeholder": "Sélectionner une ressource...",
|
||||||
@@ -1335,6 +1387,14 @@
|
|||||||
"copy": "Copier le jeton",
|
"copy": "Copier le jeton",
|
||||||
"copied": "Jeton copié dans le presse-papiers."
|
"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",
|
"logout": "Uitloggen",
|
||||||
"login": "Inloggen",
|
"login": "Inloggen",
|
||||||
"my_account": "Mijn account",
|
"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": {
|
"api": {
|
||||||
"error": {
|
"error": {
|
||||||
"unexpected": "Er is een onverwachte fout opgetreden."
|
"unexpected": "Er is een onverwachte fout opgetreden."
|
||||||
@@ -1715,7 +1796,8 @@
|
|||||||
"participants": "Deelnemers",
|
"participants": "Deelnemers",
|
||||||
"organizer": "Organisator",
|
"organizer": "Organisator",
|
||||||
"viewProfile": "Profiel bekijken",
|
"viewProfile": "Profiel bekijken",
|
||||||
"cannotRemoveOrganizer": "Kan organisator niet verwijderen"
|
"cannotRemoveOrganizer": "Kan organisator niet verwijderen",
|
||||||
|
"noResults": "Geen gebruikers gevonden"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"placeholder": "Selecteer een middel...",
|
"placeholder": "Selecteer een middel...",
|
||||||
@@ -1898,6 +1980,14 @@
|
|||||||
"copy": "Token kopiëren",
|
"copy": "Token kopiëren",
|
||||||
"copied": "Token gekopieerd naar klembord."
|
"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 router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
if (!user?.can_admin) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
const adminOptions = user.can_admin
|
||||||
<DropdownMenu
|
? [
|
||||||
isOpen={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
options={[
|
|
||||||
{
|
{
|
||||||
label: t("resources.title"),
|
label: t("resources.title"),
|
||||||
icon: <Icon name="meeting_room" type={IconType.OUTLINED} />,
|
icon: <Icon name="meeting_room" type={IconType.OUTLINED} />,
|
||||||
@@ -60,12 +57,26 @@ const ApplicationMenu = () => {
|
|||||||
icon: <Icon name="integration_instructions" type={IconType.OUTLINED} />,
|
icon: <Icon name="integration_instructions" type={IconType.OUTLINED} />,
|
||||||
callback: () => void router.push("/integrations"),
|
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
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
icon={<Icon name="settings" type={IconType.OUTLINED} />}
|
icon={<Icon name="settings" type={IconType.OUTLINED} />}
|
||||||
aria-label={t("settings")}
|
aria-label={t("settings.label")}
|
||||||
color="brand"
|
color="brand"
|
||||||
variant="tertiary"
|
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/sass/fonts";
|
||||||
@use "@gouvfr-lasuite/ui-kit/style";
|
@use "@gouvfr-lasuite/ui-kit/style";
|
||||||
@use "./cunningham-tokens.css";
|
@use "./cunningham-tokens.css";
|
||||||
@use "./../features/layouts/components/global/GlobalLayout.scss";
|
@use "./../features/layouts/components/global/GlobalLayout";
|
||||||
@use "./../features/feedback/Feedback.scss";
|
@use "./../features/feedback/Feedback";
|
||||||
@use "./../features/layouts/components/header/index.scss";
|
@use "./../features/layouts/components/header/index";
|
||||||
@use "./../features/ui/components/toaster";
|
@use "./../features/ui/components/toaster";
|
||||||
@use "./../features/ui/components/generic-disclaimer/GenericDisclaimer.scss";
|
@use "./../features/ui/components/generic-disclaimer/GenericDisclaimer";
|
||||||
@use "./../features/ui/components/spinner/SpinnerPage.scss";
|
@use "./../features/ui/components/spinner/SpinnerPage";
|
||||||
@use "./../features/ui/components/logo/DynamicCalendarLogo.scss";
|
@use "./../features/ui/components/logo/DynamicCalendarLogo";
|
||||||
@use "./../features/layouts/components/left-panel/LeftPanelMobile.scss";
|
@use "./../features/layouts/components/left-panel/LeftPanelMobile";
|
||||||
@use "./../features/calendar/components/left-panel/MiniCalendar.scss";
|
@use "./../features/calendar/components/left-panel/MiniCalendar";
|
||||||
@use "./../features/calendar/components/calendar-list/CalendarList.scss";
|
@use "./../features/calendar/components/calendar-list/CalendarList";
|
||||||
@use "./../features/calendar/components/left-panel/LeftPanel.scss";
|
@use "./../features/calendar/components/left-panel/LeftPanel";
|
||||||
@use "./../features/calendar/components/scheduler/EventModal.scss";
|
@use "./../features/calendar/components/scheduler/EventModal";
|
||||||
@use "./../features/calendar/components/scheduler/RecurrenceEditor.scss";
|
@use "./../features/calendar/components/scheduler/RecurrenceEditor";
|
||||||
@use "./../features/calendar/components/scheduler/AttendeesInput.scss";
|
@use "./../features/calendar/components/scheduler/AttendeesInput";
|
||||||
@use "./../features/calendar/components/scheduler/Scheduler.scss";
|
@use "./../features/calendar/components/scheduler/Scheduler";
|
||||||
@use "./../features/calendar/components/scheduler/scheduler-theme.scss";
|
@use "./../features/calendar/components/scheduler/scheduler-theme";
|
||||||
@use "./../features/calendar/components/scheduler/SchedulerToolbar.scss";
|
@use "./../features/calendar/components/scheduler/SchedulerToolbar";
|
||||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionRow.scss";
|
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionRow";
|
||||||
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection.scss";
|
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection";
|
||||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill.scss";
|
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill";
|
||||||
|
@use "./../features/calendar/components/scheduler/FreeBusyTimeline";
|
||||||
@use "./../features/resources/components/Resources";
|
@use "./../features/resources/components/Resources";
|
||||||
@use "./../pages/index.scss" as *;
|
@use "./../features/settings/components/WorkingHoursSettings";
|
||||||
@use "./../pages/calendar.scss" as *;
|
@use "./../pages/index" as *;
|
||||||
|
@use "./../pages/calendar" as *;
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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;
|
updateEvent: (event: unknown) => void;
|
||||||
removeEventById: (id: string) => void;
|
removeEventById: (id: string) => void;
|
||||||
unselect: () => void;
|
unselect: () => void;
|
||||||
$destroy?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCalendar(
|
export function createCalendar(
|
||||||
@@ -34,6 +33,8 @@ declare module "@event-calendar/core" {
|
|||||||
options: Record<string, unknown>
|
options: Record<string, unknown>
|
||||||
): Calendar;
|
): Calendar;
|
||||||
|
|
||||||
|
export function destroyCalendar(calendar: Calendar): void;
|
||||||
|
|
||||||
export const TimeGrid: unknown;
|
export const TimeGrid: unknown;
|
||||||
export const DayGrid: unknown;
|
export const DayGrid: unknown;
|
||||||
export const List: unknown;
|
export const List: unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user