diff --git a/.env.example b/.env.example index 081aa5c..c5fb2fc 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,6 @@ JWT_SECRET=dev-secret-change-me # Server SERVER_PORT=3019 ENV=development + +# Base URL (used for public iCal feed URLs; defaults to http://localhost:$SERVER_PORT) +# BASE_URL=https://api.example.com diff --git a/README.md b/README.md index f420a3b..8d4970f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ A production-grade REST API for calendar management, event scheduling, contacts, - **Contacts** - Personal contact management with search - **Availability** - Query busy/free time across calendars - **Booking Links** - Public scheduling pages with configurable working hours, duration, and buffer time -- **ICS Import/Export** - Standard iCalendar format compatibility +- **ICS Import/Export** - Full RFC 5545 iCalendar support with VALARM, ATTENDEE, TZID, all-day events +- **Public iCal Feeds** - Make calendars public and subscribe via iCal URL in any calendar app +- **iCal URL Import** - Import events from any external iCal feed URL - **Dual Auth** - JWT tokens for interactive use, scoped API keys for agents and automation - **Background Jobs** - Reminder notifications via Redis + Asynq (optional) @@ -214,6 +216,8 @@ X-API-Key: |--------|----------|-------|-------------| | GET | `/calendars/{id}/export.ics` | calendars:read | Export as ICS | | POST | `/calendars/import` | calendars:write | Import ICS file | +| POST | `/calendars/import-url` | calendars:write | Import from iCal URL | +| GET | `/cal/{token}/feed.ics` | None (public) | Public iCal feed | ## Project Structure diff --git a/SKILL.md b/SKILL.md index 0a0df7c..08df2c5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -278,10 +278,16 @@ Returns 409 CONFLICT if the slot is no longer available. |--------|----------|-------|-------------| | GET | `/calendars/{id}/export.ics` | calendars:read | Export calendar as ICS file | | POST | `/calendars/import` | calendars:write | Import ICS file (multipart/form-data) | +| POST | `/calendars/import-url` | calendars:write | Import from external iCal URL | +| GET | `/cal/{token}/feed.ics` | None (public) | Public iCal feed for subscription | -Export returns `Content-Type: text/calendar`. +Export returns `Content-Type: text/calendar` with full RFC 5545 support (VALARM, ATTENDEE, all-day events, RRULE). -Import requires multipart form with `calendar_id` (uuid) and `file` (.ics file). +Import requires multipart form with `calendar_id` (uuid) and `file` (.ics file). Supports VALARM (reminders), ATTENDEE, TZID, VALUE=DATE (all-day). + +Import URL accepts JSON body: `{"calendar_id": "uuid", "url": "https://..."}`. Supports http, https, and webcal protocols. + +Public feed: Set `is_public` to `true` via `PUT /calendars/{id}` to generate an `ical_url`. This URL can be used to subscribe in Google Calendar, Apple Calendar, Outlook, etc. ### Users @@ -375,6 +381,14 @@ When `next_cursor` is `null`, there are no more pages. To fetch the next page, p 1. `GET /calendars` or `POST /calendars` - ensure target calendar exists. 2. `POST /calendars/import` - upload `.ics` file as multipart form data with `calendar_id`. + Or: `POST /calendars/import-url` - import from an iCal URL with `{"calendar_id": "...", "url": "https://..."}`. + +### Workflow: Make a calendar publicly subscribable + +1. `PUT /calendars/{id}` with `{"is_public": true}` - generates a public iCal feed URL. +2. The response includes `ical_url` (e.g., `https://api.example.com/cal/{token}/feed.ics`). +3. Share the `ical_url` - anyone can subscribe to it in their calendar app. +4. To revoke: `PUT /calendars/{id}` with `{"is_public": false}` - removes the feed URL. ## Important Constraints diff --git a/about/overview.md b/about/overview.md index 76b5923..d896a2e 100644 --- a/about/overview.md +++ b/about/overview.md @@ -324,6 +324,8 @@ POST /booking/{token}/reserve GET /calendars/{id}/export.ics POST /calendars/import +POST /calendars/import-url +GET /cal/{token}/feed.ics (public, no auth) --- diff --git a/cmd/server/main.go b/cmd/server/main.go index 4506e9c..9a37419 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -65,7 +65,7 @@ func main() { auditSvc := service.NewAuditService(queries) authSvc := service.NewAuthService(pool, queries, jwtManager, auditSvc) userSvc := service.NewUserService(pool, queries, auditSvc) - calSvc := service.NewCalendarService(pool, queries, auditSvc) + calSvc := service.NewCalendarService(pool, queries, auditSvc, cfg.BaseURL) eventSvc := service.NewEventService(pool, queries, calSvc, auditSvc, sched) contactSvc := service.NewContactService(queries, auditSvc) availSvc := service.NewAvailabilityService(queries, calSvc, eventSvc) diff --git a/go.mod b/go.mod index e20107c..a56ee81 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/calendarapi go 1.24.0 require ( + github.com/arran4/golang-ical v0.3.3 github.com/go-chi/chi/v5 v5.2.5 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 diff --git a/go.sum b/go.sum index 3a495ed..92ce9a4 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/arran4/golang-ical v0.3.3 h1:8oEhMFS8tBoXmrMf6dKVWPNB1lNfE8xGn/c5NYm3SKg= +github.com/arran4/golang-ical v0.3.3/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/internal/api/handlers/ics.go b/internal/api/handlers/ics.go index af9b96a..a5787eb 100644 --- a/internal/api/handlers/ics.go +++ b/internal/api/handlers/ics.go @@ -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 +} diff --git a/internal/api/openapi/specs/ics.json b/internal/api/openapi/specs/ics.json index 03c77a2..08dc010 100644 --- a/internal/api/openapi/specs/ics.json +++ b/internal/api/openapi/specs/ics.json @@ -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": [] + } } } } diff --git a/internal/api/openapi/specs/schemas.json b/internal/api/openapi/specs/schemas.json index 5e66225..f354481 100644 --- a/internal/api/openapi/specs/schemas.json +++ b/internal/api/openapi/specs/schemas.json @@ -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" } diff --git a/internal/api/routes.go b/internal/api/routes.go index 98dbba6..6e91892 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 0896411..586588d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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), } } diff --git a/internal/models/models.go b/internal/models/models.go index 7e392b9..327bd4d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/repository/calendars.sql.go b/internal/repository/calendars.sql.go index 73fbb30..59fa411 100644 --- a/internal/repository/calendars.sql.go +++ b/internal/repository/calendars.sql.go @@ -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 diff --git a/internal/repository/models.go b/internal/repository/models.go index 0a17829..77f9149 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -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"` diff --git a/internal/repository/subscriptions.sql.go b/internal/repository/subscriptions.sql.go new file mode 100644 index 0000000..e9a3320 --- /dev/null +++ b/internal/repository/subscriptions.sql.go @@ -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 +} diff --git a/internal/service/calendar.go b/internal/service/calendar.go index 81ab8b5..64cbefa 100644 --- a/internal/service/calendar.go +++ b/internal/service/calendar.go @@ -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), diff --git a/llms.txt b/llms.txt index e7e793a..1a65197 100644 --- a/llms.txt +++ b/llms.txt @@ -42,7 +42,7 @@ DELETE /api-keys/{id} - Revoke key. Returns {"ok": true}. GET /calendars - List all calendars (owned + shared). Returns {"items": [Calendar], "page": {...}}. POST /calendars - Create. Body: {"name" (1-80), "color"? (#RRGGBB)}. Returns {"calendar": Calendar}. GET /calendars/{id} - Get by ID. Returns {"calendar": Calendar}. -PUT /calendars/{id} - Update. Body: {"name"?, "color"?, "is_public"? (owner only)}. Returns {"calendar": Calendar}. +PUT /calendars/{id} - Update. Body: {"name"?, "color"?, "is_public"? (owner only)}. When is_public is set to true, a public iCal feed URL (ical_url) is generated. Returns {"calendar": Calendar}. DELETE /calendars/{id} - Soft-delete (owner only). Returns {"ok": true}. ### Calendar Sharing @@ -90,13 +90,15 @@ POST /booking/{token}/reserve - Public, no auth. Body: {"name", "email", "slot_s ## ICS Import/Export -GET /calendars/{id}/export.ics - Export as ICS (scope: calendars:read). Returns text/calendar. -POST /calendars/import - Import ICS (scope: calendars:write). Multipart form: calendar_id (uuid) + file (.ics). Returns {"ok": true, "imported": {"events": N}}. +GET /calendars/{id}/export.ics - Export as ICS (scope: calendars:read). Full RFC 5545 support: VEVENT with VALARM (reminders), ATTENDEE, all-day events (VALUE=DATE), RRULE, DTSTART/DTEND, SUMMARY, DESCRIPTION, LOCATION, CREATED, LAST-MODIFIED. Returns text/calendar. +POST /calendars/import - Import ICS file (scope: calendars:write). Multipart form: calendar_id (uuid) + file (.ics). Parses VEVENT with VALARM, ATTENDEE, TZID, VALUE=DATE, RRULE. Returns {"ok": true, "imported": {"events": N}}. +POST /calendars/import-url - Import from iCal URL (scope: calendars:write). Body: {"calendar_id": uuid, "url": "https://..."}. Fetches the iCal feed and imports events. Supports http, https, webcal protocols. Returns {"ok": true, "imported": {"events": N}, "source": url}. +GET /cal/{token}/feed.ics - Public iCal feed. No auth required. Returns the calendar's events in iCal format. Subscribe to this URL in Google Calendar, Apple Calendar, Outlook, etc. The URL is available as ical_url in the Calendar object when is_public is true. ## Data Schemas User: {id, email, timezone, created_at, updated_at} -Calendar: {id, name, color, is_public, role, created_at, updated_at} +Calendar: {id, name, color, is_public, ical_url?, role, created_at, updated_at} Event: {id, calendar_id, title, description?, location?, start_time, end_time, timezone, all_day, recurrence_rule?, is_occurrence, occurrence_start_time?, occurrence_end_time?, created_by, updated_by, created_at, updated_at, reminders[], attendees[], tags[], attachments[]} Contact: {id, first_name?, last_name?, email?, phone?, company?, notes?, created_at, updated_at} Reminder: {id, minutes_before} @@ -126,4 +128,5 @@ Cursor-based. Query params: limit (default 50, max 200), cursor (opaque string). - Event list range: max 1 year - Soft deletion throughout (data recoverable) - Calendar roles: owner (full), editor (events CRUD), viewer (read-only) -- Only owners can share calendars, delete calendars, create booking links +- Only owners can share calendars, delete calendars, create booking links, change is_public +- Public calendars provide an ical_url for subscription by external calendar apps diff --git a/migrations/000002_ical_public.down.sql b/migrations/000002_ical_public.down.sql new file mode 100644 index 0000000..eb8bc56 --- /dev/null +++ b/migrations/000002_ical_public.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS calendar_subscriptions; +DROP INDEX IF EXISTS idx_calendars_public_token; diff --git a/migrations/000002_ical_public.up.sql b/migrations/000002_ical_public.up.sql new file mode 100644 index 0000000..456dde9 --- /dev/null +++ b/migrations/000002_ical_public.up.sql @@ -0,0 +1,13 @@ +-- Unique index on public_token for fast lookups of public iCal feeds +CREATE UNIQUE INDEX idx_calendars_public_token ON calendars (public_token) WHERE public_token IS NOT NULL; + +-- Track calendars imported from external iCal URLs +CREATE TABLE calendar_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + calendar_id UUID NOT NULL REFERENCES calendars(id), + source_url TEXT NOT NULL, + last_synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_calendar_subscriptions_calendar_id ON calendar_subscriptions (calendar_id); diff --git a/sqlc/queries/calendars.sql b/sqlc/queries/calendars.sql index ff6d7f8..07f5d4e 100644 --- a/sqlc/queries/calendars.sql +++ b/sqlc/queries/calendars.sql @@ -31,3 +31,13 @@ WHERE id = $1 AND deleted_at IS NULL; -- name: SoftDeleteCalendarsByOwner :exec UPDATE calendars SET deleted_at = now(), updated_at = now() WHERE owner_id = $1 AND deleted_at IS NULL; + +-- 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; + +-- name: SetCalendarPublicToken :exec +UPDATE calendars +SET public_token = $2, updated_at = now() +WHERE id = $1 AND deleted_at IS NULL; diff --git a/sqlc/queries/subscriptions.sql b/sqlc/queries/subscriptions.sql new file mode 100644 index 0000000..3aff0be --- /dev/null +++ b/sqlc/queries/subscriptions.sql @@ -0,0 +1,8 @@ +-- name: CreateCalendarSubscription :one +INSERT INTO calendar_subscriptions (id, calendar_id, source_url) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetSubscriptionByCalendar :one +SELECT * FROM calendar_subscriptions +WHERE calendar_id = $1; diff --git a/sqlc/schema.sql b/sqlc/schema.sql index 088dc56..1f48416 100644 --- a/sqlc/schema.sql +++ b/sqlc/schema.sql @@ -55,6 +55,7 @@ CREATE TABLE calendars ( ); CREATE INDEX idx_calendars_owner_id ON calendars (owner_id); +CREATE UNIQUE INDEX idx_calendars_public_token ON calendars (public_token) WHERE public_token IS NOT NULL; -- Calendar Members CREATE TABLE calendar_members ( @@ -172,3 +173,14 @@ CREATE TABLE audit_logs ( ); CREATE INDEX idx_audit_logs_entity ON audit_logs (entity_type, entity_id); + +-- Calendar Subscriptions (external iCal URL sources) +CREATE TABLE calendar_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + calendar_id UUID NOT NULL REFERENCES calendars(id), + source_url TEXT NOT NULL, + last_synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_calendar_subscriptions_calendar_id ON calendar_subscriptions (calendar_id);