first commit

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

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

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