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,7 +3,9 @@ package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"github.com/calendarapi/internal/models"
@@ -34,6 +36,15 @@ func generatePublicToken() (string, error) {
return base64.RawURLEncoding.EncodeToString(b), nil
}
func generatePrivateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
hash := sha256.Sum256(b)
return hex.EncodeToString(hash[:]), nil
}
func (s *CalendarService) icalURL(token string) string {
if token == "" {
return ""
@@ -41,6 +52,13 @@ func (s *CalendarService) icalURL(token string) string {
return fmt.Sprintf("%s/cal/%s/feed.ics", s.baseURL, token)
}
func (s *CalendarService) availabilityURL(token string) string {
if token == "" {
return ""
}
return fmt.Sprintf("%s/availability/%s", s.baseURL, token)
}
func (s *CalendarService) Create(ctx context.Context, userID uuid.UUID, name, color string) (*models.Calendar, error) {
if err := utils.ValidateCalendarName(name); err != nil {
return nil, err
@@ -80,21 +98,41 @@ func (s *CalendarService) Create(ctx context.Context, userID uuid.UUID, name, co
return nil, models.ErrInternal
}
privateToken, err := generatePrivateToken()
if err != nil {
return nil, models.ErrInternal
}
if err := qtx.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
ID: utils.ToPgUUID(calID),
PublicToken: pgtype.Text{String: privateToken, Valid: true},
}); err != nil {
return nil, models.ErrInternal
}
if err := tx.Commit(ctx); err != nil {
return nil, models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "CREATE_CALENDAR", userID)
defRem := (*int)(nil)
if cal.DefaultReminderMinutes.Valid {
v := int(cal.DefaultReminderMinutes.Int32)
defRem = &v
}
return &models.Calendar{
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
Role: "owner",
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
CountForAvailability: cal.CountForAvailability,
DefaultReminderMinutes: defRem,
SortOrder: int(cal.SortOrder),
ICalURL: s.icalURL(privateToken),
AvailabilityURL: s.availabilityURL(privateToken),
Role: "owner",
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
}, nil
}
@@ -106,24 +144,31 @@ func (s *CalendarService) List(ctx context.Context, userID uuid.UUID) ([]models.
calendars := make([]models.Calendar, len(rows))
for i, r := range rows {
defRem := (*int)(nil)
if r.DefaultReminderMinutes.Valid {
v := int(r.DefaultReminderMinutes.Int32)
defRem = &v
}
calendars[i] = models.Calendar{
ID: utils.FromPgUUID(r.ID),
Name: r.Name,
Color: r.Color,
IsPublic: r.IsPublic,
Role: r.Role,
CreatedAt: utils.FromPgTimestamptz(r.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt),
ID: utils.FromPgUUID(r.ID),
Name: r.Name,
Color: r.Color,
IsPublic: r.IsPublic,
CountForAvailability: r.CountForAvailability,
DefaultReminderMinutes: defRem,
SortOrder: int(r.SortOrder),
Role: r.Role,
CreatedAt: utils.FromPgTimestamptz(r.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt),
}
}
// Populate ICalURL for public calendars (requires a separate lookup since ListCalendarsByUser doesn't select public_token)
// Populate ICalURL and AvailabilityURL (requires separate lookup since ListCalendarsByUser doesn't select public_token)
for i := range calendars {
if calendars[i].IsPublic {
cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calendars[i].ID))
if err == nil && cal.PublicToken.Valid {
calendars[i].ICalURL = s.icalURL(cal.PublicToken.String)
}
cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calendars[i].ID))
if err == nil && cal.PublicToken.Valid {
calendars[i].ICalURL = s.icalURL(cal.PublicToken.String)
calendars[i].AvailabilityURL = s.availabilityURL(cal.PublicToken.String)
}
}
@@ -144,19 +189,40 @@ func (s *CalendarService) Get(ctx context.Context, userID uuid.UUID, calID uuid.
return nil, models.ErrInternal
}
// Backfill private token for existing calendars that don't have one
if !cal.IsPublic && !cal.PublicToken.Valid {
privateToken, err := generatePrivateToken()
if err == nil {
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
ID: utils.ToPgUUID(calID),
PublicToken: pgtype.Text{String: privateToken, Valid: true},
})
cal.PublicToken = pgtype.Text{String: privateToken, Valid: true}
}
}
defRem := (*int)(nil)
if cal.DefaultReminderMinutes.Valid {
v := int(cal.DefaultReminderMinutes.Int32)
defRem = &v
}
return &models.Calendar{
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
Role: role,
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
CountForAvailability: cal.CountForAvailability,
DefaultReminderMinutes: defRem,
SortOrder: int(cal.SortOrder),
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
AvailabilityURL: s.availabilityURL(utils.FromPgTextValue(cal.PublicToken)),
Role: role,
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
}, nil
}
func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uuid.UUID, name, color *string, isPublic *bool) (*models.Calendar, error) {
func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uuid.UUID, name, color *string, isPublic *bool, countForAvailability *bool, defaultReminderMinutes *int, sortOrder *int) (*models.Calendar, error) {
role, err := s.getRole(ctx, calID, userID)
if err != nil {
return nil, err
@@ -184,12 +250,28 @@ func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uu
if isPublic != nil {
pgPublic = pgtype.Bool{Bool: *isPublic, Valid: true}
}
var pgCountForAvail pgtype.Bool
if countForAvailability != nil {
pgCountForAvail = pgtype.Bool{Bool: *countForAvailability, Valid: true}
}
var pgDefRem pgtype.Int4
if defaultReminderMinutes != nil {
pgDefRem = pgtype.Int4{Int32: int32(*defaultReminderMinutes), Valid: true}
}
var pgSort pgtype.Int4
if sortOrder != nil {
pgSort = pgtype.Int4{Int32: int32(*sortOrder), Valid: true}
}
cal, err := s.queries.UpdateCalendar(ctx, repository.UpdateCalendarParams{
ID: utils.ToPgUUID(calID),
Name: utils.ToPgTextPtr(name),
Color: utils.ToPgTextPtr(color),
IsPublic: pgPublic,
ID: utils.ToPgUUID(calID),
Name: utils.ToPgTextPtr(name),
Color: utils.ToPgTextPtr(color),
IsPublic: pgPublic,
CountForAvailability: pgCountForAvail,
DefaultReminderMinutes: pgDefRem,
SortOrder: pgSort,
})
if err != nil {
if err == pgx.ErrNoRows {
@@ -199,7 +281,8 @@ func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uu
}
if isPublic != nil {
if *isPublic && !cal.PublicToken.Valid {
if *isPublic {
// Switching to public: use base64url token
token, err := generatePublicToken()
if err != nil {
return nil, models.ErrInternal
@@ -209,26 +292,50 @@ func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uu
PublicToken: pgtype.Text{String: token, Valid: true},
})
cal.PublicToken = pgtype.Text{String: token, Valid: true}
} else if !*isPublic && cal.PublicToken.Valid {
} else {
// Switching to private: use SHA256 token (replace existing token)
token, err := generatePrivateToken()
if err != nil {
return nil, models.ErrInternal
}
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
ID: utils.ToPgUUID(calID),
PublicToken: pgtype.Text{Valid: false},
PublicToken: pgtype.Text{String: token, Valid: true},
})
cal.PublicToken = pgtype.Text{Valid: false}
cal.PublicToken = pgtype.Text{String: token, Valid: true}
}
} else if !cal.IsPublic && !cal.PublicToken.Valid {
// Backfill: private calendar with no token (e.g. from before this feature)
privateToken, err := generatePrivateToken()
if err == nil {
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
ID: utils.ToPgUUID(calID),
PublicToken: pgtype.Text{String: privateToken, Valid: true},
})
cal.PublicToken = pgtype.Text{String: privateToken, Valid: true}
}
}
s.audit.Log(ctx, "calendar", calID, "UPDATE_CALENDAR", userID)
defRem := (*int)(nil)
if cal.DefaultReminderMinutes.Valid {
v := int(cal.DefaultReminderMinutes.Int32)
defRem = &v
}
return &models.Calendar{
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
Role: role,
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
CountForAvailability: cal.CountForAvailability,
DefaultReminderMinutes: defRem,
SortOrder: int(cal.SortOrder),
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
AvailabilityURL: s.availabilityURL(utils.FromPgTextValue(cal.PublicToken)),
Role: role,
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
}, nil
}