first commit
Made-with: Cursor
This commit is contained in:
84
internal/service/apikey.go
Normal file
84
internal/service/apikey.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type APIKeyService struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewAPIKeyService(queries *repository.Queries) *APIKeyService {
|
||||
return &APIKeyService{queries: queries}
|
||||
}
|
||||
|
||||
func (s *APIKeyService) Create(ctx context.Context, userID uuid.UUID, name string, scopes map[string][]string) (*models.APIKeyResponse, error) {
|
||||
if name == "" {
|
||||
return nil, models.NewValidationError("name is required")
|
||||
}
|
||||
|
||||
rawToken := make([]byte, 32)
|
||||
if _, err := rand.Read(rawToken); err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
token := hex.EncodeToString(rawToken)
|
||||
hash := middleware.SHA256Hash(token)
|
||||
|
||||
scopesJSON, err := json.Marshal(scopes)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
keyID := uuid.New()
|
||||
key, err := s.queries.CreateAPIKey(ctx, repository.CreateAPIKeyParams{
|
||||
ID: utils.ToPgUUID(keyID),
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
Name: name,
|
||||
KeyHash: hash,
|
||||
Scopes: scopesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
return &models.APIKeyResponse{
|
||||
ID: utils.FromPgUUID(key.ID),
|
||||
Name: key.Name,
|
||||
CreatedAt: utils.FromPgTimestamptz(key.CreatedAt),
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIKeyService) List(ctx context.Context, userID uuid.UUID) ([]models.APIKeyResponse, error) {
|
||||
keys, err := s.queries.ListAPIKeysByUser(ctx, utils.ToPgUUID(userID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
result := make([]models.APIKeyResponse, len(keys))
|
||||
for i, k := range keys {
|
||||
result[i] = models.APIKeyResponse{
|
||||
ID: utils.FromPgUUID(k.ID),
|
||||
Name: k.Name,
|
||||
CreatedAt: utils.FromPgTimestamptz(k.CreatedAt),
|
||||
RevokedAt: utils.FromPgTimestamptzPtr(k.RevokedAt),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *APIKeyService) Revoke(ctx context.Context, userID uuid.UUID, keyID uuid.UUID) error {
|
||||
return s.queries.RevokeAPIKey(ctx, repository.RevokeAPIKeyParams{
|
||||
ID: utils.ToPgUUID(keyID),
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
})
|
||||
}
|
||||
132
internal/service/attendee.go
Normal file
132
internal/service/attendee.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type AttendeeService struct {
|
||||
queries *repository.Queries
|
||||
calendar *CalendarService
|
||||
}
|
||||
|
||||
func NewAttendeeService(queries *repository.Queries, calendar *CalendarService) *AttendeeService {
|
||||
return &AttendeeService{queries: queries, calendar: calendar}
|
||||
}
|
||||
|
||||
func (s *AttendeeService) AddAttendees(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, attendees []AddAttendeeRequest) (*models.Event, error) {
|
||||
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(ev.CalendarID)
|
||||
role, err := s.calendar.GetRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role != "owner" && role != "editor" {
|
||||
return nil, models.ErrForbidden
|
||||
}
|
||||
|
||||
for _, a := range attendees {
|
||||
_, err := s.queries.CreateAttendee(ctx, repository.CreateAttendeeParams{
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
EventID: utils.ToPgUUID(eventID),
|
||||
UserID: optionalPgUUID(a.UserID),
|
||||
Email: utils.ToPgTextPtr(a.Email),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
}
|
||||
|
||||
reminders, _ := loadRemindersHelper(ctx, s.queries, eventID)
|
||||
atts, _ := loadAttendeesHelper(ctx, s.queries, eventID)
|
||||
attachments, _ := loadAttachmentsHelper(ctx, s.queries, eventID)
|
||||
return eventFromDB(ev, reminders, atts, attachments), nil
|
||||
}
|
||||
|
||||
func (s *AttendeeService) UpdateStatus(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, attendeeID uuid.UUID, status string) (*models.Event, error) {
|
||||
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(ev.CalendarID)
|
||||
role, err := s.calendar.GetRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
att, err := s.queries.GetAttendeeByID(ctx, utils.ToPgUUID(attendeeID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
isOrganizer := role == "owner" || role == "editor"
|
||||
isOwnAttendee := att.UserID.Valid && utils.FromPgUUID(att.UserID) == userID
|
||||
if !isOrganizer && !isOwnAttendee {
|
||||
return nil, models.ErrForbidden
|
||||
}
|
||||
|
||||
if status != "pending" && status != "accepted" && status != "declined" && status != "tentative" {
|
||||
return nil, models.NewValidationError("status must be pending, accepted, declined, or tentative")
|
||||
}
|
||||
|
||||
_, err = s.queries.UpdateAttendeeStatus(ctx, repository.UpdateAttendeeStatusParams{
|
||||
ID: utils.ToPgUUID(attendeeID),
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
reminders, _ := loadRemindersHelper(ctx, s.queries, eventID)
|
||||
atts, _ := loadAttendeesHelper(ctx, s.queries, eventID)
|
||||
attachments, _ := loadAttachmentsHelper(ctx, s.queries, eventID)
|
||||
return eventFromDB(ev, reminders, atts, attachments), nil
|
||||
}
|
||||
|
||||
func (s *AttendeeService) DeleteAttendee(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, attendeeID uuid.UUID) error {
|
||||
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(ev.CalendarID)
|
||||
role, err := s.calendar.GetRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role != "owner" && role != "editor" {
|
||||
return models.ErrForbidden
|
||||
}
|
||||
|
||||
return s.queries.DeleteAttendee(ctx, repository.DeleteAttendeeParams{
|
||||
ID: utils.ToPgUUID(attendeeID),
|
||||
EventID: utils.ToPgUUID(eventID),
|
||||
})
|
||||
}
|
||||
|
||||
type AddAttendeeRequest struct {
|
||||
UserID *uuid.UUID
|
||||
Email *string
|
||||
}
|
||||
30
internal/service/audit.go
Normal file
30
internal/service/audit.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuditService struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewAuditService(queries *repository.Queries) *AuditService {
|
||||
return &AuditService{queries: queries}
|
||||
}
|
||||
|
||||
func (s *AuditService) Log(ctx context.Context, entityType string, entityID uuid.UUID, action string, userID uuid.UUID) {
|
||||
err := s.queries.CreateAuditLog(ctx, repository.CreateAuditLogParams{
|
||||
EntityType: entityType,
|
||||
EntityID: utils.ToPgUUID(entityID),
|
||||
Action: action,
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("audit log failed: entity=%s id=%s action=%s err=%v", entityType, entityID, action, err)
|
||||
}
|
||||
}
|
||||
256
internal/service/auth.go
Normal file
256
internal/service/auth.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/auth"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
pool *pgxpool.Pool
|
||||
queries *repository.Queries
|
||||
jwt *auth.JWTManager
|
||||
audit *AuditService
|
||||
}
|
||||
|
||||
func NewAuthService(pool *pgxpool.Pool, queries *repository.Queries, jwt *auth.JWTManager, audit *AuditService) *AuthService {
|
||||
return &AuthService{pool: pool, queries: queries, jwt: jwt, audit: audit}
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(ctx context.Context, email, password, timezone string) (*models.AuthTokens, error) {
|
||||
email = utils.NormalizeEmail(email)
|
||||
if err := utils.ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := utils.ValidatePassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
}
|
||||
if err := utils.ValidateTimezone(timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err := s.queries.GetUserByEmail(ctx, email)
|
||||
if err == nil {
|
||||
return nil, models.NewConflictError("email already registered")
|
||||
}
|
||||
if err != pgx.ErrNoRows {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := s.queries.WithTx(tx)
|
||||
|
||||
userID := uuid.New()
|
||||
dbUser, err := qtx.CreateUser(ctx, repository.CreateUserParams{
|
||||
ID: utils.ToPgUUID(userID),
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
Timezone: timezone,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
calID := uuid.New()
|
||||
_, err = qtx.CreateCalendar(ctx, repository.CreateCalendarParams{
|
||||
ID: utils.ToPgUUID(calID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
Name: "My Calendar",
|
||||
Color: "#3B82F6",
|
||||
IsPublic: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
err = qtx.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
Role: "owner",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
accessToken, err := s.jwt.GenerateAccessToken(userID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwt.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
rtHash := hashToken(refreshToken)
|
||||
_, err = s.queries.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
TokenHash: rtHash,
|
||||
ExpiresAt: utils.ToPgTimestamptz(time.Now().Add(auth.RefreshTokenDuration)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
user := userFromCreateRow(dbUser)
|
||||
return &models.AuthTokens{User: user, AccessToken: accessToken, RefreshToken: refreshToken}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(ctx context.Context, email, password string) (*models.AuthTokens, error) {
|
||||
email = utils.NormalizeEmail(email)
|
||||
|
||||
dbUser, err := s.queries.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrAuthInvalid
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(dbUser.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, models.ErrAuthInvalid
|
||||
}
|
||||
|
||||
userID := utils.FromPgUUID(dbUser.ID)
|
||||
accessToken, err := s.jwt.GenerateAccessToken(userID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwt.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
rtHash := hashToken(refreshToken)
|
||||
_, err = s.queries.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
TokenHash: rtHash,
|
||||
ExpiresAt: utils.ToPgTimestamptz(time.Now().Add(auth.RefreshTokenDuration)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
user := userFromEmailRow(dbUser)
|
||||
return &models.AuthTokens{User: user, AccessToken: accessToken, RefreshToken: refreshToken}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*models.TokenPair, error) {
|
||||
rtHash := hashToken(refreshTokenStr)
|
||||
rt, err := s.queries.GetRefreshTokenByHash(ctx, rtHash)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrAuthInvalid
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
if utils.FromPgTimestamptz(rt.ExpiresAt).Before(time.Now()) {
|
||||
return nil, models.ErrAuthInvalid
|
||||
}
|
||||
|
||||
_ = s.queries.RevokeRefreshToken(ctx, rtHash)
|
||||
|
||||
userID := utils.FromPgUUID(rt.UserID)
|
||||
accessToken, err := s.jwt.GenerateAccessToken(userID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
newRefresh, err := s.jwt.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
newHash := hashToken(newRefresh)
|
||||
_, err = s.queries.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
TokenHash: newHash,
|
||||
ExpiresAt: utils.ToPgTimestamptz(time.Now().Add(auth.RefreshTokenDuration)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
return &models.TokenPair{AccessToken: accessToken, RefreshToken: newRefresh}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error {
|
||||
rtHash := hashToken(refreshTokenStr)
|
||||
return s.queries.RevokeRefreshToken(ctx, rtHash)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func userFromCreateRow(u repository.CreateUserRow) models.User {
|
||||
return models.User{
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func userFromEmailRow(u repository.GetUserByEmailRow) models.User {
|
||||
return models.User{
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func userFromIDRow(u repository.GetUserByIDRow) models.User {
|
||||
return models.User{
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func userFromUpdateRow(u repository.UpdateUserRow) models.User {
|
||||
return models.User{
|
||||
ID: utils.FromPgUUID(u.ID),
|
||||
Email: u.Email,
|
||||
Timezone: u.Timezone,
|
||||
CreatedAt: utils.FromPgTimestamptz(u.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
86
internal/service/availability.go
Normal file
86
internal/service/availability.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AvailabilityService struct {
|
||||
queries *repository.Queries
|
||||
calendar *CalendarService
|
||||
event *EventService
|
||||
}
|
||||
|
||||
func NewAvailabilityService(queries *repository.Queries, calendar *CalendarService, event *EventService) *AvailabilityService {
|
||||
return &AvailabilityService{queries: queries, calendar: calendar, event: event}
|
||||
}
|
||||
|
||||
func (s *AvailabilityService) GetBusyBlocks(ctx context.Context, userID uuid.UUID, calendarID uuid.UUID, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) {
|
||||
if _, err := s.calendar.GetRole(ctx, calendarID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pgCalID := utils.ToPgUUID(calendarID)
|
||||
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
|
||||
CalendarID: pgCalID,
|
||||
EndTime: utils.ToPgTimestamptz(rangeStart),
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
|
||||
CalendarID: pgCalID,
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
var busy []models.BusyBlock
|
||||
for _, ev := range events {
|
||||
if ev.RecurrenceRule.Valid {
|
||||
continue
|
||||
}
|
||||
busy = append(busy, models.BusyBlock{
|
||||
Start: utils.FromPgTimestamptz(ev.StartTime),
|
||||
End: utils.FromPgTimestamptz(ev.EndTime),
|
||||
EventID: utils.FromPgUUID(ev.ID),
|
||||
})
|
||||
}
|
||||
|
||||
for _, ev := range recurring {
|
||||
occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd)
|
||||
for _, occ := range occs {
|
||||
if occ.OccurrenceStartTime != nil {
|
||||
busy = append(busy, models.BusyBlock{
|
||||
Start: *occ.OccurrenceStartTime,
|
||||
End: *occ.OccurrenceEndTime,
|
||||
EventID: occ.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(busy, func(i, j int) bool {
|
||||
return busy[i].Start.Before(busy[j].Start)
|
||||
})
|
||||
|
||||
if busy == nil {
|
||||
busy = []models.BusyBlock{}
|
||||
}
|
||||
|
||||
return &models.AvailabilityResponse{
|
||||
CalendarID: calendarID,
|
||||
RangeStart: rangeStart,
|
||||
RangeEnd: rangeEnd,
|
||||
Busy: busy,
|
||||
}, nil
|
||||
}
|
||||
264
internal/service/booking.go
Normal file
264
internal/service/booking.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type BookingService struct {
|
||||
pool *pgxpool.Pool
|
||||
queries *repository.Queries
|
||||
calendar *CalendarService
|
||||
event *EventService
|
||||
}
|
||||
|
||||
func NewBookingService(pool *pgxpool.Pool, queries *repository.Queries, calendar *CalendarService, event *EventService) *BookingService {
|
||||
return &BookingService{pool: pool, queries: queries, calendar: calendar, event: event}
|
||||
}
|
||||
|
||||
func (s *BookingService) CreateLink(ctx context.Context, userID uuid.UUID, calID uuid.UUID, config models.BookingConfig) (*models.BookingLink, error) {
|
||||
role, err := s.calendar.GetRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role != "owner" {
|
||||
return nil, models.ErrForbidden
|
||||
}
|
||||
|
||||
tokenBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
|
||||
whJSON, err := json.Marshal(config.WorkingHours)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
_, err = s.queries.CreateBookingLink(ctx, repository.CreateBookingLinkParams{
|
||||
ID: utils.ToPgUUID(uuid.New()),
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
Token: token,
|
||||
DurationMinutes: int32(config.DurationMinutes),
|
||||
BufferMinutes: int32(config.BufferMinutes),
|
||||
Timezone: config.Timezone,
|
||||
WorkingHours: whJSON,
|
||||
Active: config.Active,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
return &models.BookingLink{
|
||||
Token: token,
|
||||
PublicURL: fmt.Sprintf("/booking/%s", token),
|
||||
Settings: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *BookingService) GetAvailability(ctx context.Context, token string, rangeStart, rangeEnd time.Time) (*models.BookingAvailability, error) {
|
||||
bl, err := s.queries.GetBookingLinkByToken(ctx, token)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
if !bl.Active {
|
||||
return nil, models.NewNotFoundError("booking link is not active")
|
||||
}
|
||||
|
||||
var workingHours map[string][]models.Slot
|
||||
if err := json.Unmarshal(bl.WorkingHours, &workingHours); err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(bl.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(bl.CalendarID)
|
||||
events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
|
||||
CalendarID: bl.CalendarID,
|
||||
EndTime: utils.ToPgTimestamptz(rangeStart),
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{
|
||||
CalendarID: bl.CalendarID,
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
var busyBlocks []models.TimeSlot
|
||||
for _, ev := range events {
|
||||
if ev.RecurrenceRule.Valid {
|
||||
continue
|
||||
}
|
||||
start := utils.FromPgTimestamptz(ev.StartTime)
|
||||
end := utils.FromPgTimestamptz(ev.EndTime)
|
||||
buf := time.Duration(bl.BufferMinutes) * time.Minute
|
||||
busyBlocks = append(busyBlocks, models.TimeSlot{
|
||||
Start: start.Add(-buf),
|
||||
End: end.Add(buf),
|
||||
})
|
||||
}
|
||||
|
||||
for _, ev := range recurring {
|
||||
occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd)
|
||||
for _, occ := range occs {
|
||||
if occ.OccurrenceStartTime != nil {
|
||||
buf := time.Duration(bl.BufferMinutes) * time.Minute
|
||||
busyBlocks = append(busyBlocks, models.TimeSlot{
|
||||
Start: occ.OccurrenceStartTime.Add(-buf),
|
||||
End: occ.OccurrenceEndTime.Add(buf),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = calID
|
||||
duration := time.Duration(bl.DurationMinutes) * time.Minute
|
||||
|
||||
dayNames := []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}
|
||||
var slots []models.TimeSlot
|
||||
|
||||
for d := rangeStart; d.Before(rangeEnd); d = d.Add(24 * time.Hour) {
|
||||
localDay := d.In(loc)
|
||||
dayName := dayNames[localDay.Weekday()]
|
||||
windows, ok := workingHours[dayName]
|
||||
if !ok || len(windows) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, w := range windows {
|
||||
wStart, err1 := time.Parse("15:04", w.Start)
|
||||
wEnd, err2 := time.Parse("15:04", w.End)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
windowStart := time.Date(localDay.Year(), localDay.Month(), localDay.Day(),
|
||||
wStart.Hour(), wStart.Minute(), 0, 0, loc).UTC()
|
||||
windowEnd := time.Date(localDay.Year(), localDay.Month(), localDay.Day(),
|
||||
wEnd.Hour(), wEnd.Minute(), 0, 0, loc).UTC()
|
||||
|
||||
for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(duration) {
|
||||
slotEnd := slotStart.Add(duration)
|
||||
if !isConflict(slotStart, slotEnd, busyBlocks) {
|
||||
slots = append(slots, models.TimeSlot{Start: slotStart, End: slotEnd})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(slots, func(i, j int) bool {
|
||||
return slots[i].Start.Before(slots[j].Start)
|
||||
})
|
||||
|
||||
if slots == nil {
|
||||
slots = []models.TimeSlot{}
|
||||
}
|
||||
|
||||
return &models.BookingAvailability{
|
||||
Token: token,
|
||||
Timezone: bl.Timezone,
|
||||
DurationMinutes: int(bl.DurationMinutes),
|
||||
Slots: slots,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *BookingService) Reserve(ctx context.Context, token string, name, email string, slotStart, slotEnd time.Time, notes *string) (*models.Event, error) {
|
||||
bl, err := s.queries.GetBookingLinkByToken(ctx, token)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
if !bl.Active {
|
||||
return nil, models.NewNotFoundError("booking link is not active")
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := s.queries.WithTx(tx)
|
||||
|
||||
overlap, err := qtx.CheckEventOverlapForUpdate(ctx, repository.CheckEventOverlapForUpdateParams{
|
||||
CalendarID: bl.CalendarID,
|
||||
EndTime: utils.ToPgTimestamptz(slotStart),
|
||||
StartTime: utils.ToPgTimestamptz(slotEnd),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
if overlap {
|
||||
return nil, models.NewConflictError("slot no longer available")
|
||||
}
|
||||
|
||||
cal, err := s.queries.GetCalendarByID(ctx, bl.CalendarID)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Booking: %s", name)
|
||||
desc := fmt.Sprintf("Booked by %s (%s)", name, email)
|
||||
if notes != nil && *notes != "" {
|
||||
desc += "\nNotes: " + *notes
|
||||
}
|
||||
|
||||
eventID := uuid.New()
|
||||
ownerID := utils.FromPgUUID(cal.OwnerID)
|
||||
ev, err := qtx.CreateEvent(ctx, repository.CreateEventParams{
|
||||
ID: utils.ToPgUUID(eventID),
|
||||
CalendarID: bl.CalendarID,
|
||||
Title: title,
|
||||
Description: utils.ToPgText(desc),
|
||||
StartTime: utils.ToPgTimestamptz(slotStart.UTC()),
|
||||
EndTime: utils.ToPgTimestamptz(slotEnd.UTC()),
|
||||
Timezone: bl.Timezone,
|
||||
Tags: []string{"booking"},
|
||||
CreatedBy: utils.ToPgUUID(ownerID),
|
||||
UpdatedBy: utils.ToPgUUID(ownerID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
return eventFromDB(ev, []models.Reminder{}, []models.Attendee{}, []models.Attachment{}), nil
|
||||
}
|
||||
|
||||
func isConflict(start, end time.Time, busy []models.TimeSlot) bool {
|
||||
for _, b := range busy {
|
||||
if start.Before(b.End) && end.After(b.Start) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
318
internal/service/calendar.go
Normal file
318
internal/service/calendar.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type CalendarService struct {
|
||||
pool *pgxpool.Pool
|
||||
queries *repository.Queries
|
||||
audit *AuditService
|
||||
}
|
||||
|
||||
func NewCalendarService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService) *CalendarService {
|
||||
return &CalendarService{pool: pool, queries: queries, audit: audit}
|
||||
}
|
||||
|
||||
func (s *CalendarService) Create(ctx context.Context, userID uuid.UUID, name, color string) (*models.Calendar, error) {
|
||||
if err := utils.ValidateCalendarName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := utils.ValidateColor(color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if color == "" {
|
||||
color = "#3B82F6"
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := s.queries.WithTx(tx)
|
||||
|
||||
calID := uuid.New()
|
||||
cal, err := qtx.CreateCalendar(ctx, repository.CreateCalendarParams{
|
||||
ID: utils.ToPgUUID(calID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
Name: name,
|
||||
Color: color,
|
||||
IsPublic: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
err = qtx.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
Role: "owner",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "calendar", calID, "CREATE_CALENDAR", userID)
|
||||
|
||||
return &models.Calendar{
|
||||
ID: utils.FromPgUUID(cal.ID),
|
||||
Name: cal.Name,
|
||||
Color: cal.Color,
|
||||
IsPublic: cal.IsPublic,
|
||||
Role: "owner",
|
||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *CalendarService) List(ctx context.Context, userID uuid.UUID) ([]models.Calendar, error) {
|
||||
rows, err := s.queries.ListCalendarsByUser(ctx, utils.ToPgUUID(userID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
calendars := make([]models.Calendar, len(rows))
|
||||
for i, r := range rows {
|
||||
calendars[i] = models.Calendar{
|
||||
ID: utils.FromPgUUID(r.ID),
|
||||
Name: r.Name,
|
||||
Color: r.Color,
|
||||
IsPublic: r.IsPublic,
|
||||
Role: r.Role,
|
||||
CreatedAt: utils.FromPgTimestamptz(r.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt),
|
||||
}
|
||||
}
|
||||
return calendars, nil
|
||||
}
|
||||
|
||||
func (s *CalendarService) Get(ctx context.Context, userID uuid.UUID, calID uuid.UUID) (*models.Calendar, error) {
|
||||
role, err := s.getRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
return &models.Calendar{
|
||||
ID: utils.FromPgUUID(cal.ID),
|
||||
Name: cal.Name,
|
||||
Color: cal.Color,
|
||||
IsPublic: cal.IsPublic,
|
||||
Role: role,
|
||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uuid.UUID, name, color *string, isPublic *bool) (*models.Calendar, error) {
|
||||
role, err := s.getRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if role != "owner" && role != "editor" {
|
||||
return nil, models.ErrForbidden
|
||||
}
|
||||
if isPublic != nil && role != "owner" {
|
||||
return nil, models.NewForbiddenError("only owner can change is_public")
|
||||
}
|
||||
|
||||
if name != nil {
|
||||
if err := utils.ValidateCalendarName(*name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if color != nil {
|
||||
if err := utils.ValidateColor(*color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var pgPublic pgtype.Bool
|
||||
if isPublic != nil {
|
||||
pgPublic = pgtype.Bool{Bool: *isPublic, Valid: true}
|
||||
}
|
||||
|
||||
cal, err := s.queries.UpdateCalendar(ctx, repository.UpdateCalendarParams{
|
||||
ID: utils.ToPgUUID(calID),
|
||||
Name: utils.ToPgTextPtr(name),
|
||||
Color: utils.ToPgTextPtr(color),
|
||||
IsPublic: pgPublic,
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "calendar", calID, "UPDATE_CALENDAR", userID)
|
||||
|
||||
return &models.Calendar{
|
||||
ID: utils.FromPgUUID(cal.ID),
|
||||
Name: cal.Name,
|
||||
Color: cal.Color,
|
||||
IsPublic: cal.IsPublic,
|
||||
Role: role,
|
||||
CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *CalendarService) Delete(ctx context.Context, userID uuid.UUID, calID uuid.UUID) error {
|
||||
role, err := s.getRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role != "owner" {
|
||||
return models.ErrForbidden
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := s.queries.WithTx(tx)
|
||||
|
||||
pgCalID := utils.ToPgUUID(calID)
|
||||
if err := qtx.SoftDeleteEventsByCalendar(ctx, pgCalID); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
if err := qtx.SoftDeleteCalendar(ctx, pgCalID); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "calendar", calID, "DELETE_CALENDAR", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CalendarService) Share(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetEmail, role string) error {
|
||||
ownerRole, err := s.getRole(ctx, calID, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ownerRole != "owner" {
|
||||
return models.ErrForbidden
|
||||
}
|
||||
if role != "editor" && role != "viewer" {
|
||||
return models.NewValidationError("role must be editor or viewer")
|
||||
}
|
||||
|
||||
targetUser, err := s.queries.GetUserByEmail(ctx, utils.NormalizeEmail(targetEmail))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.NewNotFoundError("user not found")
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
targetID := utils.FromPgUUID(targetUser.ID)
|
||||
if targetID == ownerID {
|
||||
return models.NewValidationError("cannot share with yourself")
|
||||
}
|
||||
|
||||
err = s.queries.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
UserID: utils.ToPgUUID(targetID),
|
||||
Role: role,
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "calendar", calID, "SHARE_CALENDAR", ownerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CalendarService) ListMembers(ctx context.Context, userID uuid.UUID, calID uuid.UUID) ([]models.CalendarMember, error) {
|
||||
if _, err := s.getRole(ctx, calID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.queries.ListCalendarMembers(ctx, utils.ToPgUUID(calID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
members := make([]models.CalendarMember, len(rows))
|
||||
for i, r := range rows {
|
||||
members[i] = models.CalendarMember{
|
||||
UserID: utils.FromPgUUID(r.UserID),
|
||||
Email: r.Email,
|
||||
Role: r.Role,
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (s *CalendarService) RemoveMember(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetUserID uuid.UUID) error {
|
||||
role, err := s.getRole(ctx, calID, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role != "owner" {
|
||||
return models.ErrForbidden
|
||||
}
|
||||
|
||||
targetRole, err := s.getRole(ctx, calID, targetUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if targetRole == "owner" {
|
||||
return models.NewValidationError("cannot remove owner")
|
||||
}
|
||||
|
||||
err = s.queries.DeleteCalendarMember(ctx, repository.DeleteCalendarMemberParams{
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
UserID: utils.ToPgUUID(targetUserID),
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "calendar", calID, "REMOVE_MEMBER", ownerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CalendarService) GetRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) {
|
||||
return s.getRole(ctx, calID, userID)
|
||||
}
|
||||
|
||||
func (s *CalendarService) getRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) {
|
||||
role, err := s.queries.GetCalendarMemberRole(ctx, repository.GetCalendarMemberRoleParams{
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return "", models.ErrNotFound
|
||||
}
|
||||
return "", models.ErrInternal
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
172
internal/service/contact.go
Normal file
172
internal/service/contact.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ContactService struct {
|
||||
queries *repository.Queries
|
||||
audit *AuditService
|
||||
}
|
||||
|
||||
func NewContactService(queries *repository.Queries, audit *AuditService) *ContactService {
|
||||
return &ContactService{queries: queries, audit: audit}
|
||||
}
|
||||
|
||||
func (s *ContactService) Create(ctx context.Context, userID uuid.UUID, req CreateContactRequest) (*models.Contact, error) {
|
||||
if req.FirstName == nil && req.LastName == nil && req.Email == nil && req.Phone == nil {
|
||||
return nil, models.NewValidationError("at least one identifying field required")
|
||||
}
|
||||
if req.Email != nil {
|
||||
if err := utils.ValidateEmail(*req.Email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
c, err := s.queries.CreateContact(ctx, repository.CreateContactParams{
|
||||
ID: utils.ToPgUUID(id),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
FirstName: utils.ToPgTextPtr(req.FirstName),
|
||||
LastName: utils.ToPgTextPtr(req.LastName),
|
||||
Email: utils.ToPgTextPtr(req.Email),
|
||||
Phone: utils.ToPgTextPtr(req.Phone),
|
||||
Company: utils.ToPgTextPtr(req.Company),
|
||||
Notes: utils.ToPgTextPtr(req.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "contact", id, "CREATE_CONTACT", userID)
|
||||
return contactFromDB(c), nil
|
||||
}
|
||||
|
||||
func (s *ContactService) Get(ctx context.Context, userID uuid.UUID, contactID uuid.UUID) (*models.Contact, error) {
|
||||
c, err := s.queries.GetContactByID(ctx, repository.GetContactByIDParams{
|
||||
ID: utils.ToPgUUID(contactID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
return contactFromDB(c), nil
|
||||
}
|
||||
|
||||
func (s *ContactService) List(ctx context.Context, userID uuid.UUID, search *string, limit int, cursor string) ([]models.Contact, *string, error) {
|
||||
lim := utils.ClampLimit(limit)
|
||||
|
||||
var cursorTime, cursorID interface{}
|
||||
p := repository.ListContactsParams{
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
Search: utils.ToPgTextPtr(search),
|
||||
Lim: lim + 1,
|
||||
}
|
||||
|
||||
if cursor != "" {
|
||||
ct, cid, err := utils.ParseCursor(cursor)
|
||||
if err != nil {
|
||||
return nil, nil, models.NewValidationError("invalid cursor")
|
||||
}
|
||||
p.CursorTime = utils.ToPgTimestamptzPtr(ct)
|
||||
p.CursorID = utils.ToPgUUID(*cid)
|
||||
cursorTime = ct
|
||||
cursorID = cid
|
||||
}
|
||||
_ = cursorTime
|
||||
_ = cursorID
|
||||
|
||||
rows, err := s.queries.ListContacts(ctx, p)
|
||||
if err != nil {
|
||||
return nil, nil, models.ErrInternal
|
||||
}
|
||||
|
||||
contacts := make([]models.Contact, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
contacts = append(contacts, *contactFromDB(r))
|
||||
}
|
||||
|
||||
if int32(len(contacts)) > lim {
|
||||
contacts = contacts[:lim]
|
||||
last := contacts[len(contacts)-1]
|
||||
c := utils.EncodeCursor(last.CreatedAt, last.ID)
|
||||
return contacts, &c, nil
|
||||
}
|
||||
|
||||
return contacts, nil, nil
|
||||
}
|
||||
|
||||
func (s *ContactService) Update(ctx context.Context, userID uuid.UUID, contactID uuid.UUID, req UpdateContactRequest) (*models.Contact, error) {
|
||||
c, err := s.queries.UpdateContact(ctx, repository.UpdateContactParams{
|
||||
ID: utils.ToPgUUID(contactID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
FirstName: utils.ToPgTextPtr(req.FirstName),
|
||||
LastName: utils.ToPgTextPtr(req.LastName),
|
||||
Email: utils.ToPgTextPtr(req.Email),
|
||||
Phone: utils.ToPgTextPtr(req.Phone),
|
||||
Company: utils.ToPgTextPtr(req.Company),
|
||||
Notes: utils.ToPgTextPtr(req.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "contact", contactID, "UPDATE_CONTACT", userID)
|
||||
return contactFromDB(c), nil
|
||||
}
|
||||
|
||||
func (s *ContactService) Delete(ctx context.Context, userID uuid.UUID, contactID uuid.UUID) error {
|
||||
err := s.queries.SoftDeleteContact(ctx, repository.SoftDeleteContactParams{
|
||||
ID: utils.ToPgUUID(contactID),
|
||||
OwnerID: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
s.audit.Log(ctx, "contact", contactID, "DELETE_CONTACT", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func contactFromDB(v repository.Contact) *models.Contact {
|
||||
return &models.Contact{
|
||||
ID: utils.FromPgUUID(v.ID),
|
||||
FirstName: utils.FromPgText(v.FirstName),
|
||||
LastName: utils.FromPgText(v.LastName),
|
||||
Email: utils.FromPgText(v.Email),
|
||||
Phone: utils.FromPgText(v.Phone),
|
||||
Company: utils.FromPgText(v.Company),
|
||||
Notes: utils.FromPgText(v.Notes),
|
||||
CreatedAt: utils.FromPgTimestamptz(v.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(v.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
type CreateContactRequest struct {
|
||||
FirstName *string
|
||||
LastName *string
|
||||
Email *string
|
||||
Phone *string
|
||||
Company *string
|
||||
Notes *string
|
||||
}
|
||||
|
||||
type UpdateContactRequest struct {
|
||||
FirstName *string
|
||||
LastName *string
|
||||
Email *string
|
||||
Phone *string
|
||||
Company *string
|
||||
Notes *string
|
||||
}
|
||||
583
internal/service/event.go
Normal file
583
internal/service/event.go
Normal file
@@ -0,0 +1,583 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/teambition/rrule-go"
|
||||
)
|
||||
|
||||
type EventService struct {
|
||||
pool *pgxpool.Pool
|
||||
queries *repository.Queries
|
||||
calendar *CalendarService
|
||||
audit *AuditService
|
||||
scheduler ReminderScheduler
|
||||
}
|
||||
|
||||
type ReminderScheduler interface {
|
||||
ScheduleReminder(ctx context.Context, eventID, reminderID, userID uuid.UUID, triggerAt time.Time) error
|
||||
}
|
||||
|
||||
func NewEventService(pool *pgxpool.Pool, queries *repository.Queries, calendar *CalendarService, audit *AuditService, scheduler ReminderScheduler) *EventService {
|
||||
return &EventService{pool: pool, queries: queries, calendar: calendar, audit: audit, scheduler: scheduler}
|
||||
}
|
||||
|
||||
func (s *EventService) Create(ctx context.Context, userID uuid.UUID, req CreateEventRequest) (*models.Event, error) {
|
||||
if err := utils.ValidateEventTitle(req.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Timezone == "" {
|
||||
return nil, models.NewValidationError("timezone is required")
|
||||
}
|
||||
if err := utils.ValidateTimezone(req.Timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := utils.ValidateTimeRange(req.StartTime, req.EndTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role, err := s.calendar.GetRole(ctx, req.CalendarID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role != "owner" && role != "editor" {
|
||||
return nil, models.ErrForbidden
|
||||
}
|
||||
|
||||
startUTC := req.StartTime.UTC()
|
||||
endUTC := req.EndTime.UTC()
|
||||
|
||||
if req.RecurrenceRule != nil && *req.RecurrenceRule != "" {
|
||||
if _, err := rrule.StrToRRule(*req.RecurrenceRule); err != nil {
|
||||
return nil, models.NewValidationError("invalid recurrence_rule: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := s.queries.WithTx(tx)
|
||||
|
||||
eventID := uuid.New()
|
||||
tags := req.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
dbEvent, err := qtx.CreateEvent(ctx, repository.CreateEventParams{
|
||||
ID: utils.ToPgUUID(eventID),
|
||||
CalendarID: utils.ToPgUUID(req.CalendarID),
|
||||
Title: req.Title,
|
||||
Description: utils.ToPgTextPtr(req.Description),
|
||||
Location: utils.ToPgTextPtr(req.Location),
|
||||
StartTime: utils.ToPgTimestamptz(startUTC),
|
||||
EndTime: utils.ToPgTimestamptz(endUTC),
|
||||
Timezone: req.Timezone,
|
||||
AllDay: req.AllDay,
|
||||
RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule),
|
||||
Tags: tags,
|
||||
CreatedBy: utils.ToPgUUID(userID),
|
||||
UpdatedBy: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
var reminders []models.Reminder
|
||||
for _, mins := range req.Reminders {
|
||||
if err := utils.ValidateReminderMinutes(mins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rID := uuid.New()
|
||||
r, err := qtx.CreateReminder(ctx, repository.CreateReminderParams{
|
||||
ID: utils.ToPgUUID(rID),
|
||||
EventID: utils.ToPgUUID(eventID),
|
||||
MinutesBefore: mins,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
reminders = append(reminders, models.Reminder{
|
||||
ID: utils.FromPgUUID(r.ID),
|
||||
MinutesBefore: r.MinutesBefore,
|
||||
})
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
for _, rem := range reminders {
|
||||
triggerAt := startUTC.Add(-time.Duration(rem.MinutesBefore) * time.Minute)
|
||||
if triggerAt.After(time.Now()) && s.scheduler != nil {
|
||||
_ = s.scheduler.ScheduleReminder(ctx, eventID, rem.ID, userID, triggerAt)
|
||||
}
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "event", eventID, "CREATE_EVENT", userID)
|
||||
|
||||
if reminders == nil {
|
||||
reminders = []models.Reminder{}
|
||||
}
|
||||
return eventFromDB(dbEvent, reminders, []models.Attendee{}, []models.Attachment{}), nil
|
||||
}
|
||||
|
||||
func (s *EventService) Get(ctx context.Context, userID uuid.UUID, eventID uuid.UUID) (*models.Event, error) {
|
||||
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(ev.CalendarID)
|
||||
if _, err := s.calendar.GetRole(ctx, calID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reminders, err := s.loadReminders(ctx, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attendees, err := s.loadAttendees(ctx, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments, err := s.loadAttachments(ctx, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return eventFromDB(ev, reminders, attendees, attachments), nil
|
||||
}
|
||||
|
||||
func (s *EventService) List(ctx context.Context, userID uuid.UUID, params ListEventParams) ([]models.Event, *string, error) {
|
||||
if err := utils.ValidateRecurrenceRangeLimit(params.RangeStart, params.RangeEnd); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
limit := utils.ClampLimit(params.Limit)
|
||||
|
||||
var cursorTime pgtype.Timestamptz
|
||||
var cursorID pgtype.UUID
|
||||
if params.Cursor != "" {
|
||||
ct, cid, err := utils.ParseCursor(params.Cursor)
|
||||
if err != nil {
|
||||
return nil, nil, models.NewValidationError("invalid cursor")
|
||||
}
|
||||
cursorTime = utils.ToPgTimestamptzPtr(ct)
|
||||
cursorID = utils.ToPgUUID(*cid)
|
||||
}
|
||||
|
||||
nonRecurring, err := s.queries.ListEventsInRange(ctx, repository.ListEventsInRangeParams{
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
RangeEnd: utils.ToPgTimestamptz(params.RangeEnd),
|
||||
RangeStart: utils.ToPgTimestamptz(params.RangeStart),
|
||||
CalendarID: optionalPgUUID(params.CalendarID),
|
||||
Search: utils.ToPgTextPtr(params.Search),
|
||||
Tag: utils.ToPgTextPtr(params.Tag),
|
||||
CursorTime: cursorTime,
|
||||
CursorID: cursorID,
|
||||
Lim: limit + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, models.ErrInternal
|
||||
}
|
||||
|
||||
recurring, err := s.queries.ListRecurringEventsInRange(ctx, repository.ListRecurringEventsInRangeParams{
|
||||
UserID: utils.ToPgUUID(userID),
|
||||
RangeEnd: utils.ToPgTimestamptz(params.RangeEnd),
|
||||
CalendarID: optionalPgUUID(params.CalendarID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, models.ErrInternal
|
||||
}
|
||||
|
||||
var allEvents []models.Event
|
||||
recurringIDs := make(map[uuid.UUID]bool)
|
||||
|
||||
for _, ev := range recurring {
|
||||
recurringIDs[utils.FromPgUUID(ev.ID)] = true
|
||||
occurrences := s.expandRecurrence(ev, params.RangeStart, params.RangeEnd)
|
||||
allEvents = append(allEvents, occurrences...)
|
||||
}
|
||||
|
||||
for _, ev := range nonRecurring {
|
||||
eid := utils.FromPgUUID(ev.ID)
|
||||
if recurringIDs[eid] {
|
||||
continue
|
||||
}
|
||||
allEvents = append(allEvents, *eventFromDB(ev, nil, nil, nil))
|
||||
}
|
||||
|
||||
sort.Slice(allEvents, func(i, j int) bool {
|
||||
si := effectiveStart(allEvents[i])
|
||||
sj := effectiveStart(allEvents[j])
|
||||
if si.Equal(sj) {
|
||||
return allEvents[i].ID.String() < allEvents[j].ID.String()
|
||||
}
|
||||
return si.Before(sj)
|
||||
})
|
||||
|
||||
hasMore := int32(len(allEvents)) > limit
|
||||
if hasMore {
|
||||
allEvents = allEvents[:limit]
|
||||
}
|
||||
|
||||
relatedLoaded := make(map[uuid.UUID]bool)
|
||||
remindersMap := make(map[uuid.UUID][]models.Reminder)
|
||||
attendeesMap := make(map[uuid.UUID][]models.Attendee)
|
||||
attachmentsMap := make(map[uuid.UUID][]models.Attachment)
|
||||
for _, ev := range allEvents {
|
||||
if relatedLoaded[ev.ID] {
|
||||
continue
|
||||
}
|
||||
relatedLoaded[ev.ID] = true
|
||||
remindersMap[ev.ID], _ = s.loadReminders(ctx, ev.ID)
|
||||
attendeesMap[ev.ID], _ = s.loadAttendees(ctx, ev.ID)
|
||||
attachmentsMap[ev.ID], _ = s.loadAttachments(ctx, ev.ID)
|
||||
}
|
||||
for i := range allEvents {
|
||||
allEvents[i].Reminders = remindersMap[allEvents[i].ID]
|
||||
allEvents[i].Attendees = attendeesMap[allEvents[i].ID]
|
||||
allEvents[i].Attachments = attachmentsMap[allEvents[i].ID]
|
||||
}
|
||||
|
||||
if hasMore {
|
||||
last := allEvents[len(allEvents)-1]
|
||||
cursor := utils.EncodeCursor(effectiveStart(last), last.ID)
|
||||
return allEvents, &cursor, nil
|
||||
}
|
||||
|
||||
return allEvents, nil, nil
|
||||
}
|
||||
|
||||
func (s *EventService) Update(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, req UpdateEventRequest) (*models.Event, error) {
|
||||
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(ev.CalendarID)
|
||||
role, err := s.calendar.GetRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role != "owner" && role != "editor" {
|
||||
return nil, models.ErrForbidden
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
if err := utils.ValidateEventTitle(*req.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.Timezone != nil {
|
||||
if err := utils.ValidateTimezone(*req.Timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
startTime := utils.FromPgTimestamptz(ev.StartTime)
|
||||
endTime := utils.FromPgTimestamptz(ev.EndTime)
|
||||
if req.StartTime != nil {
|
||||
startTime = req.StartTime.UTC()
|
||||
}
|
||||
if req.EndTime != nil {
|
||||
endTime = req.EndTime.UTC()
|
||||
}
|
||||
if err := utils.ValidateTimeRange(startTime, endTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.RecurrenceRule != nil && *req.RecurrenceRule != "" {
|
||||
if _, err := rrule.StrToRRule(*req.RecurrenceRule); err != nil {
|
||||
return nil, models.NewValidationError("invalid recurrence_rule: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
var pgStart, pgEnd pgtype.Timestamptz
|
||||
if req.StartTime != nil {
|
||||
pgStart = utils.ToPgTimestamptz(startTime)
|
||||
}
|
||||
if req.EndTime != nil {
|
||||
pgEnd = utils.ToPgTimestamptz(endTime)
|
||||
}
|
||||
|
||||
updated, err := s.queries.UpdateEvent(ctx, repository.UpdateEventParams{
|
||||
ID: utils.ToPgUUID(eventID),
|
||||
Title: utils.ToPgTextPtr(req.Title),
|
||||
Description: utils.ToPgTextPtr(req.Description),
|
||||
Location: utils.ToPgTextPtr(req.Location),
|
||||
StartTime: pgStart,
|
||||
EndTime: pgEnd,
|
||||
Timezone: utils.ToPgTextPtr(req.Timezone),
|
||||
AllDay: optionalPgBool(req.AllDay),
|
||||
RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule),
|
||||
Tags: req.Tags,
|
||||
UpdatedBy: utils.ToPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "event", eventID, "UPDATE_EVENT", userID)
|
||||
|
||||
reminders, _ := s.loadReminders(ctx, eventID)
|
||||
attendees, _ := s.loadAttendees(ctx, eventID)
|
||||
attachments, _ := s.loadAttachments(ctx, eventID)
|
||||
return eventFromDB(updated, reminders, attendees, attachments), nil
|
||||
}
|
||||
|
||||
func (s *EventService) Delete(ctx context.Context, userID uuid.UUID, eventID uuid.UUID) error {
|
||||
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(ev.CalendarID)
|
||||
role, err := s.calendar.GetRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role != "owner" && role != "editor" {
|
||||
return models.ErrForbidden
|
||||
}
|
||||
|
||||
if err := s.queries.SoftDeleteEvent(ctx, utils.ToPgUUID(eventID)); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "event", eventID, "DELETE_EVENT", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EventService) expandRecurrence(ev repository.Event, rangeStart, rangeEnd time.Time) []models.Event {
|
||||
if !ev.RecurrenceRule.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
ruleStr := ev.RecurrenceRule.String
|
||||
dtStart := utils.FromPgTimestamptz(ev.StartTime)
|
||||
duration := utils.FromPgTimestamptz(ev.EndTime).Sub(dtStart)
|
||||
|
||||
fullRule := fmt.Sprintf("DTSTART:%s\nRRULE:%s", dtStart.UTC().Format("20060102T150405Z"), ruleStr)
|
||||
rule, err := rrule.StrToRRuleSet(fullRule)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
exceptions, _ := s.queries.ListExceptionsByEvent(context.Background(), ev.ID)
|
||||
exceptionDates := make(map[string]bool)
|
||||
for _, ex := range exceptions {
|
||||
if ex.ExceptionDate.Valid {
|
||||
exceptionDates[ex.ExceptionDate.Time.Format("2006-01-02")] = true
|
||||
}
|
||||
}
|
||||
|
||||
occurrences := rule.Between(rangeStart.UTC(), rangeEnd.UTC(), true)
|
||||
var results []models.Event
|
||||
|
||||
for _, occ := range occurrences {
|
||||
dateKey := occ.Format("2006-01-02")
|
||||
if exceptionDates[dateKey] {
|
||||
continue
|
||||
}
|
||||
|
||||
occEnd := occ.Add(duration)
|
||||
occStart := occ
|
||||
results = append(results, models.Event{
|
||||
ID: utils.FromPgUUID(ev.ID),
|
||||
CalendarID: utils.FromPgUUID(ev.CalendarID),
|
||||
Title: ev.Title,
|
||||
Description: utils.FromPgText(ev.Description),
|
||||
Location: utils.FromPgText(ev.Location),
|
||||
StartTime: dtStart,
|
||||
EndTime: dtStart.Add(duration),
|
||||
Timezone: ev.Timezone,
|
||||
AllDay: ev.AllDay,
|
||||
RecurrenceRule: utils.FromPgText(ev.RecurrenceRule),
|
||||
IsOccurrence: true,
|
||||
OccurrenceStartTime: &occStart,
|
||||
OccurrenceEndTime: &occEnd,
|
||||
CreatedBy: utils.FromPgUUID(ev.CreatedBy),
|
||||
UpdatedBy: utils.FromPgUUID(ev.UpdatedBy),
|
||||
CreatedAt: utils.FromPgTimestamptz(ev.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(ev.UpdatedAt),
|
||||
Tags: ev.Tags,
|
||||
Reminders: []models.Reminder{},
|
||||
Attendees: []models.Attendee{},
|
||||
Attachments: []models.Attachment{},
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *EventService) loadReminders(ctx context.Context, eventID uuid.UUID) ([]models.Reminder, error) {
|
||||
rows, err := s.queries.ListRemindersByEvent(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
result := make([]models.Reminder, len(rows))
|
||||
for i, r := range rows {
|
||||
result[i] = models.Reminder{
|
||||
ID: utils.FromPgUUID(r.ID),
|
||||
MinutesBefore: r.MinutesBefore,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *EventService) loadAttendees(ctx context.Context, eventID uuid.UUID) ([]models.Attendee, error) {
|
||||
rows, err := s.queries.ListAttendeesByEvent(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
result := make([]models.Attendee, len(rows))
|
||||
for i, a := range rows {
|
||||
var uid *uuid.UUID
|
||||
if a.UserID.Valid {
|
||||
u := utils.FromPgUUID(a.UserID)
|
||||
uid = &u
|
||||
}
|
||||
result[i] = models.Attendee{
|
||||
ID: utils.FromPgUUID(a.ID),
|
||||
UserID: uid,
|
||||
Email: utils.FromPgText(a.Email),
|
||||
Status: a.Status,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *EventService) loadAttachments(ctx context.Context, eventID uuid.UUID) ([]models.Attachment, error) {
|
||||
rows, err := s.queries.ListAttachmentsByEvent(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
result := make([]models.Attachment, len(rows))
|
||||
for i, a := range rows {
|
||||
result[i] = models.Attachment{
|
||||
ID: utils.FromPgUUID(a.ID),
|
||||
FileURL: a.FileUrl,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func eventFromDB(ev repository.Event, reminders []models.Reminder, attendees []models.Attendee, attachments []models.Attachment) *models.Event {
|
||||
if reminders == nil {
|
||||
reminders = []models.Reminder{}
|
||||
}
|
||||
if attendees == nil {
|
||||
attendees = []models.Attendee{}
|
||||
}
|
||||
if attachments == nil {
|
||||
attachments = []models.Attachment{}
|
||||
}
|
||||
tags := ev.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
return &models.Event{
|
||||
ID: utils.FromPgUUID(ev.ID),
|
||||
CalendarID: utils.FromPgUUID(ev.CalendarID),
|
||||
Title: ev.Title,
|
||||
Description: utils.FromPgText(ev.Description),
|
||||
Location: utils.FromPgText(ev.Location),
|
||||
StartTime: utils.FromPgTimestamptz(ev.StartTime),
|
||||
EndTime: utils.FromPgTimestamptz(ev.EndTime),
|
||||
Timezone: ev.Timezone,
|
||||
AllDay: ev.AllDay,
|
||||
RecurrenceRule: utils.FromPgText(ev.RecurrenceRule),
|
||||
CreatedBy: utils.FromPgUUID(ev.CreatedBy),
|
||||
UpdatedBy: utils.FromPgUUID(ev.UpdatedBy),
|
||||
CreatedAt: utils.FromPgTimestamptz(ev.CreatedAt),
|
||||
UpdatedAt: utils.FromPgTimestamptz(ev.UpdatedAt),
|
||||
Reminders: reminders,
|
||||
Attendees: attendees,
|
||||
Attachments: attachments,
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
|
||||
type CreateEventRequest struct {
|
||||
CalendarID uuid.UUID
|
||||
Title string
|
||||
Description *string
|
||||
Location *string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Timezone string
|
||||
AllDay bool
|
||||
RecurrenceRule *string
|
||||
Reminders []int32
|
||||
Tags []string
|
||||
}
|
||||
|
||||
type UpdateEventRequest struct {
|
||||
Title *string
|
||||
Description *string
|
||||
Location *string
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
Timezone *string
|
||||
AllDay *bool
|
||||
RecurrenceRule *string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
type ListEventParams struct {
|
||||
RangeStart time.Time
|
||||
RangeEnd time.Time
|
||||
CalendarID *uuid.UUID
|
||||
Search *string
|
||||
Tag *string
|
||||
Limit int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
func effectiveStart(e models.Event) time.Time {
|
||||
if e.OccurrenceStartTime != nil {
|
||||
return *e.OccurrenceStartTime
|
||||
}
|
||||
return e.StartTime
|
||||
}
|
||||
|
||||
func optionalPgUUID(id *uuid.UUID) pgtype.UUID {
|
||||
if id == nil {
|
||||
return pgtype.UUID{Valid: false}
|
||||
}
|
||||
return utils.ToPgUUID(*id)
|
||||
}
|
||||
|
||||
func optionalPgBool(b *bool) pgtype.Bool {
|
||||
if b == nil {
|
||||
return pgtype.Bool{Valid: false}
|
||||
}
|
||||
return pgtype.Bool{Bool: *b, Valid: true}
|
||||
}
|
||||
132
internal/service/reminder.go
Normal file
132
internal/service/reminder.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ReminderService struct {
|
||||
queries *repository.Queries
|
||||
calendar *CalendarService
|
||||
scheduler ReminderScheduler
|
||||
}
|
||||
|
||||
func NewReminderService(queries *repository.Queries, calendar *CalendarService, scheduler ReminderScheduler) *ReminderService {
|
||||
return &ReminderService{queries: queries, calendar: calendar, scheduler: scheduler}
|
||||
}
|
||||
|
||||
func (s *ReminderService) AddReminders(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, minutesBefore []int32) (*models.Event, error) {
|
||||
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(ev.CalendarID)
|
||||
role, err := s.calendar.GetRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role != "owner" && role != "editor" {
|
||||
return nil, models.ErrForbidden
|
||||
}
|
||||
|
||||
for _, mins := range minutesBefore {
|
||||
if err := utils.ValidateReminderMinutes(mins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rID := uuid.New()
|
||||
_, err := s.queries.CreateReminder(ctx, repository.CreateReminderParams{
|
||||
ID: utils.ToPgUUID(rID),
|
||||
EventID: utils.ToPgUUID(eventID),
|
||||
MinutesBefore: mins,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
|
||||
startTime := utils.FromPgTimestamptz(ev.StartTime)
|
||||
triggerAt := startTime.Add(-time.Duration(mins) * time.Minute)
|
||||
if triggerAt.After(time.Now()) && s.scheduler != nil {
|
||||
_ = s.scheduler.ScheduleReminder(ctx, eventID, rID, userID, triggerAt)
|
||||
}
|
||||
}
|
||||
|
||||
reminders, _ := loadRemindersHelper(ctx, s.queries, eventID)
|
||||
attendees, _ := loadAttendeesHelper(ctx, s.queries, eventID)
|
||||
attachments, _ := loadAttachmentsHelper(ctx, s.queries, eventID)
|
||||
return eventFromDB(ev, reminders, attendees, attachments), nil
|
||||
}
|
||||
|
||||
func (s *ReminderService) DeleteReminder(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, reminderID uuid.UUID) error {
|
||||
ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
calID := utils.FromPgUUID(ev.CalendarID)
|
||||
role, err := s.calendar.GetRole(ctx, calID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role != "owner" && role != "editor" {
|
||||
return models.ErrForbidden
|
||||
}
|
||||
|
||||
return s.queries.DeleteReminder(ctx, repository.DeleteReminderParams{
|
||||
ID: utils.ToPgUUID(reminderID),
|
||||
EventID: utils.ToPgUUID(eventID),
|
||||
})
|
||||
}
|
||||
|
||||
func loadRemindersHelper(ctx context.Context, q *repository.Queries, eventID uuid.UUID) ([]models.Reminder, error) {
|
||||
rows, err := q.ListRemindersByEvent(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
return []models.Reminder{}, err
|
||||
}
|
||||
result := make([]models.Reminder, len(rows))
|
||||
for i, r := range rows {
|
||||
result[i] = models.Reminder{ID: utils.FromPgUUID(r.ID), MinutesBefore: r.MinutesBefore}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadAttendeesHelper(ctx context.Context, q *repository.Queries, eventID uuid.UUID) ([]models.Attendee, error) {
|
||||
rows, err := q.ListAttendeesByEvent(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
return []models.Attendee{}, err
|
||||
}
|
||||
result := make([]models.Attendee, len(rows))
|
||||
for i, a := range rows {
|
||||
var uid *uuid.UUID
|
||||
if a.UserID.Valid {
|
||||
u := utils.FromPgUUID(a.UserID)
|
||||
uid = &u
|
||||
}
|
||||
result[i] = models.Attendee{ID: utils.FromPgUUID(a.ID), UserID: uid, Email: utils.FromPgText(a.Email), Status: a.Status}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadAttachmentsHelper(ctx context.Context, q *repository.Queries, eventID uuid.UUID) ([]models.Attachment, error) {
|
||||
rows, err := q.ListAttachmentsByEvent(ctx, utils.ToPgUUID(eventID))
|
||||
if err != nil {
|
||||
return []models.Attachment{}, err
|
||||
}
|
||||
result := make([]models.Attachment, len(rows))
|
||||
for i, a := range rows {
|
||||
result[i] = models.Attachment{ID: utils.FromPgUUID(a.ID), FileURL: a.FileUrl}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
92
internal/service/user.go
Normal file
92
internal/service/user.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
pool *pgxpool.Pool
|
||||
queries *repository.Queries
|
||||
audit *AuditService
|
||||
}
|
||||
|
||||
func NewUserService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService) *UserService {
|
||||
return &UserService{pool: pool, queries: queries, audit: audit}
|
||||
}
|
||||
|
||||
func (s *UserService) GetMe(ctx context.Context, userID uuid.UUID) (*models.User, error) {
|
||||
u, err := s.queries.GetUserByID(ctx, utils.ToPgUUID(userID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
user := userFromIDRow(u)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) Update(ctx context.Context, userID uuid.UUID, timezone *string) (*models.User, error) {
|
||||
if timezone != nil {
|
||||
if err := utils.ValidateTimezone(*timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
u, err := s.queries.UpdateUser(ctx, repository.UpdateUserParams{
|
||||
ID: utils.ToPgUUID(userID),
|
||||
Timezone: utils.ToPgTextPtr(timezone),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, models.ErrNotFound
|
||||
}
|
||||
return nil, models.ErrInternal
|
||||
}
|
||||
user := userFromUpdateRow(u)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) Delete(ctx context.Context, userID uuid.UUID) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
qtx := s.queries.WithTx(tx)
|
||||
pgID := utils.ToPgUUID(userID)
|
||||
|
||||
if err := qtx.SoftDeleteContactsByOwner(ctx, pgID); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
if err := qtx.SoftDeleteEventsByCreator(ctx, pgID); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
if err := qtx.SoftDeleteCalendarsByOwner(ctx, pgID); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
if err := qtx.RevokeAllUserAPIKeys(ctx, pgID); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
if err := qtx.RevokeAllUserRefreshTokens(ctx, pgID); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
if err := qtx.SoftDeleteUser(ctx, pgID); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
s.audit.Log(ctx, "user", userID, "DELETE_USER", userID)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user