- 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
479 lines
14 KiB
Go
479 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
|
|
"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"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type CalendarService struct {
|
|
pool *pgxpool.Pool
|
|
queries *repository.Queries
|
|
audit *AuditService
|
|
baseURL string
|
|
}
|
|
|
|
func NewCalendarService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService, baseURL string) *CalendarService {
|
|
return &CalendarService{pool: pool, queries: queries, audit: audit, baseURL: baseURL}
|
|
}
|
|
|
|
func generatePublicToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
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 ""
|
|
}
|
|
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
|
|
}
|
|
if err := utils.ValidateColor(color); err != nil {
|
|
return nil, err
|
|
}
|
|
if color == "" {
|
|
color = "#3B82F6"
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, models.ErrInternal
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := s.queries.WithTx(tx)
|
|
|
|
calID := uuid.New()
|
|
cal, err := qtx.CreateCalendar(ctx, repository.CreateCalendarParams{
|
|
ID: utils.ToPgUUID(calID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
Name: name,
|
|
Color: color,
|
|
IsPublic: false,
|
|
})
|
|
if err != nil {
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
err = qtx.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
|
|
CalendarID: utils.ToPgUUID(calID),
|
|
UserID: utils.ToPgUUID(userID),
|
|
Role: "owner",
|
|
})
|
|
if err != nil {
|
|
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,
|
|
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
|
|
}
|
|
|
|
func (s *CalendarService) List(ctx context.Context, userID uuid.UUID) ([]models.Calendar, error) {
|
|
rows, err := s.queries.ListCalendarsByUser(ctx, utils.ToPgUUID(userID))
|
|
if err != nil {
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
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,
|
|
CountForAvailability: r.CountForAvailability,
|
|
DefaultReminderMinutes: defRem,
|
|
SortOrder: int(r.SortOrder),
|
|
Role: r.Role,
|
|
CreatedAt: utils.FromPgTimestamptz(r.CreatedAt),
|
|
UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
// Populate ICalURL and AvailabilityURL (requires separate lookup since ListCalendarsByUser doesn't select public_token)
|
|
for i := range calendars {
|
|
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)
|
|
}
|
|
}
|
|
|
|
return calendars, nil
|
|
}
|
|
|
|
func (s *CalendarService) Get(ctx context.Context, userID uuid.UUID, calID uuid.UUID) (*models.Calendar, error) {
|
|
role, err := s.getRole(ctx, calID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calID))
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, models.ErrNotFound
|
|
}
|
|
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,
|
|
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, countForAvailability *bool, defaultReminderMinutes *int, sortOrder *int) (*models.Calendar, error) {
|
|
role, err := s.getRole(ctx, calID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if role != "owner" && role != "editor" {
|
|
return nil, models.ErrForbidden
|
|
}
|
|
if isPublic != nil && role != "owner" {
|
|
return nil, models.NewForbiddenError("only owner can change is_public")
|
|
}
|
|
|
|
if name != nil {
|
|
if err := utils.ValidateCalendarName(*name); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if color != nil {
|
|
if err := utils.ValidateColor(*color); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
var pgPublic pgtype.Bool
|
|
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,
|
|
CountForAvailability: pgCountForAvail,
|
|
DefaultReminderMinutes: pgDefRem,
|
|
SortOrder: pgSort,
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, models.ErrNotFound
|
|
}
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
if isPublic != nil {
|
|
if *isPublic {
|
|
// Switching to public: use base64url token
|
|
token, err := generatePublicToken()
|
|
if err != nil {
|
|
return nil, models.ErrInternal
|
|
}
|
|
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
|
|
ID: utils.ToPgUUID(calID),
|
|
PublicToken: pgtype.Text{String: token, Valid: true},
|
|
})
|
|
cal.PublicToken = pgtype.Text{String: token, Valid: true}
|
|
} 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{String: token, Valid: true},
|
|
})
|
|
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,
|
|
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) Delete(ctx context.Context, userID uuid.UUID, calID uuid.UUID) error {
|
|
role, err := s.getRole(ctx, calID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if role != "owner" {
|
|
return models.ErrForbidden
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return models.ErrInternal
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := s.queries.WithTx(tx)
|
|
|
|
pgCalID := utils.ToPgUUID(calID)
|
|
if err := qtx.SoftDeleteEventsByCalendar(ctx, pgCalID); err != nil {
|
|
return models.ErrInternal
|
|
}
|
|
if err := qtx.SoftDeleteCalendar(ctx, pgCalID); err != nil {
|
|
return models.ErrInternal
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return models.ErrInternal
|
|
}
|
|
|
|
s.audit.Log(ctx, "calendar", calID, "DELETE_CALENDAR", userID)
|
|
return nil
|
|
}
|
|
|
|
func (s *CalendarService) Share(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetEmail, role string) error {
|
|
ownerRole, err := s.getRole(ctx, calID, ownerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ownerRole != "owner" {
|
|
return models.ErrForbidden
|
|
}
|
|
if role != "editor" && role != "viewer" {
|
|
return models.NewValidationError("role must be editor or viewer")
|
|
}
|
|
|
|
targetUser, err := s.queries.GetUserByEmail(ctx, utils.NormalizeEmail(targetEmail))
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return models.NewNotFoundError("user not found")
|
|
}
|
|
return models.ErrInternal
|
|
}
|
|
|
|
targetID := utils.FromPgUUID(targetUser.ID)
|
|
if targetID == ownerID {
|
|
return models.NewValidationError("cannot share with yourself")
|
|
}
|
|
|
|
err = s.queries.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
|
|
CalendarID: utils.ToPgUUID(calID),
|
|
UserID: utils.ToPgUUID(targetID),
|
|
Role: role,
|
|
})
|
|
if err != nil {
|
|
return models.ErrInternal
|
|
}
|
|
|
|
s.audit.Log(ctx, "calendar", calID, "SHARE_CALENDAR", ownerID)
|
|
return nil
|
|
}
|
|
|
|
func (s *CalendarService) ListMembers(ctx context.Context, userID uuid.UUID, calID uuid.UUID) ([]models.CalendarMember, error) {
|
|
if _, err := s.getRole(ctx, calID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := s.queries.ListCalendarMembers(ctx, utils.ToPgUUID(calID))
|
|
if err != nil {
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
members := make([]models.CalendarMember, len(rows))
|
|
for i, r := range rows {
|
|
members[i] = models.CalendarMember{
|
|
UserID: utils.FromPgUUID(r.UserID),
|
|
Email: r.Email,
|
|
Role: r.Role,
|
|
}
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
func (s *CalendarService) RemoveMember(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetUserID uuid.UUID) error {
|
|
role, err := s.getRole(ctx, calID, ownerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if role != "owner" {
|
|
return models.ErrForbidden
|
|
}
|
|
|
|
targetRole, err := s.getRole(ctx, calID, targetUserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if targetRole == "owner" {
|
|
return models.NewValidationError("cannot remove owner")
|
|
}
|
|
|
|
err = s.queries.DeleteCalendarMember(ctx, repository.DeleteCalendarMemberParams{
|
|
CalendarID: utils.ToPgUUID(calID),
|
|
UserID: utils.ToPgUUID(targetUserID),
|
|
})
|
|
if err != nil {
|
|
return models.ErrInternal
|
|
}
|
|
|
|
s.audit.Log(ctx, "calendar", calID, "REMOVE_MEMBER", ownerID)
|
|
return nil
|
|
}
|
|
|
|
func (s *CalendarService) GetRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) {
|
|
return s.getRole(ctx, calID, userID)
|
|
}
|
|
|
|
func (s *CalendarService) getRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) {
|
|
role, err := s.queries.GetCalendarMemberRole(ctx, repository.GetCalendarMemberRoleParams{
|
|
CalendarID: utils.ToPgUUID(calID),
|
|
UserID: utils.ToPgUUID(userID),
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return "", models.ErrNotFound
|
|
}
|
|
return "", models.ErrInternal
|
|
}
|
|
return role, nil
|
|
}
|