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:
Michilis
2026-03-02 14:07:55 +00:00
parent 2cb9d72a7f
commit 75105b8b46
8120 changed files with 1486881 additions and 314 deletions

View File

@@ -2,12 +2,14 @@ package handlers
import (
"net/http"
"strings"
"time"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type AvailabilityHandler struct {
@@ -18,6 +20,75 @@ func NewAvailabilityHandler(availSvc *service.AvailabilityService) *Availability
return &AvailabilityHandler{availSvc: availSvc}
}
// GetByToken returns busy blocks for a calendar by its public token. No auth required.
func (h *AvailabilityHandler) GetByToken(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
utils.WriteError(w, models.ErrNotFound)
return
}
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr == "" || endStr == "" {
utils.WriteError(w, models.NewValidationError("start and end required"))
return
}
start, err := time.Parse(time.RFC3339, startStr)
if err != nil {
utils.WriteError(w, models.NewValidationError("invalid start time"))
return
}
end, err := time.Parse(time.RFC3339, endStr)
if err != nil {
utils.WriteError(w, models.NewValidationError("invalid end time"))
return
}
result, err := h.availSvc.GetBusyBlocksByTokenPublic(r.Context(), token, start, end)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, result)
}
// GetAggregate returns merged busy blocks across multiple calendars by their tokens. No auth required.
func (h *AvailabilityHandler) GetAggregate(w http.ResponseWriter, r *http.Request) {
tokensStr := r.URL.Query().Get("tokens")
if tokensStr == "" {
utils.WriteError(w, models.NewValidationError("tokens required (comma-separated)"))
return
}
tokens := strings.Split(tokensStr, ",")
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr == "" || endStr == "" {
utils.WriteError(w, models.NewValidationError("start and end required"))
return
}
start, err := time.Parse(time.RFC3339, startStr)
if err != nil {
utils.WriteError(w, models.NewValidationError("invalid start time"))
return
}
end, err := time.Parse(time.RFC3339, endStr)
if err != nil {
utils.WriteError(w, models.NewValidationError("invalid end time"))
return
}
result, err := h.availSvc.GetBusyBlocksAggregate(r.Context(), tokens, start, end)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, result)
}
func (h *AvailabilityHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
q := r.URL.Query()

View File

@@ -77,16 +77,19 @@ func (h *CalendarHandler) Update(w http.ResponseWriter, r *http.Request) {
}
var req struct {
Name *string `json:"name"`
Color *string `json:"color"`
IsPublic *bool `json:"is_public"`
Name *string `json:"name"`
Color *string `json:"color"`
IsPublic *bool `json:"is_public"`
CountForAvailability *bool `json:"count_for_availability"`
DefaultReminderMinutes *int `json:"default_reminder_minutes"`
SortOrder *int `json:"sort_order"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
cal, err := h.calSvc.Update(r.Context(), userID, calID, req.Name, req.Color, req.IsPublic)
cal, err := h.calSvc.Update(r.Context(), userID, calID, req.Name, req.Color, req.IsPublic, req.CountForAvailability, req.DefaultReminderMinutes, req.SortOrder)
if err != nil {
utils.WriteError(w, err)
return

View File

@@ -2,9 +2,11 @@ package handlers
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -59,7 +61,7 @@ func (h *ICSHandler) PublicFeed(w http.ResponseWriter, r *http.Request) {
return
}
cal, err := h.queries.GetCalendarByPublicToken(r.Context(), pgtype.Text{String: token, Valid: true})
cal, err := h.queries.GetCalendarByToken(r.Context(), pgtype.Text{String: token, Valid: true})
if err != nil {
utils.WriteError(w, models.ErrNotFound)
return
@@ -243,32 +245,23 @@ func (h *ICSHandler) ImportURL(w http.ResponseWriter, r *http.Request) {
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
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
fetchURL = normalized
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
utils.WriteError(w, models.ErrInternal)
body, fetchErr := fetchICSURL(fetchURL)
if fetchErr != nil {
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
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,
ID: utils.ToPgUUID(uuid.New()),
CalendarID: utils.ToPgUUID(calID),
SourceUrl: req.URL,
SyncIntervalMinutes: pgtype.Int4{Valid: false},
})
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
@@ -278,6 +271,428 @@ func (h *ICSHandler) ImportURL(w http.ResponseWriter, r *http.Request) {
})
}
func (h *ICSHandler) AddFromURL(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
var req struct {
URL string `json:"url"`
Name *string `json:"name"`
Color *string `json:"color"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
req.URL = strings.TrimSpace(req.URL)
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
}
fetchURL := req.URL
if strings.HasPrefix(fetchURL, "webcal://") {
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
}
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
fetchURL = normalized
}
body, fetchErr := fetchICSURL(fetchURL)
if fetchErr != nil {
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
return
}
name := "Imported Calendar"
if req.Name != nil && *req.Name != "" {
name = *req.Name
}
color := "#3B82F6"
if req.Color != nil && *req.Color != "" {
if err := utils.ValidateColor(*req.Color); err != nil {
utils.WriteError(w, err)
return
}
color = *req.Color
}
cal, err := h.calSvc.Create(r.Context(), userID, name, color)
if err != nil {
utils.WriteError(w, err)
return
}
calID := cal.ID
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,
SyncIntervalMinutes: pgtype.Int4{Valid: false},
})
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"calendar": cal,
"imported": map[string]int{"events": count},
"source": req.URL,
})
}
func (h *ICSHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil {
utils.WriteError(w, err)
return
}
subs, err := h.queries.ListSubscriptionsByCalendar(r.Context(), utils.ToPgUUID(calID))
if err != nil {
utils.WriteError(w, models.ErrInternal)
return
}
items := make([]map[string]interface{}, len(subs))
for i, s := range subs {
syncMins := (*int32)(nil)
if s.SyncIntervalMinutes.Valid {
syncMins = &s.SyncIntervalMinutes.Int32
}
lastSynced := ""
if s.LastSyncedAt.Valid {
lastSynced = s.LastSyncedAt.Time.Format(time.RFC3339)
}
items[i] = map[string]interface{}{
"id": utils.FromPgUUID(s.ID),
"calendar_id": utils.FromPgUUID(s.CalendarID),
"source_url": s.SourceUrl,
"last_synced_at": lastSynced,
"sync_interval_minutes": syncMins,
"created_at": s.CreatedAt.Time.Format(time.RFC3339),
}
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"items": items,
"page": map[string]interface{}{"limit": 100, "next_cursor": nil},
})
}
func (h *ICSHandler) AddSubscription(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
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
}
var req struct {
URL string `json:"url"`
SyncIntervalMinutes *int32 `json:"sync_interval_minutes"`
}
if err := utils.DecodeJSON(r, &req); 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
}
fetchURL := req.URL
if strings.HasPrefix(fetchURL, "webcal://") {
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
}
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
fetchURL = normalized
}
body, fetchErr := fetchICSURL(fetchURL)
if fetchErr != nil {
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
return
}
count := h.importICSData(r.Context(), string(body), calID, userID)
syncMins := pgtype.Int4{Valid: false}
if req.SyncIntervalMinutes != nil && *req.SyncIntervalMinutes > 0 {
syncMins = pgtype.Int4{Int32: *req.SyncIntervalMinutes, Valid: true}
}
h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{
ID: utils.ToPgUUID(uuid.New()),
CalendarID: utils.ToPgUUID(calID),
SourceUrl: req.URL,
SyncIntervalMinutes: syncMins,
})
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"imported": map[string]int{"events": count},
"source": req.URL,
})
}
func (h *ICSHandler) DeleteSubscription(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
subID, err := utils.ValidateUUID(chi.URLParam(r, "subId"))
if err != nil {
utils.WriteError(w, err)
return
}
if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil {
utils.WriteError(w, err)
return
}
sub, err := h.queries.GetSubscriptionByID(r.Context(), utils.ToPgUUID(subID))
if err != nil {
utils.WriteError(w, models.ErrNotFound)
return
}
if utils.FromPgUUID(sub.CalendarID) != calID {
utils.WriteError(w, models.ErrNotFound)
return
}
if err := h.queries.DeleteSubscription(r.Context(), utils.ToPgUUID(subID)); err != nil {
utils.WriteError(w, models.ErrInternal)
return
}
utils.WriteOK(w)
}
func (h *ICSHandler) SyncSubscription(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
subID, err := utils.ValidateUUID(chi.URLParam(r, "subId"))
if err != nil {
utils.WriteError(w, err)
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
}
sub, err := h.queries.GetSubscriptionByID(r.Context(), utils.ToPgUUID(subID))
if err != nil {
utils.WriteError(w, models.ErrNotFound)
return
}
if utils.FromPgUUID(sub.CalendarID) != calID {
utils.WriteError(w, models.ErrNotFound)
return
}
fetchURL := sub.SourceUrl
if strings.HasPrefix(fetchURL, "webcal://") {
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
}
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
fetchURL = normalized
}
body, fetchErr := fetchICSURL(fetchURL)
if fetchErr != nil {
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
return
}
count := h.importICSData(r.Context(), string(body), calID, userID)
if err := h.queries.UpdateSubscriptionLastSynced(r.Context(), utils.ToPgUUID(subID)); err != nil {
utils.WriteError(w, models.ErrInternal)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"imported": map[string]int{"events": count},
})
}
// SyncSubscriptionBackground performs a subscription sync (used by background worker).
func (h *ICSHandler) SyncSubscriptionBackground(ctx context.Context, subscriptionID string) error {
subID, err := uuid.Parse(subscriptionID)
if err != nil {
return err
}
sub, err := h.queries.GetSubscriptionByID(ctx, utils.ToPgUUID(subID))
if err != nil {
return err
}
calID := utils.FromPgUUID(sub.CalendarID)
cal, err := h.queries.GetCalendarByID(ctx, sub.CalendarID)
if err != nil {
return err
}
ownerID := utils.FromPgUUID(cal.OwnerID)
fetchURL := sub.SourceUrl
if strings.HasPrefix(fetchURL, "webcal://") {
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
}
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
fetchURL = normalized
}
body, err := fetchICSURL(fetchURL)
if err != nil {
return err
}
_ = h.importICSData(ctx, string(body), calID, ownerID)
return h.queries.UpdateSubscriptionLastSynced(ctx, utils.ToPgUUID(subID))
}
// normalizeGoogleCalendarURL converts Google Calendar embed/share URLs to the iCal feed format.
// Returns (fetchURL, true) if the URL was converted or is already a Google iCal URL.
func normalizeGoogleCalendarURL(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if !strings.Contains(raw, "calendar.google.com") {
return raw, false
}
// Already an iCal URL - use as-is (may be public or private)
if strings.Contains(raw, "/calendar/ical/") && strings.HasSuffix(raw, ".ics") {
return raw, true
}
// Embed URL: https://calendar.google.com/calendar/embed?src=CALENDAR_ID
if strings.Contains(raw, "/calendar/embed") {
if u, err := url.Parse(raw); err == nil {
if src := u.Query().Get("src"); src != "" {
decoded, _ := url.QueryUnescape(src)
icalURL := "https://calendar.google.com/calendar/ical/" + url.PathEscape(decoded) + "/public/basic.ics"
return icalURL, true
}
}
}
// Calendar link with cid: https://calendar.google.com/calendar?cid=BASE64_CALENDAR_ID
if strings.Contains(raw, "cid=") {
if u, err := url.Parse(raw); err == nil {
if cid := u.Query().Get("cid"); cid != "" {
// cid can be base64, base64url, or base64url without padding
var decoded []byte
for _, enc := range []*base64.Encoding{base64.URLEncoding, base64.RawURLEncoding, base64.StdEncoding} {
d, decodeErr := enc.DecodeString(cid)
if decodeErr == nil && len(d) > 0 {
decoded = d
break
}
}
if len(decoded) > 0 {
calID := string(decoded)
icalURL := "https://calendar.google.com/calendar/ical/" + url.PathEscape(calID) + "/public/basic.ics"
return icalURL, true
}
}
}
}
return raw, true
}
// googleCalendarHelp is the error hint shown when Google Calendar requests fail.
const googleCalendarHelp = "Use the iCal link from Google: Settings → your calendar → Integrate calendar → copy \"Secret address in iCal format\" (not the embed or share URL)"
// fetchICSURL fetches an ICS file from a URL. Google Calendar blocks non-browser User-Agents,
// so we use a full Chrome User-Agent. Other providers generally accept it.
func fetchICSURL(fetchURL string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, fetchURL, nil)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
// Google Calendar returns 403 for non-browser User-Agents; use a real Chrome UA
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/calendar,text/plain,*/*")
if strings.Contains(fetchURL, "calendar.google.com") {
req.Header.Set("Referer", "https://calendar.google.com/")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch URL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
if strings.Contains(fetchURL, "calendar.google.com") {
switch resp.StatusCode {
case http.StatusNotFound:
return nil, fmt.Errorf("Google Calendar returned 404. %s", googleCalendarHelp)
case http.StatusForbidden:
return nil, fmt.Errorf("Google Calendar returned 403 (access denied). %s", googleCalendarHelp)
}
}
return nil, fmt.Errorf("URL returned HTTP %d", resp.StatusCode)
}
// Google may return 200 with an HTML login/error page instead of ICS
bodyStr := string(body)
if len(body) > 10 && (body[0] == '<' || strings.HasPrefix(bodyStr, "<!") || strings.Contains(bodyStr[:min(500, len(bodyStr))], "<html")) {
if strings.Contains(fetchURL, "calendar.google.com") {
return nil, fmt.Errorf("Google returned HTML instead of calendar data. %s", googleCalendarHelp)
}
return nil, fmt.Errorf("URL returned HTML instead of calendar data (ICS)")
}
return body, nil
}
func (h *ICSHandler) importICSData(ctx context.Context, data string, calID, userID uuid.UUID) int {
cal, err := ics.ParseCalendar(strings.NewReader(data))
if err != nil {

View File

@@ -41,14 +41,34 @@ func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) {
}
var req struct {
Timezone *string `json:"timezone"`
Timezone *string `json:"timezone"`
WeekStartDay *int `json:"week_start_day"`
DateFormat *string `json:"date_format"`
TimeFormat *string `json:"time_format"`
DefaultEventDurationMinutes *int `json:"default_event_duration_minutes"`
DefaultReminderMinutes *int `json:"default_reminder_minutes"`
ShowWeekends *bool `json:"show_weekends"`
WorkingHoursStart *string `json:"working_hours_start"`
WorkingHoursEnd *string `json:"working_hours_end"`
NotificationsEmail *bool `json:"notifications_email"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
user, err := h.userSvc.Update(r.Context(), userID, req.Timezone)
user, err := h.userSvc.Update(r.Context(), userID, &service.UserUpdateInput{
Timezone: req.Timezone,
WeekStartDay: req.WeekStartDay,
DateFormat: req.DateFormat,
TimeFormat: req.TimeFormat,
DefaultEventDurationMinutes: req.DefaultEventDurationMinutes,
DefaultReminderMinutes: req.DefaultReminderMinutes,
ShowWeekends: req.ShowWeekends,
WorkingHoursStart: req.WorkingHoursStart,
WorkingHoursEnd: req.WorkingHoursEnd,
NotificationsEmail: req.NotificationsEmail,
})
if err != nil {
utils.WriteError(w, err)
return

View File

@@ -1,5 +1,73 @@
{
"paths": {
"/availability/aggregate": {
"get": {
"tags": ["Availability"],
"summary": "Get aggregate availability (public)",
"description": "Returns merged busy time blocks across multiple calendars by their public tokens. No authentication required.",
"operationId": "getAvailabilityAggregate",
"parameters": [
{ "name": "tokens", "in": "query", "required": true, "schema": { "type": "string", "description": "Comma-separated calendar tokens from ical_url" }, "description": "Calendar tokens" },
{ "name": "start", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range start (RFC3339)" },
{ "name": "end", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range end (RFC3339)" }
],
"responses": {
"200": {
"description": "Aggregate busy blocks",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["range_start", "range_end", "busy"],
"properties": {
"range_start": { "type": "string", "format": "date-time" },
"range_end": { "type": "string", "format": "date-time" },
"busy": { "type": "array", "items": { "$ref": "#/components/schemas/BusyBlock" } }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
},
"security": []
}
},
"/availability/{token}": {
"get": {
"tags": ["Availability"],
"summary": "Get availability by token (public)",
"description": "Returns busy time blocks for a calendar by its public token. No authentication required.",
"operationId": "getAvailabilityByToken",
"parameters": [
{ "name": "token", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Calendar token from ical_url" },
{ "name": "start", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range start (RFC3339)" },
{ "name": "end", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range end (RFC3339)" }
],
"responses": {
"200": {
"description": "Busy blocks for the calendar",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["calendar_id", "range_start", "range_end", "busy"],
"properties": {
"calendar_id": { "type": "string", "format": "uuid" },
"range_start": { "type": "string", "format": "date-time" },
"range_end": { "type": "string", "format": "date-time" },
"busy": { "type": "array", "items": { "$ref": "#/components/schemas/BusyBlock" } }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
},
"security": []
}
},
"/availability": {
"get": {
"tags": ["Availability"],

View File

@@ -32,7 +32,8 @@
{ "name": "Contacts", "description": "Contact management" },
{ "name": "Availability", "description": "Calendar availability queries" },
{ "name": "Booking", "description": "Public booking links and reservations" },
{ "name": "ICS", "description": "ICS calendar import and export" }
{ "name": "ICS", "description": "ICS calendar import and export" },
{ "name": "Subscriptions", "description": "Calendar subscriptions (external iCal feeds)" }
],
"security": [
{ "BearerAuth": [] },

View File

@@ -1,5 +1,51 @@
{
"paths": {
"/calendars/add-from-url": {
"post": {
"tags": ["ICS"],
"summary": "Add calendar from iCal URL",
"description": "Creates a new calendar, fetches the iCal feed from the given URL, imports all events, and creates a subscription for future syncs. One-step flow for adding external calendars. Requires calendars:write scope.",
"operationId": "addCalendarFromURL",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["url"],
"properties": {
"url": { "type": "string", "format": "uri", "description": "iCal feed URL (http, https, or webcal)", "example": "https://example.com/calendar.ics" },
"name": { "type": "string", "description": "Optional calendar name", "example": "Work" },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#3B82F6" }
}
}
}
}
},
"responses": {
"200": {
"description": "Calendar created and events imported",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["ok", "calendar", "imported", "source"],
"properties": {
"ok": { "type": "boolean", "example": true },
"calendar": { "$ref": "#/components/schemas/Calendar" },
"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" } } } }
}
}
},
"/calendars/{id}/export.ics": {
"get": {
"tags": ["ICS"],
@@ -130,16 +176,16 @@
"/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",
"summary": "iCal feed",
"description": "Returns an iCal feed for a calendar. Works for both public and private calendars. No authentication required. Public calendars use a shorter base64url token; private calendars use a 64-character SHA256 hex token. The `ical_url` is returned in the Calendar object. Subscribe in Google Calendar, Apple Calendar, Outlook, etc.",
"operationId": "calendarFeed",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "Public calendar token (from the calendar's ical_url)"
"description": "Calendar token from ical_url (public or private)"
}
],
"responses": {
@@ -151,7 +197,7 @@
}
}
},
"404": { "description": "Calendar not found or not public", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
},
"security": []
}

View File

@@ -58,7 +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)" },
"ical_url": { "type": "string", "format": "uri", "description": "iCal feed URL. Present for both public and private calendars. Public calendars use a shorter token; private calendars use a 64-character SHA256 hex token for additional security." },
"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" }
@@ -152,6 +152,18 @@
"role": { "type": "string", "enum": ["owner", "editor", "viewer"] }
}
},
"CalendarSubscription": {
"type": "object",
"required": ["id", "calendar_id", "source_url", "created_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"calendar_id": { "type": "string", "format": "uuid" },
"source_url": { "type": "string", "format": "uri", "description": "iCal feed URL" },
"last_synced_at": { "type": "string", "format": "date-time", "nullable": true },
"sync_interval_minutes": { "type": "integer", "nullable": true },
"created_at": { "type": "string", "format": "date-time" }
}
},
"BusyBlock": {
"type": "object",
"required": ["start", "end", "event_id"],

View File

@@ -0,0 +1,146 @@
{
"paths": {
"/calendars/{id}/subscriptions": {
"get": {
"tags": ["Subscriptions"],
"summary": "List calendar subscriptions",
"description": "Returns all iCal feed subscriptions for a calendar. Requires `calendars:read` scope.",
"operationId": "listCalendarSubscriptions",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Calendar ID"
}
],
"responses": {
"200": {
"description": "List of subscriptions",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/CalendarSubscription" }
},
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"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" } } } },
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Subscriptions"],
"summary": "Add a subscription",
"description": "Adds an iCal feed subscription to a calendar. Fetches the feed, imports events, and creates a subscription for future syncs. Owner or editor only. Requires `calendars:write` scope.",
"operationId": "addCalendarSubscription",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Calendar ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["url"],
"properties": {
"url": { "type": "string", "format": "uri", "description": "iCal feed URL (http, https, or webcal)", "example": "https://example.com/calendar.ics" },
"sync_interval_minutes": { "type": "integer", "nullable": true, "description": "Optional sync interval in minutes" }
}
}
}
}
},
"responses": {
"200": {
"description": "Subscription added and events imported",
"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" } } } },
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/calendars/{id}/subscriptions/{subId}": {
"delete": {
"tags": ["Subscriptions"],
"summary": "Delete a subscription",
"description": "Removes an iCal feed subscription from a calendar. Owner or editor only. Requires `calendars:write` scope.",
"operationId": "deleteCalendarSubscription",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Calendar ID" },
{ "name": "subId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Subscription ID" }
],
"responses": {
"200": { "description": "Subscription deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"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" } } } },
"404": { "description": "Calendar or subscription not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/calendars/{id}/subscriptions/{subId}/sync": {
"post": {
"tags": ["Subscriptions"],
"summary": "Sync a subscription",
"description": "Triggers an immediate sync of an iCal feed subscription. Fetches the feed and imports/updates events. Owner or editor only. Requires `calendars:write` scope.",
"operationId": "syncCalendarSubscription",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Calendar ID" },
{ "name": "subId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Subscription ID" }
],
"responses": {
"200": {
"description": "Sync completed",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["ok", "imported"],
"properties": {
"ok": { "type": "boolean", "example": true },
"imported": { "type": "object", "properties": { "events": { "type": "integer", "example": 5 } } }
}
}
}
}
},
"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" } } } },
"404": { "description": "Calendar or subscription not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -1,11 +1,15 @@
package api
import (
"time"
"github.com/calendarapi/internal/api/handlers"
"github.com/calendarapi/internal/api/openapi"
"github.com/calendarapi/internal/config"
mw "github.com/calendarapi/internal/middleware"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
type Handlers struct {
@@ -23,12 +27,26 @@ type Handlers struct {
ICS *handlers.ICSHandler
}
func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimiter) *chi.Mux {
func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimiter, cfg *config.Config) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.RealIP)
r.Use(chimw.Timeout(30 * time.Second))
origins := cfg.CORSOrigins
if len(origins) == 0 {
origins = []string{"http://localhost:5173", "http://127.0.0.1:5173"}
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(rateLimiter.Limit)
// OpenAPI spec and Swagger UI
@@ -45,6 +63,10 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
r.Post("/booking/{token}/reserve", h.Booking.Reserve)
r.Get("/cal/{token}/feed.ics", h.ICS.PublicFeed)
// Public availability (no auth) - for external booking tools
r.Get("/availability/aggregate", h.Availability.GetAggregate)
r.Get("/availability/{token}", h.Availability.GetByToken)
})
// Authenticated routes
@@ -71,6 +93,7 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
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.With(mw.RequireScope("calendars", "write")).Post("/add-from-url", h.ICS.AddFromURL)
r.Route("/{id}", func(r chi.Router) {
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.Get)
@@ -87,6 +110,12 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
// ICS
r.With(mw.RequireScope("calendars", "read")).Get("/export.ics", h.ICS.Export)
// Subscriptions
r.With(mw.RequireScope("calendars", "read")).Get("/subscriptions", h.ICS.ListSubscriptions)
r.With(mw.RequireScope("calendars", "write")).Post("/subscriptions", h.ICS.AddSubscription)
r.With(mw.RequireScope("calendars", "write")).Delete("/subscriptions/{subId}", h.ICS.DeleteSubscription)
r.With(mw.RequireScope("calendars", "write")).Post("/subscriptions/{subId}/sync", h.ICS.SyncSubscription)
})
})