Implement Calendar API enhancements with new endpoints and features

- Added new endpoints for listing calendars, events, creating/updating/deleting events, and handling free/busy requests.
- Implemented ETag/If-Match support for event updates to ensure data integrity.
- Introduced functionality for responding to invitations and creating Meet links from events.
- Enhanced validation for event creation and updates, including attendee email checks.
- Updated README documentation to reflect the new Calendar API features and usage examples.
- Revised project checklist to indicate completion of Calendar API enhancements.
This commit is contained in:
R3D347HR4Y 2026-05-22 20:29:53 +02:00
parent 96147de108
commit 3cd50bc967
8 changed files with 775 additions and 21 deletions

138
README.md
View File

@ -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
```

View File

@ -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 {

View File

@ -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, "*")

View File

@ -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 == "" {

View File

@ -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
}

View File

@ -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, "<?xml") || strings.HasPrefix(text, "<") {
var ms calMultistatus
if err := xml.Unmarshal(raw, &ms); err == nil && len(ms.Responses) > 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"`

View File

@ -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é

View File

@ -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