first commit
Made-with: Cursor
This commit is contained in:
68
internal/api/handlers/apikeys.go
Normal file
68
internal/api/handlers/apikeys.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type APIKeyHandler struct {
|
||||
apiKeySvc *service.APIKeyService
|
||||
}
|
||||
|
||||
func NewAPIKeyHandler(apiKeySvc *service.APIKeyService) *APIKeyHandler {
|
||||
return &APIKeyHandler{apiKeySvc: apiKeySvc}
|
||||
}
|
||||
|
||||
func (h *APIKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Scopes map[string][]string `json:"scopes"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
key, err := h.apiKeySvc.Create(r.Context(), userID, req.Name, req.Scopes)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, key)
|
||||
}
|
||||
|
||||
func (h *APIKeyHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
|
||||
keys, err := h.apiKeySvc.List(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteList(w, keys, models.PageInfo{Limit: utils.DefaultLimit})
|
||||
}
|
||||
|
||||
func (h *APIKeyHandler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
keyID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.apiKeySvc.Revoke(r.Context(), userID, keyID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
106
internal/api/handlers/attendees.go
Normal file
106
internal/api/handlers/attendees.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AttendeeHandler struct {
|
||||
attendeeSvc *service.AttendeeService
|
||||
}
|
||||
|
||||
func NewAttendeeHandler(attendeeSvc *service.AttendeeService) *AttendeeHandler {
|
||||
return &AttendeeHandler{attendeeSvc: attendeeSvc}
|
||||
}
|
||||
|
||||
func (h *AttendeeHandler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Attendees []struct {
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
Email *string `json:"email"`
|
||||
} `json:"attendees"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var addReqs []service.AddAttendeeRequest
|
||||
for _, a := range req.Attendees {
|
||||
addReqs = append(addReqs, service.AddAttendeeRequest{
|
||||
UserID: a.UserID,
|
||||
Email: a.Email,
|
||||
})
|
||||
}
|
||||
|
||||
event, err := h.attendeeSvc.AddAttendees(r.Context(), userID, eventID, addReqs)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
|
||||
}
|
||||
|
||||
func (h *AttendeeHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
attendeeID, err := utils.ValidateUUID(chi.URLParam(r, "attendeeID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := h.attendeeSvc.UpdateStatus(r.Context(), userID, eventID, attendeeID, req.Status)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
|
||||
}
|
||||
|
||||
func (h *AttendeeHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
attendeeID, err := utils.ValidateUUID(chi.URLParam(r, "attendeeID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.attendeeSvc.DeleteAttendee(r.Context(), userID, eventID, attendeeID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
109
internal/api/handlers/auth.go
Normal file
109
internal/api/handlers/auth.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authSvc *service.AuthService
|
||||
userSvc *service.UserService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authSvc *service.AuthService, userSvc *service.UserService) *AuthHandler {
|
||||
return &AuthHandler{authSvc: authSvc, userSvc: userSvc}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.authSvc.Register(r.Context(), req.Email, req.Password, req.Timezone)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.authSvc.Login(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.authSvc.Refresh(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authSvc.Logout(r.Context(), req.RefreshToken); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserID(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, models.ErrAuthRequired)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSvc.GetMe(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"user": user})
|
||||
}
|
||||
60
internal/api/handlers/availability.go
Normal file
60
internal/api/handlers/availability.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
)
|
||||
|
||||
type AvailabilityHandler struct {
|
||||
availSvc *service.AvailabilityService
|
||||
}
|
||||
|
||||
func NewAvailabilityHandler(availSvc *service.AvailabilityService) *AvailabilityHandler {
|
||||
return &AvailabilityHandler{availSvc: availSvc}
|
||||
}
|
||||
|
||||
func (h *AvailabilityHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
q := r.URL.Query()
|
||||
|
||||
calIDStr := q.Get("calendar_id")
|
||||
if calIDStr == "" {
|
||||
utils.WriteError(w, models.NewValidationError("calendar_id required"))
|
||||
return
|
||||
}
|
||||
calID, err := utils.ValidateUUID(calIDStr)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
startStr := q.Get("start")
|
||||
endStr := q.Get("end")
|
||||
if startStr == "" || endStr == "" {
|
||||
utils.WriteError(w, models.NewValidationError("start and end required"))
|
||||
return
|
||||
}
|
||||
start, err := time.Parse(time.RFC3339, startStr)
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("invalid start time"))
|
||||
return
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, endStr)
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("invalid end time"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.availSvc.GetBusyBlocks(r.Context(), userID, calID, start, end)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
98
internal/api/handlers/booking.go
Normal file
98
internal/api/handlers/booking.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type BookingHandler struct {
|
||||
bookingSvc *service.BookingService
|
||||
}
|
||||
|
||||
func NewBookingHandler(bookingSvc *service.BookingService) *BookingHandler {
|
||||
return &BookingHandler{bookingSvc: bookingSvc}
|
||||
}
|
||||
|
||||
func (h *BookingHandler) CreateLink(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.BookingConfig
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
link, err := h.bookingSvc.CreateLink(r.Context(), userID, calID, req)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, link)
|
||||
}
|
||||
|
||||
func (h *BookingHandler) GetAvailability(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
q := r.URL.Query()
|
||||
|
||||
startStr := q.Get("start")
|
||||
endStr := q.Get("end")
|
||||
if startStr == "" || endStr == "" {
|
||||
utils.WriteError(w, models.NewValidationError("start and end required"))
|
||||
return
|
||||
}
|
||||
|
||||
start, err := time.Parse(time.RFC3339, startStr)
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("invalid start time"))
|
||||
return
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, endStr)
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("invalid end time"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.bookingSvc.GetAvailability(r.Context(), token, start, end)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *BookingHandler) Reserve(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
SlotStart time.Time `json:"slot_start"`
|
||||
SlotEnd time.Time `json:"slot_end"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := h.bookingSvc.Reserve(r.Context(), token, req.Name, req.Email, req.SlotStart, req.SlotEnd, req.Notes)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "event": event})
|
||||
}
|
||||
112
internal/api/handlers/calendars.go
Normal file
112
internal/api/handlers/calendars.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type CalendarHandler struct {
|
||||
calSvc *service.CalendarService
|
||||
}
|
||||
|
||||
func NewCalendarHandler(calSvc *service.CalendarService) *CalendarHandler {
|
||||
return &CalendarHandler{calSvc: calSvc}
|
||||
}
|
||||
|
||||
func (h *CalendarHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
|
||||
calendars, err := h.calSvc.List(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteList(w, calendars, models.PageInfo{Limit: utils.DefaultLimit})
|
||||
}
|
||||
|
||||
func (h *CalendarHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
cal, err := h.calSvc.Create(r.Context(), userID, req.Name, req.Color)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"calendar": cal})
|
||||
}
|
||||
|
||||
func (h *CalendarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
cal, err := h.calSvc.Get(r.Context(), userID, calID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"calendar": cal})
|
||||
}
|
||||
|
||||
func (h *CalendarHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
cal, err := h.calSvc.Update(r.Context(), userID, calID, req.Name, req.Color, req.IsPublic)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"calendar": cal})
|
||||
}
|
||||
|
||||
func (h *CalendarHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.calSvc.Delete(r.Context(), userID, calID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
146
internal/api/handlers/contacts.go
Normal file
146
internal/api/handlers/contacts.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type ContactHandler struct {
|
||||
contactSvc *service.ContactService
|
||||
}
|
||||
|
||||
func NewContactHandler(contactSvc *service.ContactService) *ContactHandler {
|
||||
return &ContactHandler{contactSvc: contactSvc}
|
||||
}
|
||||
|
||||
func (h *ContactHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
q := r.URL.Query()
|
||||
|
||||
var search *string
|
||||
if s := q.Get("search"); s != "" {
|
||||
search = &s
|
||||
}
|
||||
limit := 50
|
||||
if l := q.Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
|
||||
contacts, cursor, err := h.contactSvc.List(r.Context(), userID, search, limit, q.Get("cursor"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteList(w, contacts, models.PageInfo{Limit: int(utils.ClampLimit(limit)), NextCursor: cursor})
|
||||
}
|
||||
|
||||
func (h *ContactHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
Company *string `json:"company"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
contact, err := h.contactSvc.Create(r.Context(), userID, service.CreateContactRequest{
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Company: req.Company,
|
||||
Notes: req.Notes,
|
||||
})
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"contact": contact})
|
||||
}
|
||||
|
||||
func (h *ContactHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
contactID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
contact, err := h.contactSvc.Get(r.Context(), userID, contactID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"contact": contact})
|
||||
}
|
||||
|
||||
func (h *ContactHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
contactID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
Company *string `json:"company"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
contact, err := h.contactSvc.Update(r.Context(), userID, contactID, service.UpdateContactRequest{
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Company: req.Company,
|
||||
Notes: req.Notes,
|
||||
})
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"contact": contact})
|
||||
}
|
||||
|
||||
func (h *ContactHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
contactID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.contactSvc.Delete(r.Context(), userID, contactID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
204
internal/api/handlers/events.go
Normal file
204
internal/api/handlers/events.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type EventHandler struct {
|
||||
eventSvc *service.EventService
|
||||
}
|
||||
|
||||
func NewEventHandler(eventSvc *service.EventService) *EventHandler {
|
||||
return &EventHandler{eventSvc: eventSvc}
|
||||
}
|
||||
|
||||
func (h *EventHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
q := r.URL.Query()
|
||||
|
||||
startStr := q.Get("start")
|
||||
endStr := q.Get("end")
|
||||
if startStr == "" || endStr == "" {
|
||||
utils.WriteError(w, models.NewValidationError("start and end query params required"))
|
||||
return
|
||||
}
|
||||
start, err := time.Parse(time.RFC3339, startStr)
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("invalid start time"))
|
||||
return
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, endStr)
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("invalid end time"))
|
||||
return
|
||||
}
|
||||
|
||||
var calendarID *uuid.UUID
|
||||
if cid := q.Get("calendar_id"); cid != "" {
|
||||
id, err := utils.ValidateUUID(cid)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
calendarID = &id
|
||||
}
|
||||
|
||||
var search *string
|
||||
if s := q.Get("search"); s != "" {
|
||||
search = &s
|
||||
}
|
||||
var tag *string
|
||||
if t := q.Get("tag"); t != "" {
|
||||
tag = &t
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l := q.Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
|
||||
events, cursor, err := h.eventSvc.List(r.Context(), userID, service.ListEventParams{
|
||||
RangeStart: start,
|
||||
RangeEnd: end,
|
||||
CalendarID: calendarID,
|
||||
Search: search,
|
||||
Tag: tag,
|
||||
Limit: limit,
|
||||
Cursor: q.Get("cursor"),
|
||||
})
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteList(w, events, models.PageInfo{Limit: int(utils.ClampLimit(limit)), NextCursor: cursor})
|
||||
}
|
||||
|
||||
func (h *EventHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
CalendarID uuid.UUID `json:"calendar_id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Location *string `json:"location"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Timezone string `json:"timezone"`
|
||||
AllDay bool `json:"all_day"`
|
||||
RecurrenceRule *string `json:"recurrence_rule"`
|
||||
Reminders []int32 `json:"reminders"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := h.eventSvc.Create(r.Context(), userID, service.CreateEventRequest{
|
||||
CalendarID: req.CalendarID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Location: req.Location,
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
Timezone: req.Timezone,
|
||||
AllDay: req.AllDay,
|
||||
RecurrenceRule: req.RecurrenceRule,
|
||||
Reminders: req.Reminders,
|
||||
Tags: req.Tags,
|
||||
})
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
|
||||
}
|
||||
|
||||
func (h *EventHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := h.eventSvc.Get(r.Context(), userID, eventID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
|
||||
}
|
||||
|
||||
func (h *EventHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Location *string `json:"location"`
|
||||
StartTime *time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time"`
|
||||
Timezone *string `json:"timezone"`
|
||||
AllDay *bool `json:"all_day"`
|
||||
RecurrenceRule *string `json:"recurrence_rule"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := h.eventSvc.Update(r.Context(), userID, eventID, service.UpdateEventRequest{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Location: req.Location,
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
Timezone: req.Timezone,
|
||||
AllDay: req.AllDay,
|
||||
RecurrenceRule: req.RecurrenceRule,
|
||||
Tags: req.Tags,
|
||||
})
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
|
||||
}
|
||||
|
||||
func (h *EventHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.eventSvc.Delete(r.Context(), userID, eventID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
189
internal/api/handlers/ics.go
Normal file
189
internal/api/handlers/ics.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ICSHandler struct {
|
||||
calSvc *service.CalendarService
|
||||
eventSvc *service.EventService
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewICSHandler(calSvc *service.CalendarService, eventSvc *service.EventService, queries *repository.Queries) *ICSHandler {
|
||||
return &ICSHandler{calSvc: calSvc, eventSvc: eventSvc, queries: queries}
|
||||
}
|
||||
|
||||
func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rangeStart := now.AddDate(-1, 0, 0)
|
||||
rangeEnd := now.AddDate(1, 0, 0)
|
||||
|
||||
events, err := h.queries.ListEventsByCalendarInRange(r.Context(), repository.ListEventsByCalendarInRangeParams{
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
EndTime: utils.ToPgTimestamptz(rangeStart),
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CalendarAPI//EN\r\n")
|
||||
|
||||
for _, ev := range events {
|
||||
b.WriteString("BEGIN:VEVENT\r\n")
|
||||
b.WriteString(fmt.Sprintf("UID:%s\r\n", utils.FromPgUUID(ev.ID).String()))
|
||||
b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", utils.FromPgTimestamptz(ev.StartTime).Format("20060102T150405Z")))
|
||||
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", utils.FromPgTimestamptz(ev.EndTime).Format("20060102T150405Z")))
|
||||
b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", ev.Title))
|
||||
if ev.Description.Valid {
|
||||
b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", ev.Description.String))
|
||||
}
|
||||
if ev.Location.Valid {
|
||||
b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", ev.Location.String))
|
||||
}
|
||||
if ev.RecurrenceRule.Valid {
|
||||
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", ev.RecurrenceRule.String))
|
||||
}
|
||||
b.WriteString("END:VEVENT\r\n")
|
||||
}
|
||||
|
||||
b.WriteString("END:VCALENDAR\r\n")
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=calendar.ics")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("invalid multipart form"))
|
||||
return
|
||||
}
|
||||
|
||||
calIDStr := r.FormValue("calendar_id")
|
||||
calID, err := utils.ValidateUUID(calIDStr)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
role, err := h.calSvc.GetRole(r.Context(), calID, userID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
if role != "owner" && role != "editor" {
|
||||
utils.WriteError(w, models.ErrForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("file required"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
count := h.parseAndImportICS(r.Context(), string(data), calID, userID)
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"imported": map[string]int{"events": count},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ICSHandler) parseAndImportICS(ctx context.Context, data string, calID, userID uuid.UUID) int {
|
||||
count := 0
|
||||
lines := strings.Split(data, "\n")
|
||||
var inEvent bool
|
||||
var title, description, location, rruleStr string
|
||||
var dtstart, dtend time.Time
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "BEGIN:VEVENT" {
|
||||
inEvent = true
|
||||
title, description, location, rruleStr = "", "", "", ""
|
||||
dtstart, dtend = time.Time{}, time.Time{}
|
||||
continue
|
||||
}
|
||||
if line == "END:VEVENT" && inEvent {
|
||||
inEvent = false
|
||||
if title != "" && !dtstart.IsZero() && !dtend.IsZero() {
|
||||
_, err := h.queries.CreateEvent(ctx, repository.CreateEventParams{
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
Title: title,
|
||||
Description: utils.ToPgText(description),
|
||||
Location: utils.ToPgText(location),
|
||||
StartTime: utils.ToPgTimestamptz(dtstart),
|
||||
EndTime: utils.ToPgTimestamptz(dtend),
|
||||
Timezone: "UTC",
|
||||
RecurrenceRule: utils.ToPgText(rruleStr),
|
||||
Tags: []string{},
|
||||
CreatedBy: utils.ToPgUUID(userID),
|
||||
UpdatedBy: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !inEvent {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "SUMMARY:"):
|
||||
title = strings.TrimPrefix(line, "SUMMARY:")
|
||||
case strings.HasPrefix(line, "DESCRIPTION:"):
|
||||
description = strings.TrimPrefix(line, "DESCRIPTION:")
|
||||
case strings.HasPrefix(line, "LOCATION:"):
|
||||
location = strings.TrimPrefix(line, "LOCATION:")
|
||||
case strings.HasPrefix(line, "RRULE:"):
|
||||
rruleStr = strings.TrimPrefix(line, "RRULE:")
|
||||
case strings.HasPrefix(line, "DTSTART:"):
|
||||
dtstart, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTSTART:"))
|
||||
case strings.HasPrefix(line, "DTEND:"):
|
||||
dtend, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTEND:"))
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
64
internal/api/handlers/reminders.go
Normal file
64
internal/api/handlers/reminders.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type ReminderHandler struct {
|
||||
reminderSvc *service.ReminderService
|
||||
}
|
||||
|
||||
func NewReminderHandler(reminderSvc *service.ReminderService) *ReminderHandler {
|
||||
return &ReminderHandler{reminderSvc: reminderSvc}
|
||||
}
|
||||
|
||||
func (h *ReminderHandler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
MinutesBefore []int32 `json:"minutes_before"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := h.reminderSvc.AddReminders(r.Context(), userID, eventID, req.MinutesBefore)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event})
|
||||
}
|
||||
|
||||
func (h *ReminderHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
eventID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
reminderID, err := utils.ValidateUUID(chi.URLParam(r, "reminderID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.reminderSvc.DeleteReminder(r.Context(), userID, eventID, reminderID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
84
internal/api/handlers/sharing.go
Normal file
84
internal/api/handlers/sharing.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type SharingHandler struct {
|
||||
calSvc *service.CalendarService
|
||||
}
|
||||
|
||||
func NewSharingHandler(calSvc *service.CalendarService) *SharingHandler {
|
||||
return &SharingHandler{calSvc: calSvc}
|
||||
}
|
||||
|
||||
func (h *SharingHandler) Share(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Target struct {
|
||||
Email string `json:"email"`
|
||||
} `json:"target"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.calSvc.Share(r.Context(), userID, calID, req.Target.Email, req.Role); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
|
||||
func (h *SharingHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
members, err := h.calSvc.ListMembers(r.Context(), userID, calID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteList(w, members, models.PageInfo{Limit: utils.DefaultLimit})
|
||||
}
|
||||
|
||||
func (h *SharingHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
targetID, err := utils.ValidateUUID(chi.URLParam(r, "userID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.calSvc.RemoveMember(r.Context(), userID, calID, targetID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
73
internal/api/handlers/users.go
Normal file
73
internal/api/handlers/users.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/service"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
userSvc *service.UserService
|
||||
}
|
||||
|
||||
func NewUserHandler(userSvc *service.UserService) *UserHandler {
|
||||
return &UserHandler{userSvc: userSvc}
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserID(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, models.ErrAuthRequired)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSvc.GetMe(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"user": user})
|
||||
}
|
||||
|
||||
func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserID(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, models.ErrAuthRequired)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Timezone *string `json:"timezone"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSvc.Update(r.Context(), userID, req.Timezone)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"user": user})
|
||||
}
|
||||
|
||||
func (h *UserHandler) DeleteMe(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserID(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, models.ErrAuthRequired)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userSvc.Delete(r.Context(), userID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
120
internal/api/openapi/openapi.go
Normal file
120
internal/api/openapi/openapi.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed specs/*.json
|
||||
var specFiles embed.FS
|
||||
|
||||
var mergedSpec []byte
|
||||
|
||||
func init() {
|
||||
spec, err := buildSpec()
|
||||
if err != nil {
|
||||
log.Fatalf("openapi: build spec: %v", err)
|
||||
}
|
||||
mergedSpec = spec
|
||||
}
|
||||
|
||||
func buildSpec() ([]byte, error) {
|
||||
base, err := loadJSON("specs/base.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schemas, err := loadJSON("specs/schemas.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if comps, ok := schemas["components"]; ok {
|
||||
base["components"] = comps
|
||||
}
|
||||
|
||||
allPaths := make(map[string]interface{})
|
||||
|
||||
entries, err := fs.ReadDir(specFiles, "specs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == "base.json" || name == "schemas.json" || !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
partial, err := loadJSON("specs/" + name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if paths, ok := partial["paths"].(map[string]interface{}); ok {
|
||||
for path, ops := range paths {
|
||||
allPaths[path] = ops
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base["paths"] = allPaths
|
||||
|
||||
return json.MarshalIndent(base, "", " ")
|
||||
}
|
||||
|
||||
func loadJSON(path string) (map[string]interface{}, error) {
|
||||
data, err := specFiles.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func SpecHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(mergedSpec)
|
||||
}
|
||||
|
||||
const swaggerHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Calendar & Contacts API – Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
<style>
|
||||
html { box-sizing: border-box; overflow-y: scroll; }
|
||||
*, *:before, *:after { box-sizing: inherit; }
|
||||
body { margin: 0; background: #fafafa; }
|
||||
.topbar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
func DocsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(swaggerHTML))
|
||||
}
|
||||
101
internal/api/openapi/specs/apikeys.json
Normal file
101
internal/api/openapi/specs/apikeys.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"paths": {
|
||||
"/api-keys": {
|
||||
"post": {
|
||||
"tags": ["API Keys"],
|
||||
"summary": "Create a new API key",
|
||||
"description": "Creates a new API key with specified scopes for agent/programmatic access. The raw token is returned only once in the response.",
|
||||
"operationId": "createApiKey",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["name", "scopes"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "example": "My agent key" },
|
||||
"scopes": {
|
||||
"type": "object",
|
||||
"description": "Permission scopes for the API key",
|
||||
"properties": {
|
||||
"calendars": { "type": "array", "items": { "type": "string", "enum": ["read", "write"] } },
|
||||
"events": { "type": "array", "items": { "type": "string", "enum": ["read", "write"] } },
|
||||
"contacts": { "type": "array", "items": { "type": "string", "enum": ["read", "write"] } },
|
||||
"availability": { "type": "array", "items": { "type": "string", "enum": ["read"] } },
|
||||
"booking": { "type": "array", "items": { "type": "string", "enum": ["write"] } }
|
||||
},
|
||||
"example": {
|
||||
"calendars": ["read", "write"],
|
||||
"events": ["read", "write"],
|
||||
"contacts": ["read"],
|
||||
"availability": ["read"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "API key created (token shown only once)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/APIKeyResponse" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": ["API Keys"],
|
||||
"summary": "List API keys",
|
||||
"description": "Returns all API keys for the authenticated user. Tokens are never returned in list responses.",
|
||||
"operationId": "listApiKeys",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of API keys",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["items", "page"],
|
||||
"properties": {
|
||||
"items": { "type": "array", "items": { "$ref": "#/components/schemas/APIKeyResponse" } },
|
||||
"page": { "$ref": "#/components/schemas/PageInfo" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api-keys/{id}": {
|
||||
"delete": {
|
||||
"tags": ["API Keys"],
|
||||
"summary": "Revoke an API key",
|
||||
"description": "Revokes the specified API key, preventing further use.",
|
||||
"operationId": "revokeApiKey",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "API key ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "API key revoked", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "API key not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
183
internal/api/openapi/specs/auth.json
Normal file
183
internal/api/openapi/specs/auth.json
Normal file
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Register a new user",
|
||||
"description": "Creates a new user account with email and password. A default calendar is automatically created. Returns the user profile along with access and refresh tokens.",
|
||||
"operationId": "registerUser",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["email", "password"],
|
||||
"properties": {
|
||||
"email": { "type": "string", "format": "email", "example": "user@example.com" },
|
||||
"password": { "type": "string", "minLength": 10, "example": "securepassword123" },
|
||||
"timezone": { "type": "string", "example": "America/Asuncion", "description": "IANA timezone name, defaults to UTC" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User registered successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["user", "access_token", "refresh_token"],
|
||||
"properties": {
|
||||
"user": { "$ref": "#/components/schemas/User" },
|
||||
"access_token": { "type": "string" },
|
||||
"refresh_token": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"409": { "description": "Email already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Login with credentials",
|
||||
"description": "Authenticates a user with email and password. Returns the user profile along with access and refresh tokens.",
|
||||
"operationId": "loginUser",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["email", "password"],
|
||||
"properties": {
|
||||
"email": { "type": "string", "format": "email", "example": "user@example.com" },
|
||||
"password": { "type": "string", "example": "securepassword123" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Login successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["user", "access_token", "refresh_token"],
|
||||
"properties": {
|
||||
"user": { "$ref": "#/components/schemas/User" },
|
||||
"access_token": { "type": "string" },
|
||||
"refresh_token": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Invalid credentials", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/refresh": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Refresh access token",
|
||||
"description": "Exchanges a valid refresh token for a new access/refresh token pair.",
|
||||
"operationId": "refreshToken",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["refresh_token"],
|
||||
"properties": {
|
||||
"refresh_token": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Tokens refreshed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["access_token", "refresh_token"],
|
||||
"properties": {
|
||||
"access_token": { "type": "string" },
|
||||
"refresh_token": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Invalid or expired refresh token", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/logout": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Logout and revoke refresh token",
|
||||
"description": "Revokes the provided refresh token, ending the session.",
|
||||
"operationId": "logoutUser",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["refresh_token"],
|
||||
"properties": {
|
||||
"refresh_token": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Logged out", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/me": {
|
||||
"get": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Get current authenticated user",
|
||||
"description": "Returns the profile of the currently authenticated user.",
|
||||
"operationId": "getCurrentUser",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["user"],
|
||||
"properties": {
|
||||
"user": { "$ref": "#/components/schemas/User" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
internal/api/openapi/specs/availability.json
Normal file
42
internal/api/openapi/specs/availability.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"paths": {
|
||||
"/availability": {
|
||||
"get": {
|
||||
"tags": ["Availability"],
|
||||
"summary": "Get calendar availability",
|
||||
"description": "Returns busy time blocks for a calendar within a given range. Includes expanded recurring event occurrences. User must have at least viewer role on the calendar. Requires `availability:read` scope.",
|
||||
"operationId": "getAvailability",
|
||||
"parameters": [
|
||||
{ "name": "calendar_id", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Calendar to query" },
|
||||
{ "name": "start", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range start (RFC3339)" },
|
||||
{ "name": "end", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range end (RFC3339)" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Availability with busy blocks",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["calendar_id", "range_start", "range_end", "busy"],
|
||||
"properties": {
|
||||
"calendar_id": { "type": "string", "format": "uuid" },
|
||||
"range_start": { "type": "string", "format": "date-time" },
|
||||
"range_end": { "type": "string", "format": "date-time" },
|
||||
"busy": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/BusyBlock" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
internal/api/openapi/specs/base.json
Normal file
41
internal/api/openapi/specs/base.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Calendar & Contacts API",
|
||||
"description": "Production-grade Calendar and Contacts REST API supporting human users, AI agents, and programmatic automation. Features JWT and API key authentication, calendar sharing, recurring events, booking links, ICS import/export, and background reminder processing.",
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "API Support"
|
||||
},
|
||||
"license": {
|
||||
"name": "MIT"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:8080",
|
||||
"description": "Local development"
|
||||
},
|
||||
{
|
||||
"url": "https://api.example.com",
|
||||
"description": "Production"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{ "name": "Auth", "description": "Authentication and session management" },
|
||||
{ "name": "Users", "description": "User profile management" },
|
||||
{ "name": "API Keys", "description": "API key management for agents" },
|
||||
{ "name": "Calendars", "description": "Calendar CRUD and sharing" },
|
||||
{ "name": "Events", "description": "Event CRUD with recurrence support" },
|
||||
{ "name": "Reminders", "description": "Event reminder management" },
|
||||
{ "name": "Attendees", "description": "Event attendee management" },
|
||||
{ "name": "Contacts", "description": "Contact management" },
|
||||
{ "name": "Availability", "description": "Calendar availability queries" },
|
||||
{ "name": "Booking", "description": "Public booking links and reservations" },
|
||||
{ "name": "ICS", "description": "ICS calendar import and export" }
|
||||
],
|
||||
"security": [
|
||||
{ "BearerAuth": [] },
|
||||
{ "ApiKeyAuth": [] }
|
||||
]
|
||||
}
|
||||
191
internal/api/openapi/specs/booking.json
Normal file
191
internal/api/openapi/specs/booking.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"paths": {
|
||||
"/calendars/{id}/booking-link": {
|
||||
"post": {
|
||||
"tags": ["Booking"],
|
||||
"summary": "Create a booking link",
|
||||
"description": "Creates a public booking link for a calendar with configurable duration, buffer time, working hours, and timezone. Only the calendar owner can create booking links. Requires `booking:write` scope.",
|
||||
"operationId": "createBookingLink",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Calendar ID"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["duration_minutes", "timezone", "working_hours"],
|
||||
"properties": {
|
||||
"duration_minutes": { "type": "integer", "minimum": 5, "example": 30 },
|
||||
"buffer_minutes": { "type": "integer", "minimum": 0, "default": 0, "example": 0 },
|
||||
"timezone": { "type": "string", "example": "America/Asuncion" },
|
||||
"working_hours": {
|
||||
"type": "object",
|
||||
"description": "Working hour windows per day of week",
|
||||
"properties": {
|
||||
"mon": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
|
||||
"tue": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
|
||||
"wed": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
|
||||
"thu": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
|
||||
"fri": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
|
||||
"sat": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } },
|
||||
"sun": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } }
|
||||
},
|
||||
"example": {
|
||||
"mon": [{ "start": "09:00", "end": "17:00" }],
|
||||
"tue": [{ "start": "09:00", "end": "17:00" }],
|
||||
"wed": [{ "start": "09:00", "end": "17:00" }],
|
||||
"thu": [{ "start": "09:00", "end": "17:00" }],
|
||||
"fri": [{ "start": "09:00", "end": "17:00" }],
|
||||
"sat": [],
|
||||
"sun": []
|
||||
}
|
||||
},
|
||||
"active": { "type": "boolean", "default": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Booking link created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["token", "settings"],
|
||||
"properties": {
|
||||
"token": { "type": "string" },
|
||||
"public_url": { "type": "string", "format": "uri", "example": "https://app.example.com/booking/abc123" },
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_minutes": { "type": "integer" },
|
||||
"buffer_minutes": { "type": "integer" },
|
||||
"timezone": { "type": "string" },
|
||||
"working_hours": { "type": "object" },
|
||||
"active": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Only owner can create booking links", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/booking/{token}/availability": {
|
||||
"get": {
|
||||
"tags": ["Booking"],
|
||||
"summary": "Get public booking availability",
|
||||
"description": "Returns available time slots for a public booking link within a date range. No authentication required. Computes available slots by subtracting busy blocks and applying buffer time to the working hour windows.",
|
||||
"operationId": "getBookingAvailability",
|
||||
"security": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string" },
|
||||
"description": "Booking link token"
|
||||
},
|
||||
{ "name": "start", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range start (RFC3339)" },
|
||||
{ "name": "end", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range end (RFC3339)" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Available booking slots",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["token", "timezone", "duration_minutes", "slots"],
|
||||
"properties": {
|
||||
"token": { "type": "string" },
|
||||
"timezone": { "type": "string" },
|
||||
"duration_minutes": { "type": "integer" },
|
||||
"slots": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/TimeSlot" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Booking link not found or inactive", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/booking/{token}/reserve": {
|
||||
"post": {
|
||||
"tags": ["Booking"],
|
||||
"summary": "Reserve a booking slot",
|
||||
"description": "Reserves a time slot on a public booking link. Creates an event on the calendar. Uses a database transaction with row locking to prevent double-booking. No authentication required.",
|
||||
"operationId": "reserveBookingSlot",
|
||||
"security": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string" },
|
||||
"description": "Booking link token"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["name", "email", "slot_start", "slot_end"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "example": "Visitor Name" },
|
||||
"email": { "type": "string", "format": "email", "example": "visitor@example.com" },
|
||||
"slot_start": { "type": "string", "format": "date-time" },
|
||||
"slot_end": { "type": "string", "format": "date-time" },
|
||||
"notes": { "type": "string", "example": "Looking forward to the meeting" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Booking confirmed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["ok", "event"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean", "example": true },
|
||||
"event": { "$ref": "#/components/schemas/Event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Booking link not found or inactive", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"409": { "description": "Slot no longer available (conflict)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
291
internal/api/openapi/specs/calendars.json
Normal file
291
internal/api/openapi/specs/calendars.json
Normal file
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"paths": {
|
||||
"/calendars": {
|
||||
"get": {
|
||||
"tags": ["Calendars"],
|
||||
"summary": "List calendars",
|
||||
"description": "Returns all calendars the user owns or has been shared with. Each calendar includes the user's role (owner, editor, or viewer). Requires `calendars:read` scope.",
|
||||
"operationId": "listCalendars",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of calendars",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["items", "page"],
|
||||
"properties": {
|
||||
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Calendar" } },
|
||||
"page": { "$ref": "#/components/schemas/PageInfo" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Calendars"],
|
||||
"summary": "Create a calendar",
|
||||
"description": "Creates a new calendar owned by the authenticated user. Requires `calendars:write` scope.",
|
||||
"operationId": "createCalendar",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 80, "example": "Work" },
|
||||
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Calendar created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["calendar"],
|
||||
"properties": {
|
||||
"calendar": { "$ref": "#/components/schemas/Calendar" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/calendars/{id}": {
|
||||
"get": {
|
||||
"tags": ["Calendars"],
|
||||
"summary": "Get a calendar",
|
||||
"description": "Returns a single calendar by ID. User must be owner or member. Requires `calendars:read` scope.",
|
||||
"operationId": "getCalendar",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Calendar ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Calendar details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["calendar"],
|
||||
"properties": {
|
||||
"calendar": { "$ref": "#/components/schemas/Calendar" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": ["Calendars"],
|
||||
"summary": "Update a calendar",
|
||||
"description": "Updates a calendar's name, color, or public status. Only the owner can change `is_public`. Requires `calendars:write` scope.",
|
||||
"operationId": "updateCalendar",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Calendar ID"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 80, "example": "Work Calendar" },
|
||||
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" },
|
||||
"is_public": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Calendar updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["calendar"],
|
||||
"properties": {
|
||||
"calendar": { "$ref": "#/components/schemas/Calendar" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Calendars"],
|
||||
"summary": "Delete a calendar",
|
||||
"description": "Soft-deletes a calendar and all its events. Only the owner can delete. Requires `calendars:write` scope.",
|
||||
"operationId": "deleteCalendar",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Calendar ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Calendar deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Only owner can delete", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/calendars/{id}/share": {
|
||||
"post": {
|
||||
"tags": ["Calendars"],
|
||||
"summary": "Share a calendar",
|
||||
"description": "Shares a calendar with another user by email, granting them a role (editor or viewer). Only the owner can share. Cannot share with self. Requires `calendars:write` scope.",
|
||||
"operationId": "shareCalendar",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Calendar ID"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["target", "role"],
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "object",
|
||||
"required": ["email"],
|
||||
"properties": {
|
||||
"email": { "type": "string", "format": "email", "example": "other@example.com" }
|
||||
}
|
||||
},
|
||||
"role": { "type": "string", "enum": ["editor", "viewer"], "example": "editor" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Calendar shared", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
|
||||
"400": { "description": "Validation error (e.g. sharing with self)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Only owner can share", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Calendar or target user not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/calendars/{id}/members": {
|
||||
"get": {
|
||||
"tags": ["Calendars"],
|
||||
"summary": "List calendar members",
|
||||
"description": "Returns all members of a calendar with their roles. Requires `calendars:read` scope.",
|
||||
"operationId": "listCalendarMembers",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Calendar ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of members",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["items", "page"],
|
||||
"properties": {
|
||||
"items": { "type": "array", "items": { "$ref": "#/components/schemas/CalendarMember" } },
|
||||
"page": { "$ref": "#/components/schemas/PageInfo" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/calendars/{id}/members/{userID}": {
|
||||
"delete": {
|
||||
"tags": ["Calendars"],
|
||||
"summary": "Remove a calendar member",
|
||||
"description": "Removes a member from a shared calendar. Only the owner can remove members. The owner cannot be removed. Requires `calendars:write` scope.",
|
||||
"operationId": "removeCalendarMember",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Calendar ID"
|
||||
},
|
||||
{
|
||||
"name": "userID",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "User ID of the member to remove"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Member removed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
|
||||
"400": { "description": "Cannot remove owner", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Only owner can remove members", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Calendar or member not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
internal/api/openapi/specs/contacts.json
Normal file
189
internal/api/openapi/specs/contacts.json
Normal file
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"paths": {
|
||||
"/contacts": {
|
||||
"get": {
|
||||
"tags": ["Contacts"],
|
||||
"summary": "List contacts",
|
||||
"description": "Returns the authenticated user's contacts. Supports search (case-insensitive match on first_name, last_name, email, company) and cursor-based pagination. Requires `contacts:read` scope.",
|
||||
"operationId": "listContacts",
|
||||
"parameters": [
|
||||
{ "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Search term for name, email, or company" },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, "description": "Page size" },
|
||||
{ "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of contacts",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["items", "page"],
|
||||
"properties": {
|
||||
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Contact" } },
|
||||
"page": { "$ref": "#/components/schemas/PageInfo" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Contacts"],
|
||||
"summary": "Create a contact",
|
||||
"description": "Creates a new contact for the authenticated user. At least one identifying field (first_name, last_name, email, or phone) must be provided. Requires `contacts:write` scope.",
|
||||
"operationId": "createContact",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first_name": { "type": "string", "example": "Jane" },
|
||||
"last_name": { "type": "string", "example": "Doe" },
|
||||
"email": { "type": "string", "format": "email", "example": "jane@example.com" },
|
||||
"phone": { "type": "string", "example": "+595981000000" },
|
||||
"company": { "type": "string", "example": "Example SA" },
|
||||
"notes": { "type": "string", "example": "Met at event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Contact created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["contact"],
|
||||
"properties": {
|
||||
"contact": { "$ref": "#/components/schemas/Contact" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error (e.g. no identifying field)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/contacts/{id}": {
|
||||
"get": {
|
||||
"tags": ["Contacts"],
|
||||
"summary": "Get a contact",
|
||||
"description": "Returns a single contact by ID. Only the owner can access their contacts. Requires `contacts:read` scope.",
|
||||
"operationId": "getContact",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Contact ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Contact details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["contact"],
|
||||
"properties": {
|
||||
"contact": { "$ref": "#/components/schemas/Contact" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Contact not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": ["Contacts"],
|
||||
"summary": "Update a contact",
|
||||
"description": "Updates a contact's fields. Only the owner can update their contacts. Requires `contacts:write` scope.",
|
||||
"operationId": "updateContact",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Contact ID"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first_name": { "type": "string" },
|
||||
"last_name": { "type": "string" },
|
||||
"email": { "type": "string", "format": "email" },
|
||||
"phone": { "type": "string" },
|
||||
"company": { "type": "string" },
|
||||
"notes": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Contact updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["contact"],
|
||||
"properties": {
|
||||
"contact": { "$ref": "#/components/schemas/Contact" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Contact not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Contacts"],
|
||||
"summary": "Delete a contact",
|
||||
"description": "Soft-deletes a contact. Only the owner can delete their contacts. Requires `contacts:write` scope.",
|
||||
"operationId": "deleteContact",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Contact ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Contact deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Contact not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
438
internal/api/openapi/specs/events.json
Normal file
438
internal/api/openapi/specs/events.json
Normal file
@@ -0,0 +1,438 @@
|
||||
{
|
||||
"paths": {
|
||||
"/events": {
|
||||
"get": {
|
||||
"tags": ["Events"],
|
||||
"summary": "List events",
|
||||
"description": "Returns events within a time range across all accessible calendars. Recurring events are expanded into individual occurrences within the requested range. Supports filtering by calendar, search text, and tags. Uses cursor-based pagination. Requires `events:read` scope.",
|
||||
"operationId": "listEvents",
|
||||
"parameters": [
|
||||
{ "name": "start", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range start (RFC3339)" },
|
||||
{ "name": "end", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range end (RFC3339)" },
|
||||
{ "name": "calendar_id", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Filter by calendar" },
|
||||
{ "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Full-text search on title/description" },
|
||||
{ "name": "tag", "in": "query", "schema": { "type": "string" }, "description": "Filter by tag" },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, "description": "Page size" },
|
||||
{ "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor from previous response" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of events (including expanded recurrence occurrences)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["items", "page"],
|
||||
"properties": {
|
||||
"items": { "type": "array", "items": { "$ref": "#/components/schemas/Event" } },
|
||||
"page": { "$ref": "#/components/schemas/PageInfo" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error (e.g. missing start/end, range too large)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Events"],
|
||||
"summary": "Create an event",
|
||||
"description": "Creates a new event on the specified calendar. Times are converted to UTC for storage. Supports recurrence rules (RFC5545 RRULE), reminders, and tags. User must have editor or owner role on the calendar. Requires `events:write` scope.",
|
||||
"operationId": "createEvent",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["calendar_id", "title", "start_time", "end_time", "timezone"],
|
||||
"properties": {
|
||||
"calendar_id": { "type": "string", "format": "uuid" },
|
||||
"title": { "type": "string", "minLength": 1, "maxLength": 140, "example": "Meeting" },
|
||||
"description": { "type": "string", "example": "Project sync" },
|
||||
"location": { "type": "string", "example": "Zoom" },
|
||||
"start_time": { "type": "string", "format": "date-time", "example": "2026-03-01T14:00:00-03:00" },
|
||||
"end_time": { "type": "string", "format": "date-time", "example": "2026-03-01T15:00:00-03:00" },
|
||||
"timezone": { "type": "string", "example": "America/Asuncion" },
|
||||
"all_day": { "type": "boolean", "default": false },
|
||||
"recurrence_rule": { "type": "string", "nullable": true, "example": "FREQ=WEEKLY;BYDAY=MO,WE,FR" },
|
||||
"reminders": { "type": "array", "items": { "type": "integer", "minimum": 0, "maximum": 10080 }, "description": "Minutes before event to remind", "example": [10, 60] },
|
||||
"tags": { "type": "array", "items": { "type": "string" }, "example": ["work", "sync"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Event created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["event"],
|
||||
"properties": {
|
||||
"event": { "$ref": "#/components/schemas/Event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or calendar permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events/{id}": {
|
||||
"get": {
|
||||
"tags": ["Events"],
|
||||
"summary": "Get an event",
|
||||
"description": "Returns a single event by ID with all related data (reminders, attendees, tags, attachments). Requires `events:read` scope.",
|
||||
"operationId": "getEvent",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Event ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Event details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["event"],
|
||||
"properties": {
|
||||
"event": { "$ref": "#/components/schemas/Event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": ["Events"],
|
||||
"summary": "Update an event",
|
||||
"description": "Updates an existing event. Times are re-validated and converted to UTC. If recurrence rule changes, it is re-validated. Reminders are rescheduled. Requires `events:write` scope and editor/owner role.",
|
||||
"operationId": "updateEvent",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Event ID"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string", "minLength": 1, "maxLength": 140 },
|
||||
"description": { "type": "string", "nullable": true },
|
||||
"location": { "type": "string", "nullable": true },
|
||||
"start_time": { "type": "string", "format": "date-time" },
|
||||
"end_time": { "type": "string", "format": "date-time" },
|
||||
"timezone": { "type": "string" },
|
||||
"all_day": { "type": "boolean" },
|
||||
"recurrence_rule": { "type": "string", "nullable": true },
|
||||
"tags": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Event updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["event"],
|
||||
"properties": {
|
||||
"event": { "$ref": "#/components/schemas/Event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Events"],
|
||||
"summary": "Delete an event",
|
||||
"description": "Soft-deletes an event. Requires `events:write` scope and editor/owner role on the calendar.",
|
||||
"operationId": "deleteEvent",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Event ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Event deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events/{id}/reminders": {
|
||||
"post": {
|
||||
"tags": ["Reminders"],
|
||||
"summary": "Add reminders to an event",
|
||||
"description": "Adds one or more reminders to an event, specified as minutes before the event start. Background jobs are scheduled for each reminder. Requires `events:write` scope.",
|
||||
"operationId": "addReminders",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Event ID"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["minutes_before"],
|
||||
"properties": {
|
||||
"minutes_before": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer", "minimum": 0, "maximum": 10080 },
|
||||
"example": [5, 15, 60]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reminders added, returns updated event",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["event"],
|
||||
"properties": {
|
||||
"event": { "$ref": "#/components/schemas/Event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events/{id}/reminders/{reminderID}": {
|
||||
"delete": {
|
||||
"tags": ["Reminders"],
|
||||
"summary": "Delete a reminder",
|
||||
"description": "Removes a specific reminder from an event. Requires `events:write` scope.",
|
||||
"operationId": "deleteReminder",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Event ID"
|
||||
},
|
||||
{
|
||||
"name": "reminderID",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Reminder ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Reminder deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Event or reminder not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events/{id}/attendees": {
|
||||
"post": {
|
||||
"tags": ["Attendees"],
|
||||
"summary": "Add attendees to an event",
|
||||
"description": "Adds one or more attendees to an event, identified by email or user ID. Initial status is `pending`. Requires `events:write` scope.",
|
||||
"operationId": "addAttendees",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Event ID"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["attendees"],
|
||||
"properties": {
|
||||
"attendees": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": { "type": "string", "format": "email" },
|
||||
"user_id": { "type": "string", "format": "uuid" }
|
||||
}
|
||||
},
|
||||
"example": [
|
||||
{ "email": "guest@example.com" },
|
||||
{ "user_id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Attendees added, returns updated event",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["event"],
|
||||
"properties": {
|
||||
"event": { "$ref": "#/components/schemas/Event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events/{id}/attendees/{attendeeID}": {
|
||||
"put": {
|
||||
"tags": ["Attendees"],
|
||||
"summary": "Update attendee status",
|
||||
"description": "Updates an attendee's RSVP status. The event organizer can update any attendee; attendees can update their own status. Requires `events:write` scope.",
|
||||
"operationId": "updateAttendeeStatus",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Event ID"
|
||||
},
|
||||
{
|
||||
"name": "attendeeID",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Attendee ID"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["status"],
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["accepted", "declined", "tentative"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Attendee updated, returns updated event",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["event"],
|
||||
"properties": {
|
||||
"event": { "$ref": "#/components/schemas/Event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Event or attendee not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Attendees"],
|
||||
"summary": "Remove an attendee",
|
||||
"description": "Removes an attendee from an event. Requires `events:write` scope.",
|
||||
"operationId": "deleteAttendee",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Event ID"
|
||||
},
|
||||
{
|
||||
"name": "attendeeID",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Attendee ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Attendee removed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Event or attendee not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
internal/api/openapi/specs/ics.json
Normal file
82
internal/api/openapi/specs/ics.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"paths": {
|
||||
"/calendars/{id}/export.ics": {
|
||||
"get": {
|
||||
"tags": ["ICS"],
|
||||
"summary": "Export calendar as ICS",
|
||||
"description": "Exports all events from a calendar in ICS (iCalendar) format. Requires `calendars:read` scope.",
|
||||
"operationId": "exportCalendarICS",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Calendar ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ICS calendar file",
|
||||
"content": {
|
||||
"text/calendar": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/calendars/import": {
|
||||
"post": {
|
||||
"tags": ["ICS"],
|
||||
"summary": "Import an ICS file",
|
||||
"description": "Imports events from an ICS file into a specified calendar. The file is sent as multipart form data. Requires `calendars:write` scope.",
|
||||
"operationId": "importCalendarICS",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["calendar_id", "file"],
|
||||
"properties": {
|
||||
"calendar_id": { "type": "string", "format": "uuid", "description": "Target calendar ID" },
|
||||
"file": { "type": "string", "format": "binary", "description": "ICS file to import" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Import successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["ok", "imported"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean", "example": true },
|
||||
"imported": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"events": { "type": "integer", "example": 12 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error or invalid ICS", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
internal/api/openapi/specs/schemas.json
Normal file
181
internal/api/openapi/specs/schemas.json
Normal file
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"BearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "JWT access token obtained from /auth/login or /auth/register"
|
||||
},
|
||||
"ApiKeyAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "API key token for agent/programmatic access with scoped permissions"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"required": ["error", "code"],
|
||||
"properties": {
|
||||
"error": { "type": "string", "description": "Human-readable error message" },
|
||||
"code": { "type": "string", "description": "Machine-readable error code", "enum": ["VALIDATION_ERROR", "AUTH_REQUIRED", "AUTH_INVALID", "FORBIDDEN", "NOT_FOUND", "CONFLICT", "RATE_LIMITED", "INTERNAL"] },
|
||||
"details": { "description": "Additional error context" }
|
||||
}
|
||||
},
|
||||
"OkResponse": {
|
||||
"type": "object",
|
||||
"required": ["ok"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean", "example": true }
|
||||
}
|
||||
},
|
||||
"PageInfo": {
|
||||
"type": "object",
|
||||
"required": ["limit", "next_cursor"],
|
||||
"properties": {
|
||||
"limit": { "type": "integer", "example": 50 },
|
||||
"next_cursor": { "type": "string", "nullable": true, "description": "Opaque cursor for next page, null if no more results" }
|
||||
}
|
||||
},
|
||||
"User": {
|
||||
"type": "object",
|
||||
"required": ["id", "email", "timezone", "created_at", "updated_at"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"email": { "type": "string", "format": "email" },
|
||||
"timezone": { "type": "string", "example": "America/New_York", "description": "IANA timezone name" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"Calendar": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "color", "is_public", "created_at", "updated_at"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" },
|
||||
"is_public": { "type": "boolean" },
|
||||
"role": { "type": "string", "enum": ["owner", "editor", "viewer"], "description": "Current user's role on this calendar" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"Reminder": {
|
||||
"type": "object",
|
||||
"required": ["id", "minutes_before"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"minutes_before": { "type": "integer", "minimum": 0, "maximum": 10080 }
|
||||
}
|
||||
},
|
||||
"Attendee": {
|
||||
"type": "object",
|
||||
"required": ["id", "status"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"user_id": { "type": "string", "format": "uuid", "nullable": true },
|
||||
"email": { "type": "string", "format": "email", "nullable": true },
|
||||
"status": { "type": "string", "enum": ["pending", "accepted", "declined", "tentative"] }
|
||||
}
|
||||
},
|
||||
"Attachment": {
|
||||
"type": "object",
|
||||
"required": ["id", "file_url"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"file_url": { "type": "string", "format": "uri" }
|
||||
}
|
||||
},
|
||||
"Event": {
|
||||
"type": "object",
|
||||
"required": ["id", "calendar_id", "title", "start_time", "end_time", "timezone", "all_day", "created_by", "updated_by", "created_at", "updated_at"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"calendar_id": { "type": "string", "format": "uuid" },
|
||||
"title": { "type": "string", "minLength": 1, "maxLength": 140 },
|
||||
"description": { "type": "string", "nullable": true },
|
||||
"location": { "type": "string", "nullable": true },
|
||||
"start_time": { "type": "string", "format": "date-time", "description": "UTC start time in RFC3339" },
|
||||
"end_time": { "type": "string", "format": "date-time", "description": "UTC end time in RFC3339" },
|
||||
"timezone": { "type": "string", "example": "America/Asuncion", "description": "Original IANA timezone" },
|
||||
"all_day": { "type": "boolean" },
|
||||
"recurrence_rule": { "type": "string", "nullable": true, "description": "RFC5545 RRULE string", "example": "FREQ=WEEKLY;BYDAY=MO,WE,FR" },
|
||||
"is_occurrence": { "type": "boolean", "description": "True if this is an expanded recurrence occurrence" },
|
||||
"occurrence_start_time": { "type": "string", "format": "date-time", "nullable": true },
|
||||
"occurrence_end_time": { "type": "string", "format": "date-time", "nullable": true },
|
||||
"created_by": { "type": "string", "format": "uuid" },
|
||||
"updated_by": { "type": "string", "format": "uuid" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" },
|
||||
"reminders": { "type": "array", "items": { "$ref": "#/components/schemas/Reminder" } },
|
||||
"attendees": { "type": "array", "items": { "$ref": "#/components/schemas/Attendee" } },
|
||||
"tags": { "type": "array", "items": { "type": "string" } },
|
||||
"attachments": { "type": "array", "items": { "$ref": "#/components/schemas/Attachment" } }
|
||||
}
|
||||
},
|
||||
"Contact": {
|
||||
"type": "object",
|
||||
"required": ["id", "created_at", "updated_at"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"first_name": { "type": "string", "nullable": true },
|
||||
"last_name": { "type": "string", "nullable": true },
|
||||
"email": { "type": "string", "format": "email", "nullable": true },
|
||||
"phone": { "type": "string", "nullable": true },
|
||||
"company": { "type": "string", "nullable": true },
|
||||
"notes": { "type": "string", "nullable": true },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"APIKeyResponse": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "created_at"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"name": { "type": "string" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"revoked_at": { "type": "string", "format": "date-time", "nullable": true },
|
||||
"token": { "type": "string", "description": "Raw token, only returned once on creation" }
|
||||
}
|
||||
},
|
||||
"CalendarMember": {
|
||||
"type": "object",
|
||||
"required": ["user_id", "email", "role"],
|
||||
"properties": {
|
||||
"user_id": { "type": "string", "format": "uuid" },
|
||||
"email": { "type": "string", "format": "email" },
|
||||
"role": { "type": "string", "enum": ["owner", "editor", "viewer"] }
|
||||
}
|
||||
},
|
||||
"BusyBlock": {
|
||||
"type": "object",
|
||||
"required": ["start", "end", "event_id"],
|
||||
"properties": {
|
||||
"start": { "type": "string", "format": "date-time" },
|
||||
"end": { "type": "string", "format": "date-time" },
|
||||
"event_id": { "type": "string", "format": "uuid" }
|
||||
}
|
||||
},
|
||||
"WorkingHourSlot": {
|
||||
"type": "object",
|
||||
"required": ["start", "end"],
|
||||
"properties": {
|
||||
"start": { "type": "string", "example": "09:00", "pattern": "^\\d{2}:\\d{2}$" },
|
||||
"end": { "type": "string", "example": "17:00", "pattern": "^\\d{2}:\\d{2}$" }
|
||||
}
|
||||
},
|
||||
"TimeSlot": {
|
||||
"type": "object",
|
||||
"required": ["start", "end"],
|
||||
"properties": {
|
||||
"start": { "type": "string", "format": "date-time" },
|
||||
"end": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
internal/api/openapi/specs/users.json
Normal file
76
internal/api/openapi/specs/users.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"paths": {
|
||||
"/users/me": {
|
||||
"get": {
|
||||
"tags": ["Users"],
|
||||
"summary": "Get current user profile",
|
||||
"description": "Returns the full profile of the authenticated user.",
|
||||
"operationId": "getUserProfile",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User profile",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["user"],
|
||||
"properties": {
|
||||
"user": { "$ref": "#/components/schemas/User" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": ["Users"],
|
||||
"summary": "Update current user profile",
|
||||
"description": "Updates the authenticated user's profile fields such as timezone.",
|
||||
"operationId": "updateUserProfile",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timezone": { "type": "string", "example": "America/Asuncion", "description": "IANA timezone name" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["user"],
|
||||
"properties": {
|
||||
"user": { "$ref": "#/components/schemas/User" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Users"],
|
||||
"summary": "Delete current user (soft delete)",
|
||||
"description": "Soft-deletes the authenticated user account along with all associated calendars, events, contacts, and revokes all API keys.",
|
||||
"operationId": "deleteUser",
|
||||
"responses": {
|
||||
"200": { "description": "User deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
internal/api/routes.go
Normal file
128
internal/api/routes.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/calendarapi/internal/api/handlers"
|
||||
"github.com/calendarapi/internal/api/openapi"
|
||||
mw "github.com/calendarapi/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
Auth *handlers.AuthHandler
|
||||
User *handlers.UserHandler
|
||||
Calendar *handlers.CalendarHandler
|
||||
Sharing *handlers.SharingHandler
|
||||
Event *handlers.EventHandler
|
||||
Reminder *handlers.ReminderHandler
|
||||
Attendee *handlers.AttendeeHandler
|
||||
Contact *handlers.ContactHandler
|
||||
Availability *handlers.AvailabilityHandler
|
||||
Booking *handlers.BookingHandler
|
||||
APIKey *handlers.APIKeyHandler
|
||||
ICS *handlers.ICSHandler
|
||||
}
|
||||
|
||||
func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimiter) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.RealIP)
|
||||
r.Use(rateLimiter.Limit)
|
||||
|
||||
// OpenAPI spec and Swagger UI
|
||||
r.Get("/openapi.json", openapi.SpecHandler)
|
||||
r.Get("/docs", openapi.DocsHandler)
|
||||
|
||||
// Public routes (no auth)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Post("/auth/register", h.Auth.Register)
|
||||
r.Post("/auth/login", h.Auth.Login)
|
||||
r.Post("/auth/refresh", h.Auth.Refresh)
|
||||
|
||||
r.Get("/booking/{token}/availability", h.Booking.GetAvailability)
|
||||
r.Post("/booking/{token}/reserve", h.Booking.Reserve)
|
||||
})
|
||||
|
||||
// Authenticated routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMW.Authenticate)
|
||||
|
||||
// Auth
|
||||
r.Post("/auth/logout", h.Auth.Logout)
|
||||
r.Get("/auth/me", h.Auth.Me)
|
||||
|
||||
// Users
|
||||
r.Get("/users/me", h.User.GetMe)
|
||||
r.Put("/users/me", h.User.UpdateMe)
|
||||
r.Delete("/users/me", h.User.DeleteMe)
|
||||
|
||||
// API Keys
|
||||
r.Post("/api-keys", h.APIKey.Create)
|
||||
r.Get("/api-keys", h.APIKey.List)
|
||||
r.Delete("/api-keys/{id}", h.APIKey.Revoke)
|
||||
|
||||
// Calendars
|
||||
r.Route("/calendars", func(r chi.Router) {
|
||||
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.List)
|
||||
r.With(mw.RequireScope("calendars", "write")).Post("/", h.Calendar.Create)
|
||||
r.With(mw.RequireScope("calendars", "write")).Post("/import", h.ICS.Import)
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.Get)
|
||||
r.With(mw.RequireScope("calendars", "write")).Put("/", h.Calendar.Update)
|
||||
r.With(mw.RequireScope("calendars", "write")).Delete("/", h.Calendar.Delete)
|
||||
|
||||
// Sharing
|
||||
r.With(mw.RequireScope("calendars", "write")).Post("/share", h.Sharing.Share)
|
||||
r.With(mw.RequireScope("calendars", "read")).Get("/members", h.Sharing.ListMembers)
|
||||
r.With(mw.RequireScope("calendars", "write")).Delete("/members/{userID}", h.Sharing.RemoveMember)
|
||||
|
||||
// Booking link
|
||||
r.With(mw.RequireScope("booking", "write")).Post("/booking-link", h.Booking.CreateLink)
|
||||
|
||||
// ICS
|
||||
r.With(mw.RequireScope("calendars", "read")).Get("/export.ics", h.ICS.Export)
|
||||
})
|
||||
})
|
||||
|
||||
// Events
|
||||
r.Route("/events", func(r chi.Router) {
|
||||
r.With(mw.RequireScope("events", "read")).Get("/", h.Event.List)
|
||||
r.With(mw.RequireScope("events", "write")).Post("/", h.Event.Create)
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.With(mw.RequireScope("events", "read")).Get("/", h.Event.Get)
|
||||
r.With(mw.RequireScope("events", "write")).Put("/", h.Event.Update)
|
||||
r.With(mw.RequireScope("events", "write")).Delete("/", h.Event.Delete)
|
||||
|
||||
// Reminders
|
||||
r.With(mw.RequireScope("events", "write")).Post("/reminders", h.Reminder.Add)
|
||||
r.With(mw.RequireScope("events", "write")).Delete("/reminders/{reminderID}", h.Reminder.Delete)
|
||||
|
||||
// Attendees
|
||||
r.With(mw.RequireScope("events", "write")).Post("/attendees", h.Attendee.Add)
|
||||
r.With(mw.RequireScope("events", "write")).Put("/attendees/{attendeeID}", h.Attendee.UpdateStatus)
|
||||
r.With(mw.RequireScope("events", "write")).Delete("/attendees/{attendeeID}", h.Attendee.Delete)
|
||||
})
|
||||
})
|
||||
|
||||
// Contacts
|
||||
r.Route("/contacts", func(r chi.Router) {
|
||||
r.With(mw.RequireScope("contacts", "read")).Get("/", h.Contact.List)
|
||||
r.With(mw.RequireScope("contacts", "write")).Post("/", h.Contact.Create)
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.With(mw.RequireScope("contacts", "read")).Get("/", h.Contact.Get)
|
||||
r.With(mw.RequireScope("contacts", "write")).Put("/", h.Contact.Update)
|
||||
r.With(mw.RequireScope("contacts", "write")).Delete("/", h.Contact.Delete)
|
||||
})
|
||||
})
|
||||
|
||||
// Availability
|
||||
r.With(mw.RequireScope("availability", "read")).Get("/availability", h.Availability.Get)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user