Add OpenAPI docs, frontend, migrations, and API updates

- OpenAPI: add missing endpoints (add-from-url, subscriptions, public availability)
- OpenAPI: CalendarSubscription schema, Subscriptions tag
- Frontend app
- Migrations: count_for_availability, subscriptions_sync, user_preferences, calendar_settings
- Config, rate limit, auth, calendar, booking, ICS, availability, user service updates

Made-with: Cursor
This commit is contained in:
Michilis
2026-03-02 14:07:55 +00:00
parent 2cb9d72a7f
commit 75105b8b46
8120 changed files with 1486881 additions and 314 deletions

View File

@@ -3,12 +3,15 @@ package service
import (
"context"
"sort"
"strings"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type AvailabilityService struct {
@@ -84,3 +87,142 @@ func (s *AvailabilityService) GetBusyBlocks(ctx context.Context, userID uuid.UUI
Busy: busy,
}, nil
}
// getBusyBlocksForCalendar
func (s *AvailabilityService) getBusyBlocksForCalendar(ctx context.Context, calID uuid.UUID, rangeStart, rangeEnd time.Time) ([]models.BusyBlock, error) {
pgCalID := utils.ToPgUUID(calID)
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
CalendarID: pgCalID,
EndTime: utils.ToPgTimestamptz(rangeStart),
StartTime: utils.ToPgTimestamptz(rangeEnd),
})
if err != nil {
return nil, err
}
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
CalendarID: pgCalID,
StartTime: utils.ToPgTimestamptz(rangeEnd),
})
if err != nil {
return nil, err
}
var busy []models.BusyBlock
for _, ev := range events {
if ev.RecurrenceRule.Valid {
continue
}
busy = append(busy, models.BusyBlock{
Start: utils.FromPgTimestamptz(ev.StartTime),
End: utils.FromPgTimestamptz(ev.EndTime),
EventID: utils.FromPgUUID(ev.ID),
})
}
for _, ev := range recurring {
occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd)
for _, occ := range occs {
if occ.OccurrenceStartTime != nil {
busy = append(busy, models.BusyBlock{
Start: *occ.OccurrenceStartTime,
End: *occ.OccurrenceEndTime,
EventID: occ.ID,
})
}
}
}
return busy, nil
}
// GetBusyBlocksByTokenPublic returns busy blocks for a calendar by its public token. No auth required.
func (s *AvailabilityService) GetBusyBlocksByTokenPublic(ctx context.Context, token string, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) {
if token == "" {
return nil, models.ErrNotFound
}
cal, err := s.queries.GetCalendarByToken(ctx, pgtype.Text{String: token, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
calID := utils.FromPgUUID(cal.ID)
busy, err := s.getBusyBlocksForCalendar(ctx, calID, rangeStart, rangeEnd)
if err != nil {
return nil, models.ErrInternal
}
sort.Slice(busy, func(i, j int) bool {
return busy[i].Start.Before(busy[j].Start)
})
if busy == nil {
busy = []models.BusyBlock{}
}
return &models.AvailabilityResponse{
CalendarID: calID,
RangeStart: rangeStart,
RangeEnd: rangeEnd,
Busy: busy,
}, nil
}
// GetBusyBlocksAggregate returns merged busy blocks across multiple calendars by their tokens. No auth required.
func (s *AvailabilityService) GetBusyBlocksAggregate(ctx context.Context, tokens []string, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) {
if len(tokens) == 0 {
return &models.AvailabilityResponse{
RangeStart: rangeStart,
RangeEnd: rangeEnd,
Busy: []models.BusyBlock{},
}, nil
}
seen := make(map[uuid.UUID]bool)
var allBusy []models.BusyBlock
for _, token := range tokens {
token = strings.TrimSpace(token)
if token == "" {
continue
}
cal, err := s.queries.GetCalendarByToken(ctx, pgtype.Text{String: token, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
continue
}
return nil, models.ErrInternal
}
calID := utils.FromPgUUID(cal.ID)
if seen[calID] {
continue
}
seen[calID] = true
busy, err := s.getBusyBlocksForCalendar(ctx, calID, rangeStart, rangeEnd)
if err != nil {
return nil, models.ErrInternal
}
allBusy = append(allBusy, busy...)
}
sort.Slice(allBusy, func(i, j int) bool {
return allBusy[i].Start.Before(allBusy[j].Start)
})
if allBusy == nil {
allBusy = []models.BusyBlock{}
}
return &models.AvailabilityResponse{
RangeStart: rangeStart,
RangeEnd: rangeEnd,
Busy: allBusy,
}, nil
}