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