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:
parent
96147de108
commit
3cd50bc967
138
README.md
138
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
|
||||
|
||||
```
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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, "*")
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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é
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user