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) |
|
| Reverse Proxy | nginx (TLS à ajouter via certbot ou autre) |
|
||||||
| Search | PostgreSQL tsvector + GIN |
|
| 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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -208,7 +208,7 @@ func main() {
|
|||||||
|
|
||||||
if ncClient != nil {
|
if ncClient != nil {
|
||||||
r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes())
|
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())
|
r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient).Routes())
|
||||||
}
|
}
|
||||||
if meetCfg != nil {
|
if meetCfg != nil {
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
package calendar
|
package calendar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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/apivalidate"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"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/nextcloud"
|
||||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||||
)
|
)
|
||||||
@ -19,9 +22,9 @@ type Handler struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(nc *nextcloud.Client) *Handler {
|
func NewHandler(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
svc: NewService(nc),
|
svc: NewService(nc, meetCfg),
|
||||||
logger: slog.Default().With("component", "calendar-api"),
|
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("/", h.ListCalendars)
|
||||||
r.With(read).Get("/{calID}/events", h.ListEvents)
|
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).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)
|
r.With(write).Delete("/events/*", h.DeleteEvent)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@ -66,6 +73,28 @@ func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
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) {
|
func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
@ -86,6 +115,115 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusCreated)
|
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) {
|
func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
eventPath := chi.URLParam(r, "*")
|
eventPath := chi.URLParam(r, "*")
|
||||||
|
|||||||
@ -2,20 +2,26 @@ package calendar
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"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/nextcloud"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
nc *nextcloud.Client
|
nc *nextcloud.Client
|
||||||
|
meetCfg *meetpkg.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(nc *nextcloud.Client) *Service {
|
var ErrMeetDisabled = errors.New("meet is disabled")
|
||||||
return &Service{nc: nc}
|
|
||||||
|
func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Service {
|
||||||
|
return &Service{nc: nc, meetCfg: meetCfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func calendarPath(userID, calID string) string {
|
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 {
|
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)
|
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 {
|
func (s *Service) DeleteEvent(ctx context.Context, userID, eventPath string) error {
|
||||||
return s.nc.DeleteEvent(ctx, userID, eventPath)
|
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 {
|
func filterEvents(events []nextcloud.Event, q string) []nextcloud.Event {
|
||||||
q = strings.ToLower(strings.TrimSpace(q))
|
q = strings.ToLower(strings.TrimSpace(q))
|
||||||
if q == "" {
|
if q == "" {
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
package calendar
|
package calendar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
@ -9,6 +12,124 @@ import (
|
|||||||
|
|
||||||
const maxRequestBody = 256 << 10
|
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 {
|
func validateCreateEvent(event *nextcloud.Event) *apivalidate.ValidationError {
|
||||||
var details []apivalidate.FieldDetail
|
var details []apivalidate.FieldDetail
|
||||||
if strings.TrimSpace(event.Summary) == "" {
|
if strings.TrimSpace(event.Summary) == "" {
|
||||||
@ -20,6 +141,12 @@ func validateCreateEvent(event *nextcloud.Event) *apivalidate.ValidationError {
|
|||||||
if strings.TrimSpace(event.End) == "" {
|
if strings.TrimSpace(event.End) == "" {
|
||||||
details = append(details, apivalidate.FieldDetail{Field: "end", Message: "required"})
|
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 {
|
if len(details) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,10 @@ package nextcloud
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -17,14 +19,52 @@ type Calendar struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
UID string `json:"uid"`
|
UID string `json:"uid"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
Start string `json:"start"`
|
Start string `json:"start"`
|
||||||
End string `json:"end"`
|
End string `json:"end"`
|
||||||
AllDay bool `json:"all_day"`
|
AllDay bool `json:"all_day"`
|
||||||
RawICS string `json:"raw_ics,omitempty"`
|
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) {
|
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
|
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 {
|
func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error {
|
||||||
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
|
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -112,6 +195,128 @@ func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) erro
|
|||||||
return nil
|
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 {
|
func buildICS(event *Event) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("BEGIN:VCALENDAR\r\n")
|
b.WriteString("BEGIN:VCALENDAR\r\n")
|
||||||
@ -128,6 +333,30 @@ func buildICS(event *Event) string {
|
|||||||
if event.Location != "" {
|
if event.Location != "" {
|
||||||
b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", 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("DTSTART:%s\r\n", event.Start))
|
||||||
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", event.End))
|
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", event.End))
|
||||||
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
|
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
|
ics := r.Propstat.Prop.CalendarData
|
||||||
event := parseICS(ics)
|
event := parseICS(ics)
|
||||||
event.RawICS = ics
|
event.RawICS = ics
|
||||||
|
event.Path = r.Href
|
||||||
|
event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag)
|
||||||
events = append(events, event)
|
events = append(events, event)
|
||||||
}
|
}
|
||||||
return events, nil
|
return events, nil
|
||||||
@ -190,6 +421,14 @@ func parseICS(ics string) Event {
|
|||||||
e.Description = strings.TrimPrefix(line, "DESCRIPTION:")
|
e.Description = strings.TrimPrefix(line, "DESCRIPTION:")
|
||||||
case strings.HasPrefix(line, "LOCATION:"):
|
case strings.HasPrefix(line, "LOCATION:"):
|
||||||
e.Location = strings.TrimPrefix(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"):
|
case strings.HasPrefix(line, "DTSTART"):
|
||||||
e.Start = extractValue(line)
|
e.Start = extractValue(line)
|
||||||
case strings.HasPrefix(line, "DTEND"):
|
case strings.HasPrefix(line, "DTEND"):
|
||||||
@ -206,6 +445,41 @@ func extractValue(line string) string {
|
|||||||
return line
|
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 {
|
type calMultistatus struct {
|
||||||
XMLName xml.Name `xml:"multistatus"`
|
XMLName xml.Name `xml:"multistatus"`
|
||||||
Responses []calResponse `xml:"response"`
|
Responses []calResponse `xml:"response"`
|
||||||
|
|||||||
@ -14,13 +14,15 @@ Calendrier reproduisant rigoureusement le comportement et l'interface de Google
|
|||||||
### Déjà implémenté
|
### Déjà implémenté
|
||||||
|
|
||||||
- API backend montée sous `/api/v1/calendar` (si Nextcloud activé).
|
- 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.
|
- Client CalDAV Nextcloud intégré côté serveur.
|
||||||
|
|
||||||
### Partiel / incomplet
|
### 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.
|
- Recherche globale events pas encore branchée.
|
||||||
|
|
||||||
### Non commencé
|
### Non commencé
|
||||||
|
|||||||
@ -145,10 +145,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
|
|||||||
|
|
||||||
#### Calendar
|
#### Calendar
|
||||||
|
|
||||||
- [ ] Ajouter update événement + gestion ETag/If-Match.
|
- [x] Ajouter update événement + gestion ETag/If-Match.
|
||||||
- [ ] Ajouter invitations/réponses participants.
|
- [x] Ajouter invitations/réponses participants.
|
||||||
- [ ] Ajouter free/busy endpoint.
|
- [x] Ajouter free/busy endpoint.
|
||||||
- [ ] Ajouter création lien Meet auto depuis event.
|
- [x] Ajouter création lien Meet auto depuis event.
|
||||||
|
|
||||||
#### Contacts
|
#### Contacts
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user