first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-02-28 02:17:55 +00:00
commit 41f6ae916f
92 changed files with 12332 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
package handlers
import (
"net/http"
"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 APIKeyHandler struct {
apiKeySvc *service.APIKeyService
}
func NewAPIKeyHandler(apiKeySvc *service.APIKeyService) *APIKeyHandler {
return &APIKeyHandler{apiKeySvc: apiKeySvc}
}
func (h *APIKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Scopes map[string][]string `json:"scopes"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
key, err := h.apiKeySvc.Create(r.Context(), userID, req.Name, req.Scopes)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, key)
}
func (h *APIKeyHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
keys, err := h.apiKeySvc.List(r.Context(), userID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, keys, models.PageInfo{Limit: utils.DefaultLimit})
}
func (h *APIKeyHandler) Revoke(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
keyID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.apiKeySvc.Revoke(r.Context(), userID, keyID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,106 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type AttendeeHandler struct {
attendeeSvc *service.AttendeeService
}
func NewAttendeeHandler(attendeeSvc *service.AttendeeService) *AttendeeHandler {
return &AttendeeHandler{attendeeSvc: attendeeSvc}
}
func (h *AttendeeHandler) Add(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Attendees []struct {
UserID *uuid.UUID `json:"user_id"`
Email *string `json:"email"`
} `json:"attendees"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
var addReqs []service.AddAttendeeRequest
for _, a := range req.Attendees {
addReqs = append(addReqs, service.AddAttendeeRequest{
UserID: a.UserID,
Email: a.Email,
})
}
event, err := h.attendeeSvc.AddAttendees(r.Context(), userID, eventID, addReqs)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
}
func (h *AttendeeHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
attendeeID, err := utils.ValidateUUID(chi.URLParam(r, "attendeeID"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Status string `json:"status"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
event, err := h.attendeeSvc.UpdateStatus(r.Context(), userID, eventID, attendeeID, req.Status)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
}
func (h *AttendeeHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
attendeeID, err := utils.ValidateUUID(chi.URLParam(r, "attendeeID"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.attendeeSvc.DeleteAttendee(r.Context(), userID, eventID, attendeeID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,109 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
)
type AuthHandler struct {
authSvc *service.AuthService
userSvc *service.UserService
}
func NewAuthHandler(authSvc *service.AuthService, userSvc *service.UserService) *AuthHandler {
return &AuthHandler{authSvc: authSvc, userSvc: userSvc}
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
Timezone string `json:"timezone"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
result, err := h.authSvc.Register(r.Context(), req.Email, req.Password, req.Timezone)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, result)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
result, err := h.authSvc.Login(r.Context(), req.Email, req.Password)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, result)
}
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
result, err := h.authSvc.Refresh(r.Context(), req.RefreshToken)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, result)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
if err := h.authSvc.Logout(r.Context(), req.RefreshToken); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserID(r.Context())
if !ok {
utils.WriteError(w, models.ErrAuthRequired)
return
}
user, err := h.userSvc.GetMe(r.Context(), userID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"user": user})
}

View File

@@ -0,0 +1,60 @@
package handlers
import (
"net/http"
"time"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
)
type AvailabilityHandler struct {
availSvc *service.AvailabilityService
}
func NewAvailabilityHandler(availSvc *service.AvailabilityService) *AvailabilityHandler {
return &AvailabilityHandler{availSvc: availSvc}
}
func (h *AvailabilityHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
q := r.URL.Query()
calIDStr := q.Get("calendar_id")
if calIDStr == "" {
utils.WriteError(w, models.NewValidationError("calendar_id required"))
return
}
calID, err := utils.ValidateUUID(calIDStr)
if err != nil {
utils.WriteError(w, err)
return
}
startStr := q.Get("start")
endStr := q.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.GetBusyBlocks(r.Context(), userID, calID, start, end)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, result)
}

View File

