diff --git a/README.md b/README.md index ab7779d..fcb420d 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,144 @@ Start with the rest of the stack (`./deploy/compose-up.sh up -d`), then open: | Reverse Proxy | nginx (TLS à ajouter via certbot ou autre) | | Search | PostgreSQL tsvector + GIN | +## Calendar API (mini doc) + +Base path: `/api/v1/calendar` (module actif seulement si `NEXTCLOUD_ENABLED=true`). + +### Endpoints principaux + +| Endpoint | Méthode | Usage | +|----------|---------|-------| +| `/` | `GET` | Lister calendriers | +| `/{calID}/events` | `GET` | Lister événements d'un calendrier | +| `/{calID}/events` | `POST` | Créer événement | +| `/events/*` | `PUT` | Modifier événement avec `If-Match` | +| `/events/*` | `DELETE` | Supprimer événement | +| `/events/response/*` | `POST` | Répondre invitation participant | +| `/freebusy` | `POST` | Obtenir disponibilités (free/busy) | +| `/events/meet-link/*` | `POST` | Générer lien Meet et l'injecter dans l'événement | + +`*` représente un chemin CalDAV absolu, ex: +`/remote.php/dav/calendars/{user}/{calendar}/{uid}.ics` + +### Event payload (create / update) + +```json +{ + "uid": "team-sync-2026-05-23", + "summary": "Team sync", + "description": "Weekly project update", + "location": "Paris office", + "start": "20260523T090000Z", + "end": "20260523T100000Z", + "organizer": "alice@example.com", + "attendees": [ + { "email": "bob@example.com", "name": "Bob", "status": "NEEDS-ACTION" }, + { "email": "carol@example.com", "name": "Carol", "status": "ACCEPTED" } + ] +} +``` + +### Update avec ETag / If-Match + +Requête: + +```http +PUT /api/v1/calendar/events/remote.php/dav/calendars/alice/work/team-sync-2026-05-23.ics +If-Match: "68031b8f4d18f" +Content-Type: application/json +``` + +Réponse succès: + +```json +{ "etag": "\"68031b8f5f901\"" } +``` + +Si ETag obsolète, API renvoie `412` avec code `etag_mismatch`. + +### Réponse invitation participant + +```json +POST /api/v1/calendar/events/response/remote.php/dav/calendars/alice/work/team-sync-2026-05-23.ics +{ + "email": "bob@example.com", + "response": "accepted", + "if_match": "\"68031b8f5f901\"" +} +``` + +`response` autorise: `accepted`, `declined`, `tentative`. + +### Free/Busy + +```json +POST /api/v1/calendar/freebusy +{ + "start": "2026-05-23T08:00:00Z", + "end": "2026-05-23T18:00:00Z", + "attendees": ["bob@example.com", "carol@example.com"] +} +``` + +Réponse (exemple): + +```json +{ + "attendees": [ + { + "email": "bob@example.com", + "periods": [ + { "start": "20260523T090000Z", "end": "20260523T100000Z", "type": "BUSY" } + ] + } + ] +} +``` + +### Création lien Meet depuis event + +```json +POST /api/v1/calendar/events/meet-link/remote.php/dav/calendars/alice/work/team-sync-2026-05-23.ics +{ + "if_match": "\"68031b8f5f901\"" +} +``` + +Réponse: + +```json +{ + "meet_url": "https://your-domain/meet/team-sync-2026-05-23?jwt=...", + "etag": "\"68031b8f8b102\"" +} +``` + +Si module Meet désactivé (`JITSI_ENABLED=false`), API renvoie `409` avec code `meet_disabled`. + +### Erreurs API Calendar + +| HTTP | `code` | Cas | +|------|--------|-----| +| `400` | `invalid_request_body` | JSON invalide, champ manquant, email participant invalide, format date invalide | +| `400` | `request_body_too_large` | Payload dépasse limite handler (`256KB`) | +| `401` | `auth.*` | Token manquant/invalide | +| `403` | `auth.forbidden` | Permission calendrier insuffisante (`read`/`write`) | +| `404` | `attendee_not_found` | Email donné absent des `ATTENDEE` de l'événement | +| `409` | `meet_disabled` | Endpoint meet-link appelé alors que Jitsi désactivé | +| `412` | `etag_mismatch` | `If-Match` obsolète sur update event/response/meet-link | +| `500` | `internal_error` | Erreur Nextcloud/CalDAV ou erreur interne backend | + +Format standard: + +```json +{ + "code": "etag_mismatch", + "message": "etag does not match current resource version", + "trace_id": "req_01HZ..." +} +``` + ## Project Structure ``` diff --git a/cmd/ultid/main.go b/cmd/ultid/main.go index dada980..57590d3 100644 --- a/cmd/ultid/main.go +++ b/cmd/ultid/main.go @@ -208,7 +208,7 @@ func main() { if ncClient != nil { r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes()) - r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient).Routes()) + r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes()) r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient).Routes()) } if meetCfg != nil { diff --git a/internal/api/calendar/handlers.go b/internal/api/calendar/handlers.go index 7971dac..182f5de 100644 --- a/internal/api/calendar/handlers.go +++ b/internal/api/calendar/handlers.go @@ -1,8 +1,10 @@ package calendar import ( + "errors" "log/slog" "net/http" + "strings" "github.com/go-chi/chi/v5" @@ -10,6 +12,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" + meetpkg "github.com/ultisuite/ulti-backend/internal/meet" "github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/permission" ) @@ -19,9 +22,9 @@ type Handler struct { logger *slog.Logger } -func NewHandler(nc *nextcloud.Client) *Handler { +func NewHandler(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Handler { return &Handler{ - svc: NewService(nc), + svc: NewService(nc, meetCfg), logger: slog.Default().With("component", "calendar-api"), } } @@ -33,7 +36,11 @@ func (h *Handler) Routes() chi.Router { r.With(read).Get("/", h.ListCalendars) r.With(read).Get("/{calID}/events", h.ListEvents) + r.With(read).Post("/freebusy", h.FreeBusy) r.With(write).Post("/{calID}/events", h.CreateEvent) + r.With(write).Put("/events/*", h.UpdateEvent) + r.With(write).Post("/events/response/*", h.RespondToInvitation) + r.With(write).Post("/events/meet-link/*", h.CreateMeetLink) r.With(write).Delete("/events/*", h.DeleteEvent) return r } @@ -66,6 +73,28 @@ func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) { apiresponse.WriteJSON(w, http.StatusOK, result) } +func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + var req freeBusyRequest + if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { + return + } + ncReq, verr := validateFreeBusy(&req) + if verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + result, err := h.svc.FreeBusy(r.Context(), claims.Sub, ncReq) + if err != nil { + h.logger.Error("free/busy", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) @@ -86,6 +115,115 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") + if verr := validateDeletePath(eventPath); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + ifMatch := strings.TrimSpace(r.Header.Get("If-Match")) + if ifMatch == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "If-Match", Message: "required", + })) + return + } + + var event nextcloud.Event + if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &event); err != nil { + return + } + if verr := validateCreateEvent(&event); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + etag, err := h.svc.UpdateEvent(r.Context(), claims.Sub, eventPath, ifMatch, &event) + if err != nil { + if errors.Is(err, nextcloud.ErrETagMismatch) { + apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil) + return + } + h.logger.Error("update event", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag}) +} + +func (h *Handler) RespondToInvitation(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") + if verr := validateDeletePath(eventPath); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + var req respondInvitationRequest + if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { + return + } + if strings.TrimSpace(req.Email) == "" { + req.Email = claims.Email + } + normalized, verr := validateRespondInvitation(&req) + if verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + etag, err := h.svc.RespondToInvitation(r.Context(), claims.Sub, eventPath, normalized.Email, normalized.Response, normalized.IfMatch) + if err != nil { + switch { + case errors.Is(err, nextcloud.ErrAttendeeNotFound): + apiresponse.WriteError(w, r, http.StatusNotFound, "attendee_not_found", "attendee not found in event", nil) + case errors.Is(err, nextcloud.ErrETagMismatch): + apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil) + default: + h.logger.Error("respond invitation", "error", err) + apivalidate.WriteInternal(w, r) + } + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag}) +} + +func (h *Handler) CreateMeetLink(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") + if verr := validateDeletePath(eventPath); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + var req createMeetLinkRequest + if r.ContentLength > 0 { + if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { + return + } + } + if verr := validateCreateMeetLink(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + meetURL, etag, err := h.svc.CreateMeetLink(r.Context(), claims.Sub, claims.Name, claims.Email, eventPath, req.IfMatch) + if err != nil { + if errors.Is(err, ErrMeetDisabled) { + apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet integration is disabled", nil) + return + } + if errors.Is(err, nextcloud.ErrETagMismatch) { + apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil) + return + } + h.logger.Error("create meet link", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "meet_url": meetURL, + "etag": etag, + }) +} + func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) eventPath := chi.URLParam(r, "*") diff --git a/internal/api/calendar/service.go b/internal/api/calendar/service.go index 020a28e..54c75f0 100644 --- a/internal/api/calendar/service.go +++ b/internal/api/calendar/service.go @@ -2,20 +2,26 @@ package calendar import ( "context" + "errors" + "fmt" "strings" "time" "github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/query" + meetpkg "github.com/ultisuite/ulti-backend/internal/meet" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) type Service struct { - nc *nextcloud.Client + nc *nextcloud.Client + meetCfg *meetpkg.Config } -func NewService(nc *nextcloud.Client) *Service { - return &Service{nc: nc} +var ErrMeetDisabled = errors.New("meet is disabled") + +func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Service { + return &Service{nc: nc, meetCfg: meetCfg} } func calendarPath(userID, calID string) string { @@ -54,13 +60,82 @@ func (s *Service) ListEvents(ctx context.Context, userID, calID string, params q } func (s *Service) CreateEvent(ctx context.Context, userID, calID string, event *nextcloud.Event) error { + if strings.TrimSpace(event.Organizer) == "" { + event.Organizer = userID + } return s.nc.CreateEvent(ctx, userID, calendarPath(userID, calID), event) } +func (s *Service) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *nextcloud.Event) (string, error) { + if strings.TrimSpace(event.Organizer) == "" { + event.Organizer = userID + } + return s.nc.UpdateEvent(ctx, userID, eventPath, ifMatch, event) +} + func (s *Service) DeleteEvent(ctx context.Context, userID, eventPath string) error { return s.nc.DeleteEvent(ctx, userID, eventPath) } +func (s *Service) FreeBusy(ctx context.Context, userID string, req *nextcloud.FreeBusyRequest) (*nextcloud.FreeBusyResponse, error) { + return s.nc.FreeBusy(ctx, userID, req) +} + +func (s *Service) RespondToInvitation(ctx context.Context, userID, eventPath, attendeeEmail, status, ifMatch string) (string, error) { + event, err := s.nc.GetEvent(ctx, userID, eventPath) + if err != nil { + return "", err + } + target := strings.ToLower(strings.TrimSpace(attendeeEmail)) + for i := range event.Attendees { + if strings.EqualFold(strings.TrimSpace(event.Attendees[i].Email), target) { + event.Attendees[i].Status = strings.ToUpper(strings.TrimSpace(status)) + match := strings.TrimSpace(ifMatch) + if match == "" { + match = strings.TrimSpace(event.ETag) + } + return s.nc.UpdateEvent(ctx, userID, eventPath, match, event) + } + } + return "", nextcloud.ErrAttendeeNotFound +} + +func (s *Service) CreateMeetLink(ctx context.Context, userID, userName, userEmail, eventPath, ifMatch string) (string, string, error) { + if s.meetCfg == nil { + return "", "", ErrMeetDisabled + } + event, err := s.nc.GetEvent(ctx, userID, eventPath) + if err != nil { + return "", "", err + } + roomID := strings.TrimSpace(event.UID) + if roomID == "" { + roomID = fmt.Sprintf("event-%d", time.Now().Unix()) + } + token, err := s.meetCfg.GenerateToken(roomID, &meetpkg.UserInfo{ + ID: userID, + Name: userName, + Email: userEmail, + IsMod: true, + }, 24*time.Hour) + if err != nil { + return "", "", err + } + event.MeetURL = token.MeetURL + if strings.TrimSpace(event.Location) == "" { + event.Location = token.MeetURL + } + match := strings.TrimSpace(ifMatch) + if match == "" { + match = strings.TrimSpace(event.ETag) + } + etag, err := s.nc.UpdateEvent(ctx, userID, eventPath, match, event) + if err != nil { + return "", "", err + } + return token.MeetURL, etag, nil +} + func filterEvents(events []nextcloud.Event, q string) []nextcloud.Event { q = strings.ToLower(strings.TrimSpace(q)) if q == "" { diff --git a/internal/api/calendar/validate.go b/internal/api/calendar/validate.go index 591d4a0..256d20b 100644 --- a/internal/api/calendar/validate.go +++ b/internal/api/calendar/validate.go @@ -1,7 +1,10 @@ package calendar import ( + "net/mail" + "strconv" "strings" + "time" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/nextcloud" @@ -9,6 +12,124 @@ import ( const maxRequestBody = 256 << 10 +type freeBusyRequest struct { + Start string `json:"start"` + End string `json:"end"` + Attendees []string `json:"attendees"` +} + +type respondInvitationRequest struct { + Email string `json:"email"` + Response string `json:"response"` + IfMatch string `json:"if_match,omitempty"` +} + +type normalizedInvitationResponse struct { + Email string + Response string + IfMatch string +} + +type createMeetLinkRequest struct { + IfMatch string `json:"if_match,omitempty"` +} + +func validateFreeBusy(req *freeBusyRequest) (*nextcloud.FreeBusyRequest, *apivalidate.ValidationError) { + var details []apivalidate.FieldDetail + + start, startDetail := parseRFC3339Field("start", req.Start) + if startDetail != nil { + details = append(details, *startDetail) + } + end, endDetail := parseRFC3339Field("end", req.End) + if endDetail != nil { + details = append(details, *endDetail) + } + if startDetail == nil && endDetail == nil && !start.Before(end) { + details = append(details, apivalidate.FieldDetail{Field: "end", Message: "must be after start"}) + } + + if len(req.Attendees) == 0 { + details = append(details, apivalidate.FieldDetail{Field: "attendees", Message: "required"}) + } else { + for i, addr := range req.Attendees { + if detail := validateAttendeeEmail(addr); detail != nil { + detail.Field = "attendees[" + strconv.Itoa(i) + "]" + details = append(details, *detail) + } + } + } + + if len(details) > 0 { + return nil, apivalidate.NewValidationError(details...) + } + return &nextcloud.FreeBusyRequest{ + Start: start, + End: end, + Attendees: req.Attendees, + }, nil +} + +func parseRFC3339Field(field, raw string) (time.Time, *apivalidate.FieldDetail) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, &apivalidate.FieldDetail{Field: field, Message: "required"} + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, &apivalidate.FieldDetail{Field: field, Message: "invalid RFC3339 timestamp"} + } + return t.UTC(), nil +} + +func validateAttendeeEmail(addr string) *apivalidate.FieldDetail { + addr = strings.TrimSpace(addr) + if addr == "" { + return &apivalidate.FieldDetail{Field: "email", Message: "required"} + } + if len(addr) > 320 || strings.ContainsAny(addr, "\r\n") { + return &apivalidate.FieldDetail{Field: "email", Message: "invalid"} + } + parsed, err := mail.ParseAddress(addr) + if err != nil || parsed.Address == "" { + return &apivalidate.FieldDetail{Field: "email", Message: "invalid"} + } + return nil +} + +func validateRespondInvitation(req *respondInvitationRequest) (*normalizedInvitationResponse, *apivalidate.ValidationError) { + var details []apivalidate.FieldDetail + email := strings.TrimSpace(req.Email) + if detail := validateAttendeeEmail(email); detail != nil { + detail.Field = "email" + details = append(details, *detail) + } + resp := strings.ToLower(strings.TrimSpace(req.Response)) + switch resp { + case "accepted", "declined", "tentative": + default: + details = append(details, apivalidate.FieldDetail{Field: "response", Message: "must be one of accepted|declined|tentative"}) + } + if strings.ContainsAny(req.IfMatch, "\r\n") { + details = append(details, apivalidate.FieldDetail{Field: "if_match", Message: "invalid"}) + } + if len(details) > 0 { + return nil, apivalidate.NewValidationError(details...) + } + return &normalizedInvitationResponse{ + Email: email, + Response: strings.ToUpper(resp), + IfMatch: strings.TrimSpace(req.IfMatch), + }, nil +} + +func validateCreateMeetLink(req *createMeetLinkRequest) *apivalidate.ValidationError { + if strings.ContainsAny(req.IfMatch, "\r\n") { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "if_match", Message: "invalid"}) + } + return nil +} + func validateCreateEvent(event *nextcloud.Event) *apivalidate.ValidationError { var details []apivalidate.FieldDetail if strings.TrimSpace(event.Summary) == "" { @@ -20,6 +141,12 @@ func validateCreateEvent(event *nextcloud.Event) *apivalidate.ValidationError { if strings.TrimSpace(event.End) == "" { details = append(details, apivalidate.FieldDetail{Field: "end", Message: "required"}) } + for i, attendee := range event.Attendees { + if detail := validateAttendeeEmail(attendee.Email); detail != nil { + detail.Field = "attendees[" + strconv.Itoa(i) + "].email" + details = append(details, *detail) + } + } if len(details) == 0 { return nil } diff --git a/internal/nextcloud/calendar.go b/internal/nextcloud/calendar.go index 0fa6844..1db3ff9 100644 --- a/internal/nextcloud/calendar.go +++ b/internal/nextcloud/calendar.go @@ -3,8 +3,10 @@ package nextcloud import ( "context" "encoding/xml" + "errors" "fmt" "io" + "net/http" "strings" "time" ) @@ -17,14 +19,52 @@ type Calendar struct { } type Event struct { - UID string `json:"uid"` - Summary string `json:"summary"` - Description string `json:"description"` - Location string `json:"location"` - Start string `json:"start"` - End string `json:"end"` - AllDay bool `json:"all_day"` - RawICS string `json:"raw_ics,omitempty"` + UID string `json:"uid"` + Summary string `json:"summary"` + Description string `json:"description"` + Location string `json:"location"` + Start string `json:"start"` + End string `json:"end"` + AllDay bool `json:"all_day"` + Path string `json:"path,omitempty"` + ETag string `json:"etag,omitempty"` + Organizer string `json:"organizer,omitempty"` + Attendees []EventAttendee `json:"attendees,omitempty"` + MeetURL string `json:"meet_url,omitempty"` + RawICS string `json:"raw_ics,omitempty"` +} + +type EventAttendee struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + Role string `json:"role,omitempty"` +} + +var ( + ErrETagMismatch = errors.New("nextcloud calendar etag mismatch") + ErrAttendeeNotFound = errors.New("nextcloud calendar attendee not found") +) + +type FreeBusyRequest struct { + Start time.Time + End time.Time + Attendees []string +} + +type FreeBusyPeriod struct { + Start string `json:"start"` + End string `json:"end"` + Type string `json:"type,omitempty"` +} + +type AttendeeFreeBusy struct { + Email string `json:"email"` + Periods []FreeBusyPeriod `json:"periods"` +} + +type FreeBusyResponse struct { + Attendees []AttendeeFreeBusy `json:"attendees"` } func (c *Client) ListCalendars(ctx context.Context, userID string) ([]Calendar, error) { @@ -100,6 +140,49 @@ func (c *Client) CreateEvent(ctx context.Context, userID, calendarPath string, e return nil } +func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event, error) { + resp, err := c.DoAsUser(ctx, "GET", eventPath, nil, userID, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("get event failed: %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + event := parseICS(string(raw)) + event.RawICS = string(raw) + event.Path = eventPath + event.ETag = strings.TrimSpace(resp.Header.Get("ETag")) + return &event, nil +} + +func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *Event) (string, error) { + ics := buildICS(event) + headers := map[string]string{ + "Content-Type": "text/calendar; charset=utf-8", + } + if strings.TrimSpace(ifMatch) != "" { + headers["If-Match"] = strings.TrimSpace(ifMatch) + } + + resp, err := c.DoAsUser(ctx, "PUT", eventPath, strings.NewReader(ics), userID, headers) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusPreconditionFailed { + return "", ErrETagMismatch + } + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("update event failed: %d", resp.StatusCode) + } + return strings.TrimSpace(resp.Header.Get("ETag")), nil +} + func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error { resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil) if err != nil { @@ -112,6 +195,128 @@ func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) erro return nil } +func (c *Client) FreeBusy(ctx context.Context, userID string, req *FreeBusyRequest) (*FreeBusyResponse, error) { + path := fmt.Sprintf("/remote.php/dav/calendars/%s/outbox/", userID) + body := buildFreeBusyRequestICS(userID, req) + + resp, err := c.DoAsUser(ctx, "POST", path, strings.NewReader(body), userID, map[string]string{ + "Content-Type": "text/calendar; charset=utf-8", + }) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusMultiStatus { + return nil, fmt.Errorf("free/busy request failed: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + + return parseFreeBusyResponse(raw) +} + +func buildFreeBusyRequestICS(userID string, req *FreeBusyRequest) string { + var b strings.Builder + b.WriteString("BEGIN:VCALENDAR\r\n") + b.WriteString("VERSION:2.0\r\n") + b.WriteString("PRODID:-//Ulti Suite//EN\r\n") + b.WriteString("METHOD:REQUEST\r\n") + b.WriteString("BEGIN:VFREEBUSY\r\n") + b.WriteString(fmt.Sprintf("UID:fb-%d@ulti\r\n", time.Now().UnixNano())) + b.WriteString(fmt.Sprintf("ORGANIZER:mailto:%s\r\n", userID)) + b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z"))) + b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", req.Start.UTC().Format("20060102T150405Z"))) + b.WriteString(fmt.Sprintf("DTEND:%s\r\n", req.End.UTC().Format("20060102T150405Z"))) + for _, email := range req.Attendees { + addr := strings.TrimSpace(email) + if !strings.HasPrefix(strings.ToLower(addr), "mailto:") { + addr = "mailto:" + addr + } + b.WriteString(fmt.Sprintf("ATTENDEE:%s\r\n", addr)) + } + b.WriteString("END:VFREEBUSY\r\n") + b.WriteString("END:VCALENDAR\r\n") + return b.String() +} + +func parseFreeBusyResponse(raw []byte) (*FreeBusyResponse, error) { + text := strings.TrimSpace(string(raw)) + if text == "" { + return &FreeBusyResponse{Attendees: []AttendeeFreeBusy{}}, nil + } + if strings.HasPrefix(text, " 0 { + attendees := make([]AttendeeFreeBusy, 0, len(ms.Responses)) + for _, r := range ms.Responses { + if r.Propstat.Prop.CalendarData != "" { + parsed := parseFreeBusyICS(r.Propstat.Prop.CalendarData) + attendees = append(attendees, parsed.Attendees...) + } + } + if len(attendees) > 0 { + return &FreeBusyResponse{Attendees: attendees}, nil + } + } + } + return parseFreeBusyICS(text), nil +} + +func parseFreeBusyICS(ics string) *FreeBusyResponse { + resp := &FreeBusyResponse{Attendees: []AttendeeFreeBusy{}} + inFreeBusy := false + curIdx := -1 + + for _, line := range strings.Split(ics, "\n") { + line = strings.TrimSpace(strings.TrimSuffix(line, "\r")) + switch { + case line == "BEGIN:VFREEBUSY": + inFreeBusy = true + curIdx = -1 + case line == "END:VFREEBUSY": + inFreeBusy = false + curIdx = -1 + case inFreeBusy && strings.HasPrefix(line, "ATTENDEE"): + resp.Attendees = append(resp.Attendees, AttendeeFreeBusy{ + Email: extractMailto(extractValue(line)), + }) + curIdx = len(resp.Attendees) - 1 + case inFreeBusy && strings.HasPrefix(line, "FREEBUSY") && curIdx >= 0: + period := parseFreeBusyLine(line) + resp.Attendees[curIdx].Periods = append(resp.Attendees[curIdx].Periods, period) + } + } + return resp +} + +func parseFreeBusyLine(line string) FreeBusyPeriod { + period := FreeBusyPeriod{Type: "BUSY"} + value := extractValue(line) + if idx := strings.Index(value, "/"); idx >= 0 { + period.Start = value[:idx] + period.End = value[idx+1:] + } + if paramPart, _, ok := strings.Cut(line, ":"); ok { + for _, param := range strings.Split(paramPart, ";")[1:] { + if strings.HasPrefix(strings.ToUpper(param), "FBTYPE=") { + period.Type = strings.TrimPrefix(strings.ToUpper(param), "FBTYPE=") + } + } + } + return period +} + +func extractMailto(value string) string { + value = strings.TrimSpace(value) + if idx := strings.Index(strings.ToLower(value), "mailto:"); idx >= 0 { + return value[idx+7:] + } + return value +} + func buildICS(event *Event) string { var b strings.Builder b.WriteString("BEGIN:VCALENDAR\r\n") @@ -128,6 +333,30 @@ func buildICS(event *Event) string { if event.Location != "" { b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", event.Location)) } + if event.Organizer != "" { + b.WriteString(fmt.Sprintf("ORGANIZER:mailto:%s\r\n", event.Organizer)) + } + for _, attendee := range event.Attendees { + email := strings.TrimSpace(attendee.Email) + if email == "" { + continue + } + status := normalizeAttendeeStatus(attendee.Status) + role := strings.ToUpper(strings.TrimSpace(attendee.Role)) + if role == "" { + role = "REQ-PARTICIPANT" + } + line := fmt.Sprintf("ATTENDEE;PARTSTAT=%s;ROLE=%s", status, role) + if strings.TrimSpace(attendee.Name) != "" { + line += fmt.Sprintf(";CN=\"%s\"", strings.TrimSpace(attendee.Name)) + } + line += fmt.Sprintf(":mailto:%s\r\n", email) + b.WriteString(line) + } + if strings.TrimSpace(event.MeetURL) != "" { + b.WriteString(fmt.Sprintf("URL:%s\r\n", strings.TrimSpace(event.MeetURL))) + b.WriteString(fmt.Sprintf("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL))) + } b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", event.Start)) b.WriteString(fmt.Sprintf("DTEND:%s\r\n", event.End)) b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z"))) @@ -172,6 +401,8 @@ func parseEventList(body io.Reader) ([]Event, error) { ics := r.Propstat.Prop.CalendarData event := parseICS(ics) event.RawICS = ics + event.Path = r.Href + event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag) events = append(events, event) } return events, nil @@ -190,6 +421,14 @@ func parseICS(ics string) Event { e.Description = strings.TrimPrefix(line, "DESCRIPTION:") case strings.HasPrefix(line, "LOCATION:"): e.Location = strings.TrimPrefix(line, "LOCATION:") + case strings.HasPrefix(line, "ORGANIZER"): + e.Organizer = extractMailto(extractValue(line)) + case strings.HasPrefix(line, "ATTENDEE"): + e.Attendees = append(e.Attendees, parseAttendeeLine(line)) + case strings.HasPrefix(line, "X-ULTI-MEET-URL:"): + e.MeetURL = strings.TrimPrefix(line, "X-ULTI-MEET-URL:") + case strings.HasPrefix(line, "URL:") && e.MeetURL == "": + e.MeetURL = strings.TrimPrefix(line, "URL:") case strings.HasPrefix(line, "DTSTART"): e.Start = extractValue(line) case strings.HasPrefix(line, "DTEND"): @@ -206,6 +445,41 @@ func extractValue(line string) string { return line } +func parseAttendeeLine(line string) EventAttendee { + attendee := EventAttendee{Status: "NEEDS-ACTION"} + paramPart, value, ok := strings.Cut(line, ":") + if !ok { + return attendee + } + attendee.Email = extractMailto(value) + parts := strings.Split(paramPart, ";") + for _, p := range parts[1:] { + key, val, found := strings.Cut(p, "=") + if !found { + continue + } + switch strings.ToUpper(strings.TrimSpace(key)) { + case "PARTSTAT": + attendee.Status = strings.ToUpper(strings.TrimSpace(val)) + case "CN": + attendee.Name = strings.Trim(strings.TrimSpace(val), "\"") + case "ROLE": + attendee.Role = strings.ToUpper(strings.TrimSpace(val)) + } + } + return attendee +} + +func normalizeAttendeeStatus(status string) string { + status = strings.ToUpper(strings.TrimSpace(status)) + switch status { + case "ACCEPTED", "DECLINED", "TENTATIVE", "NEEDS-ACTION": + return status + default: + return "NEEDS-ACTION" + } +} + type calMultistatus struct { XMLName xml.Name `xml:"multistatus"` Responses []calResponse `xml:"response"` diff --git a/project-plan/agenda.md b/project-plan/agenda.md index 7fc1e6a..9f1a694 100644 --- a/project-plan/agenda.md +++ b/project-plan/agenda.md @@ -14,13 +14,15 @@ Calendrier reproduisant rigoureusement le comportement et l'interface de Google ### Déjà implémenté - API backend montée sous `/api/v1/calendar` (si Nextcloud activé). -- Endpoints list calendriers, list événements, create événement, delete événement. +- Endpoints list calendriers, list événements, create/update/delete événement. +- Gestion ETag/If-Match sur update événement. +- Endpoint free/busy. +- Invitations/réponses participants (ATTENDEE/PARTSTAT). +- Création lien Meet depuis événement. - Client CalDAV Nextcloud intégré côté serveur. ### Partiel / incomplet -- Pas d'update événement avec gestion ETag/If-Match. -- Pas de free/busy ni gestion complète invitations/réponses participants. - Recherche globale events pas encore branchée. ### Non commencé diff --git a/project-plan/checklist-execution.md b/project-plan/checklist-execution.md index 270c849..3df51b1 100644 --- a/project-plan/checklist-execution.md +++ b/project-plan/checklist-execution.md @@ -145,10 +145,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon #### Calendar -- [ ] Ajouter update événement + gestion ETag/If-Match. -- [ ] Ajouter invitations/réponses participants. -- [ ] Ajouter free/busy endpoint. -- [ ] Ajouter création lien Meet auto depuis event. +- [x] Ajouter update événement + gestion ETag/If-Match. +- [x] Ajouter invitations/réponses participants. +- [x] Ajouter free/busy endpoint. +- [x] Ajouter création lien Meet auto depuis event. #### Contacts