- 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.
164 lines
4.9 KiB
Go
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
|
|
}
|