Add OpenAPI docs, frontend, migrations, and API updates
- OpenAPI: add missing endpoints (add-from-url, subscriptions, public availability) - OpenAPI: CalendarSubscription schema, Subscriptions tag - Frontend app - Migrations: count_for_availability, subscriptions_sync, user_preferences, calendar_settings - Config, rate limit, auth, calendar, booking, ICS, availability, user service updates Made-with: Cursor
This commit is contained in:
@@ -2,12 +2,14 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"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 AvailabilityHandler struct {
|
||||
@@ -18,6 +20,75 @@ func NewAvailabilityHandler(availSvc *service.AvailabilityService) *Availability
|
||||
return &AvailabilityHandler{availSvc: availSvc}
|
||||
}
|
||||
|
||||
// GetByToken returns busy blocks for a calendar by its public token. No auth required.
|
||||
func (h *AvailabilityHandler) GetByToken(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
if token == "" {
|
||||
utils.WriteError(w, models.ErrNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
startStr := r.URL.Query().Get("start")
|
||||
endStr := r.URL.Query().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.GetBusyBlocksByTokenPublic(r.Context(), token, start, end)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetAggregate returns merged busy blocks across multiple calendars by their tokens. No auth required.
|
||||
func (h *AvailabilityHandler) GetAggregate(w http.ResponseWriter, r *http.Request) {
|
||||
tokensStr := r.URL.Query().Get("tokens")
|
||||
if tokensStr == "" {
|
||||
utils.WriteError(w, models.NewValidationError("tokens required (comma-separated)"))
|
||||
return
|
||||
}
|
||||
tokens := strings.Split(tokensStr, ",")
|
||||
|
||||
startStr := r.URL.Query().Get("start")
|
||||
endStr := r.URL.Query().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.GetBusyBlocksAggregate(r.Context(), tokens, start, end)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *AvailabilityHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
q := r.URL.Query()
|
||||
|
||||
@@ -77,16 +77,19 @@ func (h *CalendarHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
CountForAvailability *bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes *int `json:"default_reminder_minutes"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
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)
|
||||
cal, err := h.calSvc.Update(r.Context(), userID, calID, req.Name, req.Color, req.IsPublic, req.CountForAvailability, req.DefaultReminderMinutes, req.SortOrder)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
|
||||
@@ -2,9 +2,11 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -59,7 +61,7 @@ func (h *ICSHandler) PublicFeed(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cal, err := h.queries.GetCalendarByPublicToken(r.Context(), pgtype.Text{String: token, Valid: true})
|
||||
cal, err := h.queries.GetCalendarByToken(r.Context(), pgtype.Text{String: token, Valid: true})
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.ErrNotFound)
|
||||
return
|
||||
@@ -243,32 +245,23 @@ func (h *ICSHandler) ImportURL(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(fetchURL, "webcal://") {
|
||||
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(fetchURL)
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("failed to fetch URL: "+err.Error()))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
utils.WriteError(w, models.NewValidationError(fmt.Sprintf("URL returned status %d", resp.StatusCode)))
|
||||
return
|
||||
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
||||
fetchURL = normalized
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.ErrInternal)
|
||||
body, fetchErr := fetchICSURL(fetchURL)
|
||||
if fetchErr != nil {
|
||||
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
count := h.importICSData(r.Context(), string(body), calID, userID)
|
||||
|
||||
h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
SourceUrl: req.URL,
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
SourceUrl: req.URL,
|
||||
SyncIntervalMinutes: pgtype.Int4{Valid: false},
|
||||
})
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
@@ -278,6 +271,428 @@ func (h *ICSHandler) ImportURL(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ICSHandler) AddFromURL(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
if req.URL == "" {
|
||||
utils.WriteError(w, models.NewValidationError("url is required"))
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") {
|
||||
utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal"))
|
||||
return
|
||||
}
|
||||
|
||||
fetchURL := req.URL
|
||||
if strings.HasPrefix(fetchURL, "webcal://") {
|
||||
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
||||
}
|
||||
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
||||
fetchURL = normalized
|
||||
}
|
||||
|
||||
body, fetchErr := fetchICSURL(fetchURL)
|
||||
if fetchErr != nil {
|
||||
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
name := "Imported Calendar"
|
||||
if req.Name != nil && *req.Name != "" {
|
||||
name = *req.Name
|
||||
}
|
||||
color := "#3B82F6"
|
||||
if req.Color != nil && *req.Color != "" {
|
||||
if err := utils.ValidateColor(*req.Color); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
color = *req.Color
|
||||
}
|
||||
|
||||
cal, err := h.calSvc.Create(r.Context(), userID, name, color)
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
calID := cal.ID
|
||||
|
||||
count := h.importICSData(r.Context(), string(body), calID, userID)
|
||||
|
||||
h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
SourceUrl: req.URL,
|
||||
SyncIntervalMinutes: pgtype.Int4{Valid: false},
|
||||
})
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"calendar": cal,
|
||||
"imported": map[string]int{"events": count},
|
||||
"source": req.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ICSHandler) ListSubscriptions(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
|
||||
}
|
||||
|
||||
subs, err := h.queries.ListSubscriptionsByCalendar(r.Context(), utils.ToPgUUID(calID))
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, len(subs))
|
||||
for i, s := range subs {
|
||||
syncMins := (*int32)(nil)
|
||||
if s.SyncIntervalMinutes.Valid {
|
||||
syncMins = &s.SyncIntervalMinutes.Int32
|
||||
}
|
||||
lastSynced := ""
|
||||
if s.LastSyncedAt.Valid {
|
||||
lastSynced = s.LastSyncedAt.Time.Format(time.RFC3339)
|
||||
}
|
||||
items[i] = map[string]interface{}{
|
||||
"id": utils.FromPgUUID(s.ID),
|
||||
"calendar_id": utils.FromPgUUID(s.CalendarID),
|
||||
"source_url": s.SourceUrl,
|
||||
"last_synced_at": lastSynced,
|
||||
"sync_interval_minutes": syncMins,
|
||||
"created_at": s.CreatedAt.Time.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"items": items,
|
||||
"page": map[string]interface{}{"limit": 100, "next_cursor": nil},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ICSHandler) AddSubscription(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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
SyncIntervalMinutes *int32 `json:"sync_interval_minutes"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL == "" {
|
||||
utils.WriteError(w, models.NewValidationError("url is required"))
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") {
|
||||
utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal"))
|
||||
return
|
||||
}
|
||||
|
||||
fetchURL := req.URL
|
||||
if strings.HasPrefix(fetchURL, "webcal://") {
|
||||
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
||||
}
|
||||
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
||||
fetchURL = normalized
|
||||
}
|
||||
|
||||
body, fetchErr := fetchICSURL(fetchURL)
|
||||
if fetchErr != nil {
|
||||
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
count := h.importICSData(r.Context(), string(body), calID, userID)
|
||||
|
||||
syncMins := pgtype.Int4{Valid: false}
|
||||
if req.SyncIntervalMinutes != nil && *req.SyncIntervalMinutes > 0 {
|
||||
syncMins = pgtype.Int4{Int32: *req.SyncIntervalMinutes, Valid: true}
|
||||
}
|
||||
|
||||
h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
SourceUrl: req.URL,
|
||||
SyncIntervalMinutes: syncMins,
|
||||
})
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"imported": map[string]int{"events": count},
|
||||
"source": req.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ICSHandler) DeleteSubscription(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
|
||||
}
|
||||
subID, err := utils.ValidateUUID(chi.URLParam(r, "subId"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
sub, err := h.queries.GetSubscriptionByID(r.Context(), utils.ToPgUUID(subID))
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.ErrNotFound)
|
||||
return
|
||||
}
|
||||
if utils.FromPgUUID(sub.CalendarID) != calID {
|
||||
utils.WriteError(w, models.ErrNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.queries.DeleteSubscription(r.Context(), utils.ToPgUUID(subID)); err != nil {
|
||||
utils.WriteError(w, models.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteOK(w)
|
||||
}
|
||||
|
||||
func (h *ICSHandler) SyncSubscription(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
|
||||
}
|
||||
subID, err := utils.ValidateUUID(chi.URLParam(r, "subId"))
|
||||
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
|
||||
}
|
||||
|
||||
sub, err := h.queries.GetSubscriptionByID(r.Context(), utils.ToPgUUID(subID))
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.ErrNotFound)
|
||||
return
|
||||
}
|
||||
if utils.FromPgUUID(sub.CalendarID) != calID {
|
||||
utils.WriteError(w, models.ErrNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
fetchURL := sub.SourceUrl
|
||||
if strings.HasPrefix(fetchURL, "webcal://") {
|
||||
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
||||
}
|
||||
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
||||
fetchURL = normalized
|
||||
}
|
||||
|
||||
body, fetchErr := fetchICSURL(fetchURL)
|
||||
if fetchErr != nil {
|
||||
utils.WriteError(w, models.NewValidationError(fetchErr.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
count := h.importICSData(r.Context(), string(body), calID, userID)
|
||||
|
||||
if err := h.queries.UpdateSubscriptionLastSynced(r.Context(), utils.ToPgUUID(subID)); err != nil {
|
||||
utils.WriteError(w, models.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"imported": map[string]int{"events": count},
|
||||
})
|
||||
}
|
||||
|
||||
// SyncSubscriptionBackground performs a subscription sync (used by background worker).
|
||||
func (h *ICSHandler) SyncSubscriptionBackground(ctx context.Context, subscriptionID string) error {
|
||||
subID, err := uuid.Parse(subscriptionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sub, err := h.queries.GetSubscriptionByID(ctx, utils.ToPgUUID(subID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calID := utils.FromPgUUID(sub.CalendarID)
|
||||
cal, err := h.queries.GetCalendarByID(ctx, sub.CalendarID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownerID := utils.FromPgUUID(cal.OwnerID)
|
||||
|
||||
fetchURL := sub.SourceUrl
|
||||
if strings.HasPrefix(fetchURL, "webcal://") {
|
||||
fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://")
|
||||
}
|
||||
if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok {
|
||||
fetchURL = normalized
|
||||
}
|
||||
|
||||
body, err := fetchICSURL(fetchURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = h.importICSData(ctx, string(body), calID, ownerID)
|
||||
return h.queries.UpdateSubscriptionLastSynced(ctx, utils.ToPgUUID(subID))
|
||||
}
|
||||
|
||||
// normalizeGoogleCalendarURL converts Google Calendar embed/share URLs to the iCal feed format.
|
||||
// Returns (fetchURL, true) if the URL was converted or is already a Google iCal URL.
|
||||
func normalizeGoogleCalendarURL(raw string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !strings.Contains(raw, "calendar.google.com") {
|
||||
return raw, false
|
||||
}
|
||||
|
||||
// Already an iCal URL - use as-is (may be public or private)
|
||||
if strings.Contains(raw, "/calendar/ical/") && strings.HasSuffix(raw, ".ics") {
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// Embed URL: https://calendar.google.com/calendar/embed?src=CALENDAR_ID
|
||||
if strings.Contains(raw, "/calendar/embed") {
|
||||
if u, err := url.Parse(raw); err == nil {
|
||||
if src := u.Query().Get("src"); src != "" {
|
||||
decoded, _ := url.QueryUnescape(src)
|
||||
icalURL := "https://calendar.google.com/calendar/ical/" + url.PathEscape(decoded) + "/public/basic.ics"
|
||||
return icalURL, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar link with cid: https://calendar.google.com/calendar?cid=BASE64_CALENDAR_ID
|
||||
if strings.Contains(raw, "cid=") {
|
||||
if u, err := url.Parse(raw); err == nil {
|
||||
if cid := u.Query().Get("cid"); cid != "" {
|
||||
// cid can be base64, base64url, or base64url without padding
|
||||
var decoded []byte
|
||||
for _, enc := range []*base64.Encoding{base64.URLEncoding, base64.RawURLEncoding, base64.StdEncoding} {
|
||||
d, decodeErr := enc.DecodeString(cid)
|
||||
if decodeErr == nil && len(d) > 0 {
|
||||
decoded = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(decoded) > 0 {
|
||||
calID := string(decoded)
|
||||
icalURL := "https://calendar.google.com/calendar/ical/" + url.PathEscape(calID) + "/public/basic.ics"
|
||||
return icalURL, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// googleCalendarHelp is the error hint shown when Google Calendar requests fail.
|
||||
const googleCalendarHelp = "Use the iCal link from Google: Settings → your calendar → Integrate calendar → copy \"Secret address in iCal format\" (not the embed or share URL)"
|
||||
|
||||
// fetchICSURL fetches an ICS file from a URL. Google Calendar blocks non-browser User-Agents,
|
||||
// so we use a full Chrome User-Agent. Other providers generally accept it.
|
||||
func fetchICSURL(fetchURL string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, fetchURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
// Google Calendar returns 403 for non-browser User-Agents; use a real Chrome UA
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/calendar,text/plain,*/*")
|
||||
if strings.Contains(fetchURL, "calendar.google.com") {
|
||||
req.Header.Set("Referer", "https://calendar.google.com/")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if strings.Contains(fetchURL, "calendar.google.com") {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNotFound:
|
||||
return nil, fmt.Errorf("Google Calendar returned 404. %s", googleCalendarHelp)
|
||||
case http.StatusForbidden:
|
||||
return nil, fmt.Errorf("Google Calendar returned 403 (access denied). %s", googleCalendarHelp)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("URL returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Google may return 200 with an HTML login/error page instead of ICS
|
||||
bodyStr := string(body)
|
||||
if len(body) > 10 && (body[0] == '<' || strings.HasPrefix(bodyStr, "<!") || strings.Contains(bodyStr[:min(500, len(bodyStr))], "<html")) {
|
||||
if strings.Contains(fetchURL, "calendar.google.com") {
|
||||
return nil, fmt.Errorf("Google returned HTML instead of calendar data. %s", googleCalendarHelp)
|
||||
}
|
||||
return nil, fmt.Errorf("URL returned HTML instead of calendar data (ICS)")
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (h *ICSHandler) importICSData(ctx context.Context, data string, calID, userID uuid.UUID) int {
|
||||
cal, err := ics.ParseCalendar(strings.NewReader(data))
|
||||
if err != nil {
|
||||
|
||||
@@ -41,14 +41,34 @@ func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Timezone *string `json:"timezone"`
|
||||
Timezone *string `json:"timezone"`
|
||||
WeekStartDay *int `json:"week_start_day"`
|
||||
DateFormat *string `json:"date_format"`
|
||||
TimeFormat *string `json:"time_format"`
|
||||
DefaultEventDurationMinutes *int `json:"default_event_duration_minutes"`
|
||||
DefaultReminderMinutes *int `json:"default_reminder_minutes"`
|
||||
ShowWeekends *bool `json:"show_weekends"`
|
||||
WorkingHoursStart *string `json:"working_hours_start"`
|
||||
WorkingHoursEnd *string `json:"working_hours_end"`
|
||||
NotificationsEmail *bool `json:"notifications_email"`
|
||||
}
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSvc.Update(r.Context(), userID, req.Timezone)
|
||||
user, err := h.userSvc.Update(r.Context(), userID, &service.UserUpdateInput{
|
||||
Timezone: req.Timezone,
|
||||
WeekStartDay: req.WeekStartDay,
|
||||
DateFormat: req.DateFormat,
|
||||
TimeFormat: req.TimeFormat,
|
||||
DefaultEventDurationMinutes: req.DefaultEventDurationMinutes,
|
||||
DefaultReminderMinutes: req.DefaultReminderMinutes,
|
||||
ShowWeekends: req.ShowWeekends,
|
||||
WorkingHoursStart: req.WorkingHoursStart,
|
||||
WorkingHoursEnd: req.WorkingHoursEnd,
|
||||
NotificationsEmail: req.NotificationsEmail,
|
||||
})
|
||||
if err != nil {
|
||||
utils.WriteError(w, err)
|
||||
return
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
{
|
||||
"paths": {
|
||||
"/availability/aggregate": {
|
||||
"get": {
|
||||
"tags": ["Availability"],
|
||||
"summary": "Get aggregate availability (public)",
|
||||
"description": "Returns merged busy time blocks across multiple calendars by their public tokens. No authentication required.",
|
||||
"operationId": "getAvailabilityAggregate",
|
||||
"parameters": [
|
||||
{ "name": "tokens", "in": "query", "required": true, "schema": { "type": "string", "description": "Comma-separated calendar tokens from ical_url" }, "description": "Calendar tokens" },
|
||||
{ "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": "Aggregate busy blocks",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["range_start", "range_end", "busy"],
|
||||
"properties": {
|
||||
"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" } } } }
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/availability/{token}": {
|
||||
"get": {
|
||||
"tags": ["Availability"],
|
||||
"summary": "Get availability by token (public)",
|
||||
"description": "Returns busy time blocks for a calendar by its public token. No authentication required.",
|
||||
"operationId": "getAvailabilityByToken",
|
||||
"parameters": [
|
||||
{ "name": "token", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Calendar token from ical_url" },
|
||||
{ "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": "Busy blocks for the calendar",
|
||||
"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" } } } },
|
||||
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/availability": {
|
||||
"get": {
|
||||
"tags": ["Availability"],
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
{ "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" }
|
||||
{ "name": "ICS", "description": "ICS calendar import and export" },
|
||||
{ "name": "Subscriptions", "description": "Calendar subscriptions (external iCal feeds)" }
|
||||
],
|
||||
"security": [
|
||||
{ "BearerAuth": [] },
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
{
|
||||
"paths": {
|
||||
"/calendars/add-from-url": {
|
||||
"post": {
|
||||
"tags": ["ICS"],
|
||||
"summary": "Add calendar from iCal URL",
|
||||
"description": "Creates a new calendar, fetches the iCal feed from the given URL, imports all events, and creates a subscription for future syncs. One-step flow for adding external calendars. Requires calendars:write scope.",
|
||||
"operationId": "addCalendarFromURL",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"url": { "type": "string", "format": "uri", "description": "iCal feed URL (http, https, or webcal)", "example": "https://example.com/calendar.ics" },
|
||||
"name": { "type": "string", "description": "Optional calendar name", "example": "Work" },
|
||||
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#3B82F6" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Calendar created and events imported",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["ok", "calendar", "imported", "source"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean", "example": true },
|
||||
"calendar": { "$ref": "#/components/schemas/Calendar" },
|
||||
"imported": { "type": "object", "properties": { "events": { "type": "integer", "example": 12 } } },
|
||||
"source": { "type": "string", "format": "uri", "description": "The URL that was imported" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error, unreachable URL, 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" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/calendars/{id}/export.ics": {
|
||||
"get": {
|
||||
"tags": ["ICS"],
|
||||
@@ -130,16 +176,16 @@
|
||||
"/cal/{token}/feed.ics": {
|
||||
"get": {
|
||||
"tags": ["ICS"],
|
||||
"summary": "Public iCal feed",
|
||||
"description": "Returns a public iCal feed for a calendar that has been marked as public. No authentication required. This URL can be used to subscribe to the calendar in Google Calendar, Apple Calendar, Outlook, etc. The `ical_url` is returned in the Calendar object when `is_public` is true.",
|
||||
"operationId": "publicCalendarFeed",
|
||||
"summary": "iCal feed",
|
||||
"description": "Returns an iCal feed for a calendar. Works for both public and private calendars. No authentication required. Public calendars use a shorter base64url token; private calendars use a 64-character SHA256 hex token. The `ical_url` is returned in the Calendar object. Subscribe in Google Calendar, Apple Calendar, Outlook, etc.",
|
||||
"operationId": "calendarFeed",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string" },
|
||||
"description": "Public calendar token (from the calendar's ical_url)"
|
||||
"description": "Calendar token from ical_url (public or private)"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -151,7 +197,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": { "description": "Calendar not found or not public", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" },
|
||||
"is_public": { "type": "boolean" },
|
||||
"ical_url": { "type": "string", "format": "uri", "description": "Public iCal feed URL (only present when is_public is true)" },
|
||||
"ical_url": { "type": "string", "format": "uri", "description": "iCal feed URL. Present for both public and private calendars. Public calendars use a shorter token; private calendars use a 64-character SHA256 hex token for additional security." },
|
||||
"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" }
|
||||
@@ -152,6 +152,18 @@
|
||||
"role": { "type": "string", "enum": ["owner", "editor", "viewer"] }
|
||||
}
|
||||
},
|
||||
"CalendarSubscription": {
|
||||
"type": "object",
|
||||
"required": ["id", "calendar_id", "source_url", "created_at"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"calendar_id": { "type": "string", "format": "uuid" },
|
||||
"source_url": { "type": "string", "format": "uri", "description": "iCal feed URL" },
|
||||
"last_synced_at": { "type": "string", "format": "date-time", "nullable": true },
|
||||
"sync_interval_minutes": { "type": "integer", "nullable": true },
|
||||
"created_at": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"BusyBlock": {
|
||||
"type": "object",
|
||||
"required": ["start", "end", "event_id"],
|
||||
|
||||
146
internal/api/openapi/specs/subscriptions.json
Normal file
146
internal/api/openapi/specs/subscriptions.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"paths": {
|
||||
"/calendars/{id}/subscriptions": {
|
||||
"get": {
|
||||
"tags": ["Subscriptions"],
|
||||
"summary": "List calendar subscriptions",
|
||||
"description": "Returns all iCal feed subscriptions for a calendar. Requires `calendars:read` scope.",
|
||||
"operationId": "listCalendarSubscriptions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "format": "uuid" },
|
||||
"description": "Calendar ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of subscriptions",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["items", "page"],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/CalendarSubscription" }
|
||||
},
|
||||
"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" } } } }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Subscriptions"],
|
||||
"summary": "Add a subscription",
|
||||
"description": "Adds an iCal feed subscription to a calendar. Fetches the feed, imports events, and creates a subscription for future syncs. Owner or editor only. Requires `calendars:write` scope.",
|
||||
"operationId": "addCalendarSubscription",
|
||||
"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": ["url"],
|
||||
"properties": {
|
||||
"url": { "type": "string", "format": "uri", "description": "iCal feed URL (http, https, or webcal)", "example": "https://example.com/calendar.ics" },
|
||||
"sync_interval_minutes": { "type": "integer", "nullable": true, "description": "Optional sync interval in minutes" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Subscription added and events imported",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["ok", "imported", "source"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean", "example": true },
|
||||
"imported": { "type": "object", "properties": { "events": { "type": "integer", "example": 12 } } },
|
||||
"source": { "type": "string", "format": "uri", "description": "The URL that was imported" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error, unreachable URL, 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" } } } },
|
||||
"404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/calendars/{id}/subscriptions/{subId}": {
|
||||
"delete": {
|
||||
"tags": ["Subscriptions"],
|
||||
"summary": "Delete a subscription",
|
||||
"description": "Removes an iCal feed subscription from a calendar. Owner or editor only. Requires `calendars:write` scope.",
|
||||
"operationId": "deleteCalendarSubscription",
|
||||
"parameters": [
|
||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Calendar ID" },
|
||||
{ "name": "subId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Subscription ID" }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Subscription 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": "Calendar or subscription not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/calendars/{id}/subscriptions/{subId}/sync": {
|
||||
"post": {
|
||||
"tags": ["Subscriptions"],
|
||||
"summary": "Sync a subscription",
|
||||
"description": "Triggers an immediate sync of an iCal feed subscription. Fetches the feed and imports/updates events. Owner or editor only. Requires `calendars:write` scope.",
|
||||
"operationId": "syncCalendarSubscription",
|
||||
"parameters": [
|
||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Calendar ID" },
|
||||
{ "name": "subId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Subscription ID" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Sync completed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["ok", "imported"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean", "example": true },
|
||||
"imported": { "type": "object", "properties": { "events": { "type": "integer", "example": 5 } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 or subscription not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/api/handlers"
|
||||
"github.com/calendarapi/internal/api/openapi"
|
||||
"github.com/calendarapi/internal/config"
|
||||
mw "github.com/calendarapi/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
@@ -23,12 +27,26 @@ type Handlers struct {
|
||||
ICS *handlers.ICSHandler
|
||||
}
|
||||
|
||||
func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimiter) *chi.Mux {
|
||||
func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimiter, cfg *config.Config) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.RealIP)
|
||||
r.Use(chimw.Timeout(30 * time.Second))
|
||||
origins := cfg.CORSOrigins
|
||||
if len(origins) == 0 {
|
||||
origins = []string{"http://localhost:5173", "http://127.0.0.1:5173"}
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: origins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
r.Use(rateLimiter.Limit)
|
||||
|
||||
// OpenAPI spec and Swagger UI
|
||||
@@ -45,6 +63,10 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
|
||||
r.Post("/booking/{token}/reserve", h.Booking.Reserve)
|
||||
|
||||
r.Get("/cal/{token}/feed.ics", h.ICS.PublicFeed)
|
||||
|
||||
// Public availability (no auth) - for external booking tools
|
||||
r.Get("/availability/aggregate", h.Availability.GetAggregate)
|
||||
r.Get("/availability/{token}", h.Availability.GetByToken)
|
||||
})
|
||||
|
||||
// Authenticated routes
|
||||
@@ -71,6 +93,7 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
|
||||
r.With(mw.RequireScope("calendars", "write")).Post("/", h.Calendar.Create)
|
||||
r.With(mw.RequireScope("calendars", "write")).Post("/import", h.ICS.Import)
|
||||
r.With(mw.RequireScope("calendars", "write")).Post("/import-url", h.ICS.ImportURL)
|
||||
r.With(mw.RequireScope("calendars", "write")).Post("/add-from-url", h.ICS.AddFromURL)
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.Get)
|
||||
@@ -87,6 +110,12 @@ func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimite
|
||||
|
||||
// ICS
|
||||
r.With(mw.RequireScope("calendars", "read")).Get("/export.ics", h.ICS.Export)
|
||||
|
||||
// Subscriptions
|
||||
r.With(mw.RequireScope("calendars", "read")).Get("/subscriptions", h.ICS.ListSubscriptions)
|
||||
r.With(mw.RequireScope("calendars", "write")).Post("/subscriptions", h.ICS.AddSubscription)
|
||||
r.With(mw.RequireScope("calendars", "write")).Delete("/subscriptions/{subId}", h.ICS.DeleteSubscription)
|
||||
r.With(mw.RequireScope("calendars", "write")).Post("/subscriptions/{subId}/sync", h.ICS.SyncSubscription)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,31 +2,84 @@ package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
RedisAddr string
|
||||
ServerPort string
|
||||
Env string
|
||||
BaseURL string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
RedisAddr string
|
||||
ServerPort string
|
||||
Env string
|
||||
BaseURL string
|
||||
CORSOrigins []string
|
||||
RateLimitRPS float64
|
||||
RateLimitBurst int
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
loadEnvFile(".env")
|
||||
|
||||
port := getEnv("SERVER_PORT", "8080")
|
||||
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: port,
|
||||
Env: getEnv("ENV", "development"),
|
||||
BaseURL: getEnv("BASE_URL", "http://localhost:"+port),
|
||||
jwtSecret := getEnv("JWT_SECRET", "dev-secret-change-me")
|
||||
env := getEnv("ENV", "development")
|
||||
|
||||
if env == "production" && (jwtSecret == "" || jwtSecret == "dev-secret-change-me") {
|
||||
log.Fatal("JWT_SECRET must be set to a secure value in production")
|
||||
}
|
||||
|
||||
corsOrigins := parseCORSOrigins(getEnv("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"))
|
||||
rateRPS := parseFloat(getEnv("RATE_LIMIT_RPS", "100"), 100)
|
||||
rateBurst := parseInt(getEnv("RATE_LIMIT_BURST", "200"), 200)
|
||||
|
||||
return &Config{
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/calendarapi?sslmode=disable"),
|
||||
JWTSecret: jwtSecret,
|
||||
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||
ServerPort: port,
|
||||
Env: env,
|
||||
BaseURL: getEnv("BASE_URL", "http://localhost:"+port),
|
||||
CORSOrigins: corsOrigins,
|
||||
RateLimitRPS: rateRPS,
|
||||
RateLimitBurst: rateBurst,
|
||||
}
|
||||
}
|
||||
|
||||
func parseCORSOrigins(s string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{"http://localhost:5173", "http://127.0.0.1:5173"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseFloat(s string, def float64) float64 {
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseInt(s string, def int) int {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func loadEnvFile(path string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -35,7 +36,12 @@ 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
|
||||
// Use leftmost (client) IP when behind proxies
|
||||
if idx := strings.Index(fwd, ","); idx > 0 {
|
||||
ip = strings.TrimSpace(fwd[:idx])
|
||||
} else {
|
||||
ip = strings.TrimSpace(fwd)
|
||||
}
|
||||
}
|
||||
|
||||
if !rl.allow(ip) {
|
||||
|
||||
@@ -28,7 +28,7 @@ var (
|
||||
)
|
||||
|
||||
func NewValidationError(detail string) *AppError {
|
||||
return &AppError{Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: "Validation failed", Details: detail}
|
||||
return &AppError{Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: detail}
|
||||
}
|
||||
|
||||
func NewConflictError(detail string) *AppError {
|
||||
|
||||
@@ -7,22 +7,35 @@ import (
|
||||
)
|
||||
|
||||
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"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Timezone string `json:"timezone"`
|
||||
WeekStartDay int `json:"week_start_day"`
|
||||
DateFormat string `json:"date_format"`
|
||||
TimeFormat string `json:"time_format"`
|
||||
DefaultEventDurationMinutes int `json:"default_event_duration_minutes"`
|
||||
DefaultReminderMinutes int `json:"default_reminder_minutes"`
|
||||
ShowWeekends bool `json:"show_weekends"`
|
||||
WorkingHoursStart string `json:"working_hours_start"`
|
||||
WorkingHoursEnd string `json:"working_hours_end"`
|
||||
NotificationsEmail bool `json:"notifications_email"`
|
||||
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"`
|
||||
ICalURL string `json:"ical_url,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
CountForAvailability bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes *int `json:"default_reminder_minutes,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
ICalURL string `json:"ical_url,omitempty"`
|
||||
AvailabilityURL string `json:"availability_url,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
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
|
||||
RETURNING id, owner_id, name, color, is_public, public_token, count_for_availability, default_reminder_minutes, sort_order, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateCalendarParams struct {
|
||||
@@ -26,14 +26,17 @@ type CreateCalendarParams struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
CountForAvailability bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes pgtype.Int4 `json:"default_reminder_minutes"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateCalendar(ctx context.Context, arg CreateCalendarParams) (CreateCalendarRow, error) {
|
||||
@@ -52,6 +55,9 @@ func (q *Queries) CreateCalendar(ctx context.Context, arg CreateCalendarParams)
|
||||
&i.Color,
|
||||
&i.IsPublic,
|
||||
&i.PublicToken,
|
||||
&i.CountForAvailability,
|
||||
&i.DefaultReminderMinutes,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -59,20 +65,23 @@ func (q *Queries) CreateCalendar(ctx context.Context, arg CreateCalendarParams)
|
||||
}
|
||||
|
||||
const getCalendarByID = `-- name: GetCalendarByID :one
|
||||
SELECT id, owner_id, name, color, is_public, public_token, created_at, updated_at
|
||||
SELECT id, owner_id, name, color, is_public, public_token, count_for_availability, default_reminder_minutes, sort_order, 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"`
|
||||
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"`
|
||||
CountForAvailability bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes pgtype.Int4 `json:"default_reminder_minutes"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetCalendarByID(ctx context.Context, id pgtype.UUID) (GetCalendarByIDRow, error) {
|
||||
@@ -85,32 +94,38 @@ func (q *Queries) GetCalendarByID(ctx context.Context, id pgtype.UUID) (GetCalen
|
||||
&i.Color,
|
||||
&i.IsPublic,
|
||||
&i.PublicToken,
|
||||
&i.CountForAvailability,
|
||||
&i.DefaultReminderMinutes,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getCalendarByPublicToken = `-- name: GetCalendarByPublicToken :one
|
||||
SELECT id, owner_id, name, color, is_public, public_token, created_at, updated_at
|
||||
const getCalendarByToken = `-- name: GetCalendarByToken :one
|
||||
SELECT id, owner_id, name, color, is_public, public_token, count_for_availability, default_reminder_minutes, sort_order, created_at, updated_at
|
||||
FROM calendars
|
||||
WHERE public_token = $1 AND is_public = true AND deleted_at IS NULL
|
||||
WHERE public_token = $1 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
type GetCalendarByPublicTokenRow 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"`
|
||||
type GetCalendarByTokenRow 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"`
|
||||
CountForAvailability bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes pgtype.Int4 `json:"default_reminder_minutes"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetCalendarByPublicToken(ctx context.Context, publicToken pgtype.Text) (GetCalendarByPublicTokenRow, error) {
|
||||
row := q.db.QueryRow(ctx, getCalendarByPublicToken, publicToken)
|
||||
var i GetCalendarByPublicTokenRow
|
||||
func (q *Queries) GetCalendarByToken(ctx context.Context, publicToken pgtype.Text) (GetCalendarByTokenRow, error) {
|
||||
row := q.db.QueryRow(ctx, getCalendarByToken, publicToken)
|
||||
var i GetCalendarByTokenRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
@@ -118,14 +133,69 @@ func (q *Queries) GetCalendarByPublicToken(ctx context.Context, publicToken pgty
|
||||
&i.Color,
|
||||
&i.IsPublic,
|
||||
&i.PublicToken,
|
||||
&i.CountForAvailability,
|
||||
&i.DefaultReminderMinutes,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listCalendarsByOwnerForAvailability = `-- name: ListCalendarsByOwnerForAvailability :many
|
||||
SELECT id, owner_id, name, color, is_public, public_token, count_for_availability, default_reminder_minutes, sort_order, created_at, updated_at
|
||||
FROM calendars
|
||||
WHERE owner_id = $1 AND deleted_at IS NULL AND count_for_availability = true
|
||||
`
|
||||
|
||||
type ListCalendarsByOwnerForAvailabilityRow 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"`
|
||||
CountForAvailability bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes pgtype.Int4 `json:"default_reminder_minutes"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListCalendarsByOwnerForAvailability(ctx context.Context, ownerID pgtype.UUID) ([]ListCalendarsByOwnerForAvailabilityRow, error) {
|
||||
rows, err := q.db.Query(ctx, listCalendarsByOwnerForAvailability, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListCalendarsByOwnerForAvailabilityRow{}
|
||||
for rows.Next() {
|
||||
var i ListCalendarsByOwnerForAvailabilityRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
&i.Name,
|
||||
&i.Color,
|
||||
&i.IsPublic,
|
||||
&i.PublicToken,
|
||||
&i.CountForAvailability,
|
||||
&i.DefaultReminderMinutes,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
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
|
||||
SELECT c.id, c.owner_id, c.name, c.color, c.is_public, c.count_for_availability, c.default_reminder_minutes, c.sort_order, 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
|
||||
@@ -133,14 +203,17 @@ 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"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
CountForAvailability bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes pgtype.Int4 `json:"default_reminder_minutes"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
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) {
|
||||
@@ -158,6 +231,9 @@ func (q *Queries) ListCalendarsByUser(ctx context.Context, userID pgtype.UUID) (
|
||||
&i.Name,
|
||||
&i.Color,
|
||||
&i.IsPublic,
|
||||
&i.CountForAvailability,
|
||||
&i.DefaultReminderMinutes,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Role,
|
||||
@@ -213,27 +289,36 @@ UPDATE calendars
|
||||
SET name = COALESCE($1::TEXT, name),
|
||||
color = COALESCE($2::TEXT, color),
|
||||
is_public = COALESCE($3::BOOLEAN, is_public),
|
||||
count_for_availability = COALESCE($4::BOOLEAN, count_for_availability),
|
||||
default_reminder_minutes = COALESCE($5::INTEGER, default_reminder_minutes),
|
||||
sort_order = COALESCE($6::INTEGER, sort_order),
|
||||
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
|
||||
WHERE id = $7 AND deleted_at IS NULL
|
||||
RETURNING id, owner_id, name, color, is_public, public_token, count_for_availability, default_reminder_minutes, sort_order, 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"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Color pgtype.Text `json:"color"`
|
||||
IsPublic pgtype.Bool `json:"is_public"`
|
||||
CountForAvailability pgtype.Bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes pgtype.Int4 `json:"default_reminder_minutes"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
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"`
|
||||
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"`
|
||||
CountForAvailability bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes pgtype.Int4 `json:"default_reminder_minutes"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateCalendar(ctx context.Context, arg UpdateCalendarParams) (UpdateCalendarRow, error) {
|
||||
@@ -241,6 +326,9 @@ func (q *Queries) UpdateCalendar(ctx context.Context, arg UpdateCalendarParams)
|
||||
arg.Name,
|
||||
arg.Color,
|
||||
arg.IsPublic,
|
||||
arg.CountForAvailability,
|
||||
arg.DefaultReminderMinutes,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
)
|
||||
var i UpdateCalendarRow
|
||||
@@ -251,6 +339,9 @@ func (q *Queries) UpdateCalendar(ctx context.Context, arg UpdateCalendarParams)
|
||||
&i.Color,
|
||||
&i.IsPublic,
|
||||
&i.PublicToken,
|
||||
&i.CountForAvailability,
|
||||
&i.DefaultReminderMinutes,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -41,15 +41,18 @@ type BookingLink struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
CountForAvailability bool `json:"count_for_availability"`
|
||||
DefaultReminderMinutes pgtype.Int4 `json:"default_reminder_minutes"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||
}
|
||||
|
||||
type CalendarMember struct {
|
||||
@@ -59,11 +62,12 @@ type CalendarMember struct {
|
||||
}
|
||||
|
||||
type CalendarSubscription struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
CalendarID pgtype.UUID `json:"calendar_id"`
|
||||
SourceUrl string `json:"source_url"`
|
||||
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
CalendarID pgtype.UUID `json:"calendar_id"`
|
||||
SourceUrl string `json:"source_url"`
|
||||
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
|
||||
SyncIntervalMinutes pgtype.Int4 `json:"sync_interval_minutes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
@@ -136,12 +140,21 @@ type RefreshToken struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Timezone string `json:"timezone"`
|
||||
IsActive bool `json:"is_active"`
|
||||
WeekStartDay int16 `json:"week_start_day"`
|
||||
DateFormat string `json:"date_format"`
|
||||
TimeFormat string `json:"time_format"`
|
||||
DefaultEventDurationMinutes int32 `json:"default_event_duration_minutes"`
|
||||
DefaultReminderMinutes int32 `json:"default_reminder_minutes"`
|
||||
ShowWeekends bool `json:"show_weekends"`
|
||||
WorkingHoursStart string `json:"working_hours_start"`
|
||||
WorkingHoursEnd string `json:"working_hours_end"`
|
||||
NotificationsEmail bool `json:"notifications_email"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||
}
|
||||
|
||||
@@ -12,44 +12,143 @@ import (
|
||||
)
|
||||
|
||||
const createCalendarSubscription = `-- name: CreateCalendarSubscription :one
|
||||
INSERT INTO calendar_subscriptions (id, calendar_id, source_url)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, calendar_id, source_url, last_synced_at, created_at
|
||||
INSERT INTO calendar_subscriptions (id, calendar_id, source_url, sync_interval_minutes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, calendar_id, source_url, last_synced_at, sync_interval_minutes, created_at
|
||||
`
|
||||
|
||||
type CreateCalendarSubscriptionParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
CalendarID pgtype.UUID `json:"calendar_id"`
|
||||
SourceUrl string `json:"source_url"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
CalendarID pgtype.UUID `json:"calendar_id"`
|
||||
SourceUrl string `json:"source_url"`
|
||||
SyncIntervalMinutes pgtype.Int4 `json:"sync_interval_minutes"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateCalendarSubscription(ctx context.Context, arg CreateCalendarSubscriptionParams) (CalendarSubscription, error) {
|
||||
row := q.db.QueryRow(ctx, createCalendarSubscription, arg.ID, arg.CalendarID, arg.SourceUrl)
|
||||
row := q.db.QueryRow(ctx, createCalendarSubscription,
|
||||
arg.ID,
|
||||
arg.CalendarID,
|
||||
arg.SourceUrl,
|
||||
arg.SyncIntervalMinutes,
|
||||
)
|
||||
var i CalendarSubscription
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CalendarID,
|
||||
&i.SourceUrl,
|
||||
&i.LastSyncedAt,
|
||||
&i.SyncIntervalMinutes,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSubscriptionByCalendar = `-- name: GetSubscriptionByCalendar :one
|
||||
SELECT id, calendar_id, source_url, last_synced_at, created_at FROM calendar_subscriptions
|
||||
WHERE calendar_id = $1
|
||||
const deleteSubscription = `-- name: DeleteSubscription :exec
|
||||
DELETE FROM calendar_subscriptions
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSubscriptionByCalendar(ctx context.Context, calendarID pgtype.UUID) (CalendarSubscription, error) {
|
||||
row := q.db.QueryRow(ctx, getSubscriptionByCalendar, calendarID)
|
||||
func (q *Queries) DeleteSubscription(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteSubscription, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSubscriptionByID = `-- name: GetSubscriptionByID :one
|
||||
SELECT id, calendar_id, source_url, last_synced_at, sync_interval_minutes, created_at FROM calendar_subscriptions
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSubscriptionByID(ctx context.Context, id pgtype.UUID) (CalendarSubscription, error) {
|
||||
row := q.db.QueryRow(ctx, getSubscriptionByID, id)
|
||||
var i CalendarSubscription
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CalendarID,
|
||||
&i.SourceUrl,
|
||||
&i.LastSyncedAt,
|
||||
&i.SyncIntervalMinutes,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listSubscriptionsByCalendar = `-- name: ListSubscriptionsByCalendar :many
|
||||
SELECT id, calendar_id, source_url, last_synced_at, sync_interval_minutes, created_at FROM calendar_subscriptions
|
||||
WHERE calendar_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListSubscriptionsByCalendar(ctx context.Context, calendarID pgtype.UUID) ([]CalendarSubscription, error) {
|
||||
rows, err := q.db.Query(ctx, listSubscriptionsByCalendar, calendarID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []CalendarSubscription{}
|
||||
for rows.Next() {
|
||||
var i CalendarSubscription
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.CalendarID,
|
||||
&i.SourceUrl,
|
||||
&i.LastSyncedAt,
|
||||
&i.SyncIntervalMinutes,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateSubscriptionLastSynced = `-- name: UpdateSubscriptionLastSynced :exec
|
||||
UPDATE calendar_subscriptions
|
||||
SET last_synced_at = now()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateSubscriptionLastSynced(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updateSubscriptionLastSynced, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const listSubscriptionsDueForSync = `-- name: ListSubscriptionsDueForSync :many
|
||||
SELECT s.id, s.calendar_id, s.source_url, c.owner_id
|
||||
FROM calendar_subscriptions s
|
||||
JOIN calendars c ON c.id = s.calendar_id AND c.deleted_at IS NULL
|
||||
WHERE s.sync_interval_minutes IS NOT NULL
|
||||
AND s.sync_interval_minutes > 0
|
||||
AND (s.last_synced_at IS NULL
|
||||
OR s.last_synced_at + make_interval(mins => s.sync_interval_minutes::numeric) <= now())
|
||||
`
|
||||
|
||||
type ListSubscriptionsDueForSyncRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
CalendarID pgtype.UUID `json:"calendar_id"`
|
||||
SourceUrl string `json:"source_url"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListSubscriptionsDueForSync(ctx context.Context) ([]ListSubscriptionsDueForSyncRow, error) {
|
||||
rows, err := q.db.Query(ctx, listSubscriptionsDueForSync)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListSubscriptionsDueForSyncRow
|
||||
for rows.Next() {
|
||||
var i ListSubscriptionsDueForSyncRow
|
||||
if err := rows.Scan(&i.ID, &i.CalendarID, &i.SourceUrl, &i.OwnerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -55,19 +55,30 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, email, password_hash, timezone, is_active, created_at, updated_at
|
||||
SELECT id, email, password_hash, timezone, is_active, week_start_day, date_format, time_format,
|
||||
default_event_duration_minutes, default_reminder_minutes, show_weekends,
|
||||
working_hours_start, working_hours_end, notifications_email, 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"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Timezone string `json:"timezone"`
|
||||
IsActive bool `json:"is_active"`
|
||||
WeekStartDay int16 `json:"week_start_day"`
|
||||
DateFormat string `json:"date_format"`
|
||||
TimeFormat string `json:"time_format"`
|
||||
DefaultEventDurationMinutes int32 `json:"default_event_duration_minutes"`
|
||||
DefaultReminderMinutes int32 `json:"default_reminder_minutes"`
|
||||
ShowWeekends bool `json:"show_weekends"`
|
||||
WorkingHoursStart string `json:"working_hours_start"`
|
||||
WorkingHoursEnd string `json:"working_hours_end"`
|
||||
NotificationsEmail bool `json:"notifications_email"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
|
||||
@@ -79,6 +90,15 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm
|
||||
&i.PasswordHash,
|
||||
&i.Timezone,
|
||||
&i.IsActive,
|
||||
&i.WeekStartDay,
|
||||
&i.DateFormat,
|
||||
&i.TimeFormat,
|
||||
&i.DefaultEventDurationMinutes,
|
||||
&i.DefaultReminderMinutes,
|
||||
&i.ShowWeekends,
|
||||
&i.WorkingHoursStart,
|
||||
&i.WorkingHoursEnd,
|
||||
&i.NotificationsEmail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -86,19 +106,30 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, email, password_hash, timezone, is_active, created_at, updated_at
|
||||
SELECT id, email, password_hash, timezone, is_active, week_start_day, date_format, time_format,
|
||||
default_event_duration_minutes, default_reminder_minutes, show_weekends,
|
||||
working_hours_start, working_hours_end, notifications_email, 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"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Timezone string `json:"timezone"`
|
||||
IsActive bool `json:"is_active"`
|
||||
WeekStartDay int16 `json:"week_start_day"`
|
||||
DateFormat string `json:"date_format"`
|
||||
TimeFormat string `json:"time_format"`
|
||||
DefaultEventDurationMinutes int32 `json:"default_event_duration_minutes"`
|
||||
DefaultReminderMinutes int32 `json:"default_reminder_minutes"`
|
||||
ShowWeekends bool `json:"show_weekends"`
|
||||
WorkingHoursStart string `json:"working_hours_start"`
|
||||
WorkingHoursEnd string `json:"working_hours_end"`
|
||||
NotificationsEmail bool `json:"notifications_email"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error) {
|
||||
@@ -110,6 +141,15 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDR
|
||||
&i.PasswordHash,
|
||||
&i.Timezone,
|
||||
&i.IsActive,
|
||||
&i.WeekStartDay,
|
||||
&i.DateFormat,
|
||||
&i.TimeFormat,
|
||||
&i.DefaultEventDurationMinutes,
|
||||
&i.DefaultReminderMinutes,
|
||||
&i.ShowWeekends,
|
||||
&i.WorkingHoursStart,
|
||||
&i.WorkingHoursEnd,
|
||||
&i.NotificationsEmail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -129,28 +169,69 @@ func (q *Queries) SoftDeleteUser(ctx context.Context, id pgtype.UUID) error {
|
||||
const updateUser = `-- name: UpdateUser :one
|
||||
UPDATE users
|
||||
SET timezone = COALESCE($1::TEXT, timezone),
|
||||
week_start_day = COALESCE($2::SMALLINT, week_start_day),
|
||||
date_format = COALESCE($3::TEXT, date_format),
|
||||
time_format = COALESCE($4::TEXT, time_format),
|
||||
default_event_duration_minutes = COALESCE($5::INTEGER, default_event_duration_minutes),
|
||||
default_reminder_minutes = COALESCE($6::INTEGER, default_reminder_minutes),
|
||||
show_weekends = COALESCE($7::BOOLEAN, show_weekends),
|
||||
working_hours_start = COALESCE($8::TEXT, working_hours_start),
|
||||
working_hours_end = COALESCE($9::TEXT, working_hours_end),
|
||||
notifications_email = COALESCE($10::BOOLEAN, notifications_email),
|
||||
updated_at = now()
|
||||
WHERE id = $2 AND deleted_at IS NULL
|
||||
RETURNING id, email, password_hash, timezone, is_active, created_at, updated_at
|
||||
WHERE id = $11 AND deleted_at IS NULL
|
||||
RETURNING id, email, password_hash, timezone, is_active, week_start_day, date_format, time_format,
|
||||
default_event_duration_minutes, default_reminder_minutes, show_weekends,
|
||||
working_hours_start, working_hours_end, notifications_email, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateUserParams struct {
|
||||
Timezone pgtype.Text `json:"timezone"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Timezone pgtype.Text `json:"timezone"`
|
||||
WeekStartDay pgtype.Int2 `json:"week_start_day"`
|
||||
DateFormat pgtype.Text `json:"date_format"`
|
||||
TimeFormat pgtype.Text `json:"time_format"`
|
||||
DefaultEventDurationMinutes pgtype.Int4 `json:"default_event_duration_minutes"`
|
||||
DefaultReminderMinutes pgtype.Int4 `json:"default_reminder_minutes"`
|
||||
ShowWeekends pgtype.Bool `json:"show_weekends"`
|
||||
WorkingHoursStart pgtype.Text `json:"working_hours_start"`
|
||||
WorkingHoursEnd pgtype.Text `json:"working_hours_end"`
|
||||
NotificationsEmail pgtype.Bool `json:"notifications_email"`
|
||||
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"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Timezone string `json:"timezone"`
|
||||
IsActive bool `json:"is_active"`
|
||||
WeekStartDay int16 `json:"week_start_day"`
|
||||
DateFormat string `json:"date_format"`
|
||||
TimeFormat string `json:"time_format"`
|
||||
DefaultEventDurationMinutes int32 `json:"default_event_duration_minutes"`
|
||||
DefaultReminderMinutes int32 `json:"default_reminder_minutes"`
|
||||
ShowWeekends bool `json:"show_weekends"`
|
||||
WorkingHoursStart string `json:"working_hours_start"`
|
||||
WorkingHoursEnd string `json:"working_hours_end"`
|
||||
NotificationsEmail bool `json:"notifications_email"`
|
||||
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)
|
||||
row := q.db.QueryRow(ctx, updateUser,
|
||||
arg.Timezone,
|
||||
arg.WeekStartDay,
|
||||
arg.DateFormat,
|
||||
arg.TimeFormat,
|
||||
arg.DefaultEventDurationMinutes,
|
||||
arg.DefaultReminderMinutes,
|
||||
arg.ShowWeekends,
|
||||
arg.WorkingHoursStart,
|
||||
arg.WorkingHoursEnd,
|
||||
arg.NotificationsEmail,
|
||||
arg.ID,
|
||||
)
|
||||
var i UpdateUserRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
@@ -158,6 +239,15 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateU
|
||||
&i.PasswordHash,
|
||||
&i.Timezone,
|
||||
&i.IsActive,
|
||||
&i.WeekStartDay,
|
||||
&i.DateFormat,
|
||||
&i.TimeFormat,
|
||||
&i.DefaultEventDurationMinutes,
|
||||
&i.DefaultReminderMinutes,
|
||||
&i.ShowWeekends,
|
||||
&i.WorkingHoursStart,
|
||||
&i.WorkingHoursEnd,
|
||||
&i.NotificationsEmail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -4,13 +4,17 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
const TypeReminder = "reminder:send"
|
||||
const TypeSubscriptionSync = "subscription:sync"
|
||||
|
||||
type ReminderPayload struct {
|
||||
EventID uuid.UUID `json:"event_id"`
|
||||
@@ -50,10 +54,58 @@ func (s *Scheduler) ScheduleReminder(_ context.Context, eventID, reminderID, use
|
||||
return nil
|
||||
}
|
||||
|
||||
type SubscriptionSyncPayload struct {
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
}
|
||||
|
||||
func (s *Scheduler) EnqueueSubscriptionSync(ctx context.Context, subscriptionID string) error {
|
||||
payload, err := json.Marshal(SubscriptionSyncPayload{SubscriptionID: subscriptionID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal subscription sync payload: %w", err)
|
||||
}
|
||||
task := asynq.NewTask(TypeSubscriptionSync, payload)
|
||||
_, err = s.client.Enqueue(task,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Queue("subscriptions"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue subscription sync: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) Close() error {
|
||||
return s.client.Close()
|
||||
}
|
||||
|
||||
// StartSubscriptionEnqueuer runs a goroutine that every interval lists subscriptions due for sync
|
||||
// and enqueues a sync task for each. Call with a cancellable context to stop.
|
||||
func StartSubscriptionEnqueuer(ctx context.Context, queries *repository.Queries, sched *Scheduler, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
subs, err := queries.ListSubscriptionsDueForSync(ctx)
|
||||
if err != nil {
|
||||
log.Printf("subscription enqueuer: list due for sync: %v", err)
|
||||
continue
|
||||
}
|
||||
for _, sub := range subs {
|
||||
subID := utils.FromPgUUID(sub.ID)
|
||||
if err := sched.EnqueueSubscriptionSync(ctx, subID.String()); err != nil {
|
||||
log.Printf("subscription enqueuer: enqueue %s: %v", subID, err)
|
||||
}
|
||||
}
|
||||
if len(subs) > 0 {
|
||||
log.Printf("subscription enqueuer: enqueued %d subscriptions", len(subs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type NoopScheduler struct{}
|
||||
|
||||
func (NoopScheduler) ScheduleReminder(_ context.Context, _, _, _ uuid.UUID, _ time.Time) error {
|
||||
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
// SubscriptionSyncer performs a subscription sync (used by background worker).
|
||||
type SubscriptionSyncer interface {
|
||||
SyncSubscriptionBackground(ctx context.Context, subscriptionID string) error
|
||||
}
|
||||
|
||||
type ReminderWorker struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
@@ -41,14 +46,35 @@ func (w *ReminderWorker) HandleReminderTask(ctx context.Context, t *asynq.Task)
|
||||
return nil
|
||||
}
|
||||
|
||||
func StartWorker(redisAddr string, worker *ReminderWorker) *asynq.Server {
|
||||
type SubscriptionSyncWorker struct {
|
||||
syncer SubscriptionSyncer
|
||||
}
|
||||
|
||||
func NewSubscriptionSyncWorker(syncer SubscriptionSyncer) *SubscriptionSyncWorker {
|
||||
return &SubscriptionSyncWorker{syncer: syncer}
|
||||
}
|
||||
|
||||
func (w *SubscriptionSyncWorker) HandleSubscriptionSync(ctx context.Context, t *asynq.Task) error {
|
||||
var payload SubscriptionSyncPayload
|
||||
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal subscription sync payload: %w", err)
|
||||
}
|
||||
if err := w.syncer.SyncSubscriptionBackground(ctx, payload.SubscriptionID); err != nil {
|
||||
return fmt.Errorf("sync subscription %s: %w", payload.SubscriptionID, err)
|
||||
}
|
||||
log.Printf("subscription sync completed: %s", payload.SubscriptionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func StartWorker(redisAddr string, worker *ReminderWorker, subSyncWorker *SubscriptionSyncWorker) *asynq.Server {
|
||||
srv := asynq.NewServer(
|
||||
asynq.RedisClientOpt{Addr: redisAddr},
|
||||
asynq.Config{
|
||||
Concurrency: 10,
|
||||
Queues: map[string]int{
|
||||
"reminders": 6,
|
||||
"default": 3,
|
||||
"reminders": 6,
|
||||
"subscriptions": 2,
|
||||
"default": 3,
|
||||
},
|
||||
RetryDelayFunc: asynq.DefaultRetryDelayFunc,
|
||||
},
|
||||
@@ -56,6 +82,9 @@ func StartWorker(redisAddr string, worker *ReminderWorker) *asynq.Server {
|
||||
|
||||
mux := asynq.NewServeMux()
|
||||
mux.HandleFunc(TypeReminder, worker.HandleReminderTask)
|
||||
if subSyncWorker != nil {
|
||||
mux.HandleFunc(TypeSubscriptionSync, subSyncWorker.HandleSubscriptionSync)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := srv.Run(mux); err != nil {
|
||||
|
||||
@@ -217,40 +217,76 @@ func hashToken(token string) string {
|
||||
|
||||
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),
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
WeekStartDay: 0,
|
||||
DateFormat: "MM/dd/yyyy",
|
||||
TimeFormat: "12h",
|
||||
DefaultEventDurationMinutes: 60,
|
||||
DefaultReminderMinutes: 10,
|
||||
ShowWeekends: true,
|
||||
WorkingHoursStart: "09:00",
|
||||
WorkingHoursEnd: "17:00",
|
||||
NotificationsEmail: true,
|
||||
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),
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
WeekStartDay: int(u.WeekStartDay),
|
||||
DateFormat: u.DateFormat,
|
||||
TimeFormat: u.TimeFormat,
|
||||
DefaultEventDurationMinutes: int(u.DefaultEventDurationMinutes),
|
||||
DefaultReminderMinutes: int(u.DefaultReminderMinutes),
|
||||
ShowWeekends: u.ShowWeekends,
|
||||
WorkingHoursStart: u.WorkingHoursStart,
|
||||
WorkingHoursEnd: u.WorkingHoursEnd,
|
||||
NotificationsEmail: u.NotificationsEmail,
|
||||
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),
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
WeekStartDay: int(u.WeekStartDay),
|
||||
DateFormat: u.DateFormat,
|
||||
TimeFormat: u.TimeFormat,
|
||||
DefaultEventDurationMinutes: int(u.DefaultEventDurationMinutes),
|
||||
DefaultReminderMinutes: int(u.DefaultReminderMinutes),
|
||||
ShowWeekends: u.ShowWeekends,
|
||||
WorkingHoursStart: u.WorkingHoursStart,
|
||||
WorkingHoursEnd: u.WorkingHoursEnd,
|
||||
NotificationsEmail: u.NotificationsEmail,
|
||||
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),
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
WeekStartDay: int(u.WeekStartDay),
|
||||
DateFormat: u.DateFormat,
|
||||
TimeFormat: u.TimeFormat,
|
||||
DefaultEventDurationMinutes: int(u.DefaultEventDurationMinutes),
|
||||
DefaultReminderMinutes: int(u.DefaultReminderMinutes),
|
||||
ShowWeekends: u.ShowWeekends,
|
||||
WorkingHoursStart: u.WorkingHoursStart,
|
||||
WorkingHoursEnd: u.WorkingHoursEnd,
|
||||
NotificationsEmail: u.NotificationsEmail,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
type AvailabilityService struct {
|
||||
@@ -84,3 +87,142 @@ func (s *AvailabilityService) GetBusyBlocks(ctx context.Context, userID uuid.UUI
|
||||
Busy: busy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getBusyBlocksForCalendar
|
||||
func (s *AvailabilityService) getBusyBlocksForCalendar(ctx context.Context, calID uuid.UUID, rangeStart, rangeEnd time.Time) ([]models.BusyBlock, error) {
|
||||
pgCalID := utils.ToPgUUID(calID)
|
||||
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
|
||||
CalendarID: pgCalID,
|
||||
EndTime: utils.ToPgTimestamptz(rangeStart),
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
|
||||
CalendarID: pgCalID,
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return busy, nil
|
||||
}
|
||||
|
||||
// GetBusyBlocksByTokenPublic returns busy blocks for a calendar by its public token. No auth required.
|
||||
func (s *AvailabilityService) GetBusyBlocksByTokenPublic(ctx context.Context, token string, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) {
|
||||
if token == "" {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
|
||||
cal, err := s.queries.GetCalendarByToken(ctx, pgtype.Text{String: token, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(cal.ID)
|
||||
busy, err := s.getBusyBlocksForCalendar(ctx, calID, rangeStart, rangeEnd)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
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: calID,
|
||||
RangeStart: rangeStart,
|
||||
RangeEnd: rangeEnd,
|
||||
Busy: busy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBusyBlocksAggregate returns merged busy blocks across multiple calendars by their tokens. No auth required.
|
||||
func (s *AvailabilityService) GetBusyBlocksAggregate(ctx context.Context, tokens []string, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) {
|
||||
if len(tokens) == 0 {
|
||||
return &models.AvailabilityResponse{
|
||||
RangeStart: rangeStart,
|
||||
RangeEnd: rangeEnd,
|
||||
Busy: []models.BusyBlock{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
seen := make(map[uuid.UUID]bool)
|
||||
var allBusy []models.BusyBlock
|
||||
|
||||
for _, token := range tokens {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cal, err := s.queries.GetCalendarByToken(ctx, pgtype.Text{String: token, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
continue
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(cal.ID)
|
||||
if seen[calID] {
|
||||
continue
|
||||
}
|
||||
seen[calID] = true
|
||||
|
||||
busy, err := s.getBusyBlocksForCalendar(ctx, calID, rangeStart, rangeEnd)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
allBusy = append(allBusy, busy...)
|
||||
}
|
||||
|
||||
sort.Slice(allBusy, func(i, j int) bool {
|
||||
return allBusy[i].Start.Before(allBusy[j].Start)
|
||||
})
|
||||
|
||||
if allBusy == nil {
|
||||
allBusy = []models.BusyBlock{}
|
||||
}
|
||||
|
||||
return &models.AvailabilityResponse{
|
||||
RangeStart: rangeStart,
|
||||
RangeEnd: rangeEnd,
|
||||
Busy: allBusy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -91,52 +91,64 @@ func (s *BookingService) GetAvailability(ctx context.Context, token string, rang
|
||||
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),
|
||||
})
|
||||
// Get owner to fetch all calendars that count for availability
|
||||
cal, err := s.queries.GetCalendarByID(ctx, bl.CalendarID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
ownerID := cal.OwnerID
|
||||
|
||||
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
|
||||
CalendarID: bl.CalendarID,
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
// Include busy blocks from ALL calendars owned by the user that have count_for_availability=true
|
||||
availCals, err := s.queries.ListCalendarsByOwnerForAvailability(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
var busyBlocks []models.TimeSlot
|
||||
for _, ev := range events {
|
||||
if ev.RecurrenceRule.Valid {
|
||||
buf := time.Duration(bl.BufferMinutes) * time.Minute
|
||||
|
||||
for _, ac := range availCals {
|
||||
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
|
||||
CalendarID: ac.ID,
|
||||
EndTime: utils.ToPgTimestamptz(rangeStart),
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
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),
|
||||
})
|
||||
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
|
||||
CalendarID: ac.ID,
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ev := range events {
|
||||
if ev.RecurrenceRule.Valid {
|
||||
continue
|
||||
}
|
||||
start := utils.FromPgTimestamptz(ev.StartTime)
|
||||
end := utils.FromPgTimestamptz(ev.EndTime)
|
||||
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 {
|
||||
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"}
|
||||
|
||||
@@ -3,7 +3,9 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
@@ -34,6 +36,15 @@ func generatePublicToken() (string, error) {
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func generatePrivateToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := sha256.Sum256(b)
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
func (s *CalendarService) icalURL(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
@@ -41,6 +52,13 @@ func (s *CalendarService) icalURL(token string) string {
|
||||
return fmt.Sprintf("%s/cal/%s/feed.ics", s.baseURL, token)
|
||||
}
|
||||
|
||||
func (s *CalendarService) availabilityURL(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s/availability/%s", s.baseURL, token)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -80,21 +98,41 @@ func (s *CalendarService) Create(ctx context.Context, userID uuid.UUID, name, co
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
privateToken, err := generatePrivateToken()
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
if err := qtx.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
|
||||
ID: utils.ToPgUUID(calID),
|
||||
PublicToken: pgtype.Text{String: privateToken, Valid: true},
|
||||
}); 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)
|
||||
|
||||
defRem := (*int)(nil)
|
||||
if cal.DefaultReminderMinutes.Valid {
|
||||
v := int(cal.DefaultReminderMinutes.Int32)
|
||||
defRem = &v
|
||||
}
|
||||
return &models.Calendar{
|
||||
ID: utils.FromPgUUID(cal.ID),
|
||||
Name: cal.Name,
|
||||
Color: cal.Color,
|
||||
IsPublic: cal.IsPublic,
|
||||
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||
Role: "owner",
|
||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
||||
ID: utils.FromPgUUID(cal.ID),
|
||||
Name: cal.Name,
|
||||
Color: cal.Color,
|
||||
IsPublic: cal.IsPublic,
|
||||
CountForAvailability: cal.CountForAvailability,
|
||||
DefaultReminderMinutes: defRem,
|
||||
SortOrder: int(cal.SortOrder),
|
||||
ICalURL: s.icalURL(privateToken),
|
||||
AvailabilityURL: s.availabilityURL(privateToken),
|
||||
Role: "owner",
|
||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -106,24 +144,31 @@ func (s *CalendarService) List(ctx context.Context, userID uuid.UUID) ([]models.
|
||||
|
||||
calendars := make([]models.Calendar, len(rows))
|
||||
for i, r := range rows {
|
||||
defRem := (*int)(nil)
|
||||
if r.DefaultReminderMinutes.Valid {
|
||||
v := int(r.DefaultReminderMinutes.Int32)
|
||||
defRem = &v
|
||||
}
|
||||
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),
|
||||
ID: utils.FromPgUUID(r.ID),
|
||||
Name: r.Name,
|
||||
Color: r.Color,
|
||||
IsPublic: r.IsPublic,
|
||||
CountForAvailability: r.CountForAvailability,
|
||||
DefaultReminderMinutes: defRem,
|
||||
SortOrder: int(r.SortOrder),
|
||||
Role: r.Role,
|
||||
CreatedAt: utils.FromPgTimestamptz(r.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// Populate ICalURL for public calendars (requires a separate lookup since ListCalendarsByUser doesn't select public_token)
|
||||
// Populate ICalURL and AvailabilityURL (requires separate lookup since ListCalendarsByUser doesn't select public_token)
|
||||
for i := range calendars {
|
||||
if calendars[i].IsPublic {
|
||||
cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calendars[i].ID))
|
||||
if err == nil && cal.PublicToken.Valid {
|
||||
calendars[i].ICalURL = s.icalURL(cal.PublicToken.String)
|
||||
}
|
||||
cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calendars[i].ID))
|
||||
if err == nil && cal.PublicToken.Valid {
|
||||
calendars[i].ICalURL = s.icalURL(cal.PublicToken.String)
|
||||
calendars[i].AvailabilityURL = s.availabilityURL(cal.PublicToken.String)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,19 +189,40 @@ func (s *CalendarService) Get(ctx context.Context, userID uuid.UUID, calID uuid.
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
// Backfill private token for existing calendars that don't have one
|
||||
if !cal.IsPublic && !cal.PublicToken.Valid {
|
||||
privateToken, err := generatePrivateToken()
|
||||
if err == nil {
|
||||
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
|
||||
ID: utils.ToPgUUID(calID),
|
||||
PublicToken: pgtype.Text{String: privateToken, Valid: true},
|
||||
})
|
||||
cal.PublicToken = pgtype.Text{String: privateToken, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
defRem := (*int)(nil)
|
||||
if cal.DefaultReminderMinutes.Valid {
|
||||
v := int(cal.DefaultReminderMinutes.Int32)
|
||||
defRem = &v
|
||||
}
|
||||
return &models.Calendar{
|
||||
ID: utils.FromPgUUID(cal.ID),
|
||||
Name: cal.Name,
|
||||
Color: cal.Color,
|
||||
IsPublic: cal.IsPublic,
|
||||
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||
Role: role,
|
||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
||||
ID: utils.FromPgUUID(cal.ID),
|
||||
Name: cal.Name,
|
||||
Color: cal.Color,
|
||||
IsPublic: cal.IsPublic,
|
||||
CountForAvailability: cal.CountForAvailability,
|
||||
DefaultReminderMinutes: defRem,
|
||||
SortOrder: int(cal.SortOrder),
|
||||
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||
AvailabilityURL: s.availabilityURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||
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) {
|
||||
func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uuid.UUID, name, color *string, isPublic *bool, countForAvailability *bool, defaultReminderMinutes *int, sortOrder *int) (*models.Calendar, error) {
|
||||
role, err := s.getRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -184,12 +250,28 @@ func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uu
|
||||
if isPublic != nil {
|
||||
pgPublic = pgtype.Bool{Bool: *isPublic, Valid: true}
|
||||
}
|
||||
var pgCountForAvail pgtype.Bool
|
||||
if countForAvailability != nil {
|
||||
pgCountForAvail = pgtype.Bool{Bool: *countForAvailability, Valid: true}
|
||||
}
|
||||
|
||||
var pgDefRem pgtype.Int4
|
||||
if defaultReminderMinutes != nil {
|
||||
pgDefRem = pgtype.Int4{Int32: int32(*defaultReminderMinutes), Valid: true}
|
||||
}
|
||||
var pgSort pgtype.Int4
|
||||
if sortOrder != nil {
|
||||
pgSort = pgtype.Int4{Int32: int32(*sortOrder), Valid: true}
|
||||
}
|
||||
|
||||
cal, err := s.queries.UpdateCalendar(ctx, repository.UpdateCalendarParams{
|
||||
ID: utils.ToPgUUID(calID),
|
||||
Name: utils.ToPgTextPtr(name),
|
||||
Color: utils.ToPgTextPtr(color),
|
||||
IsPublic: pgPublic,
|
||||
ID: utils.ToPgUUID(calID),
|
||||
Name: utils.ToPgTextPtr(name),
|
||||
Color: utils.ToPgTextPtr(color),
|
||||
IsPublic: pgPublic,
|
||||
CountForAvailability: pgCountForAvail,
|
||||
DefaultReminderMinutes: pgDefRem,
|
||||
SortOrder: pgSort,
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -199,7 +281,8 @@ func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uu
|
||||
}
|
||||
|
||||
if isPublic != nil {
|
||||
if *isPublic && !cal.PublicToken.Valid {
|
||||
if *isPublic {
|
||||
// Switching to public: use base64url token
|
||||
token, err := generatePublicToken()
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
@@ -209,26 +292,50 @@ func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uu
|
||||
PublicToken: pgtype.Text{String: token, Valid: true},
|
||||
})
|
||||
cal.PublicToken = pgtype.Text{String: token, Valid: true}
|
||||
} else if !*isPublic && cal.PublicToken.Valid {
|
||||
} else {
|
||||
// Switching to private: use SHA256 token (replace existing token)
|
||||
token, err := generatePrivateToken()
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
|
||||
ID: utils.ToPgUUID(calID),
|
||||
PublicToken: pgtype.Text{Valid: false},
|
||||
PublicToken: pgtype.Text{String: token, Valid: true},
|
||||
})
|
||||
cal.PublicToken = pgtype.Text{Valid: false}
|
||||
cal.PublicToken = pgtype.Text{String: token, Valid: true}
|
||||
}
|
||||
} else if !cal.IsPublic && !cal.PublicToken.Valid {
|
||||
// Backfill: private calendar with no token (e.g. from before this feature)
|
||||
privateToken, err := generatePrivateToken()
|
||||
if err == nil {
|
||||
_ = s.queries.SetCalendarPublicToken(ctx, repository.SetCalendarPublicTokenParams{
|
||||
ID: utils.ToPgUUID(calID),
|
||||
PublicToken: pgtype.Text{String: privateToken, Valid: true},
|
||||
})
|
||||
cal.PublicToken = pgtype.Text{String: privateToken, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "calendar", calID, "UPDATE_CALENDAR", userID)
|
||||
|
||||
defRem := (*int)(nil)
|
||||
if cal.DefaultReminderMinutes.Valid {
|
||||
v := int(cal.DefaultReminderMinutes.Int32)
|
||||
defRem = &v
|
||||
}
|
||||
return &models.Calendar{
|
||||
ID: utils.FromPgUUID(cal.ID),
|
||||
Name: cal.Name,
|
||||
Color: cal.Color,
|
||||
IsPublic: cal.IsPublic,
|
||||
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||
Role: role,
|
||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
||||
ID: utils.FromPgUUID(cal.ID),
|
||||
Name: cal.Name,
|
||||
Color: cal.Color,
|
||||
IsPublic: cal.IsPublic,
|
||||
CountForAvailability: cal.CountForAvailability,
|
||||
DefaultReminderMinutes: defRem,
|
||||
SortOrder: int(cal.SortOrder),
|
||||
ICalURL: s.icalURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||
AvailabilityURL: s.availabilityURL(utils.FromPgTextValue(cal.PublicToken)),
|
||||
Role: role,
|
||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -414,8 +414,8 @@ func (s *EventService) expandRecurrence(ev repository.Event, rangeStart, rangeEn
|
||||
Title: ev.Title,
|
||||
Description: utils.FromPgText(ev.Description),
|
||||
Location: utils.FromPgText(ev.Location),
|
||||
StartTime: dtStart,
|
||||
EndTime: dtStart.Add(duration),
|
||||
StartTime: occStart,
|
||||
EndTime: occEnd,
|
||||
Timezone: ev.Timezone,
|
||||
AllDay: ev.AllDay,
|
||||
RecurrenceRule: utils.FromPgText(ev.RecurrenceRule),
|
||||
|
||||
@@ -33,16 +33,41 @@ func (s *UserService) GetMe(ctx context.Context, userID uuid.UUID) (*models.User
|
||||
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 {
|
||||
type UserUpdateInput struct {
|
||||
Timezone *string
|
||||
WeekStartDay *int
|
||||
DateFormat *string
|
||||
TimeFormat *string
|
||||
DefaultEventDurationMinutes *int
|
||||
DefaultReminderMinutes *int
|
||||
ShowWeekends *bool
|
||||
WorkingHoursStart *string
|
||||
WorkingHoursEnd *string
|
||||
NotificationsEmail *bool
|
||||
}
|
||||
|
||||
func (s *UserService) Update(ctx context.Context, userID uuid.UUID, in *UserUpdateInput) (*models.User, error) {
|
||||
if in == nil {
|
||||
in = &UserUpdateInput{}
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
if err := utils.ValidateTimezone(*in.Timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
u, err := s.queries.UpdateUser(ctx, repository.UpdateUserParams{
|
||||
ID: utils.ToPgUUID(userID),
|
||||
Timezone: utils.ToPgTextPtr(timezone),
|
||||
ID: utils.ToPgUUID(userID),
|
||||
Timezone: utils.ToPgTextPtr(in.Timezone),
|
||||
WeekStartDay: utils.ToPgInt2Ptr(in.WeekStartDay),
|
||||
DateFormat: utils.ToPgTextPtr(in.DateFormat),
|
||||
TimeFormat: utils.ToPgTextPtr(in.TimeFormat),
|
||||
DefaultEventDurationMinutes: utils.ToPgInt4Ptr(in.DefaultEventDurationMinutes),
|
||||
DefaultReminderMinutes: utils.ToPgInt4Ptr(in.DefaultReminderMinutes),
|
||||
ShowWeekends: utils.ToPgBoolPtr(in.ShowWeekends),
|
||||
WorkingHoursStart: utils.ToPgTextPtr(in.WorkingHoursStart),
|
||||
WorkingHoursEnd: utils.ToPgTextPtr(in.WorkingHoursEnd),
|
||||
NotificationsEmail: utils.ToPgBoolPtr(in.NotificationsEmail),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
|
||||
@@ -75,6 +75,27 @@ func ToPgBool(b bool) pgtype.Bool {
|
||||
return pgtype.Bool{Bool: b, Valid: true}
|
||||
}
|
||||
|
||||
func ToPgInt2Ptr(i *int) pgtype.Int2 {
|
||||
if i == nil {
|
||||
return pgtype.Int2{Valid: false}
|
||||
}
|
||||
return pgtype.Int2{Int16: int16(*i), Valid: true}
|
||||
}
|
||||
|
||||
func ToPgInt4Ptr(i *int) pgtype.Int4 {
|
||||
if i == nil {
|
||||
return pgtype.Int4{Valid: false}
|
||||
}
|
||||
return pgtype.Int4{Int32: int32(*i), Valid: true}
|
||||
}
|
||||
|
||||
func ToPgBoolPtr(b *bool) pgtype.Bool {
|
||||
if b == nil {
|
||||
return pgtype.Bool{Valid: false}
|
||||
}
|
||||
return pgtype.Bool{Bool: *b, Valid: true}
|
||||
}
|
||||
|
||||
func NullPgUUID() pgtype.UUID {
|
||||
return pgtype.UUID{Valid: false}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user