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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user