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:
@@ -217,40 +217,76 @@ func hashToken(token string) string {
|
||||
|
||||
func userFromCreateRow(u repository.CreateUserRow) models.User {
|
||||
return models.User{
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
WeekStartDay: 0,
|
||||
DateFormat: "MM/dd/yyyy",
|
||||
TimeFormat: "12h",
|
||||
DefaultEventDurationMinutes: 60,
|
||||
DefaultReminderMinutes: 10,
|
||||
ShowWeekends: true,
|
||||
WorkingHoursStart: "09:00",
|
||||
WorkingHoursEnd: "17:00",
|
||||
NotificationsEmail: true,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func userFromEmailRow(u repository.GetUserByEmailRow) models.User {
|
||||
return models.User{
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
WeekStartDay: int(u.WeekStartDay),
|
||||
DateFormat: u.DateFormat,
|
||||
TimeFormat: u.TimeFormat,
|
||||
DefaultEventDurationMinutes: int(u.DefaultEventDurationMinutes),
|
||||
DefaultReminderMinutes: int(u.DefaultReminderMinutes),
|
||||
ShowWeekends: u.ShowWeekends,
|
||||
WorkingHoursStart: u.WorkingHoursStart,
|
||||
WorkingHoursEnd: u.WorkingHoursEnd,
|
||||
NotificationsEmail: u.NotificationsEmail,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func userFromIDRow(u repository.GetUserByIDRow) models.User {
|
||||
return models.User{
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
WeekStartDay: int(u.WeekStartDay),
|
||||
DateFormat: u.DateFormat,
|
||||
TimeFormat: u.TimeFormat,
|
||||
DefaultEventDurationMinutes: int(u.DefaultEventDurationMinutes),
|
||||
DefaultReminderMinutes: int(u.DefaultReminderMinutes),
|
||||
ShowWeekends: u.ShowWeekends,
|
||||
WorkingHoursStart: u.WorkingHoursStart,
|
||||
WorkingHoursEnd: u.WorkingHoursEnd,
|
||||
NotificationsEmail: u.NotificationsEmail,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func userFromUpdateRow(u repository.UpdateUserRow) models.User {
|
||||
return models.User{
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
WeekStartDay: int(u.WeekStartDay),
|
||||
DateFormat: u.DateFormat,
|
||||
TimeFormat: u.TimeFormat,
|
||||
DefaultEventDurationMinutes: int(u.DefaultEventDurationMinutes),
|
||||
DefaultReminderMinutes: int(u.DefaultReminderMinutes),
|
||||
ShowWeekends: u.ShowWeekends,
|
||||
WorkingHoursStart: u.WorkingHoursStart,
|
||||
WorkingHoursEnd: u.WorkingHoursEnd,
|
||||
NotificationsEmail: u.NotificationsEmail,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -91,52 +91,64 @@ func (s *BookingService) GetAvailability(ctx context.Context, token string, rang
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(bl.CalendarID)
|
||||
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
|
||||
CalendarID: bl.CalendarID,
|
||||
EndTime: utils.ToPgTimestamptz(rangeStart),
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
// Get owner to fetch all calendars that count for availability
|
||||
cal, err := s.queries.GetCalendarByID(ctx, bl.CalendarID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
ownerID := cal.OwnerID
|
||||
|
||||
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
|
||||
CalendarID: bl.CalendarID,
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
// Include busy blocks from ALL calendars owned by the user that have count_for_availability=true
|
||||
availCals, err := s.queries.ListCalendarsByOwnerForAvailability(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
var busyBlocks []models.TimeSlot
|
||||
for _, ev := range events {
|
||||
if ev.RecurrenceRule.Valid {
|
||||
buf := time.Duration(bl.BufferMinutes) * time.Minute
|
||||
|
||||
for _, ac := range availCals {
|
||||
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
|
||||
CalendarID: ac.ID,
|
||||
EndTime: utils.ToPgTimestamptz(rangeStart),
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
start := utils.FromPgTimestamptz(ev.StartTime)
|
||||
end := utils.FromPgTimestamptz(ev.EndTime)
|
||||
buf := time.Duration(bl.BufferMinutes) * time.Minute
|
||||
busyBlocks = append(busyBlocks, models.TimeSlot{
|
||||
Start: start.Add(-buf),
|
||||
End: end.Add(buf),
|
||||
})
|
||||
}
|
||||
|
||||
for _, ev := range recurring {
|
||||
occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd)
|
||||
for _, occ := range occs {
|
||||
if occ.OccurrenceStartTime != nil {
|
||||
buf := time.Duration(bl.BufferMinutes) * time.Minute
|
||||
busyBlocks = append(busyBlocks, models.TimeSlot{
|
||||
Start: occ.OccurrenceStartTime.Add(-buf),
|
||||
End: occ.OccurrenceEndTime.Add(buf),
|
||||
})
|
||||
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
|
||||
CalendarID: ac.ID,
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ev := range events {
|
||||
if ev.RecurrenceRule.Valid {
|
||||
continue
|
||||
}
|
||||
start := utils.FromPgTimestamptz(ev.StartTime)
|
||||
end := utils.FromPgTimestamptz(ev.EndTime)
|
||||
busyBlocks = append(busyBlocks, models.TimeSlot{
|
||||
Start: start.Add(-buf),
|
||||
End: end.Add(buf),
|
||||
})
|
||||
}
|
||||
|
||||
for _, ev := range recurring {
|
||||
occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd)
|
||||
for _, occ := range occs {
|
||||
if occ.OccurrenceStartTime != nil {
|
||||
busyBlocks = append(busyBlocks, models.TimeSlot{
|
||||
Start: occ.OccurrenceStartTime.Add(-buf),
|
||||
End: occ.OccurrenceEndTime.Add(buf),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = calID
|
||||
duration := time.Duration(bl.DurationMinutes) * time.Minute
|
||||
|
||||
dayNames := []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -414,8 +414,8 @@ func (s *EventService) expandRecurrence(ev repository.Event, rangeStart, rangeEn
|
||||
Title: ev.Title,
|
||||
Description: utils.FromPgText(ev.Description),
|
||||
Location: utils.FromPgText(ev.Location),
|
||||
StartTime: dtStart,
|
||||
EndTime: dtStart.Add(duration),
|
||||
StartTime: occStart,
|
||||
EndTime: occEnd,
|
||||
Timezone: ev.Timezone,
|
||||
AllDay: ev.AllDay,
|
||||
RecurrenceRule: utils.FromPgText(ev.RecurrenceRule),
|
||||
|
||||
@@ -33,16 +33,41 @@ func (s *UserService) GetMe(ctx context.Context, userID uuid.UUID) (*models.User
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) Update(ctx context.Context, userID uuid.UUID, timezone *string) (*models.User, error) {
|
||||
if timezone != nil {
|
||||
if err := utils.ValidateTimezone(*timezone); err != nil {
|
||||
type UserUpdateInput struct {
|
||||
Timezone *string
|
||||
WeekStartDay *int
|
||||
DateFormat *string
|
||||
TimeFormat *string
|
||||
DefaultEventDurationMinutes *int
|
||||
DefaultReminderMinutes *int
|
||||
ShowWeekends *bool
|
||||
WorkingHoursStart *string
|
||||
WorkingHoursEnd *string
|
||||
NotificationsEmail *bool
|
||||
}
|
||||
|
||||
func (s *UserService) Update(ctx context.Context, userID uuid.UUID, in *UserUpdateInput) (*models.User, error) {
|
||||
if in == nil {
|
||||
in = &UserUpdateInput{}
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
if err := utils.ValidateTimezone(*in.Timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
u, err := s.queries.UpdateUser(ctx, repository.UpdateUserParams{
|
||||
ID: utils.ToPgUUID(userID),
|
||||
Timezone: utils.ToPgTextPtr(timezone),
|
||||
ID: utils.ToPgUUID(userID),
|
||||
Timezone: utils.ToPgTextPtr(in.Timezone),
|
||||
WeekStartDay: utils.ToPgInt2Ptr(in.WeekStartDay),
|
||||
DateFormat: utils.ToPgTextPtr(in.DateFormat),
|
||||
TimeFormat: utils.ToPgTextPtr(in.TimeFormat),
|
||||
DefaultEventDurationMinutes: utils.ToPgInt4Ptr(in.DefaultEventDurationMinutes),
|
||||
DefaultReminderMinutes: utils.ToPgInt4Ptr(in.DefaultReminderMinutes),
|
||||
ShowWeekends: utils.ToPgBoolPtr(in.ShowWeekends),
|
||||
WorkingHoursStart: utils.ToPgTextPtr(in.WorkingHoursStart),
|
||||
WorkingHoursEnd: utils.ToPgTextPtr(in.WorkingHoursEnd),
|
||||
NotificationsEmail: utils.ToPgBoolPtr(in.NotificationsEmail),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
|
||||
Reference in New Issue
Block a user