package calendar import ( "context" "errors" "fmt" "strings" "time" "github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/auth" meetpkg "github.com/ultisuite/ulti-backend/internal/meet" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) type Service struct { nc *nextcloud.Client meetCfg *meetpkg.Config } var ErrMeetDisabled = errors.New("meet is disabled") func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Service { return &Service{nc: nc, meetCfg: meetCfg} } func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) { if s.nc == nil { return "", fmt.Errorf("nextcloud unavailable") } return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) } // ReprovisionPrincipal drops cached CalDAV credentials and provisions a fresh app password. func (s *Service) ReprovisionPrincipal(ctx context.Context, claims *auth.Claims) (string, error) { if s.nc == nil { return "", fmt.Errorf("nextcloud unavailable") } userID := nextcloud.UserIDFromClaims(claims.Email, claims.Sub) if err := s.nc.InvalidatePrincipalCredentials(ctx, userID); err != nil { return "", err } return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) } func calendarPath(userID, calID string) string { return "/remote.php/dav/calendars/" + userID + "/" + calID + "/" } func (s *Service) ListCalendars(ctx context.Context, userID string) ([]nextcloud.Calendar, error) { return s.nc.ListCalendars(ctx, userID) } func (s *Service) CreateCalendar(ctx context.Context, userID, calID, displayName, color string) error { return s.nc.CreateCalendar(ctx, userID, calID, displayName, color) } func (s *Service) UpdateCalendar(ctx context.Context, userID, calID, displayName, color string) error { return s.nc.UpdateCalendar(ctx, userID, calID, displayName, color) } func (s *Service) DeleteCalendar(ctx context.Context, userID, calID string) error { return s.nc.DeleteCalendar(ctx, userID, calID) } type EventsList struct { Events []nextcloud.Event `json:"events"` Pagination query.PaginationMeta `json:"pagination,omitempty"` } func (s *Service) ListEvents(ctx context.Context, userID, calID string, params query.ListParams) (EventsList, error) { from := time.Now().AddDate(0, -1, 0) to := time.Now().AddDate(0, 1, 0) if params.From != nil { from = *params.From } if params.To != nil { to = *params.To } events, err := s.nc.ListEvents(ctx, userID, calendarPath(userID, calID), from, to) if err != nil { return EventsList{}, err } filtered := filterEvents(events, params.Q) page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) return EventsList{ Events: page, Pagination: params.Meta(&total), }, nil } 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 == "" { return events } out := make([]nextcloud.Event, 0, len(events)) for _, e := range events { if strings.Contains(strings.ToLower(e.Summary), q) || strings.Contains(strings.ToLower(e.Description), q) || strings.Contains(strings.ToLower(e.Location), q) { out = append(out, e) } } return out }