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

@@ -5,9 +5,11 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
ics "github.com/arran4/golang-ical"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
@@ -15,6 +17,7 @@ import (
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type ICSHandler struct {
@@ -40,11 +43,38 @@ func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) {
return
}
cal, err := h.queries.GetCalendarByID(r.Context(), utils.ToPgUUID(calID))
if err != nil {
utils.WriteError(w, models.ErrInternal)
return
}
h.writeICSFeed(w, r.Context(), cal.Name, calID)
}
func (h *ICSHandler) PublicFeed(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
utils.WriteError(w, models.ErrNotFound)
return
}
cal, err := h.queries.GetCalendarByPublicToken(r.Context(), pgtype.Text{String: token, Valid: true})
if err != nil {
utils.WriteError(w, models.ErrNotFound)
return
}
calID := utils.FromPgUUID(cal.ID)
h.writeICSFeed(w, r.Context(), cal.Name, calID)
}
func (h *ICSHandler) writeICSFeed(w http.ResponseWriter, ctx context.Context, calName string, calID uuid.UUID) {
now := time.Now().UTC()
rangeStart := now.AddDate(-1, 0, 0)
rangeEnd := now.AddDate(1, 0, 0)
events, err := h.queries.ListEventsByCalendarInRange(r.Context(), repository.ListEventsByCalendarInRangeParams{
events, err := h.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
CalendarID: utils.ToPgUUID(calID),
EndTime: utils.ToPgTimestamptz(rangeStart),
StartTime: utils.ToPgTimestamptz(rangeEnd),
@@ -54,33 +84,76 @@ func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) {
return
}
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CalendarAPI//EN\r\n")
for _, ev := range events {
b.WriteString("BEGIN:VEVENT\r\n")
b.WriteString(fmt.Sprintf("UID:%s\r\n", utils.FromPgUUID(ev.ID).String()))
b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", utils.FromPgTimestamptz(ev.StartTime).Format("20060102T150405Z")))
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", utils.FromPgTimestamptz(ev.EndTime).Format("20060102T150405Z")))
b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", ev.Title))
if ev.Description.Valid {
b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", ev.Description.String))
}
if ev.Location.Valid {
b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", ev.Location.String))
}
if ev.RecurrenceRule.Valid {
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", ev.RecurrenceRule.String))
}
b.WriteString("END:VEVENT\r\n")
eventIDs := make([]uuid.UUID, len(events))
for i, ev := range events {
eventIDs[i] = utils.FromPgUUID(ev.ID)
}
b.WriteString("END:VCALENDAR\r\n")
reminderMap := h.loadReminders(ctx, eventIDs)
attendeeMap := h.loadAttendees(ctx, eventIDs)
w.Header().Set("Content-Type", "text/calendar")
w.Header().Set("Content-Disposition", "attachment; filename=calendar.ics")
calendar := ics.NewCalendar()
calendar.SetProductId("-//CalendarAPI//EN")
calendar.SetCalscale("GREGORIAN")
calendar.SetXWRCalName(calName)
for _, ev := range events {
evID := utils.FromPgUUID(ev.ID)
event := calendar.AddEvent(evID.String() + "@calendarapi")
event.SetDtStampTime(time.Now().UTC())
event.SetCreatedTime(utils.FromPgTimestamptz(ev.CreatedAt))
event.SetModifiedAt(utils.FromPgTimestamptz(ev.UpdatedAt))
startTime := utils.FromPgTimestamptz(ev.StartTime)
endTime := utils.FromPgTimestamptz(ev.EndTime)
if ev.AllDay {
event.SetAllDayStartAt(startTime)
event.SetAllDayEndAt(endTime)
} else {
event.SetStartAt(startTime)
event.SetEndAt(endTime)
}
event.SetSummary(ev.Title)
if ev.Description.Valid {
event.SetDescription(ev.Description.String)
}
if ev.Location.Valid {
event.SetLocation(ev.Location.String)
}
if ev.RecurrenceRule.Valid {
event.AddRrule(ev.RecurrenceRule.String)
}
event.SetStatus(ics.ObjectStatusConfirmed)
for _, rem := range reminderMap[evID] {
alarm := event.AddAlarm()
alarm.SetAction(ics.ActionDisplay)
alarm.SetTrigger(fmt.Sprintf("-PT%dM", rem.MinutesBefore))
alarm.SetProperty(ics.ComponentPropertyDescription, "Reminder")
}
for _, att := range attendeeMap[evID] {
email := ""
if att.Email.Valid {
email = att.Email.String
}
if email == "" {
continue
}
partStat := mapStatusToPartStat(att.Status)
event.AddAttendee("mailto:"+email, partStat)
}
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.ics", calName))
w.WriteHeader(http.StatusOK)
w.Write([]byte(b.String()))
w.Write([]byte(calendar.Serialize()))
}
func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
@@ -121,7 +194,7 @@ func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
return
}
count := h.parseAndImportICS(r.Context(), string(data), calID, userID)
count := h.importICSData(r.Context(), string(data), calID, userID)
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
@@ -129,61 +202,292 @@ func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
})
}
func (h *ICSHandler) parseAndImportICS(ctx context.Context, data string, calID, userID uuid.UUID) int {
count := 0
lines := strings.Split(data, "\n")
var inEvent bool
var title, description, location, rruleStr string
var dtstart, dtend time.Time
func (h *ICSHandler) ImportURL(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "BEGIN:VEVENT" {
inEvent = true
title, description, location, rruleStr = "", "", "", ""
dtstart, dtend = time.Time{}, time.Time{}
continue
}
if line == "END:VEVENT" && inEvent {
inEvent = false
if title != "" && !dtstart.IsZero() && !dtend.IsZero() {
_, err := h.queries.CreateEvent(ctx, repository.CreateEventParams{
ID: utils.ToPgUUID(uuid.New()),
CalendarID: utils.ToPgUUID(calID),
Title: title,
Description: utils.ToPgText(description),
Location: utils.ToPgText(location),
StartTime: utils.ToPgTimestamptz(dtstart),
EndTime: utils.ToPgTimestamptz(dtend),
Timezone: "UTC",
RecurrenceRule: utils.ToPgText(rruleStr),
Tags: []string{},
CreatedBy: utils.ToPgUUID(userID),
UpdatedBy: utils.ToPgUUID(userID),
})
if err == nil {
count++
}
}
continue
}
if !inEvent {
continue
}
switch {
case strings.HasPrefix(line, "SUMMARY:"):
title = strings.TrimPrefix(line, "SUMMARY:")
case strings.HasPrefix(line, "DESCRIPTION:"):
description = strings.TrimPrefix(line, "DESCRIPTION:")
case strings.HasPrefix(line, "LOCATION:"):
location = strings.TrimPrefix(line, "LOCATION:")
case strings.HasPrefix(line, "RRULE:"):
rruleStr = strings.TrimPrefix(line, "RRULE:")
case strings.HasPrefix(line, "DTSTART:"):
dtstart, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTSTART:"))
case strings.HasPrefix(line, "DTEND:"):
dtend, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTEND:"))
var req struct {
CalendarID string `json:"calendar_id"`
URL string `json:"url"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
calID, err := utils.ValidateUUID(req.CalendarID)
if err != nil {
utils.WriteError(w, err)
return
}
if req.URL == "" {
utils.WriteError(w, models.NewValidationError("url is required"))
return
}
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") {
utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal"))
return
}
role, err := h.calSvc.GetRole(r.Context(), calID, userID)
if err != nil {
utils.WriteError(w, err)
return
}
if role != "owner" && role != "editor" {
utils.WriteError(w, models.ErrForbidden)
return
}
fetchURL := req.URL
if strings.HasPrefix(fetchURL, "webcal://") {
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(fetchURL)
if err != nil {
utils.WriteError(w, models.NewValidationError("failed to fetch URL: "+err.Error()))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
utils.WriteError(w, models.NewValidationError(fmt.Sprintf("URL returned status %d", resp.StatusCode)))
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
utils.WriteError(w, models.ErrInternal)
return
}
count := h.importICSData(r.Context(), string(body), calID, userID)
h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{
ID: utils.ToPgUUID(uuid.New()),
CalendarID: utils.ToPgUUID(calID),
SourceUrl: req.URL,
})
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"imported": map[string]int{"events": count},
"source": req.URL,
})
}
func (h *ICSHandler) importICSData(ctx context.Context, data string, calID, userID uuid.UUID) int {
cal, err := ics.ParseCalendar(strings.NewReader(data))
if err != nil {
return 0
}
count := 0
for _, ev := range cal.Events() {
imported := h.importEvent(ctx, ev, calID, userID)
if imported {
count++
}
}
return count
}
func (h *ICSHandler) importEvent(ctx context.Context, ev *ics.VEvent, calID, userID uuid.UUID) bool {
summaryProp := ev.GetProperty(ics.ComponentPropertySummary)
if summaryProp == nil {
return false
}
title := summaryProp.Value
allDay := false
var startTime, endTime time.Time
dtStartProp := ev.GetProperty(ics.ComponentPropertyDtStart)
if dtStartProp == nil {
return false
}
if dtStartProp.GetValueType() == ics.ValueDataTypeDate {
allDay = true
var err error
startTime, err = ev.GetAllDayStartAt()
if err != nil {
return false
}
endTime, _ = ev.GetAllDayEndAt()
if endTime.IsZero() {
endTime = startTime.AddDate(0, 0, 1)
}
} else {
var err error
startTime, err = ev.GetStartAt()
if err != nil {
return false
}
endTime, _ = ev.GetEndAt()
if endTime.IsZero() {
endTime = startTime.Add(time.Hour)
}
}
tz := "UTC"
if tzid, ok := dtStartProp.ICalParameters["TZID"]; ok && len(tzid) > 0 {
if _, err := time.LoadLocation(tzid[0]); err == nil {
tz = tzid[0]
}
}
description := ""
if p := ev.GetProperty(ics.ComponentPropertyDescription); p != nil {
description = p.Value
}
location := ""
if p := ev.GetProperty(ics.ComponentPropertyLocation); p != nil {
location = p.Value
}
rrule := ""
if p := ev.GetProperty(ics.ComponentPropertyRrule); p != nil {
rrule = p.Value
}
eventID := uuid.New()
_, err := h.queries.CreateEvent(ctx, repository.CreateEventParams{
ID: utils.ToPgUUID(eventID),
CalendarID: utils.ToPgUUID(calID),
Title: title,
Description: utils.ToPgText(description),
Location: utils.ToPgText(location),
StartTime: utils.ToPgTimestamptz(startTime.UTC()),
EndTime: utils.ToPgTimestamptz(endTime.UTC()),
Timezone: tz,
AllDay: allDay,
RecurrenceRule: utils.ToPgText(rrule),
Tags: []string{},
CreatedBy: utils.ToPgUUID(userID),
UpdatedBy: utils.ToPgUUID(userID),
})
if err != nil {
return false
}
for _, alarm := range ev.Alarms() {
triggerProp := alarm.GetProperty(ics.ComponentPropertyTrigger)
if triggerProp == nil {
continue
}
minutes := parseTriggerMinutes(triggerProp.Value)
if minutes > 0 && minutes <= 10080 {
h.queries.CreateReminder(ctx, repository.CreateReminderParams{
ID: utils.ToPgUUID(uuid.New()),
EventID: utils.ToPgUUID(eventID),
MinutesBefore: minutes,
})
}
}
for _, att := range ev.Attendees() {
email := strings.TrimPrefix(att.Value, "mailto:")
email = strings.TrimPrefix(email, "MAILTO:")
if email == "" {
continue
}
h.queries.CreateAttendee(ctx, repository.CreateAttendeeParams{
ID: utils.ToPgUUID(uuid.New()),
EventID: utils.ToPgUUID(eventID),
Email: pgtype.Text{String: email, Valid: true},
})
}
return true
}
func (h *ICSHandler) loadReminders(ctx context.Context, eventIDs []uuid.UUID) map[uuid.UUID][]repository.EventReminder {
m := make(map[uuid.UUID][]repository.EventReminder)
for _, id := range eventIDs {
rows, err := h.queries.ListRemindersByEvent(ctx, utils.ToPgUUID(id))
if err == nil && len(rows) > 0 {
m[id] = rows
}
}
return m
}
func (h *ICSHandler) loadAttendees(ctx context.Context, eventIDs []uuid.UUID) map[uuid.UUID][]repository.EventAttendee {
m := make(map[uuid.UUID][]repository.EventAttendee)
for _, id := range eventIDs {
rows, err := h.queries.ListAttendeesByEvent(ctx, utils.ToPgUUID(id))
if err == nil && len(rows) > 0 {
m[id] = rows
}
}
return m
}
func mapStatusToPartStat(status string) ics.ParticipationStatus {
switch status {
case "accepted":
return ics.ParticipationStatusAccepted
case "declined":
return ics.ParticipationStatusDeclined
case "tentative":
return ics.ParticipationStatusTentative
default:
return ics.ParticipationStatusNeedsAction
}
}
// parseTriggerMinutes parses an iCal TRIGGER value like "-PT15M", "-PT1H", "-P1D" into minutes.
func parseTriggerMinutes(trigger string) int32 {
trigger = strings.TrimPrefix(trigger, "-")
trigger = strings.TrimPrefix(trigger, "+")
if strings.HasPrefix(trigger, "PT") {
trigger = strings.TrimPrefix(trigger, "PT")
return parseTimePart(trigger)
}
if strings.HasPrefix(trigger, "P") {
trigger = strings.TrimPrefix(trigger, "P")
total := int32(0)
if idx := strings.Index(trigger, "W"); idx >= 0 {
if w, err := strconv.Atoi(trigger[:idx]); err == nil {
total += int32(w) * 7 * 24 * 60
}
return total
}
if idx := strings.Index(trigger, "D"); idx >= 0 {
if d, err := strconv.Atoi(trigger[:idx]); err == nil {
total += int32(d) * 24 * 60
}
trigger = trigger[idx+1:]
}
if strings.HasPrefix(trigger, "T") {
total += parseTimePart(strings.TrimPrefix(trigger, "T"))
}
return total
}
return 0
}
func parseTimePart(s string) int32 {
total := int32(0)
if idx := strings.Index(s, "H"); idx >= 0 {
if h, err := strconv.Atoi(s[:idx]); err == nil {
total += int32(h) * 60
}
s = s[idx+1:]
}
if idx := strings.Index(s, "M"); idx >= 0 {
if m, err := strconv.Atoi(s[:idx]); err == nil {
total += int32(m)
}
s = s[idx+1:]
}
if idx := strings.Index(s, "S"); idx >= 0 {
if sec, err := strconv.Atoi(s[:idx]); err == nil {
total += int32(sec) / 60
}
}
return total
}

View File

@@ -4,7 +4,7 @@
"get": {
"tags": ["ICS"],
"summary": "Export calendar as ICS",
"description": "Exports all events from a calendar in ICS (iCalendar) format. Requires `calendars:read` scope.",
"description": "Exports all events from a calendar in ICS (iCalendar) format with full RFC 5545 support including reminders (VALARM), attendees, all-day events, and recurrence rules. Requires `calendars:read` scope.",
"operationId": "exportCalendarICS",
"parameters": [
{
@@ -34,7 +34,7 @@
"post": {
"tags": ["ICS"],
"summary": "Import an ICS file",
"description": "Imports events from an ICS file into a specified calendar. The file is sent as multipart form data. Requires `calendars:write` scope.",
"description": "Imports events from an ICS file into a specified calendar. Supports VALARM (reminders), ATTENDEE, TZID, VALUE=DATE (all-day events), and RRULE. The file is sent as multipart form data. Requires `calendars:write` scope.",
"operationId": "importCalendarICS",
"requestBody": {
"required": true,
@@ -77,6 +77,84 @@
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/calendars/import-url": {
"post": {
"tags": ["ICS"],
"summary": "Import from iCal URL",
"description": "Fetches an iCal feed from the given URL and imports all events into the specified calendar. Supports http, https, and webcal protocols. Requires `calendars:write` scope.",
"operationId": "importCalendarFromURL",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["calendar_id", "url"],
"properties": {
"calendar_id": { "type": "string", "format": "uuid", "description": "Target calendar ID" },
"url": { "type": "string", "format": "uri", "description": "iCal feed URL (http, https, or webcal)", "example": "https://example.com/calendar.ics" }
}
}
}
}
},
"responses": {
"200": {
"description": "Import successful",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["ok", "imported", "source"],
"properties": {
"ok": { "type": "boolean", "example": true },
"imported": {
"type": "object",
"properties": {
"events": { "type": "integer", "example": 12 }
}
},
"source": { "type": "string", "format": "uri", "description": "The URL that was imported" }
}
}
}
}
},
"400": { "description": "Validation error, unreachable URL, or invalid ICS", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/cal/{token}/feed.ics": {
"get": {
"tags": ["ICS"],
"summary": "Public iCal feed",
"description": "Returns a public iCal feed for a calendar that has been marked as public. No authentication required. This URL can be used to subscribe to the calendar in Google Calendar, Apple Calendar, Outlook, etc. The `ical_url` is returned in the Calendar object when `is_public` is true.",
"operationId": "publicCalendarFeed",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "Public calendar token (from the calendar's ical_url)"
}
],
"responses": {
"200": {
"description": "ICS calendar feed",
"content": {
"text/calendar": {
"schema": { "type": "string" }
}
}
},
"404": { "description": "Calendar not found or not public", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
},
"security": []
}
}
}
}

View File

@@ -58,6 +58,7 @@
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" },
"is_public": { "type": "boolean" },
"ical_url": { "type": "string", "format": "uri", "description": "Public iCal feed URL (only present when is_public is true)" },
"role": { "type": "string", "enum": ["owner", "editor", "viewer"], "description": "Current user's role on this calendar" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" }

View File

@@ -43,6 +43,8 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
r.Get("/booking/{token}/availability", h.Booking.GetAvailability)
r.Post("/booking/{token}/reserve", h.Booking.Reserve)
r.Get("/cal/{token}/feed.ics", h.ICS.PublicFeed)
})
// Authenticated routes
@@ -68,6 +70,7 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.List)
r.With(mw.RequireScope("calendars", "write")).Post("/", h.Calendar.Create)
r.With(mw.RequireScope("calendars", "write")).Post("/import", h.ICS.Import)
r.With(mw.RequireScope("calendars", "write")).Post("/import-url", h.ICS.ImportURL)
r.Route("/{id}", func(r chi.Router) {
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.Get)

View File

@@ -12,17 +12,20 @@ type Config struct {
RedisAddr string
ServerPort string
Env string
BaseURL string
}
func Load() *Config {
loadEnvFile(".env")
port := getEnv("SERVER_PORT", "8080")
return &Config{
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/calendarapi?sslmode=disable"),
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
RedisAddr: os.Getenv("REDIS_ADDR"),
ServerPort: getEnv("SERVER_PORT", "8080"),
ServerPort: port,
Env: getEnv("ENV", "development"),
BaseURL: getEnv("BASE_URL", "http://localhost:"+port),
}
}

View File

@@ -19,6 +19,7 @@ type Calendar struct {
Name string `json:"name"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
ICalURL string `json:"ical_url,omitempty"`
Role string `json:"role,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`

View File

@@ -91,6 +91,39 @@ func (q *Queries) GetCalendarByID(ctx context.Context, id pgtype.UUID) (GetCalen
return i, err
}
const getCalendarByPublicToken = `-- name: GetCalendarByPublicToken :one
SELECT id, owner_id, name, color, is_public, public_token, created_at, updated_at
FROM calendars
WHERE public_token = $1 AND is_public = true AND deleted_at IS NULL
`
type GetCalendarByPublicTokenRow struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
PublicToken pgtype.Text `json:"public_token"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) GetCalendarByPublicToken(ctx context.Context, publicToken pgtype.Text) (GetCalendarByPublicTokenRow, error) {
row := q.db.QueryRow(ctx, getCalendarByPublicToken, publicToken)
var i GetCalendarByPublicTokenRow
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsPublic,
&i.PublicToken,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listCalendarsByUser = `-- name: ListCalendarsByUser :many
SELECT c.id, c.owner_id, c.name, c.color, c.is_public, c.created_at, c.updated_at, cm.role
FROM calendars c
@@ -139,6 +172,22 @@ func (q *Queries) ListCalendarsByUser(ctx context.Context, userID pgtype.UUID) (
return items, nil
}
const setCalendarPublicToken = `-- name: SetCalendarPublicToken :exec
UPDATE calendars
SET public_token = $2, updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
`
type SetCalendarPublicTokenParams struct {
ID pgtype.UUID `json:"id"`
PublicToken pgtype.Text `json:"public_token"`
}
func (q *Queries) SetCalendarPublicToken(ctx context.Context, arg SetCalendarPublicTokenParams) error {
_, err := q.db.Exec(ctx, setCalendarPublicToken, arg.ID, arg.PublicToken)
return err
}
const softDeleteCalendar = `-- name: SoftDeleteCalendar :exec
UPDATE calendars SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND deleted_at IS NULL

View File

@@ -58,6 +58,14 @@ type CalendarMember struct {
Role string `json:"role"`
}
type CalendarSubscription struct {
ID pgtype.UUID `json:"id"`
CalendarID pgtype.UUID `json:"calendar_id"`
SourceUrl string `json:"source_url"`
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Contact struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`

View File

@@ -0,0 +1,55 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: subscriptions.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createCalendarSubscription = `-- name: CreateCalendarSubscription :one
INSERT INTO calendar_subscriptions (id, calendar_id, source_url)
VALUES ($1, $2, $3)
RETURNING id, calendar_id, source_url, last_synced_at, created_at
`
type CreateCalendarSubscriptionParams struct {
ID pgtype.UUID `json:"id"`
CalendarID pgtype.UUID `json:"calendar_id"`
SourceUrl string `json:"source_url"`
}
func (q *Queries) CreateCalendarSubscription(ctx context.Context, arg CreateCalendarSubscriptionParams) (CalendarSubscription, error) {
row := q.db.QueryRow(ctx, createCalendarSubscription, arg.ID, arg.CalendarID, arg.SourceUrl)
var i CalendarSubscription
err := row.Scan(
&i.ID,
&i.CalendarID,
&i.SourceUrl,
&i.LastSyncedAt,
&i.CreatedAt,
)
return i, err
}
const getSubscriptionByCalendar = `-- name: GetSubscriptionByCalendar :one
SELECT id, calendar_id, source_url, last_synced_at, created_at FROM calendar_subscriptions
WHERE calendar_id = $1
`
func (q *Queries) GetSubscriptionByCalendar(ctx context.Context, calendarID pgtype.UUID) (CalendarSubscription, error) {
row := q.db.QueryRow(ctx, getSubscriptionByCalendar, calendarID)
var i CalendarSubscription
err := row.Scan(
&i.ID,
&i.CalendarID,
&i.SourceUrl,
&i.LastSyncedAt,
&i.CreatedAt,
)
return i, err
}

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