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:
Michilis
2026-03-02 14:07:55 +00:00
parent 2cb9d72a7f
commit 75105b8b46
8120 changed files with 1486881 additions and 314 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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"],

View File

@@ -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": [] },

View File

@@ -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": []
}

View File

@@ -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"],

View 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" } } } }
}
}
}
}
}

View File

@@ -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)
})
})

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,
)

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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),
}
}

View File

@@ -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
}

View File

@@ -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"}

View File

@@ -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
}

View File

@@ -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),

View File

@@ -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 {

View File

@@ -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}
}