first commit
Made-with: Cursor
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": [] }
|
||||
]
|
||||
}
|
||||
@@ -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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user