ultisuite-backend/internal/api/calendar/validate.go
R3D347HR4Y 3cd50bc967 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.
2026-05-22 20:29:53 +02:00

164 lines
4.9 KiB
Go

package calendar
import (
"net/mail"
"strconv"
"strings"
"time"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
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) == "" {
details = append(details, apivalidate.FieldDetail{Field: "summary", Message: "required"})
}
if strings.TrimSpace(event.Start) == "" {
details = append(details, apivalidate.FieldDetail{Field: "start", Message: "required"})
}
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
}
return apivalidate.NewValidationError(details...)
}
func validateDeletePath(path string) *apivalidate.ValidationError {
if strings.TrimSpace(path) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "path", Message: "required",
})
}
return nil
}