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:
@@ -10,3 +10,6 @@ JWT_SECRET=dev-secret-change-me
|
|||||||
# Server
|
# Server
|
||||||
SERVER_PORT=3019
|
SERVER_PORT=3019
|
||||||
ENV=development
|
ENV=development
|
||||||
|
|
||||||
|
# Base URL (used for public iCal feed URLs; defaults to http://localhost:$SERVER_PORT)
|
||||||
|
# BASE_URL=https://api.example.com
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ A production-grade REST API for calendar management, event scheduling, contacts,
|
|||||||
- **Contacts** - Personal contact management with search
|
- **Contacts** - Personal contact management with search
|
||||||
- **Availability** - Query busy/free time across calendars
|
- **Availability** - Query busy/free time across calendars
|
||||||
- **Booking Links** - Public scheduling pages with configurable working hours, duration, and buffer time
|
- **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
|
- **Dual Auth** - JWT tokens for interactive use, scoped API keys for agents and automation
|
||||||
- **Background Jobs** - Reminder notifications via Redis + Asynq (optional)
|
- **Background Jobs** - Reminder notifications via Redis + Asynq (optional)
|
||||||
|
|
||||||
@@ -214,6 +216,8 @@ X-API-Key: <token>
|
|||||||
|--------|----------|-------|-------------|
|
|--------|----------|-------|-------------|
|
||||||
| GET | `/calendars/{id}/export.ics` | calendars:read | Export as ICS |
|
| GET | `/calendars/{id}/export.ics` | calendars:read | Export as ICS |
|
||||||
| POST | `/calendars/import` | calendars:write | Import ICS file |
|
| 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
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
18
SKILL.md
18
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 |
|
| 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` | 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
|
### 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.
|
1. `GET /calendars` or `POST /calendars` - ensure target calendar exists.
|
||||||
2. `POST /calendars/import` - upload `.ics` file as multipart form data with `calendar_id`.
|
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
|
## Important Constraints
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ POST /booking/{token}/reserve
|
|||||||
|
|
||||||
GET /calendars/{id}/export.ics
|
GET /calendars/{id}/export.ics
|
||||||
POST /calendars/import
|
POST /calendars/import
|
||||||
|
POST /calendars/import-url
|
||||||
|
GET /cal/{token}/feed.ics (public, no auth)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func main() {
|
|||||||
auditSvc := service.NewAuditService(queries)
|
auditSvc := service.NewAuditService(queries)
|
||||||
authSvc := service.NewAuthService(pool, queries, jwtManager, auditSvc)
|
authSvc := service.NewAuthService(pool, queries, jwtManager, auditSvc)
|
||||||
userSvc := service.NewUserService(pool, queries, 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)
|
eventSvc := service.NewEventService(pool, queries, calSvc, auditSvc, sched)
|
||||||
contactSvc := service.NewContactService(queries, auditSvc)
|
contactSvc := service.NewContactService(queries, auditSvc)
|
||||||
availSvc := service.NewAvailabilityService(queries, calSvc, eventSvc)
|
availSvc := service.NewAvailabilityService(queries, calSvc, eventSvc)
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -3,6 +3,7 @@ module github.com/calendarapi
|
|||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/arran4/golang-ical v0.3.3
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
ics "github.com/arran4/golang-ical"
|
||||||
"github.com/calendarapi/internal/middleware"
|
"github.com/calendarapi/internal/middleware"
|
||||||
"github.com/calendarapi/internal/models"
|
"github.com/calendarapi/internal/models"
|
||||||
"github.com/calendarapi/internal/repository"
|
"github.com/calendarapi/internal/repository"
|
||||||
@@ -15,6 +17,7 @@ import (
|
|||||||
"github.com/calendarapi/internal/utils"
|
"github.com/calendarapi/internal/utils"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ICSHandler struct {
|
type ICSHandler struct {
|
||||||
@@ -40,11 +43,38 @@ func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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()
|
now := time.Now().UTC()
|
||||||
rangeStart := now.AddDate(-1, 0, 0)
|
rangeStart := now.AddDate(-1, 0, 0)
|
||||||
rangeEnd := 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),
|
CalendarID: utils.ToPgUUID(calID),
|
||||||
EndTime: utils.ToPgTimestamptz(rangeStart),
|
EndTime: utils.ToPgTimestamptz(rangeStart),
|
||||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||||
@@ -54,33 +84,76 @@ func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
eventIDs := make([]uuid.UUID, len(events))
|
||||||
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CalendarAPI//EN\r\n")
|
for i, ev := range events {
|
||||||
|
eventIDs[i] = utils.FromPgUUID(ev.ID)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("END:VCALENDAR\r\n")
|
reminderMap := h.loadReminders(ctx, eventIDs)
|
||||||
|
attendeeMap := h.loadAttendees(ctx, eventIDs)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/calendar")
|
calendar := ics.NewCalendar()
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=calendar.ics")
|
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.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(b.String()))
|
w.Write([]byte(calendar.Serialize()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
|
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
|
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{}{
|
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"ok": true,
|
"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 {
|
func (h *ICSHandler) ImportURL(w http.ResponseWriter, r *http.Request) {
|
||||||
count := 0
|
userID, _ := middleware.GetUserID(r.Context())
|
||||||
lines := strings.Split(data, "\n")
|
|
||||||
var inEvent bool
|
|
||||||
var title, description, location, rruleStr string
|
|
||||||
var dtstart, dtend time.Time
|
|
||||||
|
|
||||||
for _, line := range lines {
|
var req struct {
|
||||||
line = strings.TrimSpace(line)
|
CalendarID string `json:"calendar_id"`
|
||||||
if line == "BEGIN:VEVENT" {
|
URL string `json:"url"`
|
||||||
inEvent = true
|
}
|
||||||
title, description, location, rruleStr = "", "", "", ""
|
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||||
dtstart, dtend = time.Time{}, time.Time{}
|
utils.WriteError(w, err)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
if line == "END:VEVENT" && inEvent {
|
|
||||||
inEvent = false
|
calID, err := utils.ValidateUUID(req.CalendarID)
|
||||||
if title != "" && !dtstart.IsZero() && !dtend.IsZero() {
|
if err != nil {
|
||||||
_, err := h.queries.CreateEvent(ctx, repository.CreateEventParams{
|
utils.WriteError(w, err)
|
||||||
ID: utils.ToPgUUID(uuid.New()),
|
return
|
||||||
CalendarID: utils.ToPgUUID(calID),
|
}
|
||||||
Title: title,
|
|
||||||
Description: utils.ToPgText(description),
|
if req.URL == "" {
|
||||||
Location: utils.ToPgText(location),
|
utils.WriteError(w, models.NewValidationError("url is required"))
|
||||||
StartTime: utils.ToPgTimestamptz(dtstart),
|
return
|
||||||
EndTime: utils.ToPgTimestamptz(dtend),
|
}
|
||||||
Timezone: "UTC",
|
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") {
|
||||||
RecurrenceRule: utils.ToPgText(rruleStr),
|
utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal"))
|
||||||
Tags: []string{},
|
return
|
||||||
CreatedBy: utils.ToPgUUID(userID),
|
}
|
||||||
UpdatedBy: utils.ToPgUUID(userID),
|
|
||||||
})
|
role, err := h.calSvc.GetRole(r.Context(), calID, userID)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
count++
|
utils.WriteError(w, err)
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
continue
|
if role != "owner" && role != "editor" {
|
||||||
}
|
utils.WriteError(w, models.ErrForbidden)
|
||||||
if !inEvent {
|
return
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
switch {
|
fetchURL := req.URL
|
||||||
case strings.HasPrefix(line, "SUMMARY:"):
|
if strings.HasPrefix(fetchURL, "webcal://") {
|
||||||
title = strings.TrimPrefix(line, "SUMMARY:")
|
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
||||||
case strings.HasPrefix(line, "DESCRIPTION:"):
|
}
|
||||||
description = strings.TrimPrefix(line, "DESCRIPTION:")
|
|
||||||
case strings.HasPrefix(line, "LOCATION:"):
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
location = strings.TrimPrefix(line, "LOCATION:")
|
resp, err := client.Get(fetchURL)
|
||||||
case strings.HasPrefix(line, "RRULE:"):
|
if err != nil {
|
||||||
rruleStr = strings.TrimPrefix(line, "RRULE:")
|
utils.WriteError(w, models.NewValidationError("failed to fetch URL: "+err.Error()))
|
||||||
case strings.HasPrefix(line, "DTSTART:"):
|
return
|
||||||
dtstart, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTSTART:"))
|
}
|
||||||
case strings.HasPrefix(line, "DTEND:"):
|
defer resp.Body.Close()
|
||||||
dtend, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTEND:"))
|
|
||||||
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"tags": ["ICS"],
|
"tags": ["ICS"],
|
||||||
"summary": "Export calendar as 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",
|
"operationId": "exportCalendarICS",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["ICS"],
|
"tags": ["ICS"],
|
||||||
"summary": "Import an ICS file",
|
"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",
|
"operationId": "importCalendarICS",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -77,6 +77,84 @@
|
|||||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
"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": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
|
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||||
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" },
|
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" },
|
||||||
"is_public": { "type": "boolean" },
|
"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" },
|
"role": { "type": "string", "enum": ["owner", "editor", "viewer"], "description": "Current user's role on this calendar" },
|
||||||
"created_at": { "type": "string", "format": "date-time" },
|
"created_at": { "type": "string", "format": "date-time" },
|
||||||
"updated_at": { "type": "string", "format": "date-time" }
|
"updated_at": { "type": "string", "format": "date-time" }
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
|
|||||||
|
|
||||||
r.Get("/booking/{token}/availability", h.Booking.GetAvailability)
|
r.Get("/booking/{token}/availability", h.Booking.GetAvailability)
|
||||||
r.Post("/booking/{token}/reserve", h.Booking.Reserve)
|
r.Post("/booking/{token}/reserve", h.Booking.Reserve)
|
||||||
|
|
||||||
|
r.Get("/cal/{token}/feed.ics", h.ICS.PublicFeed)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Authenticated routes
|
// 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", "read")).Get("/", h.Calendar.List)
|
||||||
r.With(mw.RequireScope("calendars", "write")).Post("/", h.Calendar.Create)
|
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", h.ICS.Import)
|
||||||
|
r.With(mw.RequireScope("calendars", "write")).Post("/import-url", h.ICS.ImportURL)
|
||||||
|
|
||||||
r.Route("/{id}", func(r chi.Router) {
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.Get)
|
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.Get)
|
||||||
|
|||||||
@@ -12,17 +12,20 @@ type Config struct {
|
|||||||
RedisAddr string
|
RedisAddr string
|
||||||
ServerPort string
|
ServerPort string
|
||||||
Env string
|
Env string
|
||||||
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
loadEnvFile(".env")
|
loadEnvFile(".env")
|
||||||
|
|
||||||
|
port := getEnv("SERVER_PORT", "8080")
|
||||||
return &Config{
|
return &Config{
|
||||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/calendarapi?sslmode=disable"),
|
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/calendarapi?sslmode=disable"),
|
||||||
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
|
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
|
||||||
RedisAddr: os.Getenv("REDIS_ADDR"),
|
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
ServerPort: port,
|
||||||
Env: getEnv("ENV", "development"),
|
Env: getEnv("ENV", "development"),
|
||||||
|
BaseURL: getEnv("BASE_URL", "http://localhost:"+port),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Calendar struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool `json:"is_public"`
|
||||||
|
ICalURL string `json:"ical_url,omitempty"`
|
||||||
Role string `json:"role,omitempty"`
|
Role string `json:"role,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -91,6 +91,39 @@ func (q *Queries) GetCalendarByID(ctx context.Context, id pgtype.UUID) (GetCalen
|
|||||||
return i, err
|
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
|
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
|
SELECT c.id, c.owner_id, c.name, c.color, c.is_public, c.created_at, c.updated_at, cm.role
|
||||||
FROM calendars c
|
FROM calendars c
|
||||||
@@ -139,6 +172,22 @@ func (q *Queries) ListCalendarsByUser(ctx context.Context, userID pgtype.UUID) (
|
|||||||
return items, nil
|
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
|
const softDeleteCalendar = `-- name: SoftDeleteCalendar :exec
|
||||||
UPDATE calendars SET deleted_at = now(), updated_at = now()
|
UPDATE calendars SET deleted_at = now(), updated_at = now()
|
||||||
WHERE id = $1 AND deleted_at IS NULL
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ type CalendarMember struct {
|
|||||||
Role string `json:"role"`
|
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 {
|
type Contact struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
OwnerID pgtype.UUID `json:"owner_id"`
|
OwnerID pgtype.UUID `json:"owner_id"`
|
||||||
|
|||||||
55
internal/repository/subscriptions.sql.go
Normal file
55
internal/repository/subscriptions.sql.go
Normal 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
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/calendarapi/internal/models"
|
"github.com/calendarapi/internal/models"
|
||||||
"github.com/calendarapi/internal/repository"
|
"github.com/calendarapi/internal/repository"
|
||||||
@@ -16,10 +19,26 @@ type CalendarService struct {
|
|||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
queries *repository.Queries
|
queries *repository.Queries
|
||||||
audit *AuditService
|
audit *AuditService
|
||||||
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCalendarService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService) *CalendarService {
|
func NewCalendarService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService, baseURL string) *CalendarService {
|
||||||
return &CalendarService{pool: pool, queries: queries, audit: audit}
|
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) {
|
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,
|
Name: cal.Name,
|
||||||
Color: cal.Color,
|
Color: cal.Color,
|
||||||
IsPublic: cal.IsPublic,
|
IsPublic: cal.IsPublic,
|
||||||
|
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||||
Role: "owner",
|
Role: "owner",
|
||||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
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),
|
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
|
return calendars, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +149,7 @@ func (s *CalendarService) Get(ctx context.Context, userID uuid.UUID, calID uuid.
|
|||||||
Name: cal.Name,
|
Name: cal.Name,
|
||||||
Color: cal.Color,
|
Color: cal.Color,
|
||||||
IsPublic: cal.IsPublic,
|
IsPublic: cal.IsPublic,
|
||||||
|
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||||
Role: role,
|
Role: role,
|
||||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
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
|
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)
|
s.audit.Log(ctx, "calendar", calID, "UPDATE_CALENDAR", userID)
|
||||||
|
|
||||||
return &models.Calendar{
|
return &models.Calendar{
|
||||||
@@ -173,6 +225,7 @@ func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uu
|
|||||||
Name: cal.Name,
|
Name: cal.Name,
|
||||||
Color: cal.Color,
|
Color: cal.Color,
|
||||||
IsPublic: cal.IsPublic,
|
IsPublic: cal.IsPublic,
|
||||||
|
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||||
Role: role,
|
Role: role,
|
||||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
||||||
|
|||||||
13
llms.txt
13
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": {...}}.
|
GET /calendars - List all calendars (owned + shared). Returns {"items": [Calendar], "page": {...}}.
|
||||||
POST /calendars - Create. Body: {"name" (1-80), "color"? (#RRGGBB)}. Returns {"calendar": Calendar}.
|
POST /calendars - Create. Body: {"name" (1-80), "color"? (#RRGGBB)}. Returns {"calendar": Calendar}.
|
||||||
GET /calendars/{id} - Get by ID. 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}.
|
DELETE /calendars/{id} - Soft-delete (owner only). Returns {"ok": true}.
|
||||||
|
|
||||||
### Calendar Sharing
|
### Calendar Sharing
|
||||||
@@ -90,13 +90,15 @@ POST /booking/{token}/reserve - Public, no auth. Body: {"name", "email", "slot_s
|
|||||||
|
|
||||||
## ICS Import/Export
|
## ICS Import/Export
|
||||||
|
|
||||||
GET /calendars/{id}/export.ics - Export as ICS (scope: calendars:read). Returns text/calendar.
|
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 (scope: calendars:write). Multipart form: calendar_id (uuid) + file (.ics). Returns {"ok": true, "imported": {"events": N}}.
|
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
|
## Data Schemas
|
||||||
|
|
||||||
User: {id, email, timezone, created_at, updated_at}
|
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[]}
|
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}
|
Contact: {id, first_name?, last_name?, email?, phone?, company?, notes?, created_at, updated_at}
|
||||||
Reminder: {id, minutes_before}
|
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
|
- Event list range: max 1 year
|
||||||
- Soft deletion throughout (data recoverable)
|
- Soft deletion throughout (data recoverable)
|
||||||
- Calendar roles: owner (full), editor (events CRUD), viewer (read-only)
|
- 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
|
||||||
|
|||||||
2
migrations/000002_ical_public.down.sql
Normal file
2
migrations/000002_ical_public.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS calendar_subscriptions;
|
||||||
|
DROP INDEX IF EXISTS idx_calendars_public_token;
|
||||||
13
migrations/000002_ical_public.up.sql
Normal file
13
migrations/000002_ical_public.up.sql
Normal file
@@ -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);
|
||||||
@@ -31,3 +31,13 @@ WHERE id = $1 AND deleted_at IS NULL;
|
|||||||
-- name: SoftDeleteCalendarsByOwner :exec
|
-- name: SoftDeleteCalendarsByOwner :exec
|
||||||
UPDATE calendars SET deleted_at = now(), updated_at = now()
|
UPDATE calendars SET deleted_at = now(), updated_at = now()
|
||||||
WHERE owner_id = $1 AND deleted_at IS NULL;
|
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;
|
||||||
|
|||||||
8
sqlc/queries/subscriptions.sql
Normal file
8
sqlc/queries/subscriptions.sql
Normal file
@@ -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;
|
||||||
@@ -55,6 +55,7 @@ CREATE TABLE calendars (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_calendars_owner_id ON calendars (owner_id);
|
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
|
-- Calendar Members
|
||||||
CREATE TABLE 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);
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user