first commit

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
package handlers
import (
"net/http"
"time"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type BookingHandler struct {
bookingSvc *service.BookingService
}
func NewBookingHandler(bookingSvc *service.BookingService) *BookingHandler {
return &BookingHandler{bookingSvc: bookingSvc}
}
func (h *BookingHandler) CreateLink(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req models.BookingConfig
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
link, err := h.bookingSvc.CreateLink(r.Context(), userID, calID, req)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, link)
}
func (h *BookingHandler) GetAvailability(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
q := r.URL.Query()
startStr := q.Get("start")
endStr := q.Get("end")
if startStr == "" || endStr == "" {
utils.WriteError(w, models.NewValidationError("start and end required"))
return
}
start, err := time.Parse(time.RFC3339, startStr)
if err != nil {
utils.WriteError(w, models.NewValidationError("invalid start time"))
return
}
end, err := time.Parse(time.RFC3339, endStr)
if err != nil {
utils.WriteError(w, models.NewValidationError("invalid end time"))
return
}
result, err := h.bookingSvc.GetAvailability(r.Context(), token, start, end)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, result)
}
func (h *BookingHandler) Reserve(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
var req struct {
Name string `json:"name"`
Email string `json:"email"`
SlotStart time.Time `json:"slot_start"`
SlotEnd time.Time `json:"slot_end"`
Notes *string `json:"notes"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
event, err := h.bookingSvc.Reserve(r.Context(), token, req.Name, req.Email, req.SlotStart, req.SlotEnd, req.Notes)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "event": event})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
package handlers
import (
"net/http"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/service"
"github.com/calendarapi/internal/utils"
"github.com/go-chi/chi/v5"
)
type SharingHandler struct {
calSvc *service.CalendarService
}
func NewSharingHandler(calSvc *service.CalendarService) *SharingHandler {
return &SharingHandler{calSvc: calSvc}
}
func (h *SharingHandler) Share(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
var req struct {
Target struct {
Email string `json:"email"`
} `json:"target"`
Role string `json:"role"`
}
if err := utils.DecodeJSON(r, &req); err != nil {
utils.WriteError(w, err)
return
}
if err := h.calSvc.Share(r.Context(), userID, calID, req.Target.Email, req.Role); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}
func (h *SharingHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
members, err := h.calSvc.ListMembers(r.Context(), userID, calID)
if err != nil {
utils.WriteError(w, err)
return
}
utils.WriteList(w, members, models.PageInfo{Limit: utils.DefaultLimit})
}
func (h *SharingHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.GetUserID(r.Context())
calID, err := utils.ValidateUUID(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, err)
return
}
targetID, err := utils.ValidateUUID(chi.URLParam(r, "userID"))
if err != nil {
utils.WriteError(w, err)
return
}
if err := h.calSvc.RemoveMember(r.Context(), userID, calID, targetID); err != nil {
utils.WriteError(w, err)
return
}
utils.WriteOK(w)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
{
"paths": {
"/availability": {
"get": {
"tags": ["Availability"],
"summary": "Get calendar availability",
"description": "Returns busy time blocks for a calendar within a given range. Includes expanded recurring event occurrences. User must have at least viewer role on the calendar. Requires `availability:read` scope.",
"operationId": "getAvailability",
"parameters": [
{ "name": "calendar_id", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Calendar to query" },
{ "name": "start", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range start (RFC3339)" },
{ "name": "end", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range end (RFC3339)" }
],
"responses": {
"200": {
"description": "Availability with busy blocks",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["calendar_id", "range_start", "range_end", "busy"],
"properties": {
"calendar_id": { "type": "string", "format": "uuid" },
"range_start": { "type": "string", "format": "date-time" },
"range_end": { "type": "string", "format": "date-time" },
"busy": {
"type": "array",
"items": { "$ref": "#/components/schemas/BusyBlock" }
}
}
}
}
}
},
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
{
"paths": {
"/calendars/{id}/export.ics": {
"get": {
"tags": ["ICS"],
"summary": "Export calendar as ICS",
"description": "Exports all events from a calendar in ICS (iCalendar) format. Requires `calendars:read` scope.",
"operationId": "exportCalendarICS",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "Calendar ID"
}
],
"responses": {
"200": {
"description": "ICS calendar file",
"content": {
"text/calendar": {
"schema": { "type": "string" }
}
}
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/calendars/import": {
"post": {
"tags": ["ICS"],
"summary": "Import an ICS file",
"description": "Imports events from an ICS file into a specified calendar. The file is sent as multipart form data. Requires `calendars:write` scope.",
"operationId": "importCalendarICS",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["calendar_id", "file"],
"properties": {
"calendar_id": { "type": "string", "format": "uuid", "description": "Target calendar ID" },
"file": { "type": "string", "format": "binary", "description": "ICS file to import" }
}
}
}
}
},
"responses": {
"200": {
"description": "Import successful",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["ok", "imported"],
"properties": {
"ok": { "type": "boolean", "example": true },
"imported": {
"type": "object",
"properties": {
"events": { "type": "integer", "example": 12 }
}
}
}
}
}
}
},
"400": { "description": "Validation error or invalid ICS", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
}
}

View File

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

View File

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

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

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

67
internal/auth/jwt.go Normal file
View File

@@ -0,0 +1,67 @@
package auth
import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
AccessTokenDuration = 15 * time.Minute
RefreshTokenDuration = 30 * 24 * time.Hour
)
type JWTManager struct {
secret []byte
}
type Claims struct {
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
func NewJWTManager(secret string) *JWTManager {
return &JWTManager{secret: []byte(secret)}
}
func (m *JWTManager) GenerateAccessToken(userID uuid.UUID) (string, error) {
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *JWTManager) GenerateRefreshToken(userID uuid.UUID) (string, error) {
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, jwt.ErrSignatureInvalid
}
return claims, nil
}

59
internal/config/config.go Normal file
View File

@@ -0,0 +1,59 @@
package config
import (
"bufio"
"os"
"strings"
)
type Config struct {
DatabaseURL string
JWTSecret string
RedisAddr string
ServerPort string
Env string
}
func Load() *Config {
loadEnvFile(".env")
return &Config{
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/calendarapi?sslmode=disable"),
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
RedisAddr: os.Getenv("REDIS_ADDR"),
ServerPort: getEnv("SERVER_PORT", "8080"),
Env: getEnv("ENV", "development"),
}
}
func loadEnvFile(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
if os.Getenv(k) == "" {
os.Setenv(k, v)
}
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

102
internal/middleware/auth.go Normal file
View File

@@ -0,0 +1,102 @@
package middleware
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"github.com/calendarapi/internal/auth"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/jackc/pgx/v5"
)
type AuthMiddleware struct {
jwt *auth.JWTManager
queries *repository.Queries
}
func NewAuthMiddleware(jwt *auth.JWTManager, queries *repository.Queries) *AuthMiddleware {
return &AuthMiddleware{jwt: jwt, queries: queries}
}
func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if token := extractBearerToken(r); token != "" {
claims, err := m.jwt.ValidateToken(token)
if err != nil {
utils.WriteError(w, models.ErrAuthInvalid)
return
}
if _, err := m.queries.GetUserByID(ctx, utils.ToPgUUID(claims.UserID)); err != nil {
if err == pgx.ErrNoRows {
utils.WriteError(w, models.ErrAuthInvalid)
return
}
utils.WriteError(w, models.ErrInternal)
return
}
ctx = SetUserID(ctx, claims.UserID)
ctx = SetAuthMethod(ctx, "jwt")
next.ServeHTTP(w, r.WithContext(ctx))
return
}
if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
hash := SHA256Hash(apiKey)
key, err := m.queries.GetAPIKeyByHash(ctx, hash)
if err != nil {
if err == pgx.ErrNoRows {
utils.WriteError(w, models.ErrAuthInvalid)
return
}
utils.WriteError(w, models.ErrInternal)
return
}
var scopes Scopes
if err := json.Unmarshal(key.Scopes, &scopes); err != nil {
utils.WriteError(w, models.ErrInternal)
return
}
ctx = SetUserID(ctx, utils.FromPgUUID(key.UserID))
ctx = SetAuthMethod(ctx, "api_key")
ctx = SetScopes(ctx, scopes)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
utils.WriteError(w, models.ErrAuthRequired)
})
}
func RequireScope(resource, action string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !HasScope(r.Context(), resource, action) {
utils.WriteError(w, models.ErrForbidden)
return
}
next.ServeHTTP(w, r.WithContext(r.Context()))
})
}
}
func extractBearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
func SHA256Hash(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}

View File

@@ -0,0 +1,64 @@
package middleware
import (
"context"
"github.com/google/uuid"
)
type contextKey string
const (
userIDKey contextKey = "user_id"
authMethodKey contextKey = "auth_method"
scopesKey contextKey = "scopes"
)
type Scopes map[string][]string
func SetUserID(ctx context.Context, id uuid.UUID) context.Context {
return context.WithValue(ctx, userIDKey, id)
}
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(userIDKey).(uuid.UUID)
return id, ok
}
func SetAuthMethod(ctx context.Context, method string) context.Context {
return context.WithValue(ctx, authMethodKey, method)
}
func GetAuthMethod(ctx context.Context) string {
m, _ := ctx.Value(authMethodKey).(string)
return m
}
func SetScopes(ctx context.Context, scopes Scopes) context.Context {
return context.WithValue(ctx, scopesKey, scopes)
}
func GetScopes(ctx context.Context) Scopes {
s, _ := ctx.Value(scopesKey).(Scopes)
return s
}
func HasScope(ctx context.Context, resource, action string) bool {
if GetAuthMethod(ctx) == "jwt" {
return true
}
scopes := GetScopes(ctx)
if scopes == nil {
return false
}
actions, ok := scopes[resource]
if !ok {
return false
}
for _, a := range actions {
if a == action {
return true
}
}
return false
}

View File

@@ -0,0 +1,86 @@
package middleware
import (
"net/http"
"sync"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/utils"
)
type visitor struct {
tokens float64
lastSeen time.Time
}
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
rate float64
burst float64
}
func NewRateLimiter(ratePerSecond float64, burst int) *RateLimiter {
rl := &RateLimiter{
visitors: make(map[string]*visitor),
rate: ratePerSecond,
burst: float64(burst),
}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
}
if !rl.allow(ip) {
utils.WriteError(w, models.ErrRateLimited)
return
}
next.ServeHTTP(w, r)
})
}
func (rl *RateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
v, exists := rl.visitors[key]
now := time.Now()
if !exists {
rl.visitors[key] = &visitor{tokens: rl.burst - 1, lastSeen: now}
return true
}
elapsed := now.Sub(v.lastSeen).Seconds()
v.tokens += elapsed * rl.rate
if v.tokens > rl.burst {
v.tokens = rl.burst
}
v.lastSeen = now
if v.tokens < 1 {
return false
}
v.tokens--
return true
}
func (rl *RateLimiter) cleanup() {
for {
time.Sleep(5 * time.Minute)
rl.mu.Lock()
for key, v := range rl.visitors {
if time.Since(v.lastSeen) > 10*time.Minute {
delete(rl.visitors, key)
}
}
rl.mu.Unlock()
}
}

52
internal/models/errors.go Normal file
View File

@@ -0,0 +1,52 @@
package models
import (
"errors"
"net/http"
)
type AppError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"error"`
Details string `json:"details,omitempty"`
}
func (e *AppError) Error() string {
return e.Message
}
var (
ErrAuthRequired = &AppError{Status: http.StatusUnauthorized, Code: "AUTH_REQUIRED", Message: "Authentication required"}
ErrAuthInvalid = &AppError{Status: http.StatusUnauthorized, Code: "AUTH_INVALID", Message: "Invalid credentials"}
ErrForbidden = &AppError{Status: http.StatusForbidden, Code: "FORBIDDEN", Message: "Permission denied"}
ErrNotFound = &AppError{Status: http.StatusNotFound, Code: "NOT_FOUND", Message: "Resource not found"}
ErrValidation = &AppError{Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: "Validation failed"}
ErrConflict = &AppError{Status: http.StatusConflict, Code: "CONFLICT", Message: "Resource conflict"}
ErrRateLimited = &AppError{Status: http.StatusTooManyRequests, Code: "RATE_LIMITED", Message: "Too many requests"}
ErrInternal = &AppError{Status: http.StatusInternalServerError, Code: "INTERNAL", Message: "Internal server error"}
)
func NewValidationError(detail string) *AppError {
return &AppError{Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: "Validation failed", Details: detail}
}
func NewConflictError(detail string) *AppError {
return &AppError{Status: http.StatusConflict, Code: "CONFLICT", Message: detail}
}
func NewNotFoundError(detail string) *AppError {
return &AppError{Status: http.StatusNotFound, Code: "NOT_FOUND", Message: detail}
}
func NewForbiddenError(detail string) *AppError {
return &AppError{Status: http.StatusForbidden, Code: "FORBIDDEN", Message: detail}
}
func IsAppError(err error) (*AppError, bool) {
var appErr *AppError
if errors.As(err, &appErr) {
return appErr, true
}
return nil, false
}

164
internal/models/models.go Normal file
View File

@@ -0,0 +1,164 @@
package models
import (
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Timezone string `json:"timezone"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Calendar struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
Role string `json:"role,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Event struct {
ID uuid.UUID `json:"id"`
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"`
IsOccurrence bool `json:"is_occurrence,omitempty"`
OccurrenceStartTime *time.Time `json:"occurrence_start_time,omitempty"`
OccurrenceEndTime *time.Time `json:"occurrence_end_time,omitempty"`
CreatedBy uuid.UUID `json:"created_by"`
UpdatedBy uuid.UUID `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Reminders []Reminder `json:"reminders"`
Attendees []Attendee `json:"attendees"`
Tags []string `json:"tags"`
Attachments []Attachment `json:"attachments"`
}
type Reminder struct {
ID uuid.UUID `json:"id"`
MinutesBefore int32 `json:"minutes_before"`
}
type Attendee struct {
ID uuid.UUID `json:"id"`
UserID *uuid.UUID `json:"user_id"`
Email *string `json:"email"`
Status string `json:"status"`
}
type Attachment struct {
ID uuid.UUID `json:"id"`
FileURL string `json:"file_url"`
}
type Contact struct {
ID uuid.UUID `json:"id"`
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"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type APIKeyResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
RevokedAt *time.Time `json:"revoked_at"`
Token string `json:"token,omitempty"`
}
type CalendarMember struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
}
type BusyBlock struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
EventID uuid.UUID `json:"event_id"`
}
type AvailabilityResponse struct {
CalendarID uuid.UUID `json:"calendar_id"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
Busy []BusyBlock `json:"busy"`
}
type BookingLink struct {
Token string `json:"token"`
PublicURL string `json:"public_url,omitempty"`
Settings BookingConfig `json:"settings"`
}
type BookingConfig struct {
DurationMinutes int `json:"duration_minutes"`
BufferMinutes int `json:"buffer_minutes"`
Timezone string `json:"timezone"`
WorkingHours map[string][]Slot `json:"working_hours"`
Active bool `json:"active"`
}
type Slot struct {
Start string `json:"start"`
End string `json:"end"`
}
type BookingAvailability struct {
Token string `json:"token"`
Timezone string `json:"timezone"`
DurationMinutes int `json:"duration_minutes"`
Slots []TimeSlot `json:"slots"`
}
type TimeSlot struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
type PageInfo struct {
Limit int `json:"limit"`
NextCursor *string `json:"next_cursor"`
}
type ListResponse struct {
Items interface{} `json:"items"`
Page PageInfo `json:"page"`
}
type AuthTokens struct {
User User `json:"user"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type AuditEntry struct {
EntityType string `json:"entity_type"`
EntityID uuid.UUID `json:"entity_id"`
Action string `json:"action"`
UserID uuid.UUID `json:"user_id"`
}

View File

@@ -0,0 +1,134 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: api_keys.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createAPIKey = `-- name: CreateAPIKey :one
INSERT INTO api_keys (id, user_id, name, key_hash, scopes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, name, key_hash, scopes, created_at, revoked_at
`
type CreateAPIKeyParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Scopes []byte `json:"scopes"`
}
func (q *Queries) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (ApiKey, error) {
row := q.db.QueryRow(ctx, createAPIKey,
arg.ID,
arg.UserID,
arg.Name,
arg.KeyHash,
arg.Scopes,
)
var i ApiKey
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyHash,
&i.Scopes,
&i.CreatedAt,
&i.RevokedAt,
)
return i, err
}
const getAPIKeyByHash = `-- name: GetAPIKeyByHash :one
SELECT id, user_id, name, key_hash, scopes, created_at, revoked_at
FROM api_keys
WHERE key_hash = $1 AND revoked_at IS NULL
`
func (q *Queries) GetAPIKeyByHash(ctx context.Context, keyHash string) (ApiKey, error) {
row := q.db.QueryRow(ctx, getAPIKeyByHash, keyHash)
var i ApiKey
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyHash,
&i.Scopes,
&i.CreatedAt,
&i.RevokedAt,
)
return i, err
}
const listAPIKeysByUser = `-- name: ListAPIKeysByUser :many
SELECT id, name, scopes, created_at, revoked_at
FROM api_keys
WHERE user_id = $1
ORDER BY created_at DESC
`
type ListAPIKeysByUserRow struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Scopes []byte `json:"scopes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
}
func (q *Queries) ListAPIKeysByUser(ctx context.Context, userID pgtype.UUID) ([]ListAPIKeysByUserRow, error) {
rows, err := q.db.Query(ctx, listAPIKeysByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPIKeysByUserRow{}
for rows.Next() {
var i ListAPIKeysByUserRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Scopes,
&i.CreatedAt,
&i.RevokedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeAPIKey = `-- name: RevokeAPIKey :exec
UPDATE api_keys SET revoked_at = now()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
`
type RevokeAPIKeyParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) error {
_, err := q.db.Exec(ctx, revokeAPIKey, arg.ID, arg.UserID)
return err
}
const revokeAllUserAPIKeys = `-- name: RevokeAllUserAPIKeys :exec
UPDATE api_keys SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL
`
func (q *Queries) RevokeAllUserAPIKeys(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, revokeAllUserAPIKeys, userID)
return err
}

View File

@@ -0,0 +1,72 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: attachments.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createAttachment = `-- name: CreateAttachment :one
INSERT INTO event_attachments (id, event_id, file_url)
VALUES ($1, $2, $3)
RETURNING id, event_id, file_url
`
type CreateAttachmentParams struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
FileUrl string `json:"file_url"`
}
func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (EventAttachment, error) {
row := q.db.QueryRow(ctx, createAttachment, arg.ID, arg.EventID, arg.FileUrl)
var i EventAttachment
err := row.Scan(&i.ID, &i.EventID, &i.FileUrl)
return i, err
}
const deleteAttachment = `-- name: DeleteAttachment :exec
DELETE FROM event_attachments WHERE id = $1 AND event_id = $2
`
type DeleteAttachmentParams struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
}
func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentParams) error {
_, err := q.db.Exec(ctx, deleteAttachment, arg.ID, arg.EventID)
return err
}
const listAttachmentsByEvent = `-- name: ListAttachmentsByEvent :many
SELECT id, event_id, file_url
FROM event_attachments
WHERE event_id = $1
ORDER BY id ASC
`
func (q *Queries) ListAttachmentsByEvent(ctx context.Context, eventID pgtype.UUID) ([]EventAttachment, error) {
rows, err := q.db.Query(ctx, listAttachmentsByEvent, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []EventAttachment{}
for rows.Next() {
var i EventAttachment
if err := rows.Scan(&i.ID, &i.EventID, &i.FileUrl); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,135 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: attendees.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createAttendee = `-- name: CreateAttendee :one
INSERT INTO event_attendees (id, event_id, user_id, email, status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING id, event_id, user_id, email, status
`
type CreateAttendeeParams struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
UserID pgtype.UUID `json:"user_id"`
Email pgtype.Text `json:"email"`
}
func (q *Queries) CreateAttendee(ctx context.Context, arg CreateAttendeeParams) (EventAttendee, error) {
row := q.db.QueryRow(ctx, createAttendee,
arg.ID,
arg.EventID,
arg.UserID,
arg.Email,
)
var i EventAttendee
err := row.Scan(
&i.ID,
&i.EventID,
&i.UserID,
&i.Email,
&i.Status,
)
return i, err
}
const deleteAttendee = `-- name: DeleteAttendee :exec
DELETE FROM event_attendees
WHERE id = $1 AND event_id = $2
`
type DeleteAttendeeParams struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
}
func (q *Queries) DeleteAttendee(ctx context.Context, arg DeleteAttendeeParams) error {
_, err := q.db.Exec(ctx, deleteAttendee, arg.ID, arg.EventID)
return err
}
const getAttendeeByID = `-- name: GetAttendeeByID :one
SELECT id, event_id, user_id, email, status
FROM event_attendees
WHERE id = $1
`
func (q *Queries) GetAttendeeByID(ctx context.Context, id pgtype.UUID) (EventAttendee, error) {
row := q.db.QueryRow(ctx, getAttendeeByID, id)
var i EventAttendee
err := row.Scan(
&i.ID,
&i.EventID,
&i.UserID,
&i.Email,
&i.Status,
)
return i, err
}
const listAttendeesByEvent = `-- name: ListAttendeesByEvent :many
SELECT id, event_id, user_id, email, status
FROM event_attendees
WHERE event_id = $1
ORDER BY id ASC
`
func (q *Queries) ListAttendeesByEvent(ctx context.Context, eventID pgtype.UUID) ([]EventAttendee, error) {
rows, err := q.db.Query(ctx, listAttendeesByEvent, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []EventAttendee{}
for rows.Next() {
var i EventAttendee
if err := rows.Scan(
&i.ID,
&i.EventID,
&i.UserID,
&i.Email,
&i.Status,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateAttendeeStatus = `-- name: UpdateAttendeeStatus :one
UPDATE event_attendees
SET status = $2
WHERE id = $1
RETURNING id, event_id, user_id, email, status
`
type UpdateAttendeeStatusParams struct {
ID pgtype.UUID `json:"id"`
Status string `json:"status"`
}
func (q *Queries) UpdateAttendeeStatus(ctx context.Context, arg UpdateAttendeeStatusParams) (EventAttendee, error) {
row := q.db.QueryRow(ctx, updateAttendeeStatus, arg.ID, arg.Status)
var i EventAttendee
err := row.Scan(
&i.ID,
&i.EventID,
&i.UserID,
&i.Email,
&i.Status,
)
return i, err
}

View File

@@ -0,0 +1,34 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: audit_logs.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createAuditLog = `-- name: CreateAuditLog :exec
INSERT INTO audit_logs (entity_type, entity_id, action, user_id)
VALUES ($1, $2, $3, $4)
`
type CreateAuditLogParams struct {
EntityType string `json:"entity_type"`
EntityID pgtype.UUID `json:"entity_id"`
Action string `json:"action"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) error {
_, err := q.db.Exec(ctx, createAuditLog,
arg.EntityType,
arg.EntityID,
arg.Action,
arg.UserID,
)
return err
}

View File

@@ -0,0 +1,148 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: booking_links.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createBookingLink = `-- name: CreateBookingLink :one
INSERT INTO booking_links (id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active, created_at, updated_at
`
type CreateBookingLinkParams struct {
ID pgtype.UUID `json:"id"`
CalendarID pgtype.UUID `json:"calendar_id"`
Token string `json:"token"`
DurationMinutes int32 `json:"duration_minutes"`
BufferMinutes int32 `json:"buffer_minutes"`
Timezone string `json:"timezone"`
WorkingHours []byte `json:"working_hours"`
Active bool `json:"active"`
}
func (q *Queries) CreateBookingLink(ctx context.Context, arg CreateBookingLinkParams) (BookingLink, error) {
row := q.db.QueryRow(ctx, createBookingLink,
arg.ID,
arg.CalendarID,
arg.Token,
arg.DurationMinutes,
arg.BufferMinutes,
arg.Timezone,
arg.WorkingHours,
arg.Active,
)
var i BookingLink
err := row.Scan(
&i.ID,
&i.CalendarID,
&i.Token,
&i.DurationMinutes,
&i.BufferMinutes,
&i.Timezone,
&i.WorkingHours,
&i.Active,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getBookingLinkByCalendar = `-- name: GetBookingLinkByCalendar :one
SELECT id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active, created_at, updated_at FROM booking_links
WHERE calendar_id = $1
`
func (q *Queries) GetBookingLinkByCalendar(ctx context.Context, calendarID pgtype.UUID) (BookingLink, error) {
row := q.db.QueryRow(ctx, getBookingLinkByCalendar, calendarID)
var i BookingLink
err := row.Scan(
&i.ID,
&i.CalendarID,
&i.Token,
&i.DurationMinutes,
&i.BufferMinutes,
&i.Timezone,
&i.WorkingHours,
&i.Active,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getBookingLinkByToken = `-- name: GetBookingLinkByToken :one
SELECT id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active, created_at, updated_at FROM booking_links
WHERE token = $1
`
func (q *Queries) GetBookingLinkByToken(ctx context.Context, token string) (BookingLink, error) {
row := q.db.QueryRow(ctx, getBookingLinkByToken, token)
var i BookingLink
err := row.Scan(
&i.ID,
&i.CalendarID,
&i.Token,
&i.DurationMinutes,
&i.BufferMinutes,
&i.Timezone,
&i.WorkingHours,
&i.Active,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateBookingLink = `-- name: UpdateBookingLink :one
UPDATE booking_links
SET duration_minutes = COALESCE($2, duration_minutes),
buffer_minutes = COALESCE($3, buffer_minutes),
timezone = COALESCE($4, timezone),
working_hours = COALESCE($5, working_hours),
active = COALESCE($6, active),
updated_at = now()
WHERE id = $1
RETURNING id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active, created_at, updated_at
`
type UpdateBookingLinkParams struct {
ID pgtype.UUID `json:"id"`
DurationMinutes int32 `json:"duration_minutes"`
BufferMinutes int32 `json:"buffer_minutes"`
Timezone string `json:"timezone"`
WorkingHours []byte `json:"working_hours"`
Active bool `json:"active"`
}
func (q *Queries) UpdateBookingLink(ctx context.Context, arg UpdateBookingLinkParams) (BookingLink, error) {
row := q.db.QueryRow(ctx, updateBookingLink,
arg.ID,
arg.DurationMinutes,
arg.BufferMinutes,
arg.Timezone,
arg.WorkingHours,
arg.Active,
)
var i BookingLink
err := row.Scan(
&i.ID,
&i.CalendarID,
&i.Token,
&i.DurationMinutes,
&i.BufferMinutes,
&i.Timezone,
&i.WorkingHours,
&i.Active,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -0,0 +1,105 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: calendar_members.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteAllCalendarMembers = `-- name: DeleteAllCalendarMembers :exec
DELETE FROM calendar_members
WHERE calendar_id = $1
`
func (q *Queries) DeleteAllCalendarMembers(ctx context.Context, calendarID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAllCalendarMembers, calendarID)
return err
}
const deleteCalendarMember = `-- name: DeleteCalendarMember :exec
DELETE FROM calendar_members
WHERE calendar_id = $1 AND user_id = $2
`
type DeleteCalendarMemberParams struct {
CalendarID pgtype.UUID `json:"calendar_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) DeleteCalendarMember(ctx context.Context, arg DeleteCalendarMemberParams) error {
_, err := q.db.Exec(ctx, deleteCalendarMember, arg.CalendarID, arg.UserID)
return err
}
const getCalendarMemberRole = `-- name: GetCalendarMemberRole :one
SELECT role FROM calendar_members
WHERE calendar_id = $1 AND user_id = $2
`
type GetCalendarMemberRoleParams struct {
CalendarID pgtype.UUID `json:"calendar_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) GetCalendarMemberRole(ctx context.Context, arg GetCalendarMemberRoleParams) (string, error) {
row := q.db.QueryRow(ctx, getCalendarMemberRole, arg.CalendarID, arg.UserID)
var role string
err := row.Scan(&role)
return role, err
}
const listCalendarMembers = `-- name: ListCalendarMembers :many
SELECT cm.user_id, u.email, cm.role
FROM calendar_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.calendar_id = $1 AND u.deleted_at IS NULL
ORDER BY cm.role ASC
`
type ListCalendarMembersRow struct {
UserID pgtype.UUID `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
}
func (q *Queries) ListCalendarMembers(ctx context.Context, calendarID pgtype.UUID) ([]ListCalendarMembersRow, error) {
rows, err := q.db.Query(ctx, listCalendarMembers, calendarID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListCalendarMembersRow{}
for rows.Next() {
var i ListCalendarMembersRow
if err := rows.Scan(&i.UserID, &i.Email, &i.Role); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertCalendarMember = `-- name: UpsertCalendarMember :exec
INSERT INTO calendar_members (calendar_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (calendar_id, user_id) DO UPDATE SET role = $3
`
type UpsertCalendarMemberParams struct {
CalendarID pgtype.UUID `json:"calendar_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) UpsertCalendarMember(ctx context.Context, arg UpsertCalendarMemberParams) error {
_, err := q.db.Exec(ctx, upsertCalendarMember, arg.CalendarID, arg.UserID, arg.Role)
return err
}

View File

@@ -0,0 +1,209 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: calendars.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createCalendar = `-- name: CreateCalendar :one
INSERT INTO calendars (id, owner_id, name, color, is_public)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, owner_id, name, color, is_public, public_token, created_at, updated_at
`
type CreateCalendarParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
}
type CreateCalendarRow struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
PublicToken pgtype.Text `json:"public_token"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) CreateCalendar(ctx context.Context, arg CreateCalendarParams) (CreateCalendarRow, error) {
row := q.db.QueryRow(ctx, createCalendar,
arg.ID,
arg.OwnerID,
arg.Name,
arg.Color,
arg.IsPublic,
)
var i CreateCalendarRow
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsPublic,
&i.PublicToken,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getCalendarByID = `-- name: GetCalendarByID :one
SELECT id, owner_id, name, color, is_public, public_token, created_at, updated_at
FROM calendars
WHERE id = $1 AND deleted_at IS NULL
`
type GetCalendarByIDRow struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
PublicToken pgtype.Text `json:"public_token"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) GetCalendarByID(ctx context.Context, id pgtype.UUID) (GetCalendarByIDRow, error) {
row := q.db.QueryRow(ctx, getCalendarByID, id)
var i GetCalendarByIDRow
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsPublic,
&i.PublicToken,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listCalendarsByUser = `-- name: ListCalendarsByUser :many
SELECT c.id, c.owner_id, c.name, c.color, c.is_public, c.created_at, c.updated_at, cm.role
FROM calendars c
JOIN calendar_members cm ON cm.calendar_id = c.id
WHERE cm.user_id = $1 AND c.deleted_at IS NULL
ORDER BY c.created_at ASC
`
type ListCalendarsByUserRow struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Role string `json:"role"`
}
func (q *Queries) ListCalendarsByUser(ctx context.Context, userID pgtype.UUID) ([]ListCalendarsByUserRow, error) {
rows, err := q.db.Query(ctx, listCalendarsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListCalendarsByUserRow{}
for rows.Next() {
var i ListCalendarsByUserRow
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsPublic,
&i.CreatedAt,
&i.UpdatedAt,
&i.Role,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const softDeleteCalendar = `-- name: SoftDeleteCalendar :exec
UPDATE calendars SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
`
func (q *Queries) SoftDeleteCalendar(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteCalendar, id)
return err
}
const softDeleteCalendarsByOwner = `-- name: SoftDeleteCalendarsByOwner :exec
UPDATE calendars SET deleted_at = now(), updated_at = now()
WHERE owner_id = $1 AND deleted_at IS NULL
`
func (q *Queries) SoftDeleteCalendarsByOwner(ctx context.Context, ownerID pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteCalendarsByOwner, ownerID)
return err
}
const updateCalendar = `-- name: UpdateCalendar :one
UPDATE calendars
SET name = COALESCE($1::TEXT, name),
color = COALESCE($2::TEXT, color),
is_public = COALESCE($3::BOOLEAN, is_public),
updated_at = now()
WHERE id = $4 AND deleted_at IS NULL
RETURNING id, owner_id, name, color, is_public, public_token, created_at, updated_at
`
type UpdateCalendarParams struct {
Name pgtype.Text `json:"name"`
Color pgtype.Text `json:"color"`
IsPublic pgtype.Bool `json:"is_public"`
ID pgtype.UUID `json:"id"`
}
type UpdateCalendarRow struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
PublicToken pgtype.Text `json:"public_token"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) UpdateCalendar(ctx context.Context, arg UpdateCalendarParams) (UpdateCalendarRow, error) {
row := q.db.QueryRow(ctx, updateCalendar,
arg.Name,
arg.Color,
arg.IsPublic,
arg.ID,
)
var i UpdateCalendarRow
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Color,
&i.IsPublic,
&i.PublicToken,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -0,0 +1,228 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: contacts.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createContact = `-- name: CreateContact :one
INSERT INTO contacts (id, owner_id, first_name, last_name, email, phone, company, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, owner_id, first_name, last_name, email, phone, company, notes, created_at, updated_at, deleted_at
`
type CreateContactParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
FirstName pgtype.Text `json:"first_name"`
LastName pgtype.Text `json:"last_name"`
Email pgtype.Text `json:"email"`
Phone pgtype.Text `json:"phone"`
Company pgtype.Text `json:"company"`
Notes pgtype.Text `json:"notes"`
}
func (q *Queries) CreateContact(ctx context.Context, arg CreateContactParams) (Contact, error) {
row := q.db.QueryRow(ctx, createContact,
arg.ID,
arg.OwnerID,
arg.FirstName,
arg.LastName,
arg.Email,
arg.Phone,
arg.Company,
arg.Notes,
)
var i Contact
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.FirstName,
&i.LastName,
&i.Email,
&i.Phone,
&i.Company,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}
const getContactByID = `-- name: GetContactByID :one
SELECT id, owner_id, first_name, last_name, email, phone, company, notes, created_at, updated_at, deleted_at FROM contacts
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
`
type GetContactByIDParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) GetContactByID(ctx context.Context, arg GetContactByIDParams) (Contact, error) {
row := q.db.QueryRow(ctx, getContactByID, arg.ID, arg.OwnerID)
var i Contact
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.FirstName,
&i.LastName,
&i.Email,
&i.Phone,
&i.Company,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}
const listContacts = `-- name: ListContacts :many
SELECT id, owner_id, first_name, last_name, email, phone, company, notes, created_at, updated_at, deleted_at FROM contacts
WHERE owner_id = $1
AND deleted_at IS NULL
AND (
$2::TEXT IS NULL
OR first_name ILIKE '%' || $2::TEXT || '%'
OR last_name ILIKE '%' || $2::TEXT || '%'
OR email ILIKE '%' || $2::TEXT || '%'
OR company ILIKE '%' || $2::TEXT || '%'
)
AND (
$3::TIMESTAMPTZ IS NULL
OR (created_at, id) > ($3::TIMESTAMPTZ, $4::UUID)
)
ORDER BY created_at ASC, id ASC
LIMIT $5
`
type ListContactsParams struct {
OwnerID pgtype.UUID `json:"owner_id"`
Search pgtype.Text `json:"search"`
CursorTime pgtype.Timestamptz `json:"cursor_time"`
CursorID pgtype.UUID `json:"cursor_id"`
Lim int32 `json:"lim"`
}
func (q *Queries) ListContacts(ctx context.Context, arg ListContactsParams) ([]Contact, error) {
rows, err := q.db.Query(ctx, listContacts,
arg.OwnerID,
arg.Search,
arg.CursorTime,
arg.CursorID,
arg.Lim,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Contact{}
for rows.Next() {
var i Contact
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.FirstName,
&i.LastName,
&i.Email,
&i.Phone,
&i.Company,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const softDeleteContact = `-- name: SoftDeleteContact :exec
UPDATE contacts SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL
`
type SoftDeleteContactParams struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) SoftDeleteContact(ctx context.Context, arg SoftDeleteContactParams) error {
_, err := q.db.Exec(ctx, softDeleteContact, arg.ID, arg.OwnerID)
return err
}
const softDeleteContactsByOwner = `-- name: SoftDeleteContactsByOwner :exec
UPDATE contacts SET deleted_at = now(), updated_at = now()
WHERE owner_id = $1 AND deleted_at IS NULL
`
func (q *Queries) SoftDeleteContactsByOwner(ctx context.Context, ownerID pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteContactsByOwner, ownerID)
return err
}
const updateContact = `-- name: UpdateContact :one
UPDATE contacts
SET first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
email = COALESCE($3, email),
phone = COALESCE($4, phone),
company = COALESCE($5, company),
notes = COALESCE($6, notes),
updated_at = now()
WHERE id = $7 AND owner_id = $8 AND deleted_at IS NULL
RETURNING id, owner_id, first_name, last_name, email, phone, company, notes, created_at, updated_at, deleted_at
`
type UpdateContactParams struct {
FirstName pgtype.Text `json:"first_name"`
LastName pgtype.Text `json:"last_name"`
Email pgtype.Text `json:"email"`
Phone pgtype.Text `json:"phone"`
Company pgtype.Text `json:"company"`
Notes pgtype.Text `json:"notes"`
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) UpdateContact(ctx context.Context, arg UpdateContactParams) (Contact, error) {
row := q.db.QueryRow(ctx, updateContact,
arg.FirstName,
arg.LastName,
arg.Email,
arg.Phone,
arg.Company,
arg.Notes,
arg.ID,
arg.OwnerID,
)
var i Contact
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.FirstName,
&i.LastName,
&i.Email,
&i.Phone,
&i.Company,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}

32
internal/repository/db.go Normal file
View File

@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package repository
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -0,0 +1,74 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: event_exceptions.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createEventException = `-- name: CreateEventException :one
INSERT INTO event_exceptions (id, event_id, exception_date, action)
VALUES ($1, $2, $3, $4)
RETURNING id, event_id, exception_date, action
`
type CreateEventExceptionParams struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
ExceptionDate pgtype.Date `json:"exception_date"`
Action string `json:"action"`
}
func (q *Queries) CreateEventException(ctx context.Context, arg CreateEventExceptionParams) (EventException, error) {
row := q.db.QueryRow(ctx, createEventException,
arg.ID,
arg.EventID,
arg.ExceptionDate,
arg.Action,
)
var i EventException
err := row.Scan(
&i.ID,
&i.EventID,
&i.ExceptionDate,
&i.Action,
)
return i, err
}
const listExceptionsByEvent = `-- name: ListExceptionsByEvent :many
SELECT id, event_id, exception_date, action
FROM event_exceptions
WHERE event_id = $1
ORDER BY exception_date ASC
`
func (q *Queries) ListExceptionsByEvent(ctx context.Context, eventID pgtype.UUID) ([]EventException, error) {
rows, err := q.db.Query(ctx, listExceptionsByEvent, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []EventException{}
for rows.Next() {
var i EventException
if err := rows.Scan(
&i.ID,
&i.EventID,
&i.ExceptionDate,
&i.Action,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,479 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: events.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const checkEventOverlap = `-- name: CheckEventOverlap :one
SELECT EXISTS(
SELECT 1 FROM events
WHERE calendar_id = $1
AND deleted_at IS NULL
AND start_time < $3
AND end_time > $2
) AS overlap
`
type CheckEventOverlapParams struct {
CalendarID pgtype.UUID `json:"calendar_id"`
EndTime pgtype.Timestamptz `json:"end_time"`
StartTime pgtype.Timestamptz `json:"start_time"`
}
func (q *Queries) CheckEventOverlap(ctx context.Context, arg CheckEventOverlapParams) (bool, error) {
row := q.db.QueryRow(ctx, checkEventOverlap, arg.CalendarID, arg.EndTime, arg.StartTime)
var overlap bool
err := row.Scan(&overlap)
return overlap, err
}
const checkEventOverlapForUpdate = `-- name: CheckEventOverlapForUpdate :one
SELECT EXISTS(
SELECT 1 FROM events
WHERE calendar_id = $1
AND deleted_at IS NULL
AND start_time < $3
AND end_time > $2
FOR UPDATE
) AS overlap
`
type CheckEventOverlapForUpdateParams struct {
CalendarID pgtype.UUID `json:"calendar_id"`
EndTime pgtype.Timestamptz `json:"end_time"`
StartTime pgtype.Timestamptz `json:"start_time"`
}
func (q *Queries) CheckEventOverlapForUpdate(ctx context.Context, arg CheckEventOverlapForUpdateParams) (bool, error) {
row := q.db.QueryRow(ctx, checkEventOverlapForUpdate, arg.CalendarID, arg.EndTime, arg.StartTime)
var overlap bool
err := row.Scan(&overlap)
return overlap, err
}
const createEvent = `-- name: CreateEvent :one
INSERT INTO events (id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at
`
type CreateEventParams struct {
ID pgtype.UUID `json:"id"`
CalendarID pgtype.UUID `json:"calendar_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Location pgtype.Text `json:"location"`
StartTime pgtype.Timestamptz `json:"start_time"`
EndTime pgtype.Timestamptz `json:"end_time"`
Timezone string `json:"timezone"`
AllDay bool `json:"all_day"`
RecurrenceRule pgtype.Text `json:"recurrence_rule"`
Tags []string `json:"tags"`
CreatedBy pgtype.UUID `json:"created_by"`
UpdatedBy pgtype.UUID `json:"updated_by"`
}
func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) {
row := q.db.QueryRow(ctx, createEvent,
arg.ID,
arg.CalendarID,
arg.Title,
arg.Description,
arg.Location,
arg.StartTime,
arg.EndTime,
arg.Timezone,
arg.AllDay,
arg.RecurrenceRule,
arg.Tags,
arg.CreatedBy,
arg.UpdatedBy,
)
var i Event
err := row.Scan(
&i.ID,
&i.CalendarID,
&i.Title,
&i.Description,
&i.Location,
&i.StartTime,
&i.EndTime,
&i.Timezone,
&i.AllDay,
&i.RecurrenceRule,
&i.Tags,
&i.CreatedBy,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}
const getEventByID = `-- name: GetEventByID :one
SELECT id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at FROM events
WHERE id = $1 AND deleted_at IS NULL
`
func (q *Queries) GetEventByID(ctx context.Context, id pgtype.UUID) (Event, error) {
row := q.db.QueryRow(ctx, getEventByID, id)
var i Event
err := row.Scan(
&i.ID,
&i.CalendarID,
&i.Title,
&i.Description,
&i.Location,
&i.StartTime,
&i.EndTime,
&i.Timezone,
&i.AllDay,
&i.RecurrenceRule,
&i.Tags,
&i.CreatedBy,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}
const listEventsByCalendarInRange = `-- name: ListEventsByCalendarInRange :many
SELECT id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at FROM events
WHERE calendar_id = $1
AND deleted_at IS NULL
AND start_time < $3
AND end_time > $2
ORDER BY start_time ASC
`
type ListEventsByCalendarInRangeParams struct {
CalendarID pgtype.UUID `json:"calendar_id"`
EndTime pgtype.Timestamptz `json:"end_time"`
StartTime pgtype.Timestamptz `json:"start_time"`
}
func (q *Queries) ListEventsByCalendarInRange(ctx context.Context, arg ListEventsByCalendarInRangeParams) ([]Event, error) {
rows, err := q.db.Query(ctx, listEventsByCalendarInRange, arg.CalendarID, arg.EndTime, arg.StartTime)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Event{}
for rows.Next() {
var i Event
if err := rows.Scan(
&i.ID,
&i.CalendarID,
&i.Title,
&i.Description,
&i.Location,
&i.StartTime,
&i.EndTime,
&i.Timezone,
&i.AllDay,
&i.RecurrenceRule,
&i.Tags,
&i.CreatedBy,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEventsInRange = `-- name: ListEventsInRange :many
SELECT e.id, e.calendar_id, e.title, e.description, e.location, e.start_time, e.end_time, e.timezone, e.all_day, e.recurrence_rule, e.tags, e.created_by, e.updated_by, e.created_at, e.updated_at, e.deleted_at FROM events e
JOIN calendar_members cm ON cm.calendar_id = e.calendar_id
WHERE cm.user_id = $1
AND e.deleted_at IS NULL
AND e.start_time < $2
AND e.end_time > $3
AND ($4::UUID IS NULL OR e.calendar_id = $4::UUID)
AND ($5::TEXT IS NULL OR (e.title ILIKE '%' || $5::TEXT || '%' OR e.description ILIKE '%' || $5::TEXT || '%'))
AND ($6::TEXT IS NULL OR $6::TEXT = ANY(e.tags))
AND (
$7::TIMESTAMPTZ IS NULL
OR (e.start_time, e.id) > ($7::TIMESTAMPTZ, $8::UUID)
)
ORDER BY e.start_time ASC, e.id ASC
LIMIT $9
`
type ListEventsInRangeParams struct {
UserID pgtype.UUID `json:"user_id"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
RangeStart pgtype.Timestamptz `json:"range_start"`
CalendarID pgtype.UUID `json:"calendar_id"`
Search pgtype.Text `json:"search"`
Tag pgtype.Text `json:"tag"`
CursorTime pgtype.Timestamptz `json:"cursor_time"`
CursorID pgtype.UUID `json:"cursor_id"`
Lim int32 `json:"lim"`
}
func (q *Queries) ListEventsInRange(ctx context.Context, arg ListEventsInRangeParams) ([]Event, error) {
rows, err := q.db.Query(ctx, listEventsInRange,
arg.UserID,
arg.RangeEnd,
arg.RangeStart,
arg.CalendarID,
arg.Search,
arg.Tag,
arg.CursorTime,
arg.CursorID,
arg.Lim,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Event{}
for rows.Next() {
var i Event
if err := rows.Scan(
&i.ID,
&i.CalendarID,
&i.Title,
&i.Description,
&i.Location,
&i.StartTime,
&i.EndTime,
&i.Timezone,
&i.AllDay,
&i.RecurrenceRule,
&i.Tags,
&i.CreatedBy,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecurringEventsByCalendar = `-- name: ListRecurringEventsByCalendar :many
SELECT id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at FROM events
WHERE calendar_id = $1
AND deleted_at IS NULL
AND recurrence_rule IS NOT NULL
AND start_time <= $2
ORDER BY start_time ASC
`
type ListRecurringEventsByCalendarParams struct {
CalendarID pgtype.UUID `json:"calendar_id"`
StartTime pgtype.Timestamptz `json:"start_time"`
}
func (q *Queries) ListRecurringEventsByCalendar(ctx context.Context, arg ListRecurringEventsByCalendarParams) ([]Event, error) {
rows, err := q.db.Query(ctx, listRecurringEventsByCalendar, arg.CalendarID, arg.StartTime)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Event{}
for rows.Next() {
var i Event
if err := rows.Scan(
&i.ID,
&i.CalendarID,
&i.Title,
&i.Description,
&i.Location,
&i.StartTime,
&i.EndTime,
&i.Timezone,
&i.AllDay,
&i.RecurrenceRule,
&i.Tags,
&i.CreatedBy,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecurringEventsInRange = `-- name: ListRecurringEventsInRange :many
SELECT e.id, e.calendar_id, e.title, e.description, e.location, e.start_time, e.end_time, e.timezone, e.all_day, e.recurrence_rule, e.tags, e.created_by, e.updated_by, e.created_at, e.updated_at, e.deleted_at FROM events e
JOIN calendar_members cm ON cm.calendar_id = e.calendar_id
WHERE cm.user_id = $1
AND e.deleted_at IS NULL
AND e.recurrence_rule IS NOT NULL
AND e.start_time <= $2
AND ($3::UUID IS NULL OR e.calendar_id = $3::UUID)
ORDER BY e.start_time ASC
`
type ListRecurringEventsInRangeParams struct {
UserID pgtype.UUID `json:"user_id"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
CalendarID pgtype.UUID `json:"calendar_id"`
}
func (q *Queries) ListRecurringEventsInRange(ctx context.Context, arg ListRecurringEventsInRangeParams) ([]Event, error) {
rows, err := q.db.Query(ctx, listRecurringEventsInRange, arg.UserID, arg.RangeEnd, arg.CalendarID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Event{}
for rows.Next() {
var i Event
if err := rows.Scan(
&i.ID,
&i.CalendarID,
&i.Title,
&i.Description,
&i.Location,
&i.StartTime,
&i.EndTime,
&i.Timezone,
&i.AllDay,
&i.RecurrenceRule,
&i.Tags,
&i.CreatedBy,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const softDeleteEvent = `-- name: SoftDeleteEvent :exec
UPDATE events SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
`
func (q *Queries) SoftDeleteEvent(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteEvent, id)
return err
}
const softDeleteEventsByCalendar = `-- name: SoftDeleteEventsByCalendar :exec
UPDATE events SET deleted_at = now(), updated_at = now()
WHERE calendar_id = $1 AND deleted_at IS NULL
`
func (q *Queries) SoftDeleteEventsByCalendar(ctx context.Context, calendarID pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteEventsByCalendar, calendarID)
return err
}
const softDeleteEventsByCreator = `-- name: SoftDeleteEventsByCreator :exec
UPDATE events SET deleted_at = now(), updated_at = now()
WHERE created_by = $1 AND deleted_at IS NULL
`
func (q *Queries) SoftDeleteEventsByCreator(ctx context.Context, createdBy pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteEventsByCreator, createdBy)
return err
}
const updateEvent = `-- name: UpdateEvent :one
UPDATE events
SET title = COALESCE($1, title),
description = COALESCE($2, description),
location = COALESCE($3, location),
start_time = COALESCE($4, start_time),
end_time = COALESCE($5, end_time),
timezone = COALESCE($6, timezone),
all_day = COALESCE($7, all_day),
recurrence_rule = $8,
tags = COALESCE($9, tags),
updated_by = $10,
updated_at = now()
WHERE id = $11 AND deleted_at IS NULL
RETURNING id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at
`
type UpdateEventParams struct {
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
Location pgtype.Text `json:"location"`
StartTime pgtype.Timestamptz `json:"start_time"`
EndTime pgtype.Timestamptz `json:"end_time"`
Timezone pgtype.Text `json:"timezone"`
AllDay pgtype.Bool `json:"all_day"`
RecurrenceRule pgtype.Text `json:"recurrence_rule"`
Tags []string `json:"tags"`
UpdatedBy pgtype.UUID `json:"updated_by"`
ID pgtype.UUID `json:"id"`
}
func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) (Event, error) {
row := q.db.QueryRow(ctx, updateEvent,
arg.Title,
arg.Description,
arg.Location,
arg.StartTime,
arg.EndTime,
arg.Timezone,
arg.AllDay,
arg.RecurrenceRule,
arg.Tags,
arg.UpdatedBy,
arg.ID,
)
var i Event
err := row.Scan(
&i.ID,
&i.CalendarID,
&i.Title,
&i.Description,
&i.Location,
&i.StartTime,
&i.EndTime,
&i.Timezone,
&i.AllDay,
&i.RecurrenceRule,
&i.Tags,
&i.CreatedBy,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}

View File

@@ -0,0 +1,139 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package repository
import (
"github.com/jackc/pgx/v5/pgtype"
)
type ApiKey struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Scopes []byte `json:"scopes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
}
type AuditLog struct {
ID pgtype.UUID `json:"id"`
EntityType string `json:"entity_type"`
EntityID pgtype.UUID `json:"entity_id"`
Action string `json:"action"`
UserID pgtype.UUID `json:"user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type BookingLink struct {
ID pgtype.UUID `json:"id"`
CalendarID pgtype.UUID `json:"calendar_id"`
Token string `json:"token"`
DurationMinutes int32 `json:"duration_minutes"`
BufferMinutes int32 `json:"buffer_minutes"`
Timezone string `json:"timezone"`
WorkingHours []byte `json:"working_hours"`
Active bool `json:"active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Calendar struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
PublicToken pgtype.Text `json:"public_token"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type CalendarMember struct {
CalendarID pgtype.UUID `json:"calendar_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
}
type Contact struct {
ID pgtype.UUID `json:"id"`
OwnerID pgtype.UUID `json:"owner_id"`
FirstName pgtype.Text `json:"first_name"`
LastName pgtype.Text `json:"last_name"`
Email pgtype.Text `json:"email"`
Phone pgtype.Text `json:"phone"`
Company pgtype.Text `json:"company"`
Notes pgtype.Text `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type Event struct {
ID pgtype.UUID `json:"id"`
CalendarID pgtype.UUID `json:"calendar_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Location pgtype.Text `json:"location"`
StartTime pgtype.Timestamptz `json:"start_time"`
EndTime pgtype.Timestamptz `json:"end_time"`
Timezone string `json:"timezone"`
AllDay bool `json:"all_day"`
RecurrenceRule pgtype.Text `json:"recurrence_rule"`
Tags []string `json:"tags"`
CreatedBy pgtype.UUID `json:"created_by"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type EventAttachment struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
FileUrl string `json:"file_url"`
}
type EventAttendee struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
UserID pgtype.UUID `json:"user_id"`
Email pgtype.Text `json:"email"`
Status string `json:"status"`
}
type EventException struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
ExceptionDate pgtype.Date `json:"exception_date"`
Action string `json:"action"`
}
type EventReminder struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
MinutesBefore int32 `json:"minutes_before"`
}
type RefreshToken struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
TokenHash string `json:"token_hash"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Timezone string `json:"timezone"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}

View File

@@ -0,0 +1,83 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: refresh_tokens.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createRefreshToken = `-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, token_hash, expires_at, revoked_at, created_at
`
type CreateRefreshTokenParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
TokenHash string `json:"token_hash"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) {
row := q.db.QueryRow(ctx, createRefreshToken,
arg.ID,
arg.UserID,
arg.TokenHash,
arg.ExpiresAt,
)
var i RefreshToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.TokenHash,
&i.ExpiresAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getRefreshTokenByHash = `-- name: GetRefreshTokenByHash :one
SELECT id, user_id, token_hash, expires_at, revoked_at, created_at
FROM refresh_tokens
WHERE token_hash = $1 AND revoked_at IS NULL
`
func (q *Queries) GetRefreshTokenByHash(ctx context.Context, tokenHash string) (RefreshToken, error) {
row := q.db.QueryRow(ctx, getRefreshTokenByHash, tokenHash)
var i RefreshToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.TokenHash,
&i.ExpiresAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const revokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
UPDATE refresh_tokens SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL
`
func (q *Queries) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, revokeAllUserRefreshTokens, userID)
return err
}
const revokeRefreshToken = `-- name: RevokeRefreshToken :exec
UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1
`
func (q *Queries) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := q.db.Exec(ctx, revokeRefreshToken, tokenHash)
return err
}

View File

@@ -0,0 +1,83 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: reminders.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createReminder = `-- name: CreateReminder :one
INSERT INTO event_reminders (id, event_id, minutes_before)
VALUES ($1, $2, $3)
RETURNING id, event_id, minutes_before
`
type CreateReminderParams struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
MinutesBefore int32 `json:"minutes_before"`
}
func (q *Queries) CreateReminder(ctx context.Context, arg CreateReminderParams) (EventReminder, error) {
row := q.db.QueryRow(ctx, createReminder, arg.ID, arg.EventID, arg.MinutesBefore)
var i EventReminder
err := row.Scan(&i.ID, &i.EventID, &i.MinutesBefore)
return i, err
}
const deleteReminder = `-- name: DeleteReminder :exec
DELETE FROM event_reminders
WHERE id = $1 AND event_id = $2
`
type DeleteReminderParams struct {
ID pgtype.UUID `json:"id"`
EventID pgtype.UUID `json:"event_id"`
}
func (q *Queries) DeleteReminder(ctx context.Context, arg DeleteReminderParams) error {
_, err := q.db.Exec(ctx, deleteReminder, arg.ID, arg.EventID)
return err
}
const deleteRemindersByEvent = `-- name: DeleteRemindersByEvent :exec
DELETE FROM event_reminders
WHERE event_id = $1
`
func (q *Queries) DeleteRemindersByEvent(ctx context.Context, eventID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteRemindersByEvent, eventID)
return err
}
const listRemindersByEvent = `-- name: ListRemindersByEvent :many
SELECT id, event_id, minutes_before
FROM event_reminders
WHERE event_id = $1
ORDER BY minutes_before ASC
`
func (q *Queries) ListRemindersByEvent(ctx context.Context, eventID pgtype.UUID) ([]EventReminder, error) {
rows, err := q.db.Query(ctx, listRemindersByEvent, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []EventReminder{}
for rows.Next() {
var i EventReminder
if err := rows.Scan(&i.ID, &i.EventID, &i.MinutesBefore); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,165 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: users.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createUser = `-- name: CreateUser :one
INSERT INTO users (id, email, password_hash, timezone)
VALUES ($1, $2, $3, $4)
RETURNING id, email, password_hash, timezone, is_active, created_at, updated_at
`
type CreateUserParams struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Timezone string `json:"timezone"`
}
type CreateUserRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Timezone string `json:"timezone"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
row := q.db.QueryRow(ctx, createUser,
arg.ID,
arg.Email,
arg.PasswordHash,
arg.Timezone,
)
var i CreateUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Timezone,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, timezone, is_active, created_at, updated_at
FROM users
WHERE email = $1 AND deleted_at IS NULL
`
type GetUserByEmailRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Timezone string `json:"timezone"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email)
var i GetUserByEmailRow
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Timezone,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, timezone, is_active, created_at, updated_at
FROM users
WHERE id = $1 AND deleted_at IS NULL
`
type GetUserByIDRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Timezone string `json:"timezone"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i GetUserByIDRow
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Timezone,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const softDeleteUser = `-- name: SoftDeleteUser :exec
UPDATE users SET deleted_at = now(), is_active = false, updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
`
func (q *Queries) SoftDeleteUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteUser, id)
return err
}
const updateUser = `-- name: UpdateUser :one
UPDATE users
SET timezone = COALESCE($1::TEXT, timezone),
updated_at = now()
WHERE id = $2 AND deleted_at IS NULL
RETURNING id, email, password_hash, timezone, is_active, created_at, updated_at
`
type UpdateUserParams struct {
Timezone pgtype.Text `json:"timezone"`
ID pgtype.UUID `json:"id"`
}
type UpdateUserRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Timezone string `json:"timezone"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) {
row := q.db.QueryRow(ctx, updateUser, arg.Timezone, arg.ID)
var i UpdateUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Timezone,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -0,0 +1,63 @@
package scheduler
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/hibiken/asynq"
)
const TypeReminder = "reminder:send"
type ReminderPayload struct {
EventID uuid.UUID `json:"event_id"`
ReminderID uuid.UUID `json:"reminder_id"`
UserID uuid.UUID `json:"user_id"`
}
type Scheduler struct {
client *asynq.Client
}
func NewScheduler(redisAddr string) *Scheduler {
client := asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr})
return &Scheduler{client: client}
}
func (s *Scheduler) ScheduleReminder(_ context.Context, eventID, reminderID, userID uuid.UUID, triggerAt time.Time) error {
payload, err := json.Marshal(ReminderPayload{
EventID: eventID,
ReminderID: reminderID,
UserID: userID,
})
if err != nil {
return fmt.Errorf("marshal reminder payload: %w", err)
}
task := asynq.NewTask(TypeReminder, payload)
_, err = s.client.Enqueue(task,
asynq.ProcessAt(triggerAt),
asynq.MaxRetry(5),
asynq.Queue("reminders"),
)
if err != nil {
return fmt.Errorf("enqueue reminder: %w", err)
}
return nil
}
func (s *Scheduler) Close() error {
return s.client.Close()
}
type NoopScheduler struct{}
func (NoopScheduler) ScheduleReminder(_ context.Context, _, _, _ uuid.UUID, _ time.Time) error {
return nil
}
func (NoopScheduler) Close() error { return nil }

View File

@@ -0,0 +1,67 @@
package scheduler
import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/hibiken/asynq"
)
type ReminderWorker struct {
queries *repository.Queries
}
func NewReminderWorker(queries *repository.Queries) *ReminderWorker {
return &ReminderWorker{queries: queries}
}
func (w *ReminderWorker) HandleReminderTask(ctx context.Context, t *asynq.Task) error {
var payload ReminderPayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
ev, err := w.queries.GetEventByID(ctx, utils.ToPgUUID(payload.EventID))
if err != nil {
return fmt.Errorf("get event: %w", err)
}
if ev.DeletedAt.Valid {
log.Printf("reminder skipped: event %s deleted", payload.EventID)
return nil
}
log.Printf("reminder triggered: event=%s user=%s title=%s",
payload.EventID, payload.UserID, ev.Title)
return nil
}
func StartWorker(redisAddr string, worker *ReminderWorker) *asynq.Server {
srv := asynq.NewServer(
asynq.RedisClientOpt{Addr: redisAddr},
asynq.Config{
Concurrency: 10,
Queues: map[string]int{
"reminders": 6,
"default": 3,
},
RetryDelayFunc: asynq.DefaultRetryDelayFunc,
},
)
mux := asynq.NewServeMux()
mux.HandleFunc(TypeReminder, worker.HandleReminderTask)
go func() {
if err := srv.Run(mux); err != nil {
log.Printf("asynq worker error: %v", err)
}
}()
return srv
}

View File

@@ -0,0 +1,84 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"github.com/calendarapi/internal/middleware"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
)
type APIKeyService struct {
queries *repository.Queries
}
func NewAPIKeyService(queries *repository.Queries) *APIKeyService {
return &APIKeyService{queries: queries}
}
func (s *APIKeyService) Create(ctx context.Context, userID uuid.UUID, name string, scopes map[string][]string) (*models.APIKeyResponse, error) {
if name == "" {
return nil, models.NewValidationError("name is required")
}
rawToken := make([]byte, 32)
if _, err := rand.Read(rawToken); err != nil {
return nil, models.ErrInternal
}
token := hex.EncodeToString(rawToken)
hash := middleware.SHA256Hash(token)
scopesJSON, err := json.Marshal(scopes)
if err != nil {
return nil, models.ErrInternal
}
keyID := uuid.New()
key, err := s.queries.CreateAPIKey(ctx, repository.CreateAPIKeyParams{
ID: utils.ToPgUUID(keyID),
UserID: utils.ToPgUUID(userID),
Name: name,
KeyHash: hash,
Scopes: scopesJSON,
})
if err != nil {
return nil, models.ErrInternal
}
return &models.APIKeyResponse{
ID: utils.FromPgUUID(key.ID),
Name: key.Name,
CreatedAt: utils.FromPgTimestamptz(key.CreatedAt),
Token: token,
}, nil
}
func (s *APIKeyService) List(ctx context.Context, userID uuid.UUID) ([]models.APIKeyResponse, error) {
keys, err := s.queries.ListAPIKeysByUser(ctx, utils.ToPgUUID(userID))
if err != nil {
return nil, models.ErrInternal
}
result := make([]models.APIKeyResponse, len(keys))
for i, k := range keys {
result[i] = models.APIKeyResponse{
ID: utils.FromPgUUID(k.ID),
Name: k.Name,
CreatedAt: utils.FromPgTimestamptz(k.CreatedAt),
RevokedAt: utils.FromPgTimestamptzPtr(k.RevokedAt),
}
}
return result, nil
}
func (s *APIKeyService) Revoke(ctx context.Context, userID uuid.UUID, keyID uuid.UUID) error {
return s.queries.RevokeAPIKey(ctx, repository.RevokeAPIKeyParams{
ID: utils.ToPgUUID(keyID),
UserID: utils.ToPgUUID(userID),
})
}

View File

@@ -0,0 +1,132 @@
package service
import (
"context"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type AttendeeService struct {
queries *repository.Queries
calendar *CalendarService
}
func NewAttendeeService(queries *repository.Queries, calendar *CalendarService) *AttendeeService {
return &AttendeeService{queries: queries, calendar: calendar}
}
func (s *AttendeeService) AddAttendees(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, attendees []AddAttendeeRequest) (*models.Event, error) {
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
calID := utils.FromPgUUID(ev.CalendarID)
role, err := s.calendar.GetRole(ctx, calID, userID)
if err != nil {
return nil, err
}
if role != "owner" && role != "editor" {
return nil, models.ErrForbidden
}
for _, a := range attendees {
_, err := s.queries.CreateAttendee(ctx, repository.CreateAttendeeParams{
ID: utils.ToPgUUID(uuid.New()),
EventID: utils.ToPgUUID(eventID),
UserID: optionalPgUUID(a.UserID),
Email: utils.ToPgTextPtr(a.Email),
})
if err != nil {
return nil, models.ErrInternal
}
}
reminders, _ := loadRemindersHelper(ctx, s.queries, eventID)
atts, _ := loadAttendeesHelper(ctx, s.queries, eventID)
attachments, _ := loadAttachmentsHelper(ctx, s.queries, eventID)
return eventFromDB(ev, reminders, atts, attachments), nil
}
func (s *AttendeeService) UpdateStatus(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, attendeeID uuid.UUID, status string) (*models.Event, error) {
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
calID := utils.FromPgUUID(ev.CalendarID)
role, err := s.calendar.GetRole(ctx, calID, userID)
if err != nil {
return nil, err
}
att, err := s.queries.GetAttendeeByID(ctx, utils.ToPgUUID(attendeeID))
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
isOrganizer := role == "owner" || role == "editor"
isOwnAttendee := att.UserID.Valid && utils.FromPgUUID(att.UserID) == userID
if !isOrganizer && !isOwnAttendee {
return nil, models.ErrForbidden
}
if status != "pending" && status != "accepted" && status != "declined" && status != "tentative" {
return nil, models.NewValidationError("status must be pending, accepted, declined, or tentative")
}
_, err = s.queries.UpdateAttendeeStatus(ctx, repository.UpdateAttendeeStatusParams{
ID: utils.ToPgUUID(attendeeID),
Status: status,
})
if err != nil {
return nil, models.ErrInternal
}
reminders, _ := loadRemindersHelper(ctx, s.queries, eventID)
atts, _ := loadAttendeesHelper(ctx, s.queries, eventID)
attachments, _ := loadAttachmentsHelper(ctx, s.queries, eventID)
return eventFromDB(ev, reminders, atts, attachments), nil
}
func (s *AttendeeService) DeleteAttendee(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, attendeeID uuid.UUID) error {
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
if err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
calID := utils.FromPgUUID(ev.CalendarID)
role, err := s.calendar.GetRole(ctx, calID, userID)
if err != nil {
return err
}
if role != "owner" && role != "editor" {
return models.ErrForbidden
}
return s.queries.DeleteAttendee(ctx, repository.DeleteAttendeeParams{
ID: utils.ToPgUUID(attendeeID),
EventID: utils.ToPgUUID(eventID),
})
}
type AddAttendeeRequest struct {
UserID *uuid.UUID
Email *string
}

30
internal/service/audit.go Normal file
View File

@@ -0,0 +1,30 @@
package service
import (
"context"
"log"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
)
type AuditService struct {
queries *repository.Queries
}
func NewAuditService(queries *repository.Queries) *AuditService {
return &AuditService{queries: queries}
}
func (s *AuditService) Log(ctx context.Context, entityType string, entityID uuid.UUID, action string, userID uuid.UUID) {
err := s.queries.CreateAuditLog(ctx, repository.CreateAuditLogParams{
EntityType: entityType,
EntityID: utils.ToPgUUID(entityID),
Action: action,
UserID: utils.ToPgUUID(userID),
})
if err != nil {
log.Printf("audit log failed: entity=%s id=%s action=%s err=%v", entityType, entityID, action, err)
}
}

256
internal/service/auth.go Normal file
View File

@@ -0,0 +1,256 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"time"
"github.com/calendarapi/internal/auth"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
type AuthService struct {
pool *pgxpool.Pool
queries *repository.Queries
jwt *auth.JWTManager
audit *AuditService
}
func NewAuthService(pool *pgxpool.Pool, queries *repository.Queries, jwt *auth.JWTManager, audit *AuditService) *AuthService {
return &AuthService{pool: pool, queries: queries, jwt: jwt, audit: audit}
}
func (s *AuthService) Register(ctx context.Context, email, password, timezone string) (*models.AuthTokens, error) {
email = utils.NormalizeEmail(email)
if err := utils.ValidateEmail(email); err != nil {
return nil, err
}
if err := utils.ValidatePassword(password); err != nil {
return nil, err
}
if timezone == "" {
timezone = "UTC"
}
if err := utils.ValidateTimezone(timezone); err != nil {
return nil, err
}
_, err := s.queries.GetUserByEmail(ctx, email)
if err == nil {
return nil, models.NewConflictError("email already registered")
}
if err != pgx.ErrNoRows {
return nil, models.ErrInternal
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return nil, models.ErrInternal
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, models.ErrInternal
}
defer tx.Rollback(ctx)
qtx := s.queries.WithTx(tx)
userID := uuid.New()
dbUser, err := qtx.CreateUser(ctx, repository.CreateUserParams{
ID: utils.ToPgUUID(userID),
Email: email,
PasswordHash: string(hash),
Timezone: timezone,
})
if err != nil {
return nil, models.ErrInternal
}
calID := uuid.New()
_, err = qtx.CreateCalendar(ctx, repository.CreateCalendarParams{
ID: utils.ToPgUUID(calID),
OwnerID: utils.ToPgUUID(userID),
Name: "My Calendar",
Color: "#3B82F6",
IsPublic: false,
})
if err != nil {
return nil, models.ErrInternal
}
err = qtx.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
CalendarID: utils.ToPgUUID(calID),
UserID: utils.ToPgUUID(userID),
Role: "owner",
})
if err != nil {
return nil, models.ErrInternal
}
if err := tx.Commit(ctx); err != nil {
return nil, models.ErrInternal
}
accessToken, err := s.jwt.GenerateAccessToken(userID)
if err != nil {
return nil, models.ErrInternal
}
refreshToken, err := s.jwt.GenerateRefreshToken(userID)
if err != nil {
return nil, models.ErrInternal
}
rtHash := hashToken(refreshToken)
_, err = s.queries.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{
ID: utils.ToPgUUID(uuid.New()),
UserID: utils.ToPgUUID(userID),
TokenHash: rtHash,
ExpiresAt: utils.ToPgTimestamptz(time.Now().Add(auth.RefreshTokenDuration)),
})
if err != nil {
return nil, models.ErrInternal
}
user := userFromCreateRow(dbUser)
return &models.AuthTokens{User: user, AccessToken: accessToken, RefreshToken: refreshToken}, nil
}
func (s *AuthService) Login(ctx context.Context, email, password string) (*models.AuthTokens, error) {
email = utils.NormalizeEmail(email)
dbUser, err := s.queries.GetUserByEmail(ctx, email)
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrAuthInvalid
}
return nil, models.ErrInternal
}
if err := bcrypt.CompareHashAndPassword([]byte(dbUser.PasswordHash), []byte(password)); err != nil {
return nil, models.ErrAuthInvalid
}
userID := utils.FromPgUUID(dbUser.ID)
accessToken, err := s.jwt.GenerateAccessToken(userID)
if err != nil {
return nil, models.ErrInternal
}
refreshToken, err := s.jwt.GenerateRefreshToken(userID)
if err != nil {
return nil, models.ErrInternal
}
rtHash := hashToken(refreshToken)
_, err = s.queries.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{
ID: utils.ToPgUUID(uuid.New()),
UserID: utils.ToPgUUID(userID),
TokenHash: rtHash,
ExpiresAt: utils.ToPgTimestamptz(time.Now().Add(auth.RefreshTokenDuration)),
})
if err != nil {
return nil, models.ErrInternal
}
user := userFromEmailRow(dbUser)
return &models.AuthTokens{User: user, AccessToken: accessToken, RefreshToken: refreshToken}, nil
}
func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*models.TokenPair, error) {
rtHash := hashToken(refreshTokenStr)
rt, err := s.queries.GetRefreshTokenByHash(ctx, rtHash)
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrAuthInvalid
}
return nil, models.ErrInternal
}
if utils.FromPgTimestamptz(rt.ExpiresAt).Before(time.Now()) {
return nil, models.ErrAuthInvalid
}
_ = s.queries.RevokeRefreshToken(ctx, rtHash)
userID := utils.FromPgUUID(rt.UserID)
accessToken, err := s.jwt.GenerateAccessToken(userID)
if err != nil {
return nil, models.ErrInternal
}
newRefresh, err := s.jwt.GenerateRefreshToken(userID)
if err != nil {
return nil, models.ErrInternal
}
newHash := hashToken(newRefresh)
_, err = s.queries.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{
ID: utils.ToPgUUID(uuid.New()),
UserID: utils.ToPgUUID(userID),
TokenHash: newHash,
ExpiresAt: utils.ToPgTimestamptz(time.Now().Add(auth.RefreshTokenDuration)),
})
if err != nil {
return nil, models.ErrInternal
}
return &models.TokenPair{AccessToken: accessToken, RefreshToken: newRefresh}, nil
}
func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error {
rtHash := hashToken(refreshTokenStr)
return s.queries.RevokeRefreshToken(ctx, rtHash)
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func userFromCreateRow(u repository.CreateUserRow) models.User {
return models.User{
ID: utils.FromPgUUID(u.ID),
Email: u.Email,
Timezone: u.Timezone,
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
}
}
func userFromEmailRow(u repository.GetUserByEmailRow) models.User {
return models.User{
ID: utils.FromPgUUID(u.ID),
Email: u.Email,
Timezone: u.Timezone,
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
}
}
func userFromIDRow(u repository.GetUserByIDRow) models.User {
return models.User{
ID: utils.FromPgUUID(u.ID),
Email: u.Email,
Timezone: u.Timezone,
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
}
}
func userFromUpdateRow(u repository.UpdateUserRow) models.User {
return models.User{
ID: utils.FromPgUUID(u.ID),
Email: u.Email,
Timezone: u.Timezone,
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
}
}

View File

@@ -0,0 +1,86 @@
package service
import (
"context"
"sort"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
)
type AvailabilityService struct {
queries *repository.Queries
calendar *CalendarService
event *EventService
}
func NewAvailabilityService(queries *repository.Queries, calendar *CalendarService, event *EventService) *AvailabilityService {
return &AvailabilityService{queries: queries, calendar: calendar, event: event}
}
func (s *AvailabilityService) GetBusyBlocks(ctx context.Context, userID uuid.UUID, calendarID uuid.UUID, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) {
if _, err := s.calendar.GetRole(ctx, calendarID, userID); err != nil {
return nil, err
}
pgCalID := utils.ToPgUUID(calendarID)
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
CalendarID: pgCalID,
EndTime: utils.ToPgTimestamptz(rangeStart),
StartTime: utils.ToPgTimestamptz(rangeEnd),
})
if err != nil {
return nil, models.ErrInternal
}
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
CalendarID: pgCalID,
StartTime: utils.ToPgTimestamptz(rangeEnd),
})
if err != nil {
return nil, models.ErrInternal
}
var busy []models.BusyBlock
for _, ev := range events {
if ev.RecurrenceRule.Valid {
continue
}
busy = append(busy, models.BusyBlock{
Start: utils.FromPgTimestamptz(ev.StartTime),
End: utils.FromPgTimestamptz(ev.EndTime),
EventID: utils.FromPgUUID(ev.ID),
})
}
for _, ev := range recurring {
occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd)
for _, occ := range occs {
if occ.OccurrenceStartTime != nil {
busy = append(busy, models.BusyBlock{
Start: *occ.OccurrenceStartTime,
End: *occ.OccurrenceEndTime,
EventID: occ.ID,
})
}
}
}
sort.Slice(busy, func(i, j int) bool {
return busy[i].Start.Before(busy[j].Start)
})
if busy == nil {
busy = []models.BusyBlock{}
}
return &models.AvailabilityResponse{
CalendarID: calendarID,
RangeStart: rangeStart,
RangeEnd: rangeEnd,
Busy: busy,
}, nil
}

264
internal/service/booking.go Normal file
View File

@@ -0,0 +1,264 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type BookingService struct {
pool *pgxpool.Pool
queries *repository.Queries
calendar *CalendarService
event *EventService
}
func NewBookingService(pool *pgxpool.Pool, queries *repository.Queries, calendar *CalendarService, event *EventService) *BookingService {
return &BookingService{pool: pool, queries: queries, calendar: calendar, event: event}
}
func (s *BookingService) CreateLink(ctx context.Context, userID uuid.UUID, calID uuid.UUID, config models.BookingConfig) (*models.BookingLink, error) {
role, err := s.calendar.GetRole(ctx, calID, userID)
if err != nil {
return nil, err
}
if role != "owner" {
return nil, models.ErrForbidden
}
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, models.ErrInternal
}
token := hex.EncodeToString(tokenBytes)
whJSON, err := json.Marshal(config.WorkingHours)
if err != nil {
return nil, models.ErrInternal
}
_, err = s.queries.CreateBookingLink(ctx, repository.CreateBookingLinkParams{
ID: utils.ToPgUUID(uuid.New()),
CalendarID: utils.ToPgUUID(calID),
Token: token,
DurationMinutes: int32(config.DurationMinutes),
BufferMinutes: int32(config.BufferMinutes),
Timezone: config.Timezone,
WorkingHours: whJSON,
Active: config.Active,
})
if err != nil {
return nil, models.ErrInternal
}
return &models.BookingLink{
Token: token,
PublicURL: fmt.Sprintf("/booking/%s", token),
Settings: config,
}, nil
}
func (s *BookingService) GetAvailability(ctx context.Context, token string, rangeStart, rangeEnd time.Time) (*models.BookingAvailability, error) {
bl, err := s.queries.GetBookingLinkByToken(ctx, token)
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
if !bl.Active {
return nil, models.NewNotFoundError("booking link is not active")
}
var workingHours map[string][]models.Slot
if err := json.Unmarshal(bl.WorkingHours, &workingHours); err != nil {
return nil, models.ErrInternal
}
loc, err := time.LoadLocation(bl.Timezone)
if err != nil {
loc = time.UTC
}
calID := utils.FromPgUUID(bl.CalendarID)
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
CalendarID: bl.CalendarID,
EndTime: utils.ToPgTimestamptz(rangeStart),
StartTime: utils.ToPgTimestamptz(rangeEnd),
})
if err != nil {
return nil, models.ErrInternal
}
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
CalendarID: bl.CalendarID,
StartTime: utils.ToPgTimestamptz(rangeEnd),
})
if err != nil {
return nil, models.ErrInternal
}
var busyBlocks []models.TimeSlot
for _, ev := range events {
if ev.RecurrenceRule.Valid {
continue
}
start := utils.FromPgTimestamptz(ev.StartTime)
end := utils.FromPgTimestamptz(ev.EndTime)
buf := time.Duration(bl.BufferMinutes) * time.Minute
busyBlocks = append(busyBlocks, models.TimeSlot{
Start: start.Add(-buf),
End: end.Add(buf),
})
}
for _, ev := range recurring {
occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd)
for _, occ := range occs {
if occ.OccurrenceStartTime != nil {
buf := time.Duration(bl.BufferMinutes) * time.Minute
busyBlocks = append(busyBlocks, models.TimeSlot{
Start: occ.OccurrenceStartTime.Add(-buf),
End: occ.OccurrenceEndTime.Add(buf),
})
}
}
}
_ = calID
duration := time.Duration(bl.DurationMinutes) * time.Minute
dayNames := []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}
var slots []models.TimeSlot
for d := rangeStart; d.Before(rangeEnd); d = d.Add(24 * time.Hour) {
localDay := d.In(loc)
dayName := dayNames[localDay.Weekday()]
windows, ok := workingHours[dayName]
if !ok || len(windows) == 0 {
continue
}
for _, w := range windows {
wStart, err1 := time.Parse("15:04", w.Start)
wEnd, err2 := time.Parse("15:04", w.End)
if err1 != nil || err2 != nil {
continue
}
windowStart := time.Date(localDay.Year(), localDay.Month(), localDay.Day(),
wStart.Hour(), wStart.Minute(), 0, 0, loc).UTC()
windowEnd := time.Date(localDay.Year(), localDay.Month(), localDay.Day(),
wEnd.Hour(), wEnd.Minute(), 0, 0, loc).UTC()
for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(duration) {
slotEnd := slotStart.Add(duration)
if !isConflict(slotStart, slotEnd, busyBlocks) {
slots = append(slots, models.TimeSlot{Start: slotStart, End: slotEnd})
}
}
}
}
sort.Slice(slots, func(i, j int) bool {
return slots[i].Start.Before(slots[j].Start)
})
if slots == nil {
slots = []models.TimeSlot{}
}
return &models.BookingAvailability{
Token: token,
Timezone: bl.Timezone,
DurationMinutes: int(bl.DurationMinutes),
Slots: slots,
}, nil
}
func (s *BookingService) Reserve(ctx context.Context, token string, name, email string, slotStart, slotEnd time.Time, notes *string) (*models.Event, error) {
bl, err := s.queries.GetBookingLinkByToken(ctx, token)
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
if !bl.Active {
return nil, models.NewNotFoundError("booking link is not active")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, models.ErrInternal
}
defer tx.Rollback(ctx)
qtx := s.queries.WithTx(tx)
overlap, err := qtx.CheckEventOverlapForUpdate(ctx, repository.CheckEventOverlapForUpdateParams{
CalendarID: bl.CalendarID,
EndTime: utils.ToPgTimestamptz(slotStart),
StartTime: utils.ToPgTimestamptz(slotEnd),
})
if err != nil {
return nil, models.ErrInternal
}
if overlap {
return nil, models.NewConflictError("slot no longer available")
}
cal, err := s.queries.GetCalendarByID(ctx, bl.CalendarID)
if err != nil {
return nil, models.ErrInternal
}
title := fmt.Sprintf("Booking: %s", name)
desc := fmt.Sprintf("Booked by %s (%s)", name, email)
if notes != nil && *notes != "" {
desc += "\nNotes: " + *notes
}
eventID := uuid.New()
ownerID := utils.FromPgUUID(cal.OwnerID)
ev, err := qtx.CreateEvent(ctx, repository.CreateEventParams{
ID: utils.ToPgUUID(eventID),
CalendarID: bl.CalendarID,
Title: title,
Description: utils.ToPgText(desc),
StartTime: utils.ToPgTimestamptz(slotStart.UTC()),
EndTime: utils.ToPgTimestamptz(slotEnd.UTC()),
Timezone: bl.Timezone,
Tags: []string{"booking"},
CreatedBy: utils.ToPgUUID(ownerID),
UpdatedBy: utils.ToPgUUID(ownerID),
})
if err != nil {
return nil, models.ErrInternal
}
if err := tx.Commit(ctx); err != nil {
return nil, models.ErrInternal
}
return eventFromDB(ev, []models.Reminder{}, []models.Attendee{}, []models.Attachment{}), nil
}
func isConflict(start, end time.Time, busy []models.TimeSlot) bool {
for _, b := range busy {
if start.Before(b.End) && end.After(b.Start) {
return true
}
}
return false
}

View File

@@ -0,0 +1,318 @@
package service
import (
"context"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
type CalendarService struct {
pool *pgxpool.Pool
queries *repository.Queries
audit *AuditService
}
func NewCalendarService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService) *CalendarService {
return &CalendarService{pool: pool, queries: queries, audit: audit}
}
func (s *CalendarService) Create(ctx context.Context, userID uuid.UUID, name, color string) (*models.Calendar, error) {
if err := utils.ValidateCalendarName(name); err != nil {
return nil, err
}
if err := utils.ValidateColor(color); err != nil {
return nil, err
}
if color == "" {
color = "#3B82F6"
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, models.ErrInternal
}
defer tx.Rollback(ctx)
qtx := s.queries.WithTx(tx)
calID := uuid.New()
cal, err := qtx.CreateCalendar(ctx, repository.CreateCalendarParams{
ID: utils.ToPgUUID(calID),
OwnerID: utils.ToPgUUID(userID),
Name: name,
Color: color,
IsPublic: false,
})
if err != nil {
return nil, models.ErrInternal
}
err = qtx.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
CalendarID: utils.ToPgUUID(calID),
UserID: utils.ToPgUUID(userID),
Role: "owner",
})
if err != nil {
return nil, models.ErrInternal
}
if err := tx.Commit(ctx); err != nil {
return nil, models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "CREATE_CALENDAR", userID)
return &models.Calendar{
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
Role: "owner",
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
}, nil
}
func (s *CalendarService) List(ctx context.Context, userID uuid.UUID) ([]models.Calendar, error) {
rows, err := s.queries.ListCalendarsByUser(ctx, utils.ToPgUUID(userID))
if err != nil {
return nil, models.ErrInternal
}
calendars := make([]models.Calendar, len(rows))
for i, r := range rows {
calendars[i] = models.Calendar{
ID: utils.FromPgUUID(r.ID),
Name: r.Name,
Color: r.Color,
IsPublic: r.IsPublic,
Role: r.Role,
CreatedAt: utils.FromPgTimestamptz(r.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt),
}
}
return calendars, nil
}
func (s *CalendarService) Get(ctx context.Context, userID uuid.UUID, calID uuid.UUID) (*models.Calendar, error) {
role, err := s.getRole(ctx, calID, userID)
if err != nil {
return nil, err
}
cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calID))
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
return &models.Calendar{
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
Role: role,
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
}, nil
}
func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uuid.UUID, name, color *string, isPublic *bool) (*models.Calendar, error) {
role, err := s.getRole(ctx, calID, userID)
if err != nil {
return nil, err
}
if role != "owner" && role != "editor" {
return nil, models.ErrForbidden
}
if isPublic != nil && role != "owner" {
return nil, models.NewForbiddenError("only owner can change is_public")
}
if name != nil {
if err := utils.ValidateCalendarName(*name); err != nil {
return nil, err
}
}
if color != nil {
if err := utils.ValidateColor(*color); err != nil {
return nil, err
}
}
var pgPublic pgtype.Bool
if isPublic != nil {
pgPublic = pgtype.Bool{Bool: *isPublic, Valid: true}
}
cal, err := s.queries.UpdateCalendar(ctx, repository.UpdateCalendarParams{
ID: utils.ToPgUUID(calID),
Name: utils.ToPgTextPtr(name),
Color: utils.ToPgTextPtr(color),
IsPublic: pgPublic,
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "UPDATE_CALENDAR", userID)
return &models.Calendar{
ID: utils.FromPgUUID(cal.ID),
Name: cal.Name,
Color: cal.Color,
IsPublic: cal.IsPublic,
Role: role,
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
}, nil
}
func (s *CalendarService) Delete(ctx context.Context, userID uuid.UUID, calID uuid.UUID) error {
role, err := s.getRole(ctx, calID, userID)
if err != nil {
return err
}
if role != "owner" {
return models.ErrForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return models.ErrInternal
}
defer tx.Rollback(ctx)
qtx := s.queries.WithTx(tx)
pgCalID := utils.ToPgUUID(calID)
if err := qtx.SoftDeleteEventsByCalendar(ctx, pgCalID); err != nil {
return models.ErrInternal
}
if err := qtx.SoftDeleteCalendar(ctx, pgCalID); err != nil {
return models.ErrInternal
}
if err := tx.Commit(ctx); err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "DELETE_CALENDAR", userID)
return nil
}
func (s *CalendarService) Share(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetEmail, role string) error {
ownerRole, err := s.getRole(ctx, calID, ownerID)
if err != nil {
return err
}
if ownerRole != "owner" {
return models.ErrForbidden
}
if role != "editor" && role != "viewer" {
return models.NewValidationError("role must be editor or viewer")
}
targetUser, err := s.queries.GetUserByEmail(ctx, utils.NormalizeEmail(targetEmail))
if err != nil {
if err == pgx.ErrNoRows {
return models.NewNotFoundError("user not found")
}
return models.ErrInternal
}
targetID := utils.FromPgUUID(targetUser.ID)
if targetID == ownerID {
return models.NewValidationError("cannot share with yourself")
}
err = s.queries.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
CalendarID: utils.ToPgUUID(calID),
UserID: utils.ToPgUUID(targetID),
Role: role,
})
if err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "SHARE_CALENDAR", ownerID)
return nil
}
func (s *CalendarService) ListMembers(ctx context.Context, userID uuid.UUID, calID uuid.UUID) ([]models.CalendarMember, error) {
if _, err := s.getRole(ctx, calID, userID); err != nil {
return nil, err
}
rows, err := s.queries.ListCalendarMembers(ctx, utils.ToPgUUID(calID))
if err != nil {
return nil, models.ErrInternal
}
members := make([]models.CalendarMember, len(rows))
for i, r := range rows {
members[i] = models.CalendarMember{
UserID: utils.FromPgUUID(r.UserID),
Email: r.Email,
Role: r.Role,
}
}
return members, nil
}
func (s *CalendarService) RemoveMember(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetUserID uuid.UUID) error {
role, err := s.getRole(ctx, calID, ownerID)
if err != nil {
return err
}
if role != "owner" {
return models.ErrForbidden
}
targetRole, err := s.getRole(ctx, calID, targetUserID)
if err != nil {
return err
}
if targetRole == "owner" {
return models.NewValidationError("cannot remove owner")
}
err = s.queries.DeleteCalendarMember(ctx, repository.DeleteCalendarMemberParams{
CalendarID: utils.ToPgUUID(calID),
UserID: utils.ToPgUUID(targetUserID),
})
if err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "calendar", calID, "REMOVE_MEMBER", ownerID)
return nil
}
func (s *CalendarService) GetRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) {
return s.getRole(ctx, calID, userID)
}
func (s *CalendarService) getRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) {
role, err := s.queries.GetCalendarMemberRole(ctx, repository.GetCalendarMemberRoleParams{
CalendarID: utils.ToPgUUID(calID),
UserID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return "", models.ErrNotFound
}
return "", models.ErrInternal
}
return role, nil
}

172
internal/service/contact.go Normal file
View File

@@ -0,0 +1,172 @@
package service
import (
"context"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type ContactService struct {
queries *repository.Queries
audit *AuditService
}
func NewContactService(queries *repository.Queries, audit *AuditService) *ContactService {
return &ContactService{queries: queries, audit: audit}
}
func (s *ContactService) Create(ctx context.Context, userID uuid.UUID, req CreateContactRequest) (*models.Contact, error) {
if req.FirstName == nil && req.LastName == nil && req.Email == nil && req.Phone == nil {
return nil, models.NewValidationError("at least one identifying field required")
}
if req.Email != nil {
if err := utils.ValidateEmail(*req.Email); err != nil {
return nil, err
}
}
id := uuid.New()
c, err := s.queries.CreateContact(ctx, repository.CreateContactParams{
ID: utils.ToPgUUID(id),
OwnerID: utils.ToPgUUID(userID),
FirstName: utils.ToPgTextPtr(req.FirstName),
LastName: utils.ToPgTextPtr(req.LastName),
Email: utils.ToPgTextPtr(req.Email),
Phone: utils.ToPgTextPtr(req.Phone),
Company: utils.ToPgTextPtr(req.Company),
Notes: utils.ToPgTextPtr(req.Notes),
})
if err != nil {
return nil, models.ErrInternal
}
s.audit.Log(ctx, "contact", id, "CREATE_CONTACT", userID)
return contactFromDB(c), nil
}
func (s *ContactService) Get(ctx context.Context, userID uuid.UUID, contactID uuid.UUID) (*models.Contact, error) {
c, err := s.queries.GetContactByID(ctx, repository.GetContactByIDParams{
ID: utils.ToPgUUID(contactID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
return contactFromDB(c), nil
}
func (s *ContactService) List(ctx context.Context, userID uuid.UUID, search *string, limit int, cursor string) ([]models.Contact, *string, error) {
lim := utils.ClampLimit(limit)
var cursorTime, cursorID interface{}
p := repository.ListContactsParams{
OwnerID: utils.ToPgUUID(userID),
Search: utils.ToPgTextPtr(search),
Lim: lim + 1,
}
if cursor != "" {
ct, cid, err := utils.ParseCursor(cursor)
if err != nil {
return nil, nil, models.NewValidationError("invalid cursor")
}
p.CursorTime = utils.ToPgTimestamptzPtr(ct)
p.CursorID = utils.ToPgUUID(*cid)
cursorTime = ct
cursorID = cid
}
_ = cursorTime
_ = cursorID
rows, err := s.queries.ListContacts(ctx, p)
if err != nil {
return nil, nil, models.ErrInternal
}
contacts := make([]models.Contact, 0, len(rows))
for _, r := range rows {
contacts = append(contacts, *contactFromDB(r))
}
if int32(len(contacts)) > lim {
contacts = contacts[:lim]
last := contacts[len(contacts)-1]
c := utils.EncodeCursor(last.CreatedAt, last.ID)
return contacts, &c, nil
}
return contacts, nil, nil
}
func (s *ContactService) Update(ctx context.Context, userID uuid.UUID, contactID uuid.UUID, req UpdateContactRequest) (*models.Contact, error) {
c, err := s.queries.UpdateContact(ctx, repository.UpdateContactParams{
ID: utils.ToPgUUID(contactID),
OwnerID: utils.ToPgUUID(userID),
FirstName: utils.ToPgTextPtr(req.FirstName),
LastName: utils.ToPgTextPtr(req.LastName),
Email: utils.ToPgTextPtr(req.Email),
Phone: utils.ToPgTextPtr(req.Phone),
Company: utils.ToPgTextPtr(req.Company),
Notes: utils.ToPgTextPtr(req.Notes),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
s.audit.Log(ctx, "contact", contactID, "UPDATE_CONTACT", userID)
return contactFromDB(c), nil
}
func (s *ContactService) Delete(ctx context.Context, userID uuid.UUID, contactID uuid.UUID) error {
err := s.queries.SoftDeleteContact(ctx, repository.SoftDeleteContactParams{
ID: utils.ToPgUUID(contactID),
OwnerID: utils.ToPgUUID(userID),
})
if err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "contact", contactID, "DELETE_CONTACT", userID)
return nil
}
func contactFromDB(v repository.Contact) *models.Contact {
return &models.Contact{
ID: utils.FromPgUUID(v.ID),
FirstName: utils.FromPgText(v.FirstName),
LastName: utils.FromPgText(v.LastName),
Email: utils.FromPgText(v.Email),
Phone: utils.FromPgText(v.Phone),
Company: utils.FromPgText(v.Company),
Notes: utils.FromPgText(v.Notes),
CreatedAt: utils.FromPgTimestamptz(v.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(v.UpdatedAt),
}
}
type CreateContactRequest struct {
FirstName *string
LastName *string
Email *string
Phone *string
Company *string
Notes *string
}
type UpdateContactRequest struct {
FirstName *string
LastName *string
Email *string
Phone *string
Company *string
Notes *string
}

583
internal/service/event.go Normal file
View File

@@ -0,0 +1,583 @@
package service
import (
"context"
"fmt"
"sort"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/teambition/rrule-go"
)
type EventService struct {
pool *pgxpool.Pool
queries *repository.Queries
calendar *CalendarService
audit *AuditService
scheduler ReminderScheduler
}
type ReminderScheduler interface {
ScheduleReminder(ctx context.Context, eventID, reminderID, userID uuid.UUID, triggerAt time.Time) error
}
func NewEventService(pool *pgxpool.Pool, queries *repository.Queries, calendar *CalendarService, audit *AuditService, scheduler ReminderScheduler) *EventService {
return &EventService{pool: pool, queries: queries, calendar: calendar, audit: audit, scheduler: scheduler}
}
func (s *EventService) Create(ctx context.Context, userID uuid.UUID, req CreateEventRequest) (*models.Event, error) {
if err := utils.ValidateEventTitle(req.Title); err != nil {
return nil, err
}
if req.Timezone == "" {
return nil, models.NewValidationError("timezone is required")
}
if err := utils.ValidateTimezone(req.Timezone); err != nil {
return nil, err
}
if err := utils.ValidateTimeRange(req.StartTime, req.EndTime); err != nil {
return nil, err
}
role, err := s.calendar.GetRole(ctx, req.CalendarID, userID)
if err != nil {
return nil, err
}
if role != "owner" && role != "editor" {
return nil, models.ErrForbidden
}
startUTC := req.StartTime.UTC()
endUTC := req.EndTime.UTC()
if req.RecurrenceRule != nil && *req.RecurrenceRule != "" {
if _, err := rrule.StrToRRule(*req.RecurrenceRule); err != nil {
return nil, models.NewValidationError("invalid recurrence_rule: " + err.Error())
}
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, models.ErrInternal
}
defer tx.Rollback(ctx)
qtx := s.queries.WithTx(tx)
eventID := uuid.New()
tags := req.Tags
if tags == nil {
tags = []string{}
}
dbEvent, err := qtx.CreateEvent(ctx, repository.CreateEventParams{
ID: utils.ToPgUUID(eventID),
CalendarID: utils.ToPgUUID(req.CalendarID),
Title: req.Title,
Description: utils.ToPgTextPtr(req.Description),
Location: utils.ToPgTextPtr(req.Location),
StartTime: utils.ToPgTimestamptz(startUTC),
EndTime: utils.ToPgTimestamptz(endUTC),
Timezone: req.Timezone,
AllDay: req.AllDay,
RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule),
Tags: tags,
CreatedBy: utils.ToPgUUID(userID),
UpdatedBy: utils.ToPgUUID(userID),
})
if err != nil {
return nil, models.ErrInternal
}
var reminders []models.Reminder
for _, mins := range req.Reminders {
if err := utils.ValidateReminderMinutes(mins); err != nil {
return nil, err
}
rID := uuid.New()
r, err := qtx.CreateReminder(ctx, repository.CreateReminderParams{
ID: utils.ToPgUUID(rID),
EventID: utils.ToPgUUID(eventID),
MinutesBefore: mins,
})
if err != nil {
return nil, models.ErrInternal
}
reminders = append(reminders, models.Reminder{
ID: utils.FromPgUUID(r.ID),
MinutesBefore: r.MinutesBefore,
})
}
if err := tx.Commit(ctx); err != nil {
return nil, models.ErrInternal
}
for _, rem := range reminders {
triggerAt := startUTC.Add(-time.Duration(rem.MinutesBefore) * time.Minute)
if triggerAt.After(time.Now()) && s.scheduler != nil {
_ = s.scheduler.ScheduleReminder(ctx, eventID, rem.ID, userID, triggerAt)
}
}
s.audit.Log(ctx, "event", eventID, "CREATE_EVENT", userID)
if reminders == nil {
reminders = []models.Reminder{}
}
return eventFromDB(dbEvent, reminders, []models.Attendee{}, []models.Attachment{}), nil
}
func (s *EventService) Get(ctx context.Context, userID uuid.UUID, eventID uuid.UUID) (*models.Event, error) {
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
calID := utils.FromPgUUID(ev.CalendarID)
if _, err := s.calendar.GetRole(ctx, calID, userID); err != nil {
return nil, err
}
reminders, err := s.loadReminders(ctx, eventID)
if err != nil {
return nil, err
}
attendees, err := s.loadAttendees(ctx, eventID)
if err != nil {
return nil, err
}
attachments, err := s.loadAttachments(ctx, eventID)
if err != nil {
return nil, err
}
return eventFromDB(ev, reminders, attendees, attachments), nil
}
func (s *EventService) List(ctx context.Context, userID uuid.UUID, params ListEventParams) ([]models.Event, *string, error) {
if err := utils.ValidateRecurrenceRangeLimit(params.RangeStart, params.RangeEnd); err != nil {
return nil, nil, err
}
limit := utils.ClampLimit(params.Limit)
var cursorTime pgtype.Timestamptz
var cursorID pgtype.UUID
if params.Cursor != "" {
ct, cid, err := utils.ParseCursor(params.Cursor)
if err != nil {
return nil, nil, models.NewValidationError("invalid cursor")
}
cursorTime = utils.ToPgTimestamptzPtr(ct)
cursorID = utils.ToPgUUID(*cid)
}
nonRecurring, err := s.queries.ListEventsInRange(ctx, repository.ListEventsInRangeParams{
UserID: utils.ToPgUUID(userID),
RangeEnd: utils.ToPgTimestamptz(params.RangeEnd),
RangeStart: utils.ToPgTimestamptz(params.RangeStart),
CalendarID: optionalPgUUID(params.CalendarID),
Search: utils.ToPgTextPtr(params.Search),
Tag: utils.ToPgTextPtr(params.Tag),
CursorTime: cursorTime,
CursorID: cursorID,
Lim: limit + 1,
})
if err != nil {
return nil, nil, models.ErrInternal
}
recurring, err := s.queries.ListRecurringEventsInRange(ctx, repository.ListRecurringEventsInRangeParams{
UserID: utils.ToPgUUID(userID),
RangeEnd: utils.ToPgTimestamptz(params.RangeEnd),
CalendarID: optionalPgUUID(params.CalendarID),
})
if err != nil {
return nil, nil, models.ErrInternal
}
var allEvents []models.Event
recurringIDs := make(map[uuid.UUID]bool)
for _, ev := range recurring {
recurringIDs[utils.FromPgUUID(ev.ID)] = true
occurrences := s.expandRecurrence(ev, params.RangeStart, params.RangeEnd)
allEvents = append(allEvents, occurrences...)
}
for _, ev := range nonRecurring {
eid := utils.FromPgUUID(ev.ID)
if recurringIDs[eid] {
continue
}
allEvents = append(allEvents, *eventFromDB(ev, nil, nil, nil))
}
sort.Slice(allEvents, func(i, j int) bool {
si := effectiveStart(allEvents[i])
sj := effectiveStart(allEvents[j])
if si.Equal(sj) {
return allEvents[i].ID.String() < allEvents[j].ID.String()
}
return si.Before(sj)
})
hasMore := int32(len(allEvents)) > limit
if hasMore {
allEvents = allEvents[:limit]
}
relatedLoaded := make(map[uuid.UUID]bool)
remindersMap := make(map[uuid.UUID][]models.Reminder)
attendeesMap := make(map[uuid.UUID][]models.Attendee)
attachmentsMap := make(map[uuid.UUID][]models.Attachment)
for _, ev := range allEvents {
if relatedLoaded[ev.ID] {
continue
}
relatedLoaded[ev.ID] = true
remindersMap[ev.ID], _ = s.loadReminders(ctx, ev.ID)
attendeesMap[ev.ID], _ = s.loadAttendees(ctx, ev.ID)
attachmentsMap[ev.ID], _ = s.loadAttachments(ctx, ev.ID)
}
for i := range allEvents {
allEvents[i].Reminders = remindersMap[allEvents[i].ID]
allEvents[i].Attendees = attendeesMap[allEvents[i].ID]
allEvents[i].Attachments = attachmentsMap[allEvents[i].ID]
}
if hasMore {
last := allEvents[len(allEvents)-1]
cursor := utils.EncodeCursor(effectiveStart(last), last.ID)
return allEvents, &cursor, nil
}
return allEvents, nil, nil
}
func (s *EventService) Update(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, req UpdateEventRequest) (*models.Event, error) {
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
calID := utils.FromPgUUID(ev.CalendarID)
role, err := s.calendar.GetRole(ctx, calID, userID)
if err != nil {
return nil, err
}
if role != "owner" && role != "editor" {
return nil, models.ErrForbidden
}
if req.Title != nil {
if err := utils.ValidateEventTitle(*req.Title); err != nil {
return nil, err
}
}
if req.Timezone != nil {
if err := utils.ValidateTimezone(*req.Timezone); err != nil {
return nil, err
}
}
startTime := utils.FromPgTimestamptz(ev.StartTime)
endTime := utils.FromPgTimestamptz(ev.EndTime)
if req.StartTime != nil {
startTime = req.StartTime.UTC()
}
if req.EndTime != nil {
endTime = req.EndTime.UTC()
}
if err := utils.ValidateTimeRange(startTime, endTime); err != nil {
return nil, err
}
if req.RecurrenceRule != nil && *req.RecurrenceRule != "" {
if _, err := rrule.StrToRRule(*req.RecurrenceRule); err != nil {
return nil, models.NewValidationError("invalid recurrence_rule: " + err.Error())
}
}
var pgStart, pgEnd pgtype.Timestamptz
if req.StartTime != nil {
pgStart = utils.ToPgTimestamptz(startTime)
}
if req.EndTime != nil {
pgEnd = utils.ToPgTimestamptz(endTime)
}
updated, err := s.queries.UpdateEvent(ctx, repository.UpdateEventParams{
ID: utils.ToPgUUID(eventID),
Title: utils.ToPgTextPtr(req.Title),
Description: utils.ToPgTextPtr(req.Description),
Location: utils.ToPgTextPtr(req.Location),
StartTime: pgStart,
EndTime: pgEnd,
Timezone: utils.ToPgTextPtr(req.Timezone),
AllDay: optionalPgBool(req.AllDay),
RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule),
Tags: req.Tags,
UpdatedBy: utils.ToPgUUID(userID),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
s.audit.Log(ctx, "event", eventID, "UPDATE_EVENT", userID)
reminders, _ := s.loadReminders(ctx, eventID)
attendees, _ := s.loadAttendees(ctx, eventID)
attachments, _ := s.loadAttachments(ctx, eventID)
return eventFromDB(updated, reminders, attendees, attachments), nil
}
func (s *EventService) Delete(ctx context.Context, userID uuid.UUID, eventID uuid.UUID) error {
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
if err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
calID := utils.FromPgUUID(ev.CalendarID)
role, err := s.calendar.GetRole(ctx, calID, userID)
if err != nil {
return err
}
if role != "owner" && role != "editor" {
return models.ErrForbidden
}
if err := s.queries.SoftDeleteEvent(ctx, utils.ToPgUUID(eventID)); err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "event", eventID, "DELETE_EVENT", userID)
return nil
}
func (s *EventService) expandRecurrence(ev repository.Event, rangeStart, rangeEnd time.Time) []models.Event {
if !ev.RecurrenceRule.Valid {
return nil
}
ruleStr := ev.RecurrenceRule.String
dtStart := utils.FromPgTimestamptz(ev.StartTime)
duration := utils.FromPgTimestamptz(ev.EndTime).Sub(dtStart)
fullRule := fmt.Sprintf("DTSTART:%s\nRRULE:%s", dtStart.UTC().Format("20060102T150405Z"), ruleStr)
rule, err := rrule.StrToRRuleSet(fullRule)
if err != nil {
return nil
}
exceptions, _ := s.queries.ListExceptionsByEvent(context.Background(), ev.ID)
exceptionDates := make(map[string]bool)
for _, ex := range exceptions {
if ex.ExceptionDate.Valid {
exceptionDates[ex.ExceptionDate.Time.Format("2006-01-02")] = true
}
}
occurrences := rule.Between(rangeStart.UTC(), rangeEnd.UTC(), true)
var results []models.Event
for _, occ := range occurrences {
dateKey := occ.Format("2006-01-02")
if exceptionDates[dateKey] {
continue
}
occEnd := occ.Add(duration)
occStart := occ
results = append(results, models.Event{
ID: utils.FromPgUUID(ev.ID),
CalendarID: utils.FromPgUUID(ev.CalendarID),
Title: ev.Title,
Description: utils.FromPgText(ev.Description),
Location: utils.FromPgText(ev.Location),
StartTime: dtStart,
EndTime: dtStart.Add(duration),
Timezone: ev.Timezone,
AllDay: ev.AllDay,
RecurrenceRule: utils.FromPgText(ev.RecurrenceRule),
IsOccurrence: true,
OccurrenceStartTime: &occStart,
OccurrenceEndTime: &occEnd,
CreatedBy: utils.FromPgUUID(ev.CreatedBy),
UpdatedBy: utils.FromPgUUID(ev.UpdatedBy),
CreatedAt: utils.FromPgTimestamptz(ev.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(ev.UpdatedAt),
Tags: ev.Tags,
Reminders: []models.Reminder{},
Attendees: []models.Attendee{},
Attachments: []models.Attachment{},
})
}
return results
}
func (s *EventService) loadReminders(ctx context.Context, eventID uuid.UUID) ([]models.Reminder, error) {
rows, err := s.queries.ListRemindersByEvent(ctx, utils.ToPgUUID(eventID))
if err != nil {
return nil, models.ErrInternal
}
result := make([]models.Reminder, len(rows))
for i, r := range rows {
result[i] = models.Reminder{
ID: utils.FromPgUUID(r.ID),
MinutesBefore: r.MinutesBefore,
}
}
return result, nil
}
func (s *EventService) loadAttendees(ctx context.Context, eventID uuid.UUID) ([]models.Attendee, error) {
rows, err := s.queries.ListAttendeesByEvent(ctx, utils.ToPgUUID(eventID))
if err != nil {
return nil, models.ErrInternal
}
result := make([]models.Attendee, len(rows))
for i, a := range rows {
var uid *uuid.UUID
if a.UserID.Valid {
u := utils.FromPgUUID(a.UserID)
uid = &u
}
result[i] = models.Attendee{
ID: utils.FromPgUUID(a.ID),
UserID: uid,
Email: utils.FromPgText(a.Email),
Status: a.Status,
}
}
return result, nil
}
func (s *EventService) loadAttachments(ctx context.Context, eventID uuid.UUID) ([]models.Attachment, error) {
rows, err := s.queries.ListAttachmentsByEvent(ctx, utils.ToPgUUID(eventID))
if err != nil {
return nil, models.ErrInternal
}
result := make([]models.Attachment, len(rows))
for i, a := range rows {
result[i] = models.Attachment{
ID: utils.FromPgUUID(a.ID),
FileURL: a.FileUrl,
}
}
return result, nil
}
func eventFromDB(ev repository.Event, reminders []models.Reminder, attendees []models.Attendee, attachments []models.Attachment) *models.Event {
if reminders == nil {
reminders = []models.Reminder{}
}
if attendees == nil {
attendees = []models.Attendee{}
}
if attachments == nil {
attachments = []models.Attachment{}
}
tags := ev.Tags
if tags == nil {
tags = []string{}
}
return &models.Event{
ID: utils.FromPgUUID(ev.ID),
CalendarID: utils.FromPgUUID(ev.CalendarID),
Title: ev.Title,
Description: utils.FromPgText(ev.Description),
Location: utils.FromPgText(ev.Location),
StartTime: utils.FromPgTimestamptz(ev.StartTime),
EndTime: utils.FromPgTimestamptz(ev.EndTime),
Timezone: ev.Timezone,
AllDay: ev.AllDay,
RecurrenceRule: utils.FromPgText(ev.RecurrenceRule),
CreatedBy: utils.FromPgUUID(ev.CreatedBy),
UpdatedBy: utils.FromPgUUID(ev.UpdatedBy),
CreatedAt: utils.FromPgTimestamptz(ev.CreatedAt),
UpdatedAt: utils.FromPgTimestamptz(ev.UpdatedAt),
Reminders: reminders,
Attendees: attendees,
Attachments: attachments,
Tags: tags,
}
}
type CreateEventRequest struct {
CalendarID uuid.UUID
Title string
Description *string
Location *string
StartTime time.Time
EndTime time.Time
Timezone string
AllDay bool
RecurrenceRule *string
Reminders []int32
Tags []string
}
type UpdateEventRequest struct {
Title *string
Description *string
Location *string
StartTime *time.Time
EndTime *time.Time
Timezone *string
AllDay *bool
RecurrenceRule *string
Tags []string
}
type ListEventParams struct {
RangeStart time.Time
RangeEnd time.Time
CalendarID *uuid.UUID
Search *string
Tag *string
Limit int
Cursor string
}
func effectiveStart(e models.Event) time.Time {
if e.OccurrenceStartTime != nil {
return *e.OccurrenceStartTime
}
return e.StartTime
}
func optionalPgUUID(id *uuid.UUID) pgtype.UUID {
if id == nil {
return pgtype.UUID{Valid: false}
}
return utils.ToPgUUID(*id)
}
func optionalPgBool(b *bool) pgtype.Bool {
if b == nil {
return pgtype.Bool{Valid: false}
}
return pgtype.Bool{Bool: *b, Valid: true}
}

View File

@@ -0,0 +1,132 @@
package service
import (
"context"
"time"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type ReminderService struct {
queries *repository.Queries
calendar *CalendarService
scheduler ReminderScheduler
}
func NewReminderService(queries *repository.Queries, calendar *CalendarService, scheduler ReminderScheduler) *ReminderService {
return &ReminderService{queries: queries, calendar: calendar, scheduler: scheduler}
}
func (s *ReminderService) AddReminders(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, minutesBefore []int32) (*models.Event, error) {
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
calID := utils.FromPgUUID(ev.CalendarID)
role, err := s.calendar.GetRole(ctx, calID, userID)
if err != nil {
return nil, err
}
if role != "owner" && role != "editor" {
return nil, models.ErrForbidden
}
for _, mins := range minutesBefore {
if err := utils.ValidateReminderMinutes(mins); err != nil {
return nil, err
}
rID := uuid.New()
_, err := s.queries.CreateReminder(ctx, repository.CreateReminderParams{
ID: utils.ToPgUUID(rID),
EventID: utils.ToPgUUID(eventID),
MinutesBefore: mins,
})
if err != nil {
return nil, models.ErrInternal
}
startTime := utils.FromPgTimestamptz(ev.StartTime)
triggerAt := startTime.Add(-time.Duration(mins) * time.Minute)
if triggerAt.After(time.Now()) && s.scheduler != nil {
_ = s.scheduler.ScheduleReminder(ctx, eventID, rID, userID, triggerAt)
}
}
reminders, _ := loadRemindersHelper(ctx, s.queries, eventID)
attendees, _ := loadAttendeesHelper(ctx, s.queries, eventID)
attachments, _ := loadAttachmentsHelper(ctx, s.queries, eventID)
return eventFromDB(ev, reminders, attendees, attachments), nil
}
func (s *ReminderService) DeleteReminder(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, reminderID uuid.UUID) error {
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
if err != nil {
if err == pgx.ErrNoRows {
return models.ErrNotFound
}
return models.ErrInternal
}
calID := utils.FromPgUUID(ev.CalendarID)
role, err := s.calendar.GetRole(ctx, calID, userID)
if err != nil {
return err
}
if role != "owner" && role != "editor" {
return models.ErrForbidden
}
return s.queries.DeleteReminder(ctx, repository.DeleteReminderParams{
ID: utils.ToPgUUID(reminderID),
EventID: utils.ToPgUUID(eventID),
})
}
func loadRemindersHelper(ctx context.Context, q *repository.Queries, eventID uuid.UUID) ([]models.Reminder, error) {
rows, err := q.ListRemindersByEvent(ctx, utils.ToPgUUID(eventID))
if err != nil {
return []models.Reminder{}, err
}
result := make([]models.Reminder, len(rows))
for i, r := range rows {
result[i] = models.Reminder{ID: utils.FromPgUUID(r.ID), MinutesBefore: r.MinutesBefore}
}
return result, nil
}
func loadAttendeesHelper(ctx context.Context, q *repository.Queries, eventID uuid.UUID) ([]models.Attendee, error) {
rows, err := q.ListAttendeesByEvent(ctx, utils.ToPgUUID(eventID))
if err != nil {
return []models.Attendee{}, err
}
result := make([]models.Attendee, len(rows))
for i, a := range rows {
var uid *uuid.UUID
if a.UserID.Valid {
u := utils.FromPgUUID(a.UserID)
uid = &u
}
result[i] = models.Attendee{ID: utils.FromPgUUID(a.ID), UserID: uid, Email: utils.FromPgText(a.Email), Status: a.Status}
}
return result, nil
}
func loadAttachmentsHelper(ctx context.Context, q *repository.Queries, eventID uuid.UUID) ([]models.Attachment, error) {
rows, err := q.ListAttachmentsByEvent(ctx, utils.ToPgUUID(eventID))
if err != nil {
return []models.Attachment{}, err
}
result := make([]models.Attachment, len(rows))
for i, a := range rows {
result[i] = models.Attachment{ID: utils.FromPgUUID(a.ID), FileURL: a.FileUrl}
}
return result, nil
}

92
internal/service/user.go Normal file
View File

@@ -0,0 +1,92 @@
package service
import (
"context"
"github.com/calendarapi/internal/models"
"github.com/calendarapi/internal/repository"
"github.com/calendarapi/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type UserService struct {
pool *pgxpool.Pool
queries *repository.Queries
audit *AuditService
}
func NewUserService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService) *UserService {
return &UserService{pool: pool, queries: queries, audit: audit}
}
func (s *UserService) GetMe(ctx context.Context, userID uuid.UUID) (*models.User, error) {
u, err := s.queries.GetUserByID(ctx, utils.ToPgUUID(userID))
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
user := userFromIDRow(u)
return &user, nil
}
func (s *UserService) Update(ctx context.Context, userID uuid.UUID, timezone *string) (*models.User, error) {
if timezone != nil {
if err := utils.ValidateTimezone(*timezone); err != nil {
return nil, err
}
}
u, err := s.queries.UpdateUser(ctx, repository.UpdateUserParams{
ID: utils.ToPgUUID(userID),
Timezone: utils.ToPgTextPtr(timezone),
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, models.ErrNotFound
}
return nil, models.ErrInternal
}
user := userFromUpdateRow(u)
return &user, nil
}
func (s *UserService) Delete(ctx context.Context, userID uuid.UUID) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return models.ErrInternal
}
defer tx.Rollback(ctx)
qtx := s.queries.WithTx(tx)
pgID := utils.ToPgUUID(userID)
if err := qtx.SoftDeleteContactsByOwner(ctx, pgID); err != nil {
return models.ErrInternal
}
if err := qtx.SoftDeleteEventsByCreator(ctx, pgID); err != nil {
return models.ErrInternal
}
if err := qtx.SoftDeleteCalendarsByOwner(ctx, pgID); err != nil {
return models.ErrInternal
}
if err := qtx.RevokeAllUserAPIKeys(ctx, pgID); err != nil {
return models.ErrInternal
}
if err := qtx.RevokeAllUserRefreshTokens(ctx, pgID); err != nil {
return models.ErrInternal
}
if err := qtx.SoftDeleteUser(ctx, pgID); err != nil {
return models.ErrInternal
}
if err := tx.Commit(ctx); err != nil {
return models.ErrInternal
}
s.audit.Log(ctx, "user", userID, "DELETE_USER", userID)
return nil
}

View File

@@ -0,0 +1,59 @@
package utils
import (
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const (
DefaultLimit = 50
MaxLimit = 200
)
type CursorParams struct {
CursorTime *time.Time
CursorID *uuid.UUID
Limit int32
}
func ParseCursor(cursor string) (*time.Time, *uuid.UUID, error) {
if cursor == "" {
return nil, nil, nil
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return nil, nil, fmt.Errorf("invalid cursor encoding")
}
parts := strings.SplitN(string(raw), "|", 2)
if len(parts) != 2 {
return nil, nil, fmt.Errorf("invalid cursor format")
}
t, err := time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
return nil, nil, fmt.Errorf("invalid cursor time")
}
id, err := uuid.Parse(parts[1])
if err != nil {
return nil, nil, fmt.Errorf("invalid cursor id")
}
return &t, &id, nil
}
func EncodeCursor(t time.Time, id uuid.UUID) string {
raw := fmt.Sprintf("%s|%s", t.Format(time.RFC3339Nano), id.String())
return base64.RawURLEncoding.EncodeToString([]byte(raw))
}
func ClampLimit(limit int) int32 {
if limit <= 0 {
return DefaultLimit
}
if limit > MaxLimit {
return MaxLimit
}
return int32(limit)
}

88
internal/utils/pgtype.go Normal file
View File

@@ -0,0 +1,88 @@
package utils
import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
func ToPgUUID(id uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: id, Valid: true}
}
func FromPgUUID(id pgtype.UUID) uuid.UUID {
if !id.Valid {
return uuid.Nil
}
return uuid.UUID(id.Bytes)
}
func ToPgTimestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}
func ToPgTimestamptzPtr(t *time.Time) pgtype.Timestamptz {
if t == nil {
return pgtype.Timestamptz{Valid: false}
}
return pgtype.Timestamptz{Time: *t, Valid: true}
}
func FromPgTimestamptz(t pgtype.Timestamptz) time.Time {
if !t.Valid {
return time.Time{}
}
return t.Time
}
func FromPgTimestamptzPtr(t pgtype.Timestamptz) *time.Time {
if !t.Valid {
return nil
}
return &t.Time
}
func ToPgText(s string) pgtype.Text {
if s == "" {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: s, Valid: true}
}
func ToPgTextPtr(s *string) pgtype.Text {
if s == nil {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: *s, Valid: true}
}
func FromPgText(t pgtype.Text) *string {
if !t.Valid {
return nil
}
return &t.String
}
func FromPgTextValue(t pgtype.Text) string {
if !t.Valid {
return ""
}
return t.String
}
func ToPgBool(b bool) pgtype.Bool {
return pgtype.Bool{Bool: b, Valid: true}
}
func NullPgUUID() pgtype.UUID {
return pgtype.UUID{Valid: false}
}
func NullPgTimestamptz() pgtype.Timestamptz {
return pgtype.Timestamptz{Valid: false}
}
func NullPgText() pgtype.Text {
return pgtype.Text{Valid: false}
}

View File

@@ -0,0 +1,44 @@
package utils
import (
"encoding/json"
"net/http"
"github.com/calendarapi/internal/models"
)
func WriteJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func WriteError(w http.ResponseWriter, err error) {
if appErr, ok := models.IsAppError(err); ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.Status)
json.NewEncoder(w).Encode(appErr)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(models.ErrInternal)
}
func WriteOK(w http.ResponseWriter) {
WriteJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func WriteList(w http.ResponseWriter, items interface{}, page models.PageInfo) {
WriteJSON(w, http.StatusOK, models.ListResponse{Items: items, Page: page})
}
func DecodeJSON(r *http.Request, dst interface{}) error {
if r.Body == nil {
return models.NewValidationError("request body required")
}
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
return models.NewValidationError("invalid JSON: " + err.Error())
}
return nil
}

View File

@@ -0,0 +1,97 @@
package utils
import (
"net/mail"
"regexp"
"strings"
"time"
"github.com/calendarapi/internal/models"
"github.com/google/uuid"
)
var hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
func ValidateEmail(email string) error {
if email == "" {
return models.NewValidationError("email is required")
}
if _, err := mail.ParseAddress(email); err != nil {
return models.NewValidationError("invalid email format")
}
return nil
}
func ValidatePassword(password string) error {
if len(password) < 10 {
return models.NewValidationError("password must be at least 10 characters")
}
return nil
}
func ValidateTimezone(tz string) error {
if tz == "" {
return nil
}
if _, err := time.LoadLocation(tz); err != nil {
return models.NewValidationError("invalid IANA timezone: " + tz)
}
return nil
}
func ValidateCalendarName(name string) error {
if len(name) < 1 || len(name) > 80 {
return models.NewValidationError("calendar name must be 1-80 characters")
}
return nil
}
func ValidateColor(color string) error {
if color == "" {
return nil
}
if !hexColorRegex.MatchString(color) {
return models.NewValidationError("color must be hex format #RRGGBB")
}
return nil
}
func ValidateEventTitle(title string) error {
if len(title) < 1 || len(title) > 140 {
return models.NewValidationError("event title must be 1-140 characters")
}
return nil
}
func ValidateTimeRange(start, end time.Time) error {
if !end.After(start) {
return models.NewValidationError("end_time must be after start_time")
}
return nil
}
func ValidateReminderMinutes(minutes int32) error {
if minutes < 0 || minutes > 10080 {
return models.NewValidationError("minutes_before must be 0-10080")
}
return nil
}
func ValidateUUID(s string) (uuid.UUID, error) {
id, err := uuid.Parse(s)
if err != nil {
return uuid.Nil, models.NewValidationError("invalid UUID: " + s)
}
return id, nil
}
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func ValidateRecurrenceRangeLimit(start, end time.Time) error {
if end.Sub(start) > 366*24*time.Hour {
return models.NewValidationError("date range cannot exceed 1 year")
}
return nil
}