Add public/private calendars, full iCal support, and iCal URL import
- Public/private: toggle is_public via PUT /calendars/{id}; generate/clear
public_token and return ical_url when public
- Public feed: GET /cal/{token}/feed.ics (no auth) for subscription in
Google/Apple/Outlook calendars
- Full iCal export: use golang-ical; VALARM, ATTENDEE, all-day (VALUE=DATE),
RRULE, DTSTAMP, CREATED, LAST-MODIFIED
- Full iCal import: parse TZID, VALUE=DATE, VALARM, ATTENDEE, RRULE
- Import from URL: POST /calendars/import-url with calendar_id + url
- Migration: unique index on public_token, calendar_subscriptions table
- Config: BASE_URL for ical_url; Calendar model + API: ical_url field
- Docs: OpenAPI, llms.txt, README, SKILL.md, about/overview
Made-with: Cursor
This commit is contained in:
@@ -2,6 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
@@ -16,10 +19,26 @@ type CalendarService struct {
|
||||
pool *pgxpool.Pool
|
||||
queries *repository.Queries
|
||||
audit *AuditService
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewCalendarService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService) *CalendarService {
|
||||
return &CalendarService{pool: pool, queries: queries, audit: audit}
|
||||
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 (s *CalendarService) icalURL(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s/cal/%s/feed.ics", s.baseURL, token)
|
||||
}
|
||||
|
||||
func (s *CalendarService) Create(ctx context.Context, userID uuid.UUID, name, color string) (*models.Calendar, error) {
|
||||
@@ -72,6 +91,7 @@ func (s *CalendarService) Create(ctx context.Context, userID uuid.UUID, name, co
|
||||
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),
|
||||
@@ -96,6 +116,17 @@ func (s *CalendarService) List(ctx context.Context, userID uuid.UUID) ([]models.
|
||||
UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// Populate ICalURL for public calendars (requires a 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return calendars, nil
|
||||
}
|
||||
|
||||
@@ -118,6 +149,7 @@ func (s *CalendarService) Get(ctx context.Context, userID uuid.UUID, calID uuid.
|
||||
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),
|
||||
@@ -166,6 +198,26 @@ func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uu
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
if isPublic != nil {
|
||||
if *isPublic && !cal.PublicToken.Valid {
|
||||
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 if !*isPublic && cal.PublicToken.Valid {
|
||||
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
|
||||
ID: utils.ToPgUUID(calID),
|
||||
PublicToken: pgtype.Text{Valid: false},
|
||||
})
|
||||
cal.PublicToken = pgtype.Text{Valid: false}
|
||||
}
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "calendar", calID, "UPDATE_CALENDAR", userID)
|
||||
|
||||
return &models.Calendar{
|
||||
@@ -173,6 +225,7 @@ func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uu
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user