@@ -0,0 +1,98 @@
package handlers
import (
"net/http"
"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 BookingHandler struct {
bookingSvc *service.BookingService
}
func NewBookingHandler(bookingSvc *service.BookingService) *BookingHandler {
return &BookingHandler{bookingSvc: bookingSvc}
}
func (h *BookingHandler) CreateLink(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
}
var req models.BookingConfig
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
link, err := h.bookingSvc.CreateLink(r.Context(), userID, calID, req)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, link)
}
func (h *BookingHandler) GetAvailability(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
q := r.URL.Query()
startStr := q.Get("start")
endStr := q.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.bookingSvc.GetAvailability(r.Context(), token, start, end)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, result)
}
func (h *BookingHandler) Reserve(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
var req struct {
Name string `json:"name"`
Email string `json:"email"`
SlotStart time.Time `json:"slot_start"`
SlotEnd time.Time `json:"slot_end"`
Notes *string `json:"notes"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
event, err := h.bookingSvc.Reserve(r.Context(), token, req.Name, req.Email, req.SlotStart, req.SlotEnd, req.Notes)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "event": event})
}

View File

@@ -0,0 +1,112 @@
package handlers
import (
"net/http"
"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 CalendarHandler struct {
calSvc *service.CalendarService
}
func NewCalendarHandler(calSvc *service.CalendarService) *CalendarHandler {
return &CalendarHandler{calSvc: calSvc}
}
func (h *CalendarHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
calendars, err := h.calSvc.List(r.Context(), userID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, calendars, models.PageInfo{Limit: utils.DefaultLimit})
}
func (h *CalendarHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Color string `json:"color"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
cal, err := h.calSvc.Create(r.Context(), userID, req.Name, req.Color)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"calendar": cal})
}
func (h *CalendarHandler) Get(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
}
cal, err := h.calSvc.Get(r.Context(), userID, calID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"calendar": cal})
}
func (h *CalendarHandler) Update(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
}
var req struct {
Name *string `json:"name"`
Color *string `json:"color"`
IsPublic *bool `json:"is_public"`
}
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)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"calendar": cal})
}
func (h *CalendarHandler) Delete(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.Delete(r.Context(), userID, calID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,146 @@
package handlers
import (
"net/http"
"strconv"
"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 ContactHandler struct {
contactSvc *service.ContactService
}
func NewContactHandler(contactSvc *service.ContactService) *ContactHandler {
return &ContactHandler{contactSvc: contactSvc}
}
func (h *ContactHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
q := r.URL.Query()
var search *string
if s := q.Get("search"); s != "" {
search = &s
}
limit := 50
if l := q.Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
limit = v
}
}
contacts, cursor, err := h.contactSvc.List(r.Context(), userID, search, limit, q.Get("cursor"))
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, contacts, models.PageInfo{Limit: int(utils.ClampLimit(limit)), NextCursor: cursor})
}
func (h *ContactHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
var req struct {
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Company *string `json:"company"`
Notes *string `json:"notes"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
contact, err := h.contactSvc.Create(r.Context(), userID, service.CreateContactRequest{
FirstName: req.FirstName,
LastName: req.LastName,
Email: req.Email,
Phone: req.Phone,
Company: req.Company,
Notes: req.Notes,
})
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"contact": contact})
}
func (h *ContactHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
contactID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
contact, err := h.contactSvc.Get(r.Context(), userID, contactID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"contact": contact})
}
func (h *ContactHandler) Update(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
contactID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Company *string `json:"company"`
Notes *string `json:"notes"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
contact, err := h.contactSvc.Update(r.Context(), userID, contactID, service.UpdateContactRequest{
FirstName: req.FirstName,
LastName: req.LastName,
Email: req.Email,
Phone: req.Phone,
Company: req.Company,
Notes: req.Notes,
})
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"contact": contact})
}
func (h *ContactHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
contactID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.contactSvc.Delete(r.Context(), userID, contactID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,204 @@
package handlers
import (
"net/http"
"strconv"
"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"
"github.com/google/uuid"
)
type EventHandler struct {
eventSvc *service.EventService
}
func NewEventHandler(eventSvc *service.EventService) *EventHandler {
return &EventHandler{eventSvc: eventSvc}
}
func (h *EventHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
q := r.URL.Query()
startStr := q.Get("start")
endStr := q.Get("end")
if startStr == "" || endStr == "" {
utils.WriteError(w, models.NewValidationError("start and end query params 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
}
var calendarID *uuid.UUID
if cid := q.Get("calendar_id"); cid != "" {
id, err := utils.ValidateUUID(cid)
if err != nil {
utils.WriteError(w, err)
return
}
calendarID = &id
}
var search *string
if s := q.Get("search"); s != "" {
search = &s
}
var tag *string
if t := q.Get("tag"); t != "" {
tag = &t
}
limit := 50
if l := q.Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
limit = v
}
}
events, cursor, err := h.eventSvc.List(r.Context(), userID, service.ListEventParams{
RangeStart: start,
RangeEnd: end,
CalendarID: calendarID,
Search: search,
Tag: tag,
Limit: limit,
Cursor: q.Get("cursor"),
})
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, events, models.PageInfo{Limit: int(utils.ClampLimit(limit)), NextCursor: cursor})
}
func (h *EventHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
var req struct {
CalendarID uuid.UUID `json:"calendar_id"`
Title string `json:"title"`
Description *string `json:"description"`
Location *string `json:"location"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Timezone string `json:"timezone"`
AllDay bool `json:"all_day"`
RecurrenceRule *string `json:"recurrence_rule"`
Reminders []int32 `json:"reminders"`
Tags []string `json:"tags"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
event, err := h.eventSvc.Create(r.Context(), userID, service.CreateEventRequest{
CalendarID: req.CalendarID,
Title: req.Title,
Description: req.Description,
Location: req.Location,
StartTime: req.StartTime,
EndTime: req.EndTime,
Timezone: req.Timezone,
AllDay: req.AllDay,
RecurrenceRule: req.RecurrenceRule,
Reminders: req.Reminders,
Tags: req.Tags,
})
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
}
func (h *EventHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
event, err := h.eventSvc.Get(r.Context(), userID, eventID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
}
func (h *EventHandler) Update(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Title *string `json:"title"`
Description *string `json:"description"`
Location *string `json:"location"`
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
Timezone *string `json:"timezone"`
AllDay *bool `json:"all_day"`
RecurrenceRule *string `json:"recurrence_rule"`
Tags []string `json:"tags"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
event, err := h.eventSvc.Update(r.Context(), userID, eventID, service.UpdateEventRequest{
Title: req.Title,
Description: req.Description,
Location: req.Location,
StartTime: req.StartTime,
EndTime: req.EndTime,
Timezone: req.Timezone,
AllDay: req.AllDay,
RecurrenceRule: req.RecurrenceRule,
Tags: req.Tags,
})
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
}
func (h *EventHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.eventSvc.Delete(r.Context(), userID, eventID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,189 @@
package handlers
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type ICSHandler struct {
calSvc *service.CalendarService
eventSvc *service.EventService
queries *repository.Queries
}
func NewICSHandler(calSvc *service.CalendarService, eventSvc *service.EventService, queries *repository.Queries) *ICSHandler {
return &ICSHandler{calSvc: calSvc, eventSvc: eventSvc, queries: queries}
}
func (h *ICSHandler) Export(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
}
now := time.Now().UTC()
rangeStart := now.AddDate(-1, 0, 0)
rangeEnd := now.AddDate(1, 0, 0)
events, err := h.queries.ListEventsByCalendarInRange(r.Context(), repository.ListEventsByCalendarInRangeParams{
CalendarID: utils.ToPgUUID(calID),
EndTime: utils.ToPgTimestamptz(rangeStart),
StartTime: utils.ToPgTimestamptz(rangeEnd),
})
if err != nil {
utils.WriteError(w, models.ErrInternal)
return
}
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CalendarAPI//EN\r\n")
for _, ev := range events {
b.WriteString("BEGIN:VEVENT\r\n")
b.WriteString(fmt.Sprintf("UID:%s\r\n", utils.FromPgUUID(ev.ID).String()))
b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", utils.FromPgTimestamptz(ev.StartTime).Format("20060102T150405Z")))
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", utils.FromPgTimestamptz(ev.EndTime).Format("20060102T150405Z")))
b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", ev.Title))
if ev.Description.Valid {
b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", ev.Description.String))
}
if ev.Location.Valid {
b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", ev.Location.String))
}
if ev.RecurrenceRule.Valid {
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", ev.RecurrenceRule.String))
}
b.WriteString("END:VEVENT\r\n")
}
b.WriteString("END:VCALENDAR\r\n")
w.Header().Set("Content-Type", "text/calendar")
w.Header().Set("Content-Disposition", "attachment; filename=calendar.ics")
w.WriteHeader(http.StatusOK)
w.Write([]byte(b.String()))
}
func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
if err := r.ParseMultipartForm(10 << 20); err != nil {
utils.WriteError(w, models.NewValidationError("invalid multipart form"))
return
}
calIDStr := r.FormValue("calendar_id")
calID, err := utils.ValidateUUID(calIDStr)
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
}
file, _, err := r.FormFile("file")
if err != nil {
utils.WriteError(w, models.NewValidationError("file required"))
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
utils.WriteError(w, models.ErrInternal)
return
}
count := h.parseAndImportICS(r.Context(), string(data), calID, userID)
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"imported": map[string]int{"events": count},
})
}
func (h *ICSHandler) parseAndImportICS(ctx context.Context, data string, calID, userID uuid.UUID) int {
count := 0
lines := strings.Split(data, "\n")
var inEvent bool
var title, description, location, rruleStr string
var dtstart, dtend time.Time
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "BEGIN:VEVENT" {
inEvent = true
title, description, location, rruleStr = "", "", "", ""
dtstart, dtend = time.Time{}, time.Time{}
continue
}
if line == "END:VEVENT" && inEvent {
inEvent = false
if title != "" && !dtstart.IsZero() && !dtend.IsZero() {
_, err := h.queries.CreateEvent(ctx, repository.CreateEventParams{
ID: utils.ToPgUUID(uuid.New()),
CalendarID: utils.ToPgUUID(calID),
Title: title,
Description: utils.ToPgText(description),
Location: utils.ToPgText(location),
StartTime: utils.ToPgTimestamptz(dtstart),
EndTime: utils.ToPgTimestamptz(dtend),
Timezone: "UTC",
RecurrenceRule: utils.ToPgText(rruleStr),
Tags: []string{},
CreatedBy: utils.ToPgUUID(userID),
UpdatedBy: utils.ToPgUUID(userID),
})
if err == nil {
count++
}
}
continue
}
if !inEvent {
continue
}
switch {
case strings.HasPrefix(line, "SUMMARY:"):
title = strings.TrimPrefix(line, "SUMMARY:")
case strings.HasPrefix(line, "DESCRIPTION:"):
description = strings.TrimPrefix(line, "DESCRIPTION:")
case strings.HasPrefix(line, "LOCATION:"):
location = strings.TrimPrefix(line, "LOCATION:")
case strings.HasPrefix(line, "RRULE:"):
rruleStr = strings.TrimPrefix(line, "RRULE:")
case strings.HasPrefix(line, "DTSTART:"):
dtstart, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTSTART:"))
case strings.HasPrefix(line, "DTEND:"):
dtend, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTEND:"))
}
}
return count
}

View File

@@ -0,0 +1,64 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type ReminderHandler struct {
reminderSvc *service.ReminderService
}
func NewReminderHandler(reminderSvc *service.ReminderService) *ReminderHandler {
return &ReminderHandler{reminderSvc: reminderSvc}
}
func (h *ReminderHandler) Add(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
MinutesBefore []int32 `json:"minutes_before"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
event, err := h.reminderSvc.AddReminders(r.Context(), userID, eventID, req.MinutesBefore)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
}
func (h *ReminderHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
reminderID, err := utils.ValidateUUID(chi.URLParam(r, "reminderID"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.reminderSvc.DeleteReminder(r.Context(), userID, eventID, reminderID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,84 @@
package handlers
import (
"net/http"
"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 SharingHandler struct {
calSvc *service.CalendarService
}
func NewSharingHandler(calSvc *service.CalendarService) *SharingHandler {
return &SharingHandler{calSvc: calSvc}
}
func (h *SharingHandler) Share(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
}
var req struct {
Target struct {
Email string `json:"email"`
} `json:"target"`
Role string `json:"role"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
if err := h.calSvc.Share(r.Context(), userID, calID, req.Target.Email, req.Role); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}
func (h *SharingHandler) ListMembers(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
}
members, err := h.calSvc.ListMembers(r.Context(), userID, calID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, members, models.PageInfo{Limit: utils.DefaultLimit})
}
func (h *SharingHandler) RemoveMember(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
}
targetID, err := utils.ValidateUUID(chi.URLParam(r, "userID"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.calSvc.RemoveMember(r.Context(), userID, calID, targetID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,73 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
)
type UserHandler struct {
userSvc *service.UserService
}
func NewUserHandler(userSvc *service.UserService) *UserHandler {
return &UserHandler{userSvc: userSvc}
}
func (h *UserHandler) GetMe(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserID(r.Context())
if !ok {
utils.WriteError(w, models.ErrAuthRequired)
return
}
user, err := h.userSvc.GetMe(r.Context(), userID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"user": user})
}
func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserID(r.Context())
if !ok {
utils.WriteError(w, models.ErrAuthRequired)
return
}
var req struct {
Timezone *string `json:"timezone"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
user, err := h.userSvc.Update(r.Context(), userID, req.Timezone)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"user": user})
}
func (h *UserHandler) DeleteMe(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserID(r.Context())
if !ok {
utils.WriteError(w, models.ErrAuthRequired)
return
}
if err := h.userSvc.Delete(r.Context(), userID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

@@ -0,0 +1,120 @@
package openapi
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"strings"
)
//go:embed specs/*.json
var specFiles embed.FS
var mergedSpec []byte
func init() {
spec, err := buildSpec()
if err != nil {
log.Fatalf("openapi: build spec: %v", err)
}
mergedSpec = spec
}
func buildSpec() ([]byte, error) {
base, err := loadJSON("specs/base.json")
if err != nil {
return nil, err
}
schemas, err := loadJSON("specs/schemas.json")
if err != nil {
return nil, err
}
if comps, ok := schemas["components"]; ok {
base["components"] = comps
}
allPaths := make(map[string]interface{})
entries, err := fs.ReadDir(specFiles, "specs")
if err != nil {
return nil, err
}
for _, entry := range entries {
name := entry.Name()
if name == "base.json" || name == "schemas.json" || !strings.HasSuffix(name, ".json") {
continue
}
partial, err := loadJSON("specs/" + name)
if err != nil {
return nil, err
}
if paths, ok := partial["paths"].(map[string]interface{}); ok {
for path, ops := range paths {
allPaths[path] = ops
}
}
}
base["paths"] = allPaths
return json.MarshalIndent(base, "", " ")
}
func loadJSON(path string) (map[string]interface{}, error) {
data, err := specFiles.ReadFile(path)
if err != nil {
return nil, err
}
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return m, nil
}
func SpecHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(mergedSpec)
}
const swaggerHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar & Contacts API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
</script>
</body>
</html>`
func DocsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(swaggerHTML))
}

View File

@@ -0,0 +1,101 @@
{
"paths": {
"/api-keys": {
"post": {
"tags": ["API Keys"],
"summary": "Create a new API key",
"description": "Creates a new API key with specified scopes for agent/programmatic access. The raw token is returned only once in the response.",
"operationId": "createApiKey",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["name", "scopes"],
"properties": {
"name": { "type": "string", "example": "My agent key" },
"scopes": {
"type": "object",
"description": "Permission scopes for the API key",
"properties": {
"calendars": { "type": "array", "items": { "type": "string", "enum": ["read", "write"] } },
"events": { "type": "array", "items": { "type": "string", "enum": ["read", "write"] } },
"contacts": { "type": "array", "items": { "type": "string", "enum": ["read", "write"] } },
"availability": { "type": "array", "items": { "type": "string", "enum": ["read"] } },
"booking": { "type": "array", "items": { "type": "string", "enum": ["write"] } }
},
"example": {
"calendars": ["read", "write"],
"events": ["read", "write"],
"contacts": ["read"],
"availability": ["read"]
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "API key created (token shown only once)",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/APIKeyResponse" }
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"get": {
"tags": ["API Keys"],
"summary": "List API keys",
"description": "Returns all API keys for the authenticated user. Tokens are never returned in list responses.",
"operationId": "listApiKeys",
"responses": {
"200": {
"description": "List of API keys",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/APIKeyResponse" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/api-keys/{id}": {
"delete": {
"tags": ["API Keys"],
"summary": "Revoke an API key",
"description": "Revokes the specified API key, preventing further use.",
"operationId": "revokeApiKey",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "API key ID"
}
],
"responses": {
"200": { "description": "API key revoked", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "API key not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,183 @@
{
"paths": {
"/auth/register": {
"post": {
"tags": ["Auth"],
"summary": "Register a new user",
"description": "Creates a new user account with email and password. A default calendar is automatically created. Returns the user profile along with access and refresh tokens.",
"operationId": "registerUser",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": { "type": "string", "format": "email", "example": "user@example.com" },
"password": { "type": "string", "minLength": 10, "example": "securepassword123" },
"timezone": { "type": "string", "example": "America/Asuncion", "description": "IANA timezone name, defaults to UTC" }
}
}
}
}
},
"responses": {
"200": {
"description": "User registered successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["user", "access_token", "refresh_token"],
"properties": {
"user": { "$ref": "#/components/schemas/User" },
"access_token": { "type": "string" },
"refresh_token": { "type": "string" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"409": { "description": "Email already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/auth/login": {
"post": {
"tags": ["Auth"],
"summary": "Login with credentials",
"description": "Authenticates a user with email and password. Returns the user profile along with access and refresh tokens.",
"operationId": "loginUser",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": { "type": "string", "format": "email", "example": "user@example.com" },
"password": { "type": "string", "example": "securepassword123" }
}
}
}
}
},
"responses": {
"200": {
"description": "Login successful",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["user", "access_token", "refresh_token"],
"properties": {
"user": { "$ref": "#/components/schemas/User" },
"access_token": { "type": "string" },
"refresh_token": { "type": "string" }
}
}
}
}
},
"401": { "description": "Invalid credentials", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/auth/refresh": {
"post": {
"tags": ["Auth"],
"summary": "Refresh access token",
"description": "Exchanges a valid refresh token for a new access/refresh token pair.",
"operationId": "refreshToken",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["refresh_token"],
"properties": {
"refresh_token": { "type": "string" }
}
}
}
}
},
"responses": {
"200": {
"description": "Tokens refreshed",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["access_token", "refresh_token"],
"properties": {
"access_token": { "type": "string" },
"refresh_token": { "type": "string" }
}
}
}
}
},
"401": { "description": "Invalid or expired refresh token", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/auth/logout": {
"post": {
"tags": ["Auth"],
"summary": "Logout and revoke refresh token",
"description": "Revokes the provided refresh token, ending the session.",
"operationId": "logoutUser",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["refresh_token"],
"properties": {
"refresh_token": { "type": "string" }
}
}
}
}
},
"responses": {
"200": { "description": "Logged out", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }
}
}
},
"/auth/me": {
"get": {
"tags": ["Auth"],
"summary": "Get current authenticated user",
"description": "Returns the profile of the currently authenticated user.",
"operationId": "getCurrentUser",
"responses": {
"200": {
"description": "Current user",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["user"],
"properties": {
"user": { "$ref": "#/components/schemas/User" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
{
"paths": {
"/availability": {
"get": {
"tags": ["Availability"],
"summary": "Get calendar availability",
"description": "Returns busy time blocks for a calendar within a given range. Includes expanded recurring event occurrences. User must have at least viewer role on the calendar. Requires `availability:read` scope.",
"operationId": "getAvailability",
"parameters": [
{ "name": "calendar_id", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Calendar to query" },
{ "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": "Availability with busy blocks",
"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" } } } },
"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" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
{
"openapi": "3.1.0",
"info": {
"title": "Calendar & Contacts API",
"description": "Production-grade Calendar and Contacts REST API supporting human users, AI agents, and programmatic automation. Features JWT and API key authentication, calendar sharing, recurring events, booking links, ICS import/export, and background reminder processing.",
"version": "1.0.0",
"contact": {
"name": "API Support"
},
"license": {
"name": "MIT"
}
},
"servers": [
{
"url": "http://localhost:8080",
"description": "Local development"
},
{
"url": "https://api.example.com",
"description": "Production"
}
],
"tags": [
{ "name": "Auth", "description": "Authentication and session management" },
{ "name": "Users", "description": "User profile management" },
{ "name": "API Keys", "description": "API key management for agents" },
{ "name": "Calendars", "description": "Calendar CRUD and sharing" },
{ "name": "Events", "description": "Event CRUD with recurrence support" },
{ "name": "Reminders", "description": "Event reminder management" },
{ "name": "Attendees", "description": "Event attendee management" },
{ "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" }
],
"security": [
{ "BearerAuth": [] },
{ "ApiKeyAuth": [] }
]
}

View File

@@ -0,0 +1,191 @@
{
"paths": {
"/calendars/{id}/booking-link": {
"post": {
"tags": ["Booking"],
"summary": "Create a booking link",
"description": "Creates a public booking link for a calendar with configurable duration, buffer time, working hours, and timezone. Only the calendar owner can create booking links. Requires `booking:write` scope.",
"operationId": "createBookingLink",
"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": ["duration_minutes", "timezone", "working_hours"],
"properties": {
"duration_minutes": { "type": "integer", "minimum": 5, "example": 30 },
"buffer_minutes": { "type": "integer", "minimum": 0, "default": 0, "example": 0 },
"timezone": { "type": "string", "example": "America/Asuncion" },
"working_hours": {
"type": "object",
"description": "Working hour windows per day of week",
"properties": {
"mon": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
"tue": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
"wed": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
"thu": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
"fri": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
"sat": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
"sun": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } }
},
"example": {
"mon": [{ "start": "09:00", "end": "17:00" }],
"tue": [{ "start": "09:00", "end": "17:00" }],
"wed": [{ "start": "09:00", "end": "17:00" }],
"thu": [{ "start": "09:00", "end": "17:00" }],
"fri": [{ "start": "09:00", "end": "17:00" }],
"sat": [],
"sun": []
}
},
"active": { "type": "boolean", "default": true }
}
}
}
}
},
"responses": {
"200": {
"description": "Booking link created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["token", "settings"],
"properties": {
"token": { "type": "string" },
"public_url": { "type": "string", "format": "uri", "example": "https://app.example.com/booking/abc123" },
"settings": {
"type": "object",
"properties": {
"duration_minutes": { "type": "integer" },
"buffer_minutes": { "type": "integer" },
"timezone": { "type": "string" },
"working_hours": { "type": "object" },
"active": { "type": "boolean" }
}
}
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Only owner can create booking links", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/booking/{token}/availability": {
"get": {
"tags": ["Booking"],
"summary": "Get public booking availability",
"description": "Returns available time slots for a public booking link within a date range. No authentication required. Computes available slots by subtracting busy blocks and applying buffer time to the working hour windows.",
"operationId": "getBookingAvailability",
"security": [],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "Booking link token"
},
{ "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": "Available booking slots",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["token", "timezone", "duration_minutes", "slots"],
"properties": {
"token": { "type": "string" },
"timezone": { "type": "string" },
"duration_minutes": { "type": "integer" },
"slots": {
"type": "array",
"items": { "$ref": "#/components/schemas/TimeSlot" }
}
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Booking link not found or inactive", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/booking/{token}/reserve": {
"post": {
"tags": ["Booking"],
"summary": "Reserve a booking slot",
"description": "Reserves a time slot on a public booking link. Creates an event on the calendar. Uses a database transaction with row locking to prevent double-booking. No authentication required.",
"operationId": "reserveBookingSlot",
"security": [],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "Booking link token"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["name", "email", "slot_start", "slot_end"],
"properties": {
"name": { "type": "string", "example": "Visitor Name" },
"email": { "type": "string", "format": "email", "example": "visitor@example.com" },
"slot_start": { "type": "string", "format": "date-time" },
"slot_end": { "type": "string", "format": "date-time" },
"notes": { "type": "string", "example": "Looking forward to the meeting" }
}
}
}
}
},
"responses": {
"200": {
"description": "Booking confirmed",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["ok", "event"],
"properties": {
"ok": { "type": "boolean", "example": true },
"event": { "$ref": "#/components/schemas/Event" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Booking link not found or inactive", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"409": { "description": "Slot no longer available (conflict)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,291 @@
{
"paths": {
"/calendars": {
"get": {
"tags": ["Calendars"],
"summary": "List calendars",
"description": "Returns all calendars the user owns or has been shared with. Each calendar includes the user's role (owner, editor, or viewer). Requires `calendars:read` scope.",
"operationId": "listCalendars",
"responses": {
"200": {
"description": "List of calendars",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Calendar" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Calendars"],
"summary": "Create a calendar",
"description": "Creates a new calendar owned by the authenticated user. Requires `calendars:write` scope.",
"operationId": "createCalendar",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 80, "example": "Work" },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" }
}
}
}
}
},
"responses": {
"200": {
"description": "Calendar created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["calendar"],
"properties": {
"calendar": { "$ref": "#/components/schemas/Calendar" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/calendars/{id}": {
"get": {
"tags": ["Calendars"],
"summary": "Get a calendar",
"description": "Returns a single calendar by ID. User must be owner or member. Requires `calendars:read` scope.",
"operationId": "getCalendar",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Calendar ID"
}
],
"responses": {
"200": {
"description": "Calendar details",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["calendar"],
"properties": {
"calendar": { "$ref": "#/components/schemas/Calendar" }
}
}
}
}
},
"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" } } } }
}
},
"put": {
"tags": ["Calendars"],
"summary": "Update a calendar",
"description": "Updates a calendar's name, color, or public status. Only the owner can change `is_public`. Requires `calendars:write` scope.",
"operationId": "updateCalendar",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Calendar ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 80, "example": "Work Calendar" },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" },
"is_public": { "type": "boolean" }
}
}
}
}
},
"responses": {
"200": {
"description": "Calendar updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["calendar"],
"properties": {
"calendar": { "$ref": "#/components/schemas/Calendar" }
}
}
}
}
},
"400": { "description": "Validation error", "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" } } } }
}
},
"delete": {
"tags": ["Calendars"],
"summary": "Delete a calendar",
"description": "Soft-deletes a calendar and all its events. Only the owner can delete. Requires `calendars:write` scope.",
"operationId": "deleteCalendar",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Calendar ID"
}
],
"responses": {
"200": { "description": "Calendar deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Only owner can delete", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/calendars/{id}/share": {
"post": {
"tags": ["Calendars"],
"summary": "Share a calendar",
"description": "Shares a calendar with another user by email, granting them a role (editor or viewer). Only the owner can share. Cannot share with self. Requires `calendars:write` scope.",
"operationId": "shareCalendar",
"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": ["target", "role"],
"properties": {
"target": {
"type": "object",
"required": ["email"],
"properties": {
"email": { "type": "string", "format": "email", "example": "other@example.com" }
}
},
"role": { "type": "string", "enum": ["editor", "viewer"], "example": "editor" }
}
}
}
}
},
"responses": {
"200": { "description": "Calendar shared", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"400": { "description": "Validation error (e.g. sharing with self)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Only owner can share", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Calendar or target user not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/calendars/{id}/members": {
"get": {
"tags": ["Calendars"],
"summary": "List calendar members",
"description": "Returns all members of a calendar with their roles. Requires `calendars:read` scope.",
"operationId": "listCalendarMembers",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Calendar ID"
}
],
"responses": {
"200": {
"description": "List of members",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/CalendarMember" } },
"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" } } } }
}
}
},
"/calendars/{id}/members/{userID}": {
"delete": {
"tags": ["Calendars"],
"summary": "Remove a calendar member",
"description": "Removes a member from a shared calendar. Only the owner can remove members. The owner cannot be removed. Requires `calendars:write` scope.",
"operationId": "removeCalendarMember",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Calendar ID"
},
{
"name": "userID",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "User ID of the member to remove"
}
],
"responses": {
"200": { "description": "Member removed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"400": { "description": "Cannot remove owner", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Only owner can remove members", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Calendar or member not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,189 @@
{
"paths": {
"/contacts": {
"get": {
"tags": ["Contacts"],
"summary": "List contacts",
"description": "Returns the authenticated user's contacts. Supports search (case-insensitive match on first_name, last_name, email, company) and cursor-based pagination. Requires `contacts:read` scope.",
"operationId": "listContacts",
"parameters": [
{ "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Search term for name, email, or company" },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, "description": "Page size" },
{ "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor" }
],
"responses": {
"200": {
"description": "List of contacts",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Contact" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Contacts"],
"summary": "Create a contact",
"description": "Creates a new contact for the authenticated user. At least one identifying field (first_name, last_name, email, or phone) must be provided. Requires `contacts:write` scope.",
"operationId": "createContact",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"first_name": { "type": "string", "example": "Jane" },
"last_name": { "type": "string", "example": "Doe" },
"email": { "type": "string", "format": "email", "example": "jane@example.com" },
"phone": { "type": "string", "example": "+595981000000" },
"company": { "type": "string", "example": "Example SA" },
"notes": { "type": "string", "example": "Met at event" }
}
}
}
}
},
"responses": {
"200": {
"description": "Contact created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["contact"],
"properties": {
"contact": { "$ref": "#/components/schemas/Contact" }
}
}
}
}
},
"400": { "description": "Validation error (e.g. no identifying field)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/contacts/{id}": {
"get": {
"tags": ["Contacts"],
"summary": "Get a contact",
"description": "Returns a single contact by ID. Only the owner can access their contacts. Requires `contacts:read` scope.",
"operationId": "getContact",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Contact ID"
}
],
"responses": {
"200": {
"description": "Contact details",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["contact"],
"properties": {
"contact": { "$ref": "#/components/schemas/Contact" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Contact not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"put": {
"tags": ["Contacts"],
"summary": "Update a contact",
"description": "Updates a contact's fields. Only the owner can update their contacts. Requires `contacts:write` scope.",
"operationId": "updateContact",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Contact ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"first_name": { "type": "string" },
"last_name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"phone": { "type": "string" },
"company": { "type": "string" },
"notes": { "type": "string" }
}
}
}
}
},
"responses": {
"200": {
"description": "Contact updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["contact"],
"properties": {
"contact": { "$ref": "#/components/schemas/Contact" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Contact not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"delete": {
"tags": ["Contacts"],
"summary": "Delete a contact",
"description": "Soft-deletes a contact. Only the owner can delete their contacts. Requires `contacts:write` scope.",
"operationId": "deleteContact",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Contact ID"
}
],
"responses": {
"200": { "description": "Contact 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", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Contact not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,438 @@
{
"paths": {
"/events": {
"get": {
"tags": ["Events"],
"summary": "List events",
"description": "Returns events within a time range across all accessible calendars. Recurring events are expanded into individual occurrences within the requested range. Supports filtering by calendar, search text, and tags. Uses cursor-based pagination. Requires `events:read` scope.",
"operationId": "listEvents",
"parameters": [
{ "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)" },
{ "name": "calendar_id", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Filter by calendar" },
{ "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Full-text search on title/description" },
{ "name": "tag", "in": "query", "schema": { "type": "string" }, "description": "Filter by tag" },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, "description": "Page size" },
{ "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor from previous response" }
],
"responses": {
"200": {
"description": "List of events (including expanded recurrence occurrences)",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items", "page"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Event" } },
"page": { "$ref": "#/components/schemas/PageInfo" }
}
}
}
}
},
"400": { "description": "Validation error (e.g. missing start/end, range too large)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Events"],
"summary": "Create an event",
"description": "Creates a new event on the specified calendar. Times are converted to UTC for storage. Supports recurrence rules (RFC5545 RRULE), reminders, and tags. User must have editor or owner role on the calendar. Requires `events:write` scope.",
"operationId": "createEvent",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["calendar_id", "title", "start_time", "end_time", "timezone"],
"properties": {
"calendar_id": { "type": "string", "format": "uuid" },
"title": { "type": "string", "minLength": 1, "maxLength": 140, "example": "Meeting" },
"description": { "type": "string", "example": "Project sync" },
"location": { "type": "string", "example": "Zoom" },
"start_time": { "type": "string", "format": "date-time", "example": "2026-03-01T14:00:00-03:00" },
"end_time": { "type": "string", "format": "date-time", "example": "2026-03-01T15:00:00-03:00" },
"timezone": { "type": "string", "example": "America/Asuncion" },
"all_day": { "type": "boolean", "default": false },
"recurrence_rule": { "type": "string", "nullable": true, "example": "FREQ=WEEKLY;BYDAY=MO,WE,FR" },
"reminders": { "type": "array", "items": { "type": "integer", "minimum": 0, "maximum": 10080 }, "description": "Minutes before event to remind", "example": [10, 60] },
"tags": { "type": "array", "items": { "type": "string" }, "example": ["work", "sync"] }
}
}
}
}
},
"responses": {
"200": {
"description": "Event created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["event"],
"properties": {
"event": { "$ref": "#/components/schemas/Event" }
}
}
}
}
},
"400": { "description": "Validation error", "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 calendar permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/events/{id}": {
"get": {
"tags": ["Events"],
"summary": "Get an event",
"description": "Returns a single event by ID with all related data (reminders, attendees, tags, attachments). Requires `events:read` scope.",
"operationId": "getEvent",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Event ID"
}
],
"responses": {
"200": {
"description": "Event details",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["event"],
"properties": {
"event": { "$ref": "#/components/schemas/Event" }
}
}
}
}
},
"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": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"put": {
"tags": ["Events"],
"summary": "Update an event",
"description": "Updates an existing event. Times are re-validated and converted to UTC. If recurrence rule changes, it is re-validated. Reminders are rescheduled. Requires `events:write` scope and editor/owner role.",
"operationId": "updateEvent",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Event ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": { "type": "string", "minLength": 1, "maxLength": 140 },
"description": { "type": "string", "nullable": true },
"location": { "type": "string", "nullable": true },
"start_time": { "type": "string", "format": "date-time" },
"end_time": { "type": "string", "format": "date-time" },
"timezone": { "type": "string" },
"all_day": { "type": "boolean" },
"recurrence_rule": { "type": "string", "nullable": true },
"tags": { "type": "array", "items": { "type": "string" } }
}
}
}
}
},
"responses": {
"200": {
"description": "Event updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["event"],
"properties": {
"event": { "$ref": "#/components/schemas/Event" }
}
}
}
}
},
"400": { "description": "Validation error", "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": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"delete": {
"tags": ["Events"],
"summary": "Delete an event",
"description": "Soft-deletes an event. Requires `events:write` scope and editor/owner role on the calendar.",
"operationId": "deleteEvent",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Event ID"
}
],
"responses": {
"200": { "description": "Event 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": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/events/{id}/reminders": {
"post": {
"tags": ["Reminders"],
"summary": "Add reminders to an event",
"description": "Adds one or more reminders to an event, specified as minutes before the event start. Background jobs are scheduled for each reminder. Requires `events:write` scope.",
"operationId": "addReminders",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Event ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["minutes_before"],
"properties": {
"minutes_before": {
"type": "array",
"items": { "type": "integer", "minimum": 0, "maximum": 10080 },
"example": [5, 15, 60]
}
}
}
}
}
},
"responses": {
"200": {
"description": "Reminders added, returns updated event",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["event"],
"properties": {
"event": { "$ref": "#/components/schemas/Event" }
}
}
}
}
},
"400": { "description": "Validation error", "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": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/events/{id}/reminders/{reminderID}": {
"delete": {
"tags": ["Reminders"],
"summary": "Delete a reminder",
"description": "Removes a specific reminder from an event. Requires `events:write` scope.",
"operationId": "deleteReminder",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Event ID"
},
{
"name": "reminderID",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Reminder ID"
}
],
"responses": {
"200": { "description": "Reminder 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": "Event or reminder not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/events/{id}/attendees": {
"post": {
"tags": ["Attendees"],
"summary": "Add attendees to an event",
"description": "Adds one or more attendees to an event, identified by email or user ID. Initial status is `pending`. Requires `events:write` scope.",
"operationId": "addAttendees",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Event ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["attendees"],
"properties": {
"attendees": {
"type": "array",
"items": {
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"user_id": { "type": "string", "format": "uuid" }
}
},
"example": [
{ "email": "guest@example.com" },
{ "user_id": "550e8400-e29b-41d4-a716-446655440000" }
]
}
}
}
}
}
},
"responses": {
"200": {
"description": "Attendees added, returns updated event",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["event"],
"properties": {
"event": { "$ref": "#/components/schemas/Event" }
}
}
}
}
},
"400": { "description": "Validation error", "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": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/events/{id}/attendees/{attendeeID}": {
"put": {
"tags": ["Attendees"],
"summary": "Update attendee status",
"description": "Updates an attendee's RSVP status. The event organizer can update any attendee; attendees can update their own status. Requires `events:write` scope.",
"operationId": "updateAttendeeStatus",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Event ID"
},
{
"name": "attendeeID",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Attendee ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["status"],
"properties": {
"status": { "type": "string", "enum": ["accepted", "declined", "tentative"] }
}
}
}
}
},
"responses": {
"200": {
"description": "Attendee updated, returns updated event",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["event"],
"properties": {
"event": { "$ref": "#/components/schemas/Event" }
}
}
}
}
},
"400": { "description": "Validation error", "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": "Event or attendee not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"delete": {
"tags": ["Attendees"],
"summary": "Remove an attendee",
"description": "Removes an attendee from an event. Requires `events:write` scope.",
"operationId": "deleteAttendee",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Event ID"
},
{
"name": "attendeeID",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Attendee ID"
}
],
"responses": {
"200": { "description": "Attendee removed", "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": "Event or attendee not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
{
"paths": {
"/calendars/{id}/export.ics": {
"get": {
"tags": ["ICS"],
"summary": "Export calendar as ICS",
"description": "Exports all events from a calendar in ICS (iCalendar) format. Requires `calendars:read` scope.",
"operationId": "exportCalendarICS",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Calendar ID"
}
],
"responses": {
"200": {
"description": "ICS calendar file",
"content": {
"text/calendar": {
"schema": { "type": "string" }
}
}
},
"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/import": {
"post": {
"tags": ["ICS"],
"summary": "Import an ICS file",
"description": "Imports events from an ICS file into a specified calendar. The file is sent as multipart form data. Requires `calendars:write` scope.",
"operationId": "importCalendarICS",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["calendar_id", "file"],
"properties": {
"calendar_id": { "type": "string", "format": "uuid", "description": "Target calendar ID" },
"file": { "type": "string", "format": "binary", "description": "ICS file to import" }
}
}
}
}
},
"responses": {
"200": {
"description": "Import successful",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["ok", "imported"],
"properties": {
"ok": { "type": "boolean", "example": true },
"imported": {
"type": "object",
"properties": {
"events": { "type": "integer", "example": 12 }
}
}
}
}
}
}
},
"400": { "description": "Validation error 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" } } } }
}
}
}
}
}

View File

@@ -0,0 +1,181 @@
{
"components": {
"securitySchemes": {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "JWT access token obtained from /auth/login or /auth/register"
},
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API key token for agent/programmatic access with scoped permissions"
}
},
"schemas": {
"Error": {
"type": "object",
"required": ["error", "code"],
"properties": {
"error": { "type": "string", "description": "Human-readable error message" },
"code": { "type": "string", "description": "Machine-readable error code", "enum": ["VALIDATION_ERROR", "AUTH_REQUIRED", "AUTH_INVALID", "FORBIDDEN", "NOT_FOUND", "CONFLICT", "RATE_LIMITED", "INTERNAL"] },
"details": { "description": "Additional error context" }
}
},
"OkResponse": {
"type": "object",
"required": ["ok"],
"properties": {
"ok": { "type": "boolean", "example": true }
}
},
"PageInfo": {
"type": "object",
"required": ["limit", "next_cursor"],
"properties": {
"limit": { "type": "integer", "example": 50 },
"next_cursor": { "type": "string", "nullable": true, "description": "Opaque cursor for next page, null if no more results" }
}
},
"User": {
"type": "object",
"required": ["id", "email", "timezone", "created_at", "updated_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" },
"timezone": { "type": "string", "example": "America/New_York", "description": "IANA timezone name" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" }
}
},
"Calendar": {
"type": "object",
"required": ["id", "name", "color", "is_public", "created_at", "updated_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" },
"is_public": { "type": "boolean" },
"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" }
}
},
"Reminder": {
"type": "object",
"required": ["id", "minutes_before"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"minutes_before": { "type": "integer", "minimum": 0, "maximum": 10080 }
}
},
"Attendee": {
"type": "object",
"required": ["id", "status"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"user_id": { "type": "string", "format": "uuid", "nullable": true },
"email": { "type": "string", "format": "email", "nullable": true },
"status": { "type": "string", "enum": ["pending", "accepted", "declined", "tentative"] }
}
},
"Attachment": {
"type": "object",
"required": ["id", "file_url"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"file_url": { "type": "string", "format": "uri" }
}
},
"Event": {
"type": "object",
"required": ["id", "calendar_id", "title", "start_time", "end_time", "timezone", "all_day", "created_by", "updated_by", "created_at", "updated_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"calendar_id": { "type": "string", "format": "uuid" },
"title": { "type": "string", "minLength": 1, "maxLength": 140 },
"description": { "type": "string", "nullable": true },
"location": { "type": "string", "nullable": true },
"start_time": { "type": "string", "format": "date-time", "description": "UTC start time in RFC3339" },
"end_time": { "type": "string", "format": "date-time", "description": "UTC end time in RFC3339" },
"timezone": { "type": "string", "example": "America/Asuncion", "description": "Original IANA timezone" },
"all_day": { "type": "boolean" },
"recurrence_rule": { "type": "string", "nullable": true, "description": "RFC5545 RRULE string", "example": "FREQ=WEEKLY;BYDAY=MO,WE,FR" },
"is_occurrence": { "type": "boolean", "description": "True if this is an expanded recurrence occurrence" },
"occurrence_start_time": { "type": "string", "format": "date-time", "nullable": true },
"occurrence_end_time": { "type": "string", "format": "date-time", "nullable": true },
"created_by": { "type": "string", "format": "uuid" },
"updated_by": { "type": "string", "format": "uuid" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" },
"reminders": { "type": "array", "items": { "$ref": "#/components/schemas/Reminder" } },
"attendees": { "type": "array", "items": { "$ref": "#/components/schemas/Attendee" } },
"tags": { "type": "array", "items": { "type": "string" } },
"attachments": { "type": "array", "items": { "$ref": "#/components/schemas/Attachment" } }
}
},
"Contact": {
"type": "object",
"required": ["id", "created_at", "updated_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"first_name": { "type": "string", "nullable": true },
"last_name": { "type": "string", "nullable": true },
"email": { "type": "string", "format": "email", "nullable": true },
"phone": { "type": "string", "nullable": true },
"company": { "type": "string", "nullable": true },
"notes": { "type": "string", "nullable": true },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" }
}
},
"APIKeyResponse": {
"type": "object",
"required": ["id", "name", "created_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"name": { "type": "string" },
"created_at": { "type": "string", "format": "date-time" },
"revoked_at": { "type": "string", "format": "date-time", "nullable": true },
"token": { "type": "string", "description": "Raw token, only returned once on creation" }
}
},
"CalendarMember": {
"type": "object",
"required": ["user_id", "email", "role"],
"properties": {
"user_id": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" },
"role": { "type": "string", "enum": ["owner", "editor", "viewer"] }
}
},
"BusyBlock": {
"type": "object",
"required": ["start", "end", "event_id"],
"properties": {
"start": { "type": "string", "format": "date-time" },
"end": { "type": "string", "format": "date-time" },
"event_id": { "type": "string", "format": "uuid" }
}
},
"WorkingHourSlot": {
"type": "object",
"required": ["start", "end"],
"properties": {
"start": { "type": "string", "example": "09:00", "pattern": "^\\d{2}:\\d{2}$" },
"end": { "type": "string", "example": "17:00", "pattern": "^\\d{2}:\\d{2}$" }
}
},
"TimeSlot": {
"type": "object",
"required": ["start", "end"],
"properties": {
"start": { "type": "string", "format": "date-time" },
"end": { "type": "string", "format": "date-time" }
}
}
}
}
}

View File

@@ -0,0 +1,76 @@
{
"paths": {
"/users/me": {
"get": {
"tags": ["Users"],
"summary": "Get current user profile",
"description": "Returns the full profile of the authenticated user.",
"operationId": "getUserProfile",
"responses": {
"200": {
"description": "User profile",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["user"],
"properties": {
"user": { "$ref": "#/components/schemas/User" }
}
}
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"put": {
"tags": ["Users"],
"summary": "Update current user profile",
"description": "Updates the authenticated user's profile fields such as timezone.",
"operationId": "updateUserProfile",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"timezone": { "type": "string", "example": "America/Asuncion", "description": "IANA timezone name" }
}
}
}
}
},
"responses": {
"200": {
"description": "Updated user",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["user"],
"properties": {
"user": { "$ref": "#/components/schemas/User" }
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"delete": {
"tags": ["Users"],
"summary": "Delete current user (soft delete)",
"description": "Soft-deletes the authenticated user account along with all associated calendars, events, contacts, and revokes all API keys.",
"operationId": "deleteUser",
"responses": {
"200": { "description": "User deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

128
internal/api/routes.go Normal file
View File

@@ -0,0 +1,128 @@
package api
import (
"github.com/calendarapi/internal/api/handlers"
"github.com/calendarapi/internal/api/openapi"
mw "github.com/calendarapi/internal/middleware"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
)
type Handlers struct {
Auth *handlers.AuthHandler
User *handlers.UserHandler
Calendar *handlers.CalendarHandler
Sharing *handlers.SharingHandler
Event *handlers.EventHandler
Reminder *handlers.ReminderHandler
Attendee *handlers.AttendeeHandler
Contact *handlers.ContactHandler
Availability *handlers.AvailabilityHandler
Booking *handlers.BookingHandler
APIKey *handlers.APIKeyHandler
ICS *handlers.ICSHandler
}
func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimiter) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.RealIP)
r.Use(rateLimiter.Limit)
// OpenAPI spec and Swagger UI
r.Get("/openapi.json", openapi.SpecHandler)
r.Get("/docs", openapi.DocsHandler)
// Public routes (no auth)
r.Group(func(r chi.Router) {
r.Post("/auth/register", h.Auth.Register)
r.Post("/auth/login", h.Auth.Login)
r.Post("/auth/refresh", h.Auth.Refresh)
r.Get("/booking/{token}/availability", h.Booking.GetAvailability)
r.Post("/booking/{token}/reserve", h.Booking.Reserve)
})
// Authenticated routes
r.Group(func(r chi.Router) {
r.Use(authMW.Authenticate)
// Auth
r.Post("/auth/logout", h.Auth.Logout)
r.Get("/auth/me", h.Auth.Me)
// Users
r.Get("/users/me", h.User.GetMe)
r.Put("/users/me", h.User.UpdateMe)
r.Delete("/users/me", h.User.DeleteMe)
// API Keys
r.Post("/api-keys", h.APIKey.Create)
r.Get("/api-keys", h.APIKey.List)
r.Delete("/api-keys/{id}", h.APIKey.Revoke)
// Calendars
r.Route("/calendars", func(r chi.Router) {
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.List)
r.With(mw.RequireScope("calendars", "write")).Post("/", h.Calendar.Create)
r.With(mw.RequireScope("calendars", "write")).Post("/import", h.ICS.Import)
r.Route("/{id}", func(r chi.Router) {
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.Get)
r.With(mw.RequireScope("calendars", "write")).Put("/", h.Calendar.Update)
r.With(mw.RequireScope("calendars", "write")).Delete("/", h.Calendar.Delete)
// Sharing
r.With(mw.RequireScope("calendars", "write")).Post("/share", h.Sharing.Share)
r.With(mw.RequireScope("calendars", "read")).Get("/members", h.Sharing.ListMembers)
r.With(mw.RequireScope("calendars", "write")).Delete("/members/{userID}", h.Sharing.RemoveMember)
// Booking link
r.With(mw.RequireScope("booking", "write")).Post("/booking-link", h.Booking.CreateLink)
// ICS
r.With(mw.RequireScope("calendars", "read")).Get("/export.ics", h.ICS.Export)
})
})
// Events
r.Route("/events", func(r chi.Router) {
r.With(mw.RequireScope("events", "read")).Get("/", h.Event.List)
r.With(mw.RequireScope("events", "write")).Post("/", h.Event.Create)
r.Route("/{id}", func(r chi.Router) {
r.With(mw.RequireScope("events", "read")).Get("/", h.Event.Get)
r.With(mw.RequireScope("events", "write")).Put("/", h.Event.Update)
r.With(mw.RequireScope("events", "write")).Delete("/", h.Event.Delete)
// Reminders
r.With(mw.RequireScope("events", "write")).Post("/reminders", h.Reminder.Add)
r.With(mw.RequireScope("events", "write")).Delete("/reminders/{reminderID}", h.Reminder.Delete)
// Attendees
r.With(mw.RequireScope("events", "write")).Post("/attendees", h.Attendee.Add)
r.With(mw.RequireScope("events", "write")).Put("/attendees/{attendeeID}", h.Attendee.UpdateStatus)
r.With(mw.RequireScope("events", "write")).Delete("/attendees/{attendeeID}", h.Attendee.Delete)
})
})
// Contacts
r.Route("/contacts", func(r chi.Router) {
r.With(mw.RequireScope("contacts", "read")).Get("/", h.Contact.List)
r.With(mw.RequireScope("contacts", "write")).Post("/", h.Contact.Create)
r.Route("/{id}", func(r chi.Router) {
r.With(mw.RequireScope("contacts", "read")).Get("/", h.Contact.Get)
r.With(mw.RequireScope("contacts", "write")).Put("/", h.Contact.Update)
r.With(mw.RequireScope("contacts", "write")).Delete("/", h.Contact.Delete)
})
})
// Availability
r.With(mw.RequireScope("availability", "read")).Get("/availability", h.Availability.Get)
})
return r
}