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:
Michilis
2026-02-28 04:48:53 +00:00
parent 41f6ae916f
commit 2cb9d72a7f
23 changed files with 721 additions and 92 deletions

View File

@@ -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),