// Package main is the entrypoint for the Social Event Mapper backend server.
// It initialises the dependency container and starts the HTTP server.
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/bootstrap"
"github.com/bounswe/bounswe2026group11/backend/internal/server"
)
func main() {
ctx := context.Background()
container, err := bootstrap.New(ctx)
if err != nil {
log.Fatalf("bootstrap: %v", err)
}
defer container.Close()
container.StartEventExpiryJob(ctx, 1*time.Minute)
app := server.NewHTTP(container)
addr := fmt.Sprintf(":%d", container.Config.AppPort)
log.Fatal(app.Listen(addr))
}
package email
import (
"context"
"fmt"
"strings"
"time"
authapp "github.com/bounswe/bounswe2026group11/backend/internal/application/auth"
emailapp "github.com/bounswe/bounswe2026group11/backend/internal/application/email"
)
const authSenderLocalPart = "auth"
// AuthOTPMailer sends auth OTP emails through the shared transactional provider.
type AuthOTPMailer struct {
provider emailapp.Provider
renderer otpTemplateRenderer
}
var _ authapp.OTPMailer = (*AuthOTPMailer)(nil)
// NewAuthOTPMailer constructs the auth OTP mail adapter.
func NewAuthOTPMailer(provider emailapp.Provider) *AuthOTPMailer {
return &AuthOTPMailer{
provider: provider,
renderer: newOTPTemplateRenderer(),
}
}
func (m *AuthOTPMailer) SendRegistrationOTP(ctx context.Context, input authapp.OTPMailInput) error {
return m.send(ctx, input, otpMailContent{
Subject: "Your Social Event Mapper verification code",
PreviewText: "Use this code to finish creating your account.",
Heading: "Verify your email",
Intro: "Use this code to finish creating your Social Event Mapper account.",
})
}
func (m *AuthOTPMailer) SendPasswordResetOTP(ctx context.Context, input authapp.OTPMailInput) error {
return m.send(ctx, input, otpMailContent{
Subject: "Your Social Event Mapper password reset code",
PreviewText: "Use this code to reset your password.",
Heading: "Reset your password",
Intro: "Use this code to continue resetting your Social Event Mapper password.",
})
}
type otpMailContent struct {
Subject string
PreviewText string
Heading string
Intro string
}
func (m *AuthOTPMailer) send(ctx context.Context, input authapp.OTPMailInput, content otpMailContent) error {
if m == nil || m.provider == nil {
return fmt.Errorf("auth otp mailer is not configured")
}
htmlBody, textBody, err := m.renderer.Render(otpTemplateData{
PreviewText: content.PreviewText,
Heading: content.Heading,
Intro: content.Intro,
Code: strings.TrimSpace(input.Code),
ExpiryText: fmt.Sprintf("This code expires in %s.", formatDurationForEmail(input.ExpiresIn)),
IgnoreText: "If you did not request this code, you can safely ignore this email.",
})
if err != nil {
return fmt.Errorf("render auth otp email: %w", err)
}
if err := m.provider.Send(ctx, emailapp.Message{
FromLocalPart: authSenderLocalPart,
To: strings.TrimSpace(input.Email),
Subject: content.Subject,
HTML: htmlBody,
Text: textBody,
}); err != nil {
return fmt.Errorf("deliver auth otp email: %w", err)
}
return nil
}
func formatDurationForEmail(value time.Duration) string {
switch {
case value%time.Hour == 0 && value >= time.Hour:
hours := int(value / time.Hour)
if hours == 1 {
return "1 hour"
}
return fmt.Sprintf("%d hours", hours)
case value%time.Minute == 0 && value >= time.Minute:
minutes := int(value / time.Minute)
if minutes == 1 {
return "1 minute"
}
return fmt.Sprintf("%d minutes", minutes)
default:
seconds := int(value / time.Second)
if seconds == 1 {
return "1 second"
}
return fmt.Sprintf("%d seconds", seconds)
}
}
package email
import (
"bytes"
"embed"
"fmt"
htmltemplate "html/template"
texttemplate "text/template"
)
//go:embed templates/auth-otp.html.tmpl templates/auth-otp.txt.tmpl
var otpTemplatesFS embed.FS
var (
authOTPHTMLTemplate = htmltemplate.Must(
htmltemplate.New("auth-otp.html.tmpl").ParseFS(otpTemplatesFS, "templates/auth-otp.html.tmpl"),
)
authOTPTextTemplate = texttemplate.Must(
texttemplate.New("auth-otp.txt.tmpl").ParseFS(otpTemplatesFS, "templates/auth-otp.txt.tmpl"),
)
)
type otpTemplateRenderer struct{}
type otpTemplateData struct {
PreviewText string
Heading string
Intro string
Code string
ExpiryText string
IgnoreText string
}
func newOTPTemplateRenderer() otpTemplateRenderer {
return otpTemplateRenderer{}
}
func (otpTemplateRenderer) Render(data otpTemplateData) (string, string, error) {
var htmlBuffer bytes.Buffer
if err := authOTPHTMLTemplate.Execute(&htmlBuffer, data); err != nil {
return "", "", fmt.Errorf("render html otp template: %w", err)
}
var textBuffer bytes.Buffer
if err := authOTPTextTemplate.Execute(&textBuffer, data); err != nil {
return "", "", fmt.Errorf("render text otp template: %w", err)
}
return htmlBuffer.String(), textBuffer.String(), nil
}
package email
import (
"context"
"fmt"
"log"
"strings"
emailapp "github.com/bounswe/bounswe2026group11/backend/internal/application/email"
)
// MockProvider logs transactional emails instead of delivering them.
type MockProvider struct{}
var _ emailapp.Provider = MockProvider{}
func (MockProvider) Send(_ context.Context, message emailapp.Message) error {
to := strings.TrimSpace(message.To)
if to == "" {
return fmt.Errorf("mock email provider: recipient is required")
}
log.Printf(
"mock email provider: from_local_part=%s to=%s subject=%q text=%q",
strings.TrimSpace(message.FromLocalPart),
to,
strings.TrimSpace(message.Subject),
strings.TrimSpace(message.Text),
)
return nil
}
package email
import (
"context"
"fmt"
"net/http"
"strings"
emailapp "github.com/bounswe/bounswe2026group11/backend/internal/application/email"
"github.com/resend/resend-go/v3"
)
const senderDisplayName = "Social Event Mapper"
// ResendProvider delivers transactional emails through the Resend API.
type ResendProvider struct {
client *resend.Client
domain string
}
var _ emailapp.Provider = (*ResendProvider)(nil)
// NewResendProvider creates a Resend-backed transactional email provider.
func NewResendProvider(apiKey, domain string) *ResendProvider {
return newResendProvider(resend.NewClient(apiKey), domain)
}
func newResendProviderWithHTTPClient(apiKey, domain string, httpClient *http.Client) *ResendProvider {
return newResendProvider(resend.NewCustomClient(httpClient, apiKey), domain)
}
func newResendProvider(client *resend.Client, domain string) *ResendProvider {
return &ResendProvider{
client: client,
domain: strings.TrimSpace(domain),
}
}
func (p *ResendProvider) Send(ctx context.Context, message emailapp.Message) error {
if p == nil || p.client == nil {
return fmt.Errorf("resend email provider is not configured")
}
fromLocalPart := strings.TrimSpace(message.FromLocalPart)
if fromLocalPart == "" {
return fmt.Errorf("resend email provider: from local part is required")
}
if strings.Contains(fromLocalPart, "@") {
return fmt.Errorf("resend email provider: from local part must not contain @")
}
to := strings.TrimSpace(message.To)
if to == "" {
return fmt.Errorf("resend email provider: recipient is required")
}
subject := strings.TrimSpace(message.Subject)
if subject == "" {
return fmt.Errorf("resend email provider: subject is required")
}
params := &resend.SendEmailRequest{
From: fmt.Sprintf("%s <%s@%s>", senderDisplayName, fromLocalPart, p.domain),
To: []string{to},
Subject: subject,
Html: message.HTML,
Text: message.Text,
}
if _, err := p.client.Emails.SendWithContext(ctx, params); err != nil {
return fmt.Errorf("send email with resend: %w", err)
}
return nil
}
package hasher
import "golang.org/x/crypto/bcrypt"
// BcryptHasher implements auth.PasswordHasher using bcrypt.
type BcryptHasher struct {
Cost int
}
// Hash returns a bcrypt hash of value, using the configured cost or bcrypt.DefaultCost.
func (h BcryptHasher) Hash(value string) (string, error) {
cost := h.Cost
if cost == 0 {
cost = bcrypt.DefaultCost
}
hash, err := bcrypt.GenerateFromPassword([]byte(value), cost)
if err != nil {
return "", err
}
return string(hash), nil
}
// Compare returns nil if hash is a valid bcrypt hash of value, or an error otherwise.
func (h BcryptHasher) Compare(hash, value string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(value))
}
package jwt
import (
"errors"
"fmt"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/application/imageupload"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const imageUploadTokenSubject = "IMAGE_UPLOAD_CONFIRM" // #nosec G101 -- JWT subject constant, not a credential.
type imageUploadTokenClaims struct {
Resource string `json:"resource"`
OwnerUserID string `json:"owner_user_id"`
EventID string `json:"event_id,omitempty"`
Version int `json:"version"`
UploadID string `json:"upload_id"`
BaseURL string `json:"base_url"`
OriginalKey string `json:"original_key"`
SmallKey string `json:"small_key"`
gojwt.RegisteredClaims
}
// ImageUploadTokenManager signs and verifies image-upload confirm tokens.
type ImageUploadTokenManager struct {
Secret []byte
Now func() time.Time
}
// Sign produces a signed confirm token for the supplied payload.
func (m ImageUploadTokenManager) Sign(payload imageupload.ConfirmTokenPayload, ttl time.Duration) (string, error) {
now := time.Now().UTC()
if m.Now != nil {
now = m.Now().UTC()
}
claims := imageUploadTokenClaims{
Resource: payload.Resource,
OwnerUserID: payload.OwnerUserID.String(),
Version: payload.Version,
UploadID: payload.UploadID,
BaseURL: payload.BaseURL,
OriginalKey: payload.OriginalKey,
SmallKey: payload.SmallKey,
RegisteredClaims: gojwt.RegisteredClaims{
Subject: imageUploadTokenSubject,
IssuedAt: gojwt.NewNumericDate(now),
ExpiresAt: gojwt.NewNumericDate(now.Add(ttl)),
},
}
if payload.EventID != nil {
claims.EventID = payload.EventID.String()
}
token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims)
return token.SignedString(m.Secret)
}
// Verify validates the token signature and expiry, then returns the decoded payload.
func (m ImageUploadTokenManager) Verify(tokenString string) (*imageupload.ConfirmTokenPayload, error) {
claims := &imageUploadTokenClaims{}
token, err := gojwt.ParseWithClaims(tokenString, claims, func(token *gojwt.Token) (any, error) {
if token.Method != gojwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method %s", token.Method.Alg())
}
return m.Secret, nil
})
if err != nil {
return nil, err
}
if !token.Valid || claims.Subject != imageUploadTokenSubject {
return nil, errors.New("invalid image upload token")
}
ownerUserID, err := uuid.Parse(claims.OwnerUserID)
if err != nil {
return nil, fmt.Errorf("parse owner_user_id: %w", err)
}
var eventID *uuid.UUID
if claims.EventID != "" {
parsedEventID, err := uuid.Parse(claims.EventID)
if err != nil {
return nil, fmt.Errorf("parse event_id: %w", err)
}
eventID = &parsedEventID
}
payload := &imageupload.ConfirmTokenPayload{
Resource: claims.Resource,
OwnerUserID: ownerUserID,
EventID: eventID,
Version: claims.Version,
UploadID: claims.UploadID,
BaseURL: claims.BaseURL,
OriginalKey: claims.OriginalKey,
SmallKey: claims.SmallKey,
}
if claims.ExpiresAt != nil {
payload.ExpiresAt = claims.ExpiresAt.Time
}
return payload, nil
}
package jwt
import (
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
gojwt "github.com/golang-jwt/jwt/v5"
)
// Issuer implements auth.TokenIssuer using HS256 JWT.
type Issuer struct {
Secret []byte
TTL time.Duration
}
// IssueAccessToken creates a signed HS256 JWT containing the user's ID, username,
// and email as claims, valid for the configured TTL.
func (j Issuer) IssueAccessToken(user domain.User, issuedAt time.Time) (string, int64, error) {
expiresAt := issuedAt.Add(j.TTL)
claims := gojwt.MapClaims{
"sub": user.ID.String(),
"username": user.Username,
"email": user.Email,
"iat": issuedAt.Unix(),
"exp": expiresAt.Unix(),
}
token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims)
signed, err := token.SignedString(j.Secret)
if err != nil {
return "", 0, err
}
return signed, int64(j.TTL.Seconds()), nil
}
package jwt
import (
"fmt"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// Verifier implements domain.TokenVerifier using HS256 JWT.
type Verifier struct {
Secret []byte
}
// VerifyAccessToken parses and validates a signed HS256 JWT, returning the
// embedded claims. Returns an error if the token is malformed or expired.
func (v Verifier) VerifyAccessToken(token string) (*domain.AuthClaims, error) {
parsed, err := gojwt.Parse(token, func(t *gojwt.Token) (any, error) {
if _, ok := t.Method.(*gojwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return v.Secret, nil
}, gojwt.WithExpirationRequired())
if err != nil || !parsed.Valid {
return nil, fmt.Errorf("invalid token: %w", err)
}
claims, ok := parsed.Claims.(gojwt.MapClaims)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
sub, _ := claims["sub"].(string)
userID, err := uuid.Parse(sub)
if err != nil {
return nil, fmt.Errorf("invalid subject claim: %w", err)
}
username, _ := claims["username"].(string)
email, _ := claims["email"].(string)
return &domain.AuthClaims{
UserID: userID,
Username: username,
Email: email,
}, nil
}
package otp
import (
"crypto/rand"
"fmt"
"math/big"
)
// CodeGenerator implements auth.OTPCodeGenerator.
type CodeGenerator struct{}
// NewCode generates a cryptographically random 6-digit OTP string.
func (CodeGenerator) NewCode() string {
maxValue := big.NewInt(1000000)
n, err := rand.Int(rand.Reader, maxValue)
if err != nil {
// Fallback to a fixed code if the CSPRNG fails; should never happen in practice.
return "000000"
}
return fmt.Sprintf("%06d", n.Int64())
}
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
authapp "github.com/bounswe/bounswe2026group11/backend/internal/application/auth"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
// AuthRepository is the Postgres-backed implementation of auth.Repository.
type AuthRepository struct {
pool *pgxpool.Pool
db execer
}
// NewAuthRepository returns a repository that executes queries against the given connection pool.
func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository {
return &AuthRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
// NewAuthRepositoryWithTx returns a repository bound to an existing transaction.
// Repository methods use tx as the default runner when no ambient transaction exists.
func NewAuthRepositoryWithTx(pool *pgxpool.Pool, tx pgx.Tx) *AuthRepository {
return &AuthRepository{
pool: pool,
db: contextualRunner{fallback: tx},
}
}
func (r *AuthRepository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
row := r.db.QueryRow(ctx, `
SELECT id, username, email, phone_number, gender, birth_date, password_hash, email_verified_at, last_login, status, created_at, updated_at
FROM app_user
WHERE email = $1
`, email)
return scanUser(row)
}
func (r *AuthRepository) GetUserByUsername(ctx context.Context, username string) (*domain.User, error) {
row := r.db.QueryRow(ctx, `
SELECT id, username, email, phone_number, gender, birth_date, password_hash, email_verified_at, last_login, status, created_at, updated_at
FROM app_user
WHERE username = $1
`, username)
return scanUser(row)
}
func (r *AuthRepository) GetUserByID(ctx context.Context, userID uuid.UUID) (*domain.User, error) {
row := r.db.QueryRow(ctx, `
SELECT id, username, email, phone_number, gender, birth_date, password_hash, email_verified_at, last_login, status, created_at, updated_at
FROM app_user
WHERE id = $1
`, userID)
return scanUser(row)
}
func (r *AuthRepository) CreateUser(ctx context.Context, params authapp.CreateUserParams) (*domain.User, error) {
row := r.db.QueryRow(ctx, `
INSERT INTO app_user (username, email, phone_number, gender, birth_date, password_hash, email_verified_at, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, username, email, phone_number, gender, birth_date, password_hash, email_verified_at, last_login, status, created_at, updated_at
`, params.Username, params.Email, params.PhoneNumber, params.Gender, params.BirthDate, params.PasswordHash, params.EmailVerifiedAt, params.Status)
user, err := scanUser(row)
if err != nil {
return nil, mapConstraintError(err)
}
return user, nil
}
func (r *AuthRepository) UpdatePassword(ctx context.Context, userID uuid.UUID, passwordHash string, updatedAt time.Time) error {
result, err := r.db.Exec(ctx, `
UPDATE app_user
SET password_hash = $2, updated_at = $3
WHERE id = $1
`, userID, passwordHash, updatedAt)
if err != nil {
return fmt.Errorf("update password_hash: %w", err)
}
if result.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
func (r *AuthRepository) CreateProfile(ctx context.Context, userID uuid.UUID) error {
if _, err := r.db.Exec(ctx, `
INSERT INTO profile (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO NOTHING
`, userID); err != nil {
return fmt.Errorf("insert profile: %w", err)
}
return nil
}
func (r *AuthRepository) UpdateLastLogin(ctx context.Context, userID uuid.UUID, lastLogin time.Time) error {
if _, err := r.db.Exec(ctx, `
UPDATE app_user
SET last_login = $2, updated_at = $2
WHERE id = $1
`, userID, lastLogin); err != nil {
return fmt.Errorf("update last_login: %w", err)
}
return nil
}
func (r *AuthRepository) GetActiveOTPChallenge(ctx context.Context, destination, purpose string) (*domain.OTPChallenge, error) {
row := r.db.QueryRow(ctx, `
SELECT id, user_id, channel, destination, purpose, code_hash, expires_at, consumed_at, attempt_count, created_at, updated_at
FROM otp_challenge
WHERE destination = $1 AND purpose = $2 AND consumed_at IS NULL
ORDER BY created_at DESC
LIMIT 1
`, destination, purpose)
return scanOTPChallenge(row)
}
func (r *AuthRepository) UpsertOTPChallenge(ctx context.Context, params authapp.UpsertOTPChallengeParams) (*domain.OTPChallenge, error) {
row := r.db.QueryRow(ctx, `
INSERT INTO otp_challenge (user_id, channel, destination, purpose, code_hash, expires_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (destination, purpose) WHERE consumed_at IS NULL
DO UPDATE SET
user_id = EXCLUDED.user_id,
channel = EXCLUDED.channel,
code_hash = EXCLUDED.code_hash,
expires_at = EXCLUDED.expires_at,
attempt_count = 0,
updated_at = EXCLUDED.updated_at
RETURNING id, user_id, channel, destination, purpose, code_hash, expires_at, consumed_at, attempt_count, created_at, updated_at
`, params.UserID, params.Channel, params.Destination, params.Purpose, params.CodeHash, params.ExpiresAt, params.UpdatedAt)
return scanOTPChallenge(row)
}
func (r *AuthRepository) IncrementOTPChallengeAttempts(ctx context.Context, challengeID uuid.UUID, updatedAt time.Time) (*domain.OTPChallenge, error) {
row := r.db.QueryRow(ctx, `
UPDATE otp_challenge
SET attempt_count = attempt_count + 1, updated_at = $2
WHERE id = $1
RETURNING id, user_id, channel, destination, purpose, code_hash, expires_at, consumed_at, attempt_count, created_at, updated_at
`, challengeID, updatedAt)
return scanOTPChallenge(row)
}
func (r *AuthRepository) ConsumeOTPChallenge(ctx context.Context, challengeID uuid.UUID, consumedAt time.Time) error {
if _, err := r.db.Exec(ctx, `
UPDATE otp_challenge
SET consumed_at = $2, updated_at = $2
WHERE id = $1
`, challengeID, consumedAt); err != nil {
return fmt.Errorf("consume otp challenge: %w", err)
}
return nil
}
func (r *AuthRepository) CreateRefreshToken(ctx context.Context, params authapp.CreateRefreshTokenParams) (*domain.RefreshToken, error) {
row := r.db.QueryRow(ctx, `
INSERT INTO refresh_token (user_id, family_id, token_hash, expires_at, device_info, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)
RETURNING id, user_id, family_id, token_hash, expires_at, revoked_at, replaced_by_id, device_info, created_at, updated_at
`, params.UserID, params.FamilyID, params.TokenHash, params.ExpiresAt, params.DeviceInfo, params.CreatedAt)
return scanRefreshToken(row)
}
func (r *AuthRepository) GetRefreshTokenByHash(ctx context.Context, tokenHash string) (*domain.RefreshToken, error) {
row := r.db.QueryRow(ctx, `
SELECT id, user_id, family_id, token_hash, expires_at, revoked_at, replaced_by_id, device_info, created_at, updated_at
FROM refresh_token
WHERE token_hash = $1
`, tokenHash)
return scanRefreshToken(row)
}
func (r *AuthRepository) GetRefreshTokenFamilyCreatedAt(ctx context.Context, familyID uuid.UUID) (time.Time, error) {
row := r.db.QueryRow(ctx, `
SELECT MIN(created_at)
FROM refresh_token
WHERE family_id = $1
`, familyID)
var createdAt pgtype.Timestamptz
if err := row.Scan(&createdAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, domain.ErrNotFound
}
return time.Time{}, err
}
if !createdAt.Valid {
return time.Time{}, domain.ErrNotFound
}
return createdAt.Time, nil
}
func (r *AuthRepository) RevokeRefreshToken(ctx context.Context, tokenID uuid.UUID, revokedAt time.Time) error {
if _, err := r.db.Exec(ctx, `
UPDATE refresh_token
SET revoked_at = COALESCE(revoked_at, $2), updated_at = $2
WHERE id = $1
`, tokenID, revokedAt); err != nil {
return fmt.Errorf("revoke refresh token: %w", err)
}
return nil
}
func (r *AuthRepository) SetRefreshTokenReplacement(ctx context.Context, tokenID, replacedByID uuid.UUID, updatedAt time.Time) error {
if _, err := r.db.Exec(ctx, `
UPDATE refresh_token
SET replaced_by_id = $2, updated_at = $3
WHERE id = $1
`, tokenID, replacedByID, updatedAt); err != nil {
return fmt.Errorf("set refresh token replacement: %w", err)
}
return nil
}
func (r *AuthRepository) RevokeRefreshTokenFamily(ctx context.Context, familyID uuid.UUID, revokedAt time.Time) error {
if _, err := r.db.Exec(ctx, `
UPDATE refresh_token
SET revoked_at = COALESCE(revoked_at, $2), updated_at = $2
WHERE family_id = $1 AND revoked_at IS NULL
`, familyID, revokedAt); err != nil {
return fmt.Errorf("revoke refresh token family: %w", err)
}
return nil
}
// mapConstraintError translates Postgres unique-violation errors (code 23505)
// into domain-level ConflictErrors so the HTTP layer returns 409 instead of 500.
func mapConstraintError(err error) error {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23505" {
return err
}
switch {
case strings.Contains(pgErr.ConstraintName, "app_user_username_key"):
return domain.ConflictError(domain.ErrorCodeUsernameExists, "The username is already in use.")
case strings.Contains(pgErr.ConstraintName, "app_user_email_key"):
return domain.ConflictError(domain.ErrorCodeEmailExists, "The email is already in use.")
case strings.Contains(pgErr.ConstraintName, "idx_app_user_phone_unique"):
return domain.ConflictError(domain.ErrorCodePhoneExists, "The phone number is already in use.")
default:
return err
}
}
package postgres
import (
"context"
"fmt"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/jackc/pgx/v5/pgxpool"
)
// CategoryRepository is the Postgres-backed implementation of category.Repository.
type CategoryRepository struct {
pool *pgxpool.Pool
}
// NewCategoryRepository returns a repository that executes queries against the given connection pool.
func NewCategoryRepository(pool *pgxpool.Pool) *CategoryRepository {
return &CategoryRepository{pool: pool}
}
// ListCategories returns all event_category rows ordered by id ascending.
func (r *CategoryRepository) ListCategories(ctx context.Context) ([]domain.EventCategory, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, name
FROM event_category
ORDER BY id ASC
`)
if err != nil {
return nil, fmt.Errorf("list categories: %w", err)
}
defer rows.Close()
var categories []domain.EventCategory
for rows.Next() {
var c domain.EventCategory
if err := rows.Scan(&c.ID, &c.Name); err != nil {
return nil, fmt.Errorf("scan category: %w", err)
}
categories = append(categories, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate categories: %w", err)
}
return categories, nil
}
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
eventapp "github.com/bounswe/bounswe2026group11/backend/internal/application/event"
imageuploadapp "github.com/bounswe/bounswe2026group11/backend/internal/application/imageupload"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
// EventRepository is the Postgres-backed implementation of event.Repository.
type EventRepository struct {
pool *pgxpool.Pool
db execer
}
// NewEventRepository returns a repository that executes queries against the given connection pool.
func NewEventRepository(pool *pgxpool.Pool) *EventRepository {
return &EventRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
// CreateEvent persists the event along with its location, tags, and constraints
// in a single transaction, returning the created event.
func (r *EventRepository) CreateEvent(ctx context.Context, params eventapp.CreateEventParams) (*domain.Event, error) {
event, err := insertEventRow(ctx, r.db, params)
if err != nil {
return nil, mapEventInsertError(err)
}
if err := insertHostParticipation(ctx, r.db, event); err != nil {
return nil, err
}
if err := insertEventLocation(ctx, r.db, event.ID, params.Address, params.LocationType, params.Point, params.RoutePoints); err != nil {
return nil, err
}
if err := insertEventTags(ctx, r.db, event.ID, params.Tags); err != nil {
return nil, err
}
if err := insertEventConstraints(ctx, r.db, event.ID, params.Constraints); err != nil {
return nil, err
}
return event, nil
}
// insertEventRow inserts the core event record and returns the populated Event entity.
func insertEventRow(ctx context.Context, db execer, params eventapp.CreateEventParams) (*domain.Event, error) {
var (
id uuid.UUID
title string
privacyLevel string
status string
startTime time.Time
endTime pgtype.Timestamptz
createdAt time.Time
updatedAt time.Time
)
var preferredGender *string
if params.PreferredGender != nil {
preferredGender = new(string(*params.PreferredGender))
}
err := db.QueryRow(ctx, `
INSERT INTO event (
host_id, title, description, image_url, category_id,
start_time, end_time, privacy_level, status,
capacity, minimum_age, preferred_gender, location_type
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12, $13
)
RETURNING id, title, privacy_level, status, start_time, end_time, created_at, updated_at
`,
params.HostID, params.Title, params.Description, params.ImageURL, params.CategoryID,
params.StartTime, params.EndTime, string(params.PrivacyLevel), string(domain.EventStatusActive),
params.Capacity, params.MinimumAge, preferredGender, string(params.LocationType),
).Scan(&id, &title, &privacyLevel, &status, &startTime, &endTime, &createdAt, &updatedAt)
if err != nil {
return nil, fmt.Errorf("insert event: %w", err)
}
event := &domain.Event{
ID: id,
HostID: params.HostID,
Title: title,
Description: new(params.Description),
ImageURL: params.ImageURL,
CategoryID: new(params.CategoryID),
StartTime: startTime,
PrivacyLevel: domain.EventPrivacyLevel(privacyLevel),
Status: domain.EventStatus(status),
Capacity: params.Capacity,
MinimumAge: params.MinimumAge,
PreferredGender: params.PreferredGender,
LocationType: new(params.LocationType),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
if endTime.Valid {
event.EndTime = &endTime.Time
}
return event, nil
}
// insertHostParticipation creates the host's internal APPROVED participation
// row so downstream authorization can treat the host as part of the event
// membership set without exposing them as a normal participant.
func insertHostParticipation(ctx context.Context, db execer, event *domain.Event) error {
if _, err := db.Exec(ctx, `
INSERT INTO participation (event_id, user_id, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
`, event.ID, event.HostID, domain.ParticipationStatusApproved, event.CreatedAt, event.UpdatedAt); err != nil {
return fmt.Errorf("insert host participation: %w", err)
}
return nil
}
// mapEventInsertError maps Postgres insert constraint violations on events to
// domain errors so clients get actionable 400/409 responses instead of 500.
func mapEventInsertError(err error) error {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return err
}
switch pgErr.Code {
case "23503":
if pgErr.ConstraintName == "fk_event_category" {
return domain.ValidationError(map[string]string{
"category_id": "must reference an existing event category id",
})
}
case "23505":
if pgErr.ConstraintName == "uq_event_host_title" {
return domain.ConflictError(
domain.ErrorCodeEventTitleExists,
"The host already has an event with this title.",
)
}
}
return err
}
// insertEventLocation inserts the PostGIS geography point for the event.
func insertEventLocation(
ctx context.Context,
db execer,
eventID uuid.UUID,
address *string,
locationType domain.EventLocationType,
point *domain.GeoPoint,
routePoints []domain.GeoPoint,
) error {
switch locationType {
case domain.LocationPoint:
if point == nil {
return fmt.Errorf("insert event_location: point geometry is required")
}
_, err := db.Exec(ctx, `
INSERT INTO event_location (event_id, address, geom)
VALUES ($1, $2, ST_SetSRID(ST_MakePoint($3, $4), 4326)::geography)
`, eventID, address, point.Lon, point.Lat)
if err != nil {
return fmt.Errorf("insert event_location: %w", err)
}
case domain.LocationRoute:
if len(routePoints) < domain.MinRoutePoints {
return fmt.Errorf("insert event_location: route geometry requires at least %d points", domain.MinRoutePoints)
}
_, err := db.Exec(ctx, `
INSERT INTO event_location (event_id, address, geom)
VALUES ($1, $2, ST_GeogFromText($3))
`, eventID, address, buildRouteWKT(routePoints))
if err != nil {
return fmt.Errorf("insert event_location: %w", err)
}
default:
return fmt.Errorf("insert event_location: unsupported location type %q", locationType)
}
return nil
}
// insertEventTags inserts each tag row for the event.
func insertEventTags(ctx context.Context, db execer, eventID uuid.UUID, tags []string) error {
for _, tag := range tags {
if _, err := db.Exec(ctx, `
INSERT INTO event_tag (event_id, name) VALUES ($1, $2)
`, eventID, tag); err != nil {
return fmt.Errorf("insert event_tag %q: %w", tag, err)
}
}
return nil
}
// insertEventConstraints inserts each constraint row for the event.
func insertEventConstraints(ctx context.Context, db execer, eventID uuid.UUID, constraints []eventapp.EventConstraintParams) error {
for _, c := range constraints {
if _, err := db.Exec(ctx, `
INSERT INTO event_constraint (event_id, constraint_type, constraint_info)
VALUES ($1, $2, $3)
`, eventID, c.Type, c.Info); err != nil {
return fmt.Errorf("insert event_constraint: %w", err)
}
}
return nil
}
func buildRouteWKT(points []domain.GeoPoint) string {
segments := make([]string, len(points))
for i, point := range points {
segments[i] = fmt.Sprintf("%f %f", point.Lon, point.Lat)
}
return "SRID=4326;LINESTRING(" + strings.Join(segments, ", ") + ")"
}
// ListDiscoverableEvents returns nearby ACTIVE (not IN_PROGRESS) PUBLIC/PROTECTED events using
// combined full-text search, structured filters, and keyset pagination.
func (r *EventRepository) ListDiscoverableEvents(
ctx context.Context,
userID uuid.UUID,
params eventapp.DiscoverEventsParams,
) ([]eventapp.DiscoverableEventRecord, error) {
args := make([]any, 0, 12)
addArg := func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
}
lonPlaceholder := addArg(params.Origin.Lon)
latPlaceholder := addArg(params.Origin.Lat)
userPlaceholder := addArg(userID)
// Discovery lists joinable upcoming events only (ACTIVE), not IN_PROGRESS.
statusPlaceholder := addArg([]string{string(domain.EventStatusActive)})
privacyPlaceholder := addArg(toPrivacyLevelStringSlice(params.PrivacyLevels))
radiusPlaceholder := addArg(params.RadiusMeters)
originExpr := fmt.Sprintf("ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography", lonPlaceholder, latPlaceholder)
searchVectorExpr := "COALESCE(e.search_vector, ''::tsvector)"
routeAnchorExpr := "ST_StartPoint(el.geom::geometry)::geography"
distanceSourceExpr := fmt.Sprintf(
"CASE WHEN e.location_type = '%s' THEN %s ELSE el.geom END",
domain.LocationRoute,
routeAnchorExpr,
)
distanceExpr := "0::double precision"
if params.SortBy == domain.EventDiscoverySortDistance || params.SortBy == domain.EventDiscoverySortRelevance {
distanceExpr = fmt.Sprintf("ST_Distance(%s, %s)", distanceSourceExpr, originExpr)
}
routeRadiusExpr := fmt.Sprintf(
`EXISTS (
SELECT 1
FROM ST_DumpPoints(el.geom::geometry) AS route_point
WHERE ST_DWithin(route_point.geom::geography, %s, %s)
)`,
originExpr,
radiusPlaceholder,
)
relevanceExpr := "NULL::double precision"
filters := []string{
fmt.Sprintf("e.status = ANY(%s::text[])", statusPlaceholder),
"(e.end_time IS NULL OR e.end_time > NOW())",
fmt.Sprintf("e.privacy_level = ANY(%s::text[])", privacyPlaceholder),
fmt.Sprintf(
"((e.location_type = '%s' AND %s) OR (e.location_type <> '%s' AND ST_DWithin(el.geom, %s, %s)))",
domain.LocationRoute,
routeRadiusExpr,
domain.LocationRoute,
originExpr,
radiusPlaceholder,
),
}
if params.SearchTSQuery != "" {
queryPlaceholder := addArg(params.SearchTSQuery)
filters = append(filters, fmt.Sprintf("%s @@ to_tsquery('simple', %s)", searchVectorExpr, queryPlaceholder))
relevanceExpr = fmt.Sprintf("ts_rank_cd(%s, to_tsquery('simple', %s))::double precision", searchVectorExpr, queryPlaceholder)
}
if len(params.CategoryIDs) > 0 {
filters = append(filters, fmt.Sprintf(
"e.category_id::bigint = ANY(%s::bigint[])",
addArg(toInt64Slice(params.CategoryIDs)),
))
}
if params.StartFrom != nil {
filters = append(filters, fmt.Sprintf("e.start_time >= %s", addArg(*params.StartFrom)))
}
if params.StartTo != nil {
filters = append(filters, fmt.Sprintf("e.start_time <= %s", addArg(*params.StartTo)))
}
if len(params.TagNames) > 0 {
filters = append(filters, fmt.Sprintf(
`EXISTS (
SELECT 1
FROM event_tag et
WHERE et.event_id = e.id
AND LOWER(et.name) = ANY(%s::text[])
)`,
addArg(params.TagNames),
))
}
if params.OnlyFavorited {
filters = append(filters, "fav.event_id IS NOT NULL")
}
paginationClause, orderByClause := buildDiscoverEventsPagination(params, addArg)
limitPlaceholder := addArg(params.RepositoryFetchLimit)
query := fmt.Sprintf(`
WITH base AS (
SELECT
e.id,
e.title,
COALESCE(ec.name, '') AS category_name,
e.image_url,
e.start_time,
e.status,
el.address AS location_address,
e.privacy_level,
e.approved_participant_count,
e.favorite_count,
(fav.event_id IS NOT NULL) AS is_favorited,
us.final_score AS host_final_score,
COALESCE(us.hosted_event_rating_count, 0) AS host_rating_count,
%s AS distance_meters,
%s AS relevance_score
FROM event e
JOIN event_location el ON el.event_id = e.id
LEFT JOIN event_category ec ON ec.id = e.category_id
LEFT JOIN favorite_event fav ON fav.event_id = e.id AND fav.user_id = %s
LEFT JOIN user_score us ON us.user_id = e.host_id
WHERE %s
)
SELECT
id,
title,
category_name,
image_url,
start_time,
status,
location_address,
privacy_level,
approved_participant_count,
favorite_count,
is_favorited,
host_final_score,
host_rating_count,
distance_meters,
relevance_score
FROM base
%s
ORDER BY %s
LIMIT %s
`, distanceExpr, relevanceExpr, userPlaceholder, strings.Join(filters, "\n AND "), paginationClause, orderByClause, limitPlaceholder)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list discoverable events: %w", err)
}
defer rows.Close()
records := make([]eventapp.DiscoverableEventRecord, 0, params.RepositoryFetchLimit)
for rows.Next() {
var (
id uuid.UUID
title string
categoryName string
imageURL pgtype.Text
startTime time.Time
eventStatus string
locationAddress pgtype.Text
privacyLevel string
approvedParticipantCount int
favoriteCount int
isFavorited bool
hostFinalScore pgtype.Float8
hostRatingCount int
distanceMeters float64
relevanceScore pgtype.Float8
)
if err := rows.Scan(
&id,
&title,
&categoryName,
&imageURL,
&startTime,
&eventStatus,
&locationAddress,
&privacyLevel,
&approvedParticipantCount,
&favoriteCount,
&isFavorited,
&hostFinalScore,
&hostRatingCount,
&distanceMeters,
&relevanceScore,
); err != nil {
return nil, fmt.Errorf("scan discoverable event: %w", err)
}
record := eventapp.DiscoverableEventRecord{
ID: id,
Title: title,
CategoryName: categoryName,
StartTime: startTime,
Status: domain.EventStatus(eventStatus),
PrivacyLevel: domain.EventPrivacyLevel(privacyLevel),
ApprovedParticipantCount: approvedParticipantCount,
FavoriteCount: favoriteCount,
IsFavorited: isFavorited,
HostScore: eventapp.EventHostScoreSummaryRecord{
HostedEventRatingCount: hostRatingCount,
},
DistanceMeters: distanceMeters,
}
if imageURL.Valid {
record.ImageURL = &imageURL.String
}
if locationAddress.Valid {
record.LocationAddress = &locationAddress.String
}
if relevanceScore.Valid {
record.RelevanceScore = &relevanceScore.Float64
}
if hostFinalScore.Valid {
record.HostScore.FinalScore = &hostFinalScore.Float64
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate discoverable events: %w", err)
}
return records, nil
}
func buildDiscoverEventsPagination(
params eventapp.DiscoverEventsParams,
addArg func(value any) string,
) (string, string) {
switch params.SortBy {
case domain.EventDiscoverySortDistance:
if params.DecodedCursor == nil {
return "", "base.distance_meters ASC, base.start_time ASC, base.id ASC"
}
return fmt.Sprintf(
"WHERE (base.distance_meters, base.start_time, base.id) > (%s, %s, %s)",
addArg(*params.DecodedCursor.DistanceMeters),
addArg(params.DecodedCursor.StartTime),
addArg(params.DecodedCursor.EventID),
),
"base.distance_meters ASC, base.start_time ASC, base.id ASC"
case domain.EventDiscoverySortRelevance:
if params.DecodedCursor == nil {
return "", "base.relevance_score DESC, base.distance_meters ASC, base.start_time ASC, base.id ASC"
}
return fmt.Sprintf(
"WHERE ((base.relevance_score * -1), base.distance_meters, base.start_time, base.id) > (%s, %s, %s, %s)",
addArg(-*params.DecodedCursor.RelevanceScore),
addArg(*params.DecodedCursor.DistanceMeters),
addArg(params.DecodedCursor.StartTime),
addArg(params.DecodedCursor.EventID),
),
"base.relevance_score DESC, base.distance_meters ASC, base.start_time ASC, base.id ASC"
default:
if params.DecodedCursor == nil {
return "", "base.start_time ASC, base.id ASC"
}
return fmt.Sprintf(
"WHERE (base.start_time, base.id) > (%s, %s)",
addArg(params.DecodedCursor.StartTime),
addArg(params.DecodedCursor.EventID),
),
"base.start_time ASC, base.id ASC"
}
}
func buildEventCollectionCursorClause(
params eventapp.EventCollectionPageParams,
args *[]any,
createdAtExpr, idExpr string,
) string {
if params.DecodedCursor == nil {
return ""
}
*args = append(*args, params.DecodedCursor.CreatedAt, params.DecodedCursor.EntityID)
createdAtArgPosition := len(*args) - 1
idArgPosition := len(*args)
return fmt.Sprintf(
" AND (%s, %s) > ($%d, $%d)",
createdAtExpr,
idExpr,
createdAtArgPosition,
idArgPosition,
)
}
func toInt64Slice(values []int) []int64 {
converted := make([]int64, len(values))
for i, value := range values {
converted[i] = int64(value)
}
return converted
}
func toPrivacyLevelStringSlice(values []domain.EventPrivacyLevel) []string {
converted := make([]string, len(values))
for i, value := range values {
converted[i] = string(value)
}
return converted
}
// GetEventDetail loads the full detail projection for an event if the
// authenticated user is allowed to read it.
func (r *EventRepository) GetEventDetail(
ctx context.Context,
userID, eventID uuid.UUID,
) (*eventapp.EventDetailRecord, error) {
record, err := r.loadEventDetailCore(ctx, userID, eventID)
if err != nil {
return nil, err
}
groupCtx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
var (
location eventapp.EventDetailLocationRecord
tags []string
constraints []eventapp.EventDetailConstraintRecord
viewerEventRating *eventapp.EventDetailRatingRecord
wg sync.WaitGroup
firstErr error
errOnce sync.Once
)
runConcurrentLoad := func(load func(context.Context) error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := load(groupCtx); err != nil {
errOnce.Do(func() {
firstErr = err
cancel(err)
})
}
}()
}
runConcurrentLoad(func(ctx context.Context) error {
loadedLocation, err := r.loadEventDetailLocation(groupCtx, eventID, record.Location.Type)
if err != nil {
return err
}
location = loadedLocation
return nil
})
runConcurrentLoad(func(ctx context.Context) error {
loadedTags, err := r.loadEventTags(groupCtx, eventID)
if err != nil {
return err
}
tags = loadedTags
return nil
})
runConcurrentLoad(func(ctx context.Context) error {
loadedConstraints, err := r.loadEventConstraints(groupCtx, eventID)
if err != nil {
return err
}
constraints = loadedConstraints
return nil
})
runConcurrentLoad(func(ctx context.Context) error {
loadedViewerEventRating, err := r.loadViewerEventRating(groupCtx, eventID, userID)
if err != nil {
return err
}
viewerEventRating = loadedViewerEventRating
return nil
})
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
location.Address = record.Location.Address
record.Location = location
record.Tags = tags
record.Constraints = constraints
record.ViewerEventRating = viewerEventRating
return record, nil
}
// GetEventHostContextSummary returns host-only management counters.
func (r *EventRepository) GetEventHostContextSummary(
ctx context.Context,
eventID uuid.UUID,
) (*eventapp.EventHostContextSummaryRecord, error) {
var record eventapp.EventHostContextSummaryRecord
if err := r.pool.QueryRow(ctx, `
SELECT
(
SELECT COUNT(*)
FROM participation p
JOIN event e ON e.id = p.event_id
WHERE p.event_id = $1
AND p.status = $2
AND p.user_id <> e.host_id
) AS approved_participant_count,
(
SELECT COUNT(*)
FROM join_request jr
WHERE jr.event_id = $1
AND jr.status = $3
) AS pending_join_request_count,
(
SELECT COUNT(*)
FROM invitation inv
WHERE inv.event_id = $1
) AS invitation_count
`,
eventID,
domain.ParticipationStatusApproved,
string(domain.JoinRequestStatusPending),
).Scan(
&record.ApprovedParticipantCount,
&record.PendingJoinRequestCount,
&record.InvitationCount,
); err != nil {
return nil, fmt.Errorf("get event host context summary: %w", err)
}
return &record, nil
}
// ListEventApprovedParticipants returns a paginated approved-participant collection.
func (r *EventRepository) ListEventApprovedParticipants(
ctx context.Context,
eventID uuid.UUID,
params eventapp.EventCollectionPageParams,
) ([]eventapp.EventDetailApprovedParticipantRecord, error) {
return r.loadApprovedParticipants(ctx, eventID, params)
}
// ListEventPendingJoinRequests returns a paginated pending join-request collection.
func (r *EventRepository) ListEventPendingJoinRequests(
ctx context.Context,
eventID uuid.UUID,
params eventapp.EventCollectionPageParams,
) ([]eventapp.EventDetailPendingJoinRequestRecord, error) {
return r.loadPendingJoinRequests(ctx, eventID, params)
}
// ListEventInvitations returns a paginated invitation collection.
func (r *EventRepository) ListEventInvitations(
ctx context.Context,
eventID uuid.UUID,
params eventapp.EventCollectionPageParams,
) ([]eventapp.EventDetailInvitationRecord, error) {
return r.loadInvitations(ctx, eventID, params)
}
func (r *EventRepository) loadEventDetailCore(
ctx context.Context,
userID, eventID uuid.UUID,
) (*eventapp.EventDetailRecord, error) {
var (
id uuid.UUID
title string
description pgtype.Text
imageURL pgtype.Text
privacyLevel string
status string
startTime time.Time
endTime pgtype.Timestamptz
capacity pgtype.Int4
minimumAge pgtype.Int4
preferredGender pgtype.Text
approvedParticipantCount int
pendingParticipantCount int
favoriteCount int
createdAt time.Time
updatedAt time.Time
categoryID pgtype.Int4
categoryName pgtype.Text
hostID uuid.UUID
hostUsername string
hostDisplayName pgtype.Text
hostAvatarURL pgtype.Text
hostFinalScore pgtype.Float8
hostRatingCount int
locationType string
locationAddress pgtype.Text
isHost bool
isFavorited bool
participationStatus string
)
err := r.db.QueryRow(ctx, `
SELECT
e.id,
e.title,
e.description,
e.image_url,
e.privacy_level,
CASE
WHEN e.status = 'ACTIVE' AND e.end_time < NOW() THEN 'COMPLETED'
WHEN e.status = 'ACTIVE' AND e.start_time < NOW() THEN 'IN_PROGRESS'
WHEN e.status = 'IN_PROGRESS' AND e.end_time < NOW() THEN 'COMPLETED'
ELSE e.status
END AS status,
e.start_time,
e.end_time,
e.capacity,
e.minimum_age,
e.preferred_gender,
e.approved_participant_count,
e.pending_participant_count,
e.favorite_count,
e.created_at,
e.updated_at,
ec.id,
ec.name,
host.id,
host.username,
hp.display_name,
hp.avatar_url,
us.final_score,
COALESCE(us.hosted_event_rating_count, 0),
e.location_type,
el.address,
(e.host_id = $2) AS is_host,
EXISTS (
SELECT 1
FROM favorite_event fav
WHERE fav.event_id = e.id
AND fav.user_id = $2
) AS is_favorited,
CASE
WHEN e.host_id = $2 THEN $3
WHEN EXISTS (
SELECT 1
FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $2
AND p.status = $4
) THEN $5
WHEN EXISTS (
SELECT 1
FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $2
AND p.status = $6
) THEN $7
WHEN EXISTS (
SELECT 1
FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $2
AND p.status = $14
) THEN $15
WHEN EXISTS (
SELECT 1
FROM join_request jr
WHERE jr.event_id = e.id
AND jr.user_id = $2
AND jr.status = $8
) THEN $9
WHEN EXISTS (
SELECT 1
FROM invitation inv
WHERE inv.event_id = e.id
AND inv.invited_user_id = $2
) THEN $10
ELSE $3
END AS participation_status
FROM event e
JOIN event_location el ON el.event_id = e.id
LEFT JOIN event_category ec ON ec.id = e.category_id
JOIN app_user host ON host.id = e.host_id
LEFT JOIN profile hp ON hp.user_id = host.id
LEFT JOIN user_score us ON us.user_id = host.id
WHERE e.id = $1
AND (
e.privacy_level IN ($11, $12)
OR e.host_id = $2
OR EXISTS (
SELECT 1
FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $2
AND p.status IN ($4, $6, $14)
)
OR EXISTS (
SELECT 1
FROM invitation inv
WHERE inv.event_id = e.id
AND inv.invited_user_id = $2
AND inv.status = $13
)
)
`,
eventID,
userID,
string(domain.EventDetailParticipationStatusNone),
domain.ParticipationStatusApproved,
string(domain.EventDetailParticipationStatusJoined),
domain.ParticipationStatusLeaved,
string(domain.EventDetailParticipationStatusLeaved),
string(domain.JoinRequestStatusPending),
string(domain.EventDetailParticipationStatusPending),
string(domain.EventDetailParticipationStatusInvited),
string(domain.PrivacyPublic),
string(domain.PrivacyProtected),
string(domain.InvitationStatusAccepted),
domain.ParticipationStatusCanceled,
string(domain.EventDetailParticipationStatusCanceled),
).Scan(
&id,
&title,
&description,
&imageURL,
&privacyLevel,
&status,
&startTime,
&endTime,
&capacity,
&minimumAge,
&preferredGender,
&approvedParticipantCount,
&pendingParticipantCount,
&favoriteCount,
&createdAt,
&updatedAt,
&categoryID,
&categoryName,
&hostID,
&hostUsername,
&hostDisplayName,
&hostAvatarURL,
&hostFinalScore,
&hostRatingCount,
&locationType,
&locationAddress,
&isHost,
&isFavorited,
&participationStatus,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event detail: %w", err)
}
record := &eventapp.EventDetailRecord{
ID: id,
Title: title,
PrivacyLevel: domain.EventPrivacyLevel(privacyLevel),
Status: domain.EventStatus(status),
StartTime: startTime,
ApprovedParticipantCount: approvedParticipantCount,
PendingParticipantCount: pendingParticipantCount,
FavoriteCount: favoriteCount,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Host: eventapp.EventDetailPersonRecord{
ID: hostID,
Username: hostUsername,
},
HostScore: eventapp.EventHostScoreSummaryRecord{
HostedEventRatingCount: hostRatingCount,
},
Location: eventapp.EventDetailLocationRecord{
Type: domain.EventLocationType(locationType),
},
ViewerContext: eventapp.EventDetailViewerContextRecord{
IsHost: isHost,
IsFavorited: isFavorited,
ParticipationStatus: domain.EventDetailParticipationStatus(participationStatus),
},
Tags: make([]string, 0),
Constraints: make([]eventapp.EventDetailConstraintRecord, 0),
}
if description.Valid {
record.Description = &description.String
}
if imageURL.Valid {
record.ImageURL = &imageURL.String
}
if endTime.Valid {
record.EndTime = &endTime.Time
}
if capacity.Valid {
record.Capacity = new(int)
*record.Capacity = int(capacity.Int32)
}
if minimumAge.Valid {
record.MinimumAge = new(int)
*record.MinimumAge = int(minimumAge.Int32)
}
if preferredGender.Valid {
gender := domain.EventParticipantGender(preferredGender.String)
record.PreferredGender = &gender
}
if categoryID.Valid {
record.Category = &eventapp.EventDetailCategoryRecord{
ID: int(categoryID.Int32),
}
if categoryName.Valid {
record.Category.Name = categoryName.String
}
}
if hostDisplayName.Valid {
record.Host.DisplayName = &hostDisplayName.String
}
if hostAvatarURL.Valid {
record.Host.AvatarURL = &hostAvatarURL.String
}
if hostFinalScore.Valid {
record.HostScore.FinalScore = &hostFinalScore.Float64
}
if locationAddress.Valid {
record.Location.Address = &locationAddress.String
}
return record, nil
}
func (r *EventRepository) loadEventDetailLocation(
ctx context.Context,
eventID uuid.UUID,
locationType domain.EventLocationType,
) (eventapp.EventDetailLocationRecord, error) {
location := eventapp.EventDetailLocationRecord{
Type: locationType,
RoutePoints: make([]domain.GeoPoint, 0),
}
switch locationType {
case domain.LocationPoint:
var point domain.GeoPoint
err := r.pool.QueryRow(ctx, `
SELECT
ST_Y(el.geom::geometry) AS lat,
ST_X(el.geom::geometry) AS lon
FROM event_location el
WHERE el.event_id = $1
`, eventID).Scan(&point.Lat, &point.Lon)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return location, domain.ErrNotFound
}
return location, fmt.Errorf("load event detail point: %w", err)
}
location.Point = &point
case domain.LocationRoute:
rows, err := r.pool.Query(ctx, `
SELECT
ST_Y(dp.geom) AS lat,
ST_X(dp.geom) AS lon
FROM event_location el
CROSS JOIN LATERAL ST_DumpPoints(el.geom::geometry) AS dp
WHERE el.event_id = $1
ORDER BY dp.path
`, eventID)
if err != nil {
return location, fmt.Errorf("load event detail route points: %w", err)
}
defer rows.Close()
for rows.Next() {
var point domain.GeoPoint
if err := rows.Scan(&point.Lat, &point.Lon); err != nil {
return location, fmt.Errorf("scan event detail route point: %w", err)
}
location.RoutePoints = append(location.RoutePoints, point)
}
if err := rows.Err(); err != nil {
return location, fmt.Errorf("iterate event detail route points: %w", err)
}
default:
return location, nil
}
return location, nil
}
func (r *EventRepository) loadEventTags(ctx context.Context, eventID uuid.UUID) ([]string, error) {
rows, err := r.pool.Query(ctx, `
SELECT name
FROM event_tag
WHERE event_id = $1
ORDER BY name ASC
`, eventID)
if err != nil {
return nil, fmt.Errorf("load event tags: %w", err)
}
defer rows.Close()
tags := make([]string, 0)
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, fmt.Errorf("scan event tag: %w", err)
}
tags = append(tags, tag)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate event tags: %w", err)
}
return tags, nil
}
func (r *EventRepository) loadEventConstraints(
ctx context.Context,
eventID uuid.UUID,
) ([]eventapp.EventDetailConstraintRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT constraint_type, constraint_info
FROM event_constraint
WHERE event_id = $1
ORDER BY created_at ASC, id ASC
`, eventID)
if err != nil {
return nil, fmt.Errorf("load event constraints: %w", err)
}
defer rows.Close()
constraints := make([]eventapp.EventDetailConstraintRecord, 0)
for rows.Next() {
var (
constraintType string
constraintInfo pgtype.Text
)
if err := rows.Scan(&constraintType, &constraintInfo); err != nil {
return nil, fmt.Errorf("scan event constraint: %w", err)
}
constraint := eventapp.EventDetailConstraintRecord{Type: constraintType}
if constraintInfo.Valid {
constraint.Info = constraintInfo.String
}
constraints = append(constraints, constraint)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate event constraints: %w", err)
}
return constraints, nil
}
func (r *EventRepository) loadViewerEventRating(
ctx context.Context,
eventID, participantUserID uuid.UUID,
) (*eventapp.EventDetailRatingRecord, error) {
var (
record eventapp.EventDetailRatingRecord
message pgtype.Text
)
err := r.pool.QueryRow(ctx, `
SELECT id, rating, message, created_at, updated_at
FROM event_rating
WHERE event_id = $1
AND participant_user_id = $2
`, eventID, participantUserID).Scan(
&record.ID,
&record.Rating,
&message,
&record.CreatedAt,
&record.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("load viewer event rating: %w", err)
}
record.Message = textPtr(message)
return &record, nil
}
func (r *EventRepository) loadApprovedParticipants(
ctx context.Context,
eventID uuid.UUID,
params eventapp.EventCollectionPageParams,
) ([]eventapp.EventDetailApprovedParticipantRecord, error) {
args := []any{eventID, domain.ParticipationStatusApproved}
cursorClause := buildEventCollectionCursorClause(params, &args, "p.created_at", "p.id")
args = append(args, params.RepositoryFetchLimit)
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT
p.id,
p.status,
p.created_at,
p.updated_at,
u.id,
u.username,
pr.display_name,
pr.avatar_url,
us.final_score,
COALESCE(us.participant_rating_count, 0) + COALESCE(us.hosted_event_rating_count, 0) AS rating_count,
prt.id,
prt.rating,
prt.message,
prt.created_at,
prt.updated_at
FROM participation p
JOIN event e ON e.id = p.event_id
JOIN app_user u ON u.id = p.user_id
LEFT JOIN profile pr ON pr.user_id = u.id
LEFT JOIN user_score us ON us.user_id = u.id
LEFT JOIN participant_rating prt
ON prt.event_id = p.event_id
AND prt.host_user_id = e.host_id
AND prt.participant_user_id = p.user_id
WHERE p.event_id = $1
AND p.status = $2
AND p.user_id <> e.host_id
%s
ORDER BY p.created_at ASC, p.id ASC
LIMIT $%d
`, cursorClause, len(args)), args...)
if err != nil {
return nil, fmt.Errorf("load approved participants: %w", err)
}
defer rows.Close()
participants := make([]eventapp.EventDetailApprovedParticipantRecord, 0)
for rows.Next() {
var (
participationID uuid.UUID
status string
createdAt time.Time
updatedAt time.Time
userID uuid.UUID
username string
displayName pgtype.Text
avatarURL pgtype.Text
userFinalScore pgtype.Float8
userRatingCount int
hostRatingID pgtype.UUID
hostRatingValue pgtype.Int4
hostMessage pgtype.Text
hostCreatedAt pgtype.Timestamptz
hostUpdatedAt pgtype.Timestamptz
)
if err := rows.Scan(
&participationID,
&status,
&createdAt,
&updatedAt,
&userID,
&username,
&displayName,
&avatarURL,
&userFinalScore,
&userRatingCount,
&hostRatingID,
&hostRatingValue,
&hostMessage,
&hostCreatedAt,
&hostUpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan approved participant: %w", err)
}
participationStatus, ok := domain.ParseParticipationStatus(status)
if !ok {
return nil, fmt.Errorf("scan approved participant: unknown participation status %q", status)
}
participant := eventapp.EventDetailApprovedParticipantRecord{
ParticipationID: participationID,
Status: participationStatus,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
User: eventapp.EventDetailHostContextUserRecord{
ID: userID,
Username: username,
RatingCount: userRatingCount,
},
}
if displayName.Valid {
participant.User.DisplayName = &displayName.String
}
if avatarURL.Valid {
participant.User.AvatarURL = &avatarURL.String
}
if userFinalScore.Valid {
participant.User.FinalScore = &userFinalScore.Float64
}
if hostRatingID.Valid && hostRatingValue.Valid && hostCreatedAt.Valid && hostUpdatedAt.Valid {
participant.HostRating = &eventapp.EventDetailRatingRecord{
ID: uuid.UUID(hostRatingID.Bytes),
Rating: int(hostRatingValue.Int32),
Message: textPtr(hostMessage),
CreatedAt: hostCreatedAt.Time,
UpdatedAt: hostUpdatedAt.Time,
}
}
participants = append(participants, participant)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate approved participants: %w", err)
}
return participants, nil
}
func (r *EventRepository) loadPendingJoinRequests(
ctx context.Context,
eventID uuid.UUID,
params eventapp.EventCollectionPageParams,
) ([]eventapp.EventDetailPendingJoinRequestRecord, error) {
args := []any{eventID, string(domain.JoinRequestStatusPending)}
cursorClause := buildEventCollectionCursorClause(params, &args, "jr.created_at", "jr.id")
args = append(args, params.RepositoryFetchLimit)
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT
jr.id,
jr.status,
jr.message,
jr.created_at,
jr.updated_at,
u.id,
u.username,
pr.display_name,
pr.avatar_url,
us.final_score,
COALESCE(us.participant_rating_count, 0) + COALESCE(us.hosted_event_rating_count, 0) AS rating_count
FROM join_request jr
JOIN app_user u ON u.id = jr.user_id
LEFT JOIN profile pr ON pr.user_id = u.id
LEFT JOIN user_score us ON us.user_id = u.id
WHERE jr.event_id = $1
AND jr.status = $2
%s
ORDER BY jr.created_at ASC, jr.id ASC
LIMIT $%d
`, cursorClause, len(args)), args...)
if err != nil {
return nil, fmt.Errorf("load pending join requests: %w", err)
}
defer rows.Close()
requests := make([]eventapp.EventDetailPendingJoinRequestRecord, 0)
for rows.Next() {
var (
joinRequestID uuid.UUID
status string
message pgtype.Text
createdAt time.Time
updatedAt time.Time
userID uuid.UUID
username string
displayName pgtype.Text
avatarURL pgtype.Text
finalScore pgtype.Float8
ratingCount int
)
if err := rows.Scan(
&joinRequestID,
&status,
&message,
&createdAt,
&updatedAt,
&userID,
&username,
&displayName,
&avatarURL,
&finalScore,
&ratingCount,
); err != nil {
return nil, fmt.Errorf("scan pending join request: %w", err)
}
request := eventapp.EventDetailPendingJoinRequestRecord{
JoinRequestID: joinRequestID,
Status: status,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
User: eventapp.EventDetailHostContextUserRecord{
ID: userID,
Username: username,
RatingCount: ratingCount,
},
}
if message.Valid {
request.Message = &message.String
}
if displayName.Valid {
request.User.DisplayName = &displayName.String
}
if avatarURL.Valid {
request.User.AvatarURL = &avatarURL.String
}
if finalScore.Valid {
request.User.FinalScore = &finalScore.Float64
}
requests = append(requests, request)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending join requests: %w", err)
}
return requests, nil
}
func (r *EventRepository) loadInvitations(
ctx context.Context,
eventID uuid.UUID,
params eventapp.EventCollectionPageParams,
) ([]eventapp.EventDetailInvitationRecord, error) {
args := []any{eventID}
cursorClause := buildEventCollectionCursorClause(params, &args, "inv.created_at", "inv.id")
args = append(args, params.RepositoryFetchLimit)
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT
inv.id,
inv.status,
inv.message,
inv.expires_at,
inv.created_at,
inv.updated_at,
u.id,
u.username,
pr.display_name,
pr.avatar_url,
us.final_score,
COALESCE(us.participant_rating_count, 0) + COALESCE(us.hosted_event_rating_count, 0) AS rating_count
FROM invitation inv
JOIN app_user u ON u.id = inv.invited_user_id
LEFT JOIN profile pr ON pr.user_id = u.id
LEFT JOIN user_score us ON us.user_id = u.id
WHERE inv.event_id = $1
%s
ORDER BY inv.created_at ASC, inv.id ASC
LIMIT $%d
`, cursorClause, len(args)), args...)
if err != nil {
return nil, fmt.Errorf("load invitations: %w", err)
}
defer rows.Close()
invitations := make([]eventapp.EventDetailInvitationRecord, 0)
for rows.Next() {
var (
invitationID uuid.UUID
status string
message pgtype.Text
expiresAt pgtype.Timestamptz
createdAt time.Time
updatedAt time.Time
userID uuid.UUID
username string
displayName pgtype.Text
avatarURL pgtype.Text
finalScore pgtype.Float8
ratingCount int
)
if err := rows.Scan(
&invitationID,
&status,
&message,
&expiresAt,
&createdAt,
&updatedAt,
&userID,
&username,
&displayName,
&avatarURL,
&finalScore,
&ratingCount,
); err != nil {
return nil, fmt.Errorf("scan invitation: %w", err)
}
invitation := eventapp.EventDetailInvitationRecord{
InvitationID: invitationID,
Status: domain.InvitationStatus(status),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
User: eventapp.EventDetailHostContextUserRecord{
ID: userID,
Username: username,
RatingCount: ratingCount,
},
}
if message.Valid {
invitation.Message = &message.String
}
if expiresAt.Valid {
invitation.ExpiresAt = &expiresAt.Time
}
if displayName.Valid {
invitation.User.DisplayName = &displayName.String
}
if avatarURL.Valid {
invitation.User.AvatarURL = &avatarURL.String
}
if finalScore.Valid {
invitation.User.FinalScore = &finalScore.Float64
}
invitations = append(invitations, invitation)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate invitations: %w", err)
}
return invitations, nil
}
// GetEventByID fetches a single event row by its primary key.
// Returns domain.ErrNotFound when no matching row exists.
func (r *EventRepository) GetEventByID(ctx context.Context, eventID uuid.UUID) (*domain.Event, error) {
var (
id uuid.UUID
hostID uuid.UUID
title string
description pgtype.Text
imageURL pgtype.Text
categoryID pgtype.Int4
startTime time.Time
endTime pgtype.Timestamptz
privacyLevel string
status string
capacity pgtype.Int4
approvedCount int
minimumAge pgtype.Int4
preferredGender pgtype.Text
locationType pgtype.Text
createdAt time.Time
updatedAt time.Time
)
err := r.pool.QueryRow(ctx, `
SELECT id, host_id, title, description, image_url, category_id,
start_time, end_time, privacy_level, status, capacity,
approved_participant_count, minimum_age, preferred_gender,
location_type, created_at, updated_at
FROM event
WHERE id = $1
`, eventID).Scan(
&id, &hostID, &title, &description, &imageURL, &categoryID,
&startTime, &endTime, &privacyLevel, &status, &capacity,
&approvedCount, &minimumAge, &preferredGender,
&locationType, &createdAt, &updatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event by id: %w", err)
}
event := &domain.Event{
ID: id,
HostID: hostID,
Title: title,
PrivacyLevel: domain.EventPrivacyLevel(privacyLevel),
Status: domain.EventStatus(status),
StartTime: startTime,
ApprovedParticipantCount: approvedCount,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
if endTime.Valid {
event.EndTime = &endTime.Time
}
if description.Valid {
event.Description = &description.String
}
if imageURL.Valid {
event.ImageURL = &imageURL.String
}
if categoryID.Valid {
event.CategoryID = new(int(categoryID.Int32))
}
if capacity.Valid {
event.Capacity = new(int(capacity.Int32))
}
if minimumAge.Valid {
event.MinimumAge = new(int(minimumAge.Int32))
}
if preferredGender.Valid {
event.PreferredGender = new(domain.EventParticipantGender(preferredGender.String))
}
if locationType.Valid {
event.LocationType = new(domain.EventLocationType(locationType.String))
}
return event, nil
}
// GetEventImageState returns the event host and current image version for direct uploads.
func (r *EventRepository) GetEventImageState(ctx context.Context, eventID uuid.UUID) (*imageuploadapp.EventImageState, error) {
var state imageuploadapp.EventImageState
err := r.pool.QueryRow(ctx, `
SELECT id, host_id, image_version
FROM event
WHERE id = $1
`, eventID).Scan(&state.EventID, &state.HostID, &state.CurrentVersion)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event image state: %w", err)
}
return &state, nil
}
// SetEventImageIfVersion updates the event image URL only if the current version matches expectedVersion.
func (r *EventRepository) SetEventImageIfVersion(
ctx context.Context,
eventID uuid.UUID,
expectedVersion, nextVersion int,
baseURL string,
updatedAt time.Time,
) (bool, error) {
tag, err := r.pool.Exec(ctx, `
UPDATE event
SET image_url = $2,
image_version = $3,
updated_at = $4
WHERE id = $1
AND image_version = $5
`, eventID, baseURL, nextVersion, updatedAt, expectedVersion)
if err != nil {
return false, fmt.Errorf("set event image: %w", err)
}
return tag.RowsAffected() == 1, nil
}
var _ imageuploadapp.EventRepository = (*EventRepository)(nil)
// CancelEvent sets the event status to CANCELED and transitions active
// participations to CANCELED atomically while preserving historical LEAVED rows.
// Returns ErrEventNotCancelable if the event is not in ACTIVE status.
func (r *EventRepository) CancelEvent(ctx context.Context, eventID uuid.UUID, canceledApprovedParticipantCount int) error {
tag, err := r.db.Exec(ctx, `
UPDATE event
SET status = 'CANCELED',
canceled_approved_participant_count = $2
WHERE id = $1 AND status = 'ACTIVE'
`, eventID, canceledApprovedParticipantCount)
if err != nil {
return fmt.Errorf("cancel event: %w", err)
}
if tag.RowsAffected() == 0 {
return eventapp.ErrEventNotCancelable
}
return nil
}
// CompleteEvent sets the event status to COMPLETED when it is ACTIVE or IN_PROGRESS.
// Returns ErrEventNotCompletable when the event is CANCELED, COMPLETED, or any other non-completable status.
func (r *EventRepository) CompleteEvent(ctx context.Context, eventID uuid.UUID) error {
tag, err := r.pool.Exec(ctx, `
UPDATE event
SET status = 'COMPLETED', updated_at = NOW()
WHERE id = $1 AND status IN ('ACTIVE', 'IN_PROGRESS')
`, eventID)
if err != nil {
return fmt.Errorf("complete event: %w", err)
}
if tag.RowsAffected() == 0 {
return eventapp.ErrEventNotCompletable
}
return nil
}
// TransitionEventStatuses moves ACTIVE events to IN_PROGRESS when their
// start_time has passed, and transitions ACTIVE/IN_PROGRESS events to COMPLETED
// when any of the following conditions apply:
// - end_time has passed
// - start_time is older than 60 days (max duration, unconditional)
// - start_time is older than 30 days AND updated_at is older than 7 days (stale/zombie)
//
// updated_at reflects event-level mutations (title, description, cancel, etc.).
// Participation activity does not advance updated_at.
func (r *EventRepository) TransitionEventStatuses(ctx context.Context) error {
_, err := r.pool.Exec(ctx, `
UPDATE event
SET status = CASE
WHEN end_time IS NOT NULL AND end_time < NOW() THEN 'COMPLETED'
WHEN start_time < NOW() - INTERVAL '60 days' THEN 'COMPLETED'
WHEN start_time < NOW() - INTERVAL '30 days' AND updated_at < NOW() - INTERVAL '7 days' THEN 'COMPLETED'
ELSE 'IN_PROGRESS'
END
WHERE status IN ('ACTIVE', 'IN_PROGRESS')
AND (
start_time < NOW()
OR (end_time IS NOT NULL AND end_time < NOW())
)
`)
return err
}
// AddFavorite inserts a row into favorite_event. If the row already exists the
// operation is silently ignored (idempotent).
func (r *EventRepository) AddFavorite(ctx context.Context, userID, eventID uuid.UUID) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO favorite_event (user_id, event_id)
VALUES ($1, $2)
ON CONFLICT (user_id, event_id) DO NOTHING
`, userID, eventID)
if err != nil {
return fmt.Errorf("add favorite: %w", err)
}
return nil
}
// RemoveFavorite deletes a row from favorite_event. If no row exists the
// operation is silently ignored (idempotent).
func (r *EventRepository) RemoveFavorite(ctx context.Context, userID, eventID uuid.UUID) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM favorite_event
WHERE user_id = $1 AND event_id = $2
`, userID, eventID)
if err != nil {
return fmt.Errorf("remove favorite: %w", err)
}
return nil
}
// ListFavoriteEvents returns all events the user has favorited, ordered by most
// recently favorited first.
func (r *EventRepository) ListFavoriteEvents(ctx context.Context, userID uuid.UUID) ([]eventapp.FavoriteEventRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT e.id, e.title, ec.name, e.image_url, e.status,
e.privacy_level, el.address, e.start_time, e.end_time, fav.created_at
FROM favorite_event fav
JOIN event e ON e.id = fav.event_id
JOIN event_location el ON el.event_id = e.id
LEFT JOIN event_category ec ON ec.id = e.category_id
WHERE fav.user_id = $1
ORDER BY fav.created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("list favorite events: %w", err)
}
defer rows.Close()
var records []eventapp.FavoriteEventRecord
for rows.Next() {
var (
r eventapp.FavoriteEventRecord
status string
privacyLevel string
catName *string
locationAddress pgtype.Text
endTime pgtype.Timestamptz
)
if err := rows.Scan(&r.ID, &r.Title, &catName, &r.ImageURL, &status,
&privacyLevel, &locationAddress, &r.StartTime, &endTime, &r.FavoritedAt); err != nil {
return nil, fmt.Errorf("scan favorite event: %w", err)
}
r.Status = domain.EventStatus(status)
r.PrivacyLevel = domain.EventPrivacyLevel(privacyLevel)
r.CategoryName = catName
if locationAddress.Valid {
r.LocationAddress = &locationAddress.String
}
if endTime.Valid {
r.EndTime = &endTime.Time
}
records = append(records, r)
}
return records, rows.Err()
}
package postgres
import (
"context"
"errors"
"fmt"
favoritelocationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/favorite_location"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
// FavoriteLocationRepository is the Postgres-backed implementation of favorite_location.Repository.
type FavoriteLocationRepository struct {
pool *pgxpool.Pool
db execer
}
// NewFavoriteLocationRepository returns a repository that executes queries against the given connection pool.
func NewFavoriteLocationRepository(pool *pgxpool.Pool) *FavoriteLocationRepository {
return &FavoriteLocationRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
// ListByUserID returns all favorite locations owned by the given user ordered alphabetically by name.
func (r *FavoriteLocationRepository) ListByUserID(ctx context.Context, userID uuid.UUID) ([]domain.FavoriteLocation, error) {
rows, err := r.db.Query(ctx, `
SELECT
id,
user_id,
name,
address,
ST_Y(point::geometry) AS lat,
ST_X(point::geometry) AS lon,
created_at,
updated_at
FROM favorite_location
WHERE user_id = $1
ORDER BY LOWER(name) ASC, name ASC, id ASC
`, userID)
if err != nil {
return nil, fmt.Errorf("list favorite locations: %w", err)
}
defer rows.Close()
var locations []domain.FavoriteLocation
for rows.Next() {
location, err := scanFavoriteLocation(rows)
if err != nil {
return nil, fmt.Errorf("scan favorite location: %w", err)
}
locations = append(locations, *location)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate favorite locations: %w", err)
}
if locations == nil {
locations = []domain.FavoriteLocation{}
}
return locations, nil
}
// Create inserts a new favorite location while atomically enforcing the per-user maximum.
func (r *FavoriteLocationRepository) Create(ctx context.Context, params favoritelocationapp.CreateFavoriteLocationParams) (*domain.FavoriteLocation, error) {
if err := r.lockUserRow(ctx, r.db, params.UserID); err != nil {
return nil, err
}
var count int
if err := r.db.QueryRow(ctx, `
SELECT COUNT(*)
FROM favorite_location
WHERE user_id = $1
`, params.UserID).Scan(&count); err != nil {
return nil, fmt.Errorf("count favorite locations: %w", err)
}
if count >= favoritelocationapp.MaxFavoriteLocations {
return nil, favoritelocationapp.ErrFavoriteLocationLimitExceeded
}
location, err := scanFavoriteLocation(r.db.QueryRow(ctx, `
INSERT INTO favorite_location (
user_id,
name,
address,
point
)
VALUES (
$1,
$2,
$3,
ST_SetSRID(ST_MakePoint($4, $5), 4326)::geography
)
RETURNING
id,
user_id,
name,
address,
ST_Y(point::geometry) AS lat,
ST_X(point::geometry) AS lon,
created_at,
updated_at
`, params.UserID, params.Name, params.Address, params.Lon, params.Lat))
if err != nil {
return nil, fmt.Errorf("insert favorite location: %w", err)
}
return location, nil
}
// GetByIDForUser returns a single favorite location owned by the given user.
func (r *FavoriteLocationRepository) GetByIDForUser(ctx context.Context, userID, favoriteLocationID uuid.UUID) (*domain.FavoriteLocation, error) {
location, err := scanFavoriteLocation(r.db.QueryRow(ctx, `
SELECT
id,
user_id,
name,
address,
ST_Y(point::geometry) AS lat,
ST_X(point::geometry) AS lon,
created_at,
updated_at
FROM favorite_location
WHERE id = $1
AND user_id = $2
`, favoriteLocationID, userID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get favorite location: %w", err)
}
return location, nil
}
// Update replaces the persisted fields of a user-owned favorite location.
func (r *FavoriteLocationRepository) Update(ctx context.Context, params favoritelocationapp.UpdateFavoriteLocationParams) (*domain.FavoriteLocation, error) {
location, err := scanFavoriteLocation(r.db.QueryRow(ctx, `
UPDATE favorite_location
SET name = $3,
address = $4,
point = ST_SetSRID(ST_MakePoint($5, $6), 4326)::geography,
updated_at = now()
WHERE id = $1
AND user_id = $2
RETURNING
id,
user_id,
name,
address,
ST_Y(point::geometry) AS lat,
ST_X(point::geometry) AS lon,
created_at,
updated_at
`, params.FavoriteLocationID, params.UserID, params.Name, params.Address, params.Lon, params.Lat))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("update favorite location: %w", err)
}
return location, nil
}
// Delete removes a favorite location owned by the given user.
func (r *FavoriteLocationRepository) Delete(ctx context.Context, userID, favoriteLocationID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `
DELETE FROM favorite_location
WHERE id = $1
AND user_id = $2
`, favoriteLocationID, userID)
if err != nil {
return fmt.Errorf("delete favorite location: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
func (r *FavoriteLocationRepository) lockUserRow(ctx context.Context, db execer, userID uuid.UUID) error {
var lockedUserID uuid.UUID
if err := db.QueryRow(ctx, `
SELECT id
FROM app_user
WHERE id = $1
FOR UPDATE
`, userID).Scan(&lockedUserID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrNotFound
}
return fmt.Errorf("lock user row: %w", err)
}
return nil
}
func scanFavoriteLocation(row pgx.Row) (*domain.FavoriteLocation, error) {
var (
location domain.FavoriteLocation
name pgtype.Text
address pgtype.Text
lat pgtype.Float8
lon pgtype.Float8
)
if err := row.Scan(
&location.ID,
&location.UserID,
&name,
&address,
&lat,
&lon,
&location.CreatedAt,
&location.UpdatedAt,
); err != nil {
return nil, err
}
if !name.Valid || !address.Valid || !lat.Valid || !lon.Valid {
return nil, fmt.Errorf("favorite_location %s has null required fields", location.ID)
}
location.Name = name.String
location.Address = address.String
location.Point = domain.GeoPoint{
Lat: lat.Float64,
Lon: lon.Float64,
}
return &location, nil
}
var _ favoritelocationapp.Repository = (*FavoriteLocationRepository)(nil)
package postgres
import (
"errors"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// scanUser reads a User from a single pgx.Row, handling nullable columns
// via pgtype intermediaries. Returns domain.ErrNotFound if no row exists.
func scanUser(row pgx.Row) (*domain.User, error) {
var (
user domain.User
phoneNumber pgtype.Text
gender pgtype.Text
birthDate pgtype.Date
passwordHash pgtype.Text
emailVerifiedAt pgtype.Timestamptz
lastLogin pgtype.Timestamptz
status pgtype.Text
)
if err := row.Scan(
&user.ID,
&user.Username,
&user.Email,
&phoneNumber,
&gender,
&birthDate,
&passwordHash,
&emailVerifiedAt,
&lastLogin,
&status,
&user.CreatedAt,
&user.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
user.PhoneNumber = textPtr(phoneNumber)
user.Gender = textPtr(gender)
user.BirthDate = datePtr(birthDate)
user.PasswordHash = textValue(passwordHash)
user.EmailVerifiedAt = timestamptzPtr(emailVerifiedAt)
user.LastLogin = timestamptzPtr(lastLogin)
user.Status = textValue(status)
return &user, nil
}
// scanOTPChallenge reads an OTPChallenge from a single pgx.Row.
func scanOTPChallenge(row pgx.Row) (*domain.OTPChallenge, error) {
var (
challenge domain.OTPChallenge
userID pgtype.UUID
consumedAt pgtype.Timestamptz
)
if err := row.Scan(
&challenge.ID,
&userID,
&challenge.Channel,
&challenge.Destination,
&challenge.Purpose,
&challenge.CodeHash,
&challenge.ExpiresAt,
&consumedAt,
&challenge.AttemptCount,
&challenge.CreatedAt,
&challenge.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
challenge.UserID = uuidPtr(userID)
challenge.ConsumedAt = timestamptzPtr(consumedAt)
return &challenge, nil
}
// scanRefreshToken reads a RefreshToken from a single pgx.Row.
func scanRefreshToken(row pgx.Row) (*domain.RefreshToken, error) {
var (
token domain.RefreshToken
revokedAt pgtype.Timestamptz
replacedByID pgtype.UUID
deviceInfo pgtype.Text
)
if err := row.Scan(
&token.ID,
&token.UserID,
&token.FamilyID,
&token.TokenHash,
&token.ExpiresAt,
&revokedAt,
&replacedByID,
&deviceInfo,
&token.CreatedAt,
&token.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
token.RevokedAt = timestamptzPtr(revokedAt)
token.ReplacedByID = uuidPtr(replacedByID)
token.DeviceInfo = textPtr(deviceInfo)
return &token, nil
}
// textPtr converts a nullable pgtype.Text to a *string (nil if SQL NULL).
func textPtr(value pgtype.Text) *string {
if !value.Valid {
return nil
}
return new(value.String)
}
// textValue converts a nullable pgtype.Text to a plain string ("" if SQL NULL).
func textValue(value pgtype.Text) string {
if !value.Valid {
return ""
}
return value.String
}
// datePtr converts a nullable pgtype.Date to a *time.Time.
func datePtr(value pgtype.Date) *time.Time {
if !value.Valid {
return nil
}
return new(value.Time)
}
// timestamptzPtr converts a nullable pgtype.Timestamptz to a *time.Time.
func timestamptzPtr(value pgtype.Timestamptz) *time.Time {
if !value.Valid {
return nil
}
return new(value.Time)
}
// uuidPtr converts a nullable pgtype.UUID to a *uuid.UUID.
func uuidPtr(value pgtype.UUID) *uuid.UUID {
if !value.Valid {
return nil
}
return new(uuid.UUID(value.Bytes))
}
package postgres
import (
"context"
"errors"
"fmt"
"time"
joinrequestapp "github.com/bounswe/bounswe2026group11/backend/internal/application/join_request"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
// JoinRequestRepository is the Postgres-backed implementation of join_request.Repository.
type JoinRequestRepository struct {
pool *pgxpool.Pool
db execer
}
// NewJoinRequestRepository returns a repository that executes queries against the given connection pool.
func NewJoinRequestRepository(pool *pgxpool.Pool) *JoinRequestRepository {
return &JoinRequestRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
// CreateJoinRequest inserts or reactivates a join_request row for a protected event.
func (r *JoinRequestRepository) CreateJoinRequest(ctx context.Context, params joinrequestapp.CreateJoinRequestParams) (*domain.JoinRequest, error) {
event, err := r.loadEventState(ctx, params.EventID, false)
if err != nil {
return nil, err
}
if event == nil {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
if event.HostID == params.UserID {
return nil, domain.ForbiddenError(domain.ErrorCodeHostCannotJoin, "The event host cannot request to join their own event.")
}
if event.PrivacyLevel != domain.PrivacyProtected {
return nil, domain.ConflictError(domain.ErrorCodeEventJoinNotAllowed, "Only PROTECTED events accept join requests.")
}
participation, err := loadParticipation(ctx, r.db, params.EventID, params.UserID, false)
if err != nil {
return nil, err
}
if participation != nil && !canReactivateLeavedParticipation(participation, event.StartTime) {
return nil, mapJoinParticipationConflict(
participation,
event.StartTime,
"You are already participating in this event.",
"You cannot request to join again after leaving once the event has started.",
)
}
existing, err := r.loadJoinRequestByEventAndUser(ctx, params.EventID, params.UserID, true)
if err != nil {
return nil, err
}
if existing == nil {
created, err := r.insertJoinRequest(ctx, params)
if err == nil {
return created, nil
}
if !isConstraintError(err, "uq_join") {
return nil, fmt.Errorf("insert join_request: %w", err)
}
existing, err = r.loadJoinRequestByEventAndUser(ctx, params.EventID, params.UserID, true)
if err != nil {
return nil, err
}
if existing == nil {
return nil, fmt.Errorf("insert join_request: unique violation without matching row")
}
}
return r.handleExistingJoinRequestForCreate(ctx, existing, participation, event.StartTime, params)
}
// ApproveJoinRequest approves a pending join request and creates the corresponding
// APPROVED participation row in the same transaction.
func (r *JoinRequestRepository) ApproveJoinRequest(
ctx context.Context,
params joinrequestapp.ApproveJoinRequestParams,
) (*joinrequestapp.ApproveJoinRequestResult, error) {
event, err := r.loadEventState(ctx, params.EventID, true)
if err != nil {
return nil, err
}
if event == nil {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
if event.HostID != params.HostUserID {
return nil, domain.ForbiddenError(domain.ErrorCodeJoinRequestModerationNotAllowed, "Only the event host can moderate join requests.")
}
request, err := r.loadJoinRequestByID(ctx, params.EventID, params.JoinRequestID, true)
if err != nil {
return nil, err
}
if request == nil {
return nil, domain.NotFoundError(domain.ErrorCodeJoinRequestNotFound, "The requested join request does not exist.")
}
if request.Status != domain.JoinRequestStatusPending {
return nil, domain.ConflictError(domain.ErrorCodeJoinRequestStateInvalid, "Only PENDING join requests can be approved.")
}
if event.Capacity != nil && event.ApprovedParticipantCount >= *event.Capacity {
return nil, domain.ConflictError(domain.ErrorCodeCapacityExceeded, "This event has reached its maximum capacity.")
}
participation, err := r.insertOrReactivateApprovedParticipation(ctx, event, request.UserID)
if err != nil {
return nil, err
}
updatedRequest, err := r.updateJoinRequestStatus(ctx, request.ID, domain.JoinRequestStatusApproved, &participation.ID)
if err != nil {
return nil, err
}
return &joinrequestapp.ApproveJoinRequestResult{
JoinRequest: updatedRequest,
Participation: participation,
}, nil
}
// RejectJoinRequest rejects a pending join request and returns the resulting cooldown end time.
func (r *JoinRequestRepository) RejectJoinRequest(
ctx context.Context,
params joinrequestapp.RejectJoinRequestParams,
) (*joinrequestapp.RejectJoinRequestResult, error) {
event, err := r.loadEventState(ctx, params.EventID, false)
if err != nil {
return nil, err
}
if event == nil {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
if event.HostID != params.HostUserID {
return nil, domain.ForbiddenError(domain.ErrorCodeJoinRequestModerationNotAllowed, "Only the event host can moderate join requests.")
}
request, err := r.loadJoinRequestByID(ctx, params.EventID, params.JoinRequestID, true)
if err != nil {
return nil, err
}
if request == nil {
return nil, domain.NotFoundError(domain.ErrorCodeJoinRequestNotFound, "The requested join request does not exist.")
}
if request.Status != domain.JoinRequestStatusPending {
return nil, domain.ConflictError(domain.ErrorCodeJoinRequestStateInvalid, "Only PENDING join requests can be rejected.")
}
updatedRequest, err := r.updateJoinRequestStatus(ctx, request.ID, domain.JoinRequestStatusRejected, nil)
if err != nil {
return nil, err
}
return &joinrequestapp.RejectJoinRequestResult{
JoinRequest: updatedRequest,
CooldownEndsAt: updatedRequest.UpdatedAt.Add(domain.JoinRequestCooldown),
}, nil
}
func (r *JoinRequestRepository) handleExistingJoinRequestForCreate(
ctx context.Context,
existing *domain.JoinRequest,
participation *domain.Participation,
eventStart time.Time,
params joinrequestapp.CreateJoinRequestParams,
) (*domain.JoinRequest, error) {
switch existing.Status {
case domain.JoinRequestStatusPending:
return nil, domain.ConflictError(domain.ErrorCodeAlreadyRequested, "You already have a pending join request for this event.")
case domain.JoinRequestStatusApproved:
if canReactivateLeavedParticipation(participation, eventStart) {
return r.reactivateJoinRequest(ctx, existing.ID, params.HostUserID, params.Message)
}
return nil, domain.ConflictError(domain.ErrorCodeAlreadyParticipating, "You are already participating in this event.")
case domain.JoinRequestStatusRejected:
if time.Now().UTC().Before(existing.UpdatedAt.Add(domain.JoinRequestCooldown)) {
return nil, domain.ConflictError(domain.ErrorCodeJoinRequestCooldownActive, "You must wait 3 days after rejection before requesting to join this event again.")
}
return r.reactivateJoinRequest(ctx, existing.ID, params.HostUserID, params.Message)
default:
return nil, fmt.Errorf("unsupported join request status %q", existing.Status)
}
}
func (r *JoinRequestRepository) loadEventState(ctx context.Context, eventID uuid.UUID, forUpdate bool) (*domain.Event, error) {
query := `
SELECT host_id, privacy_level, capacity, approved_participant_count, start_time
FROM event
WHERE id = $1
`
if forUpdate {
query += ` FOR UPDATE`
}
var (
hostID uuid.UUID
privacyLevel string
capacity pgtype.Int4
approvedCount int
startTime time.Time
)
err := r.db.QueryRow(ctx, query, eventID).Scan(&hostID, &privacyLevel, &capacity, &approvedCount, &startTime)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("load event join request state: %w", err)
}
event := &domain.Event{
ID: eventID,
HostID: hostID,
PrivacyLevel: domain.EventPrivacyLevel(privacyLevel),
ApprovedParticipantCount: approvedCount,
StartTime: startTime,
}
if capacity.Valid {
value := int(capacity.Int32)
event.Capacity = &value
}
return event, nil
}
func (r *JoinRequestRepository) loadJoinRequestByEventAndUser(
ctx context.Context,
eventID, userID uuid.UUID,
forUpdate bool,
) (*domain.JoinRequest, error) {
query := `
SELECT id, event_id, user_id, participation_id, host_user_id, status, message, created_at, updated_at
FROM join_request
WHERE event_id = $1
AND user_id = $2
`
if forUpdate {
query += ` FOR UPDATE`
}
return scanJoinRequest(r.db.QueryRow(ctx, query, eventID, userID))
}
func (r *JoinRequestRepository) loadJoinRequestByID(
ctx context.Context,
eventID, joinRequestID uuid.UUID,
forUpdate bool,
) (*domain.JoinRequest, error) {
query := `
SELECT id, event_id, user_id, participation_id, host_user_id, status, message, created_at, updated_at
FROM join_request
WHERE event_id = $1
AND id = $2
`
if forUpdate {
query += ` FOR UPDATE`
}
return scanJoinRequest(r.db.QueryRow(ctx, query, eventID, joinRequestID))
}
func (r *JoinRequestRepository) insertJoinRequest(
ctx context.Context,
params joinrequestapp.CreateJoinRequestParams,
) (*domain.JoinRequest, error) {
row := r.db.QueryRow(ctx, `
INSERT INTO join_request (event_id, user_id, host_user_id, status, message)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, event_id, user_id, participation_id, host_user_id, status, message, created_at, updated_at
`, params.EventID, params.UserID, params.HostUserID, domain.JoinRequestStatusPending, params.Message)
request, err := scanJoinRequest(row)
if err != nil {
return nil, err
}
return request, nil
}
func (r *JoinRequestRepository) reactivateJoinRequest(
ctx context.Context,
joinRequestID, hostUserID uuid.UUID,
message *string,
) (*domain.JoinRequest, error) {
row := r.db.QueryRow(ctx, `
UPDATE join_request
SET host_user_id = $2,
status = $3,
participation_id = NULL,
message = $4,
created_at = now(),
updated_at = now()
WHERE id = $1
RETURNING id, event_id, user_id, participation_id, host_user_id, status, message, created_at, updated_at
`, joinRequestID, hostUserID, domain.JoinRequestStatusPending, message)
request, err := scanJoinRequest(row)
if err != nil {
return nil, fmt.Errorf("reactivate join_request: %w", err)
}
return request, nil
}
func (r *JoinRequestRepository) insertOrReactivateApprovedParticipation(
ctx context.Context,
event *domain.Event,
userID uuid.UUID,
) (*domain.Participation, error) {
participation, err := scanParticipation(r.db.QueryRow(ctx, `
WITH reactivated AS (
UPDATE participation
SET status = $3,
created_at = NOW(),
updated_at = NOW()
WHERE event_id = $1
AND user_id = $2
AND status = $4
AND updated_at < $5
RETURNING id, status, created_at, updated_at
),
inserted AS (
INSERT INTO participation (event_id, user_id, status)
SELECT $1, $2, $3
WHERE NOT EXISTS (SELECT 1 FROM reactivated)
ON CONFLICT ON CONSTRAINT uq_event_user DO NOTHING
RETURNING id, status, created_at, updated_at
)
SELECT id, status, created_at, updated_at
FROM reactivated
UNION ALL
SELECT id, status, created_at, updated_at
FROM inserted
LIMIT 1
`, event.ID, userID, domain.ParticipationStatusApproved, domain.ParticipationStatusLeaved, event.StartTime), event.ID, userID, "approve join request participation")
if err != nil {
return nil, err
}
if participation != nil {
return participation, nil
}
existing, err := loadParticipation(ctx, r.db, event.ID, userID, true)
if err != nil {
return nil, err
}
if existing != nil {
return nil, mapJoinParticipationConflict(
existing,
event.StartTime,
"The requester is already participating in this event.",
"The requester cannot rejoin this event after leaving once it has started.",
)
}
return nil, fmt.Errorf("approve join request participation: no row returned and no existing participation found")
}
func (r *JoinRequestRepository) updateJoinRequestStatus(
ctx context.Context,
joinRequestID uuid.UUID,
status domain.JoinRequestStatus,
participationID *uuid.UUID,
) (*domain.JoinRequest, error) {
row := r.db.QueryRow(ctx, `
UPDATE join_request
SET status = $2,
participation_id = $3,
updated_at = now()
WHERE id = $1
RETURNING id, event_id, user_id, participation_id, host_user_id, status, message, created_at, updated_at
`, joinRequestID, status, participationID)
request, err := scanJoinRequest(row)
if err != nil {
return nil, fmt.Errorf("update join_request status: %w", err)
}
return request, nil
}
func scanJoinRequest(row pgx.Row) (*domain.JoinRequest, error) {
var (
requestID uuid.UUID
eventID uuid.UUID
userID uuid.UUID
participationID pgtype.UUID
hostUserID uuid.UUID
status string
message pgtype.Text
createdAt time.Time
updatedAt time.Time
)
err := row.Scan(
&requestID,
&eventID,
&userID,
&participationID,
&hostUserID,
&status,
&message,
&createdAt,
&updatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
parsedStatus, ok := domain.ParseJoinRequestStatus(status)
if !ok {
return nil, fmt.Errorf("unknown join_request status %q", status)
}
request := &domain.JoinRequest{
ID: requestID,
EventID: eventID,
UserID: userID,
HostUserID: hostUserID,
Status: parsedStatus,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
if participationID.Valid {
parsedParticipationID := uuid.UUID(participationID.Bytes)
request.ParticipationID = &parsedParticipationID
}
if message.Valid {
request.Message = &message.String
}
return request, nil
}
func isConstraintError(err error, constraint string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == constraint
}
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func loadParticipation(
ctx context.Context,
db execer,
eventID, userID uuid.UUID,
forUpdate bool,
) (*domain.Participation, error) {
query := `
SELECT id, status, created_at, updated_at
FROM participation
WHERE event_id = $1 AND user_id = $2
`
if forUpdate {
query += ` FOR UPDATE`
}
return scanParticipation(db.QueryRow(ctx, query, eventID, userID), eventID, userID, "load participation")
}
func scanParticipation(
row pgx.Row,
eventID, userID uuid.UUID,
operation string,
) (*domain.Participation, error) {
var (
id uuid.UUID
status string
createdAt time.Time
updatedAt time.Time
)
err := row.Scan(&id, &status, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("%s: %w", operation, err)
}
parsedStatus, ok := domain.ParseParticipationStatus(status)
if !ok {
return nil, fmt.Errorf("%s: unknown participation status %q", operation, status)
}
return &domain.Participation{
ID: id,
EventID: eventID,
UserID: userID,
Status: parsedStatus,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}
func canReactivateLeavedParticipation(participation *domain.Participation, eventStart time.Time) bool {
return participation != nil &&
participation.Status == domain.ParticipationStatusLeaved &&
participation.UpdatedAt.Before(eventStart)
}
func mapJoinParticipationConflict(
participation *domain.Participation,
eventStart time.Time,
activeMessage string,
leftAfterStartMessage string,
) error {
if participation == nil {
return fmt.Errorf("map join participation conflict: missing participation")
}
if participation.Status == domain.ParticipationStatusLeaved {
if canReactivateLeavedParticipation(participation, eventStart) {
return fmt.Errorf("map join participation conflict: expected pre-start LEAVED participation to be reactivated")
}
return domain.ConflictError(domain.ErrorCodeAlreadyParticipating, leftAfterStartMessage)
}
return domain.ConflictError(domain.ErrorCodeAlreadyParticipating, activeMessage)
}
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
// ParticipationRepository is the Postgres-backed implementation of participation.ParticipationRepository.
type ParticipationRepository struct {
pool *pgxpool.Pool
db execer
}
// NewParticipationRepository returns a repository that executes queries against the given connection pool.
func NewParticipationRepository(pool *pgxpool.Pool) *ParticipationRepository {
return &ParticipationRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
// CreateParticipation inserts an APPROVED participation row.
// Rejoins before start reactivate the existing row instead of inserting a duplicate.
func (r *ParticipationRepository) CreateParticipation(ctx context.Context, eventID, userID uuid.UUID) (*domain.Participation, error) {
participation, err := scanParticipation(r.db.QueryRow(ctx, `
WITH joinable_event AS (
SELECT id, start_time
FROM event
WHERE id = $1
AND host_id <> $2
AND privacy_level = $3
AND (capacity IS NULL OR approved_participant_count < capacity)
),
reactivated AS (
UPDATE participation
SET status = $4,
created_at = NOW(),
updated_at = NOW()
WHERE event_id = $1
AND user_id = $2
AND status = $5
AND updated_at < (SELECT start_time FROM joinable_event)
RETURNING id, status, created_at, updated_at
),
inserted AS (
INSERT INTO participation (event_id, user_id, status)
SELECT id, $2, $4
FROM joinable_event
WHERE NOT EXISTS (SELECT 1 FROM reactivated)
ON CONFLICT ON CONSTRAINT uq_event_user DO NOTHING
RETURNING id, status, created_at, updated_at
)
SELECT id, status, created_at, updated_at
FROM reactivated
UNION ALL
SELECT id, status, created_at, updated_at
FROM inserted
LIMIT 1
`, eventID, userID, domain.PrivacyPublic, domain.ParticipationStatusApproved, domain.ParticipationStatusLeaved), eventID, userID, "create participation")
if err != nil {
return nil, err
}
if participation == nil {
return nil, r.mapCreateParticipationNoRow(ctx, eventID, userID)
}
return participation, nil
}
// LeaveParticipation transitions an APPROVED participation to LEAVED.
func (r *ParticipationRepository) LeaveParticipation(ctx context.Context, eventID, userID uuid.UUID) (*domain.Participation, error) {
participation, err := scanParticipation(r.db.QueryRow(ctx, `
UPDATE participation
SET status = $3,
updated_at = NOW()
WHERE event_id = $1
AND user_id = $2
AND status = $4
RETURNING id, status, created_at, updated_at
`, eventID, userID, domain.ParticipationStatusLeaved, domain.ParticipationStatusApproved), eventID, userID, "leave participation")
if err != nil {
return nil, err
}
if participation == nil {
return nil, r.mapLeaveParticipationNoRow(ctx, eventID)
}
return participation, nil
}
func (r *ParticipationRepository) mapCreateParticipationNoRow(ctx context.Context, eventID, userID uuid.UUID) error {
event, err := r.loadEventJoinState(ctx, eventID)
if err != nil {
return err
}
if event == nil {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
if event.HostID == userID {
return domain.ForbiddenError(domain.ErrorCodeHostCannotJoin, "The event host cannot join their own event.")
}
if event.PrivacyLevel != domain.PrivacyPublic {
return domain.ConflictError(domain.ErrorCodeEventJoinNotAllowed, "Only PUBLIC events can be joined directly.")
}
if event.Capacity != nil && event.ApprovedParticipantCount >= *event.Capacity {
return domain.ConflictError(domain.ErrorCodeCapacityExceeded, "This event has reached its maximum capacity.")
}
participation, err := loadParticipation(ctx, r.db, eventID, userID, false)
if err != nil {
return err
}
if participation != nil {
return mapJoinParticipationConflict(
participation,
event.StartTime,
"You are already participating in this event.",
"You cannot rejoin an event after leaving once it has started.",
)
}
return fmt.Errorf("create participation: join preconditions changed during insert")
}
func (r *ParticipationRepository) mapLeaveParticipationNoRow(ctx context.Context, eventID uuid.UUID) error {
event, err := r.loadEventJoinState(ctx, eventID)
if err != nil {
return err
}
if event == nil {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return domain.ConflictError(domain.ErrorCodeEventLeaveNotAllowed, "Only approved participants can leave this event.")
}
func (r *ParticipationRepository) loadEventJoinState(ctx context.Context, eventID uuid.UUID) (*domain.Event, error) {
var (
hostID uuid.UUID
privacyLevel string
capacity pgtype.Int4
approvedCount int
startTime time.Time
)
err := r.db.QueryRow(ctx, `
SELECT host_id, privacy_level, capacity, approved_participant_count, start_time
FROM event
WHERE id = $1
`, eventID).Scan(&hostID, &privacyLevel, &capacity, &approvedCount, &startTime)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("load event join state: %w", err)
}
event := &domain.Event{
ID: eventID,
HostID: hostID,
PrivacyLevel: domain.EventPrivacyLevel(privacyLevel),
ApprovedParticipantCount: approvedCount,
StartTime: startTime,
}
if capacity.Valid {
event.Capacity = new(int(capacity.Int32))
}
return event, nil
}
// CancelEventParticipations transitions every non-LEAVED participation for the
// event to CANCELED, preserving historical leave records.
func (r *ParticipationRepository) CancelEventParticipations(ctx context.Context, eventID uuid.UUID) error {
if _, err := r.db.Exec(ctx, `
UPDATE participation
SET status = $1, updated_at = NOW()
WHERE event_id = $2
AND status <> $3
`, domain.ParticipationStatusCanceled, eventID, domain.ParticipationStatusLeaved); err != nil {
return fmt.Errorf("cancel event participations: %w", err)
}
return nil
}
package postgres
import (
"context"
"errors"
"fmt"
"time"
imageuploadapp "github.com/bounswe/bounswe2026group11/backend/internal/application/imageupload"
profileapp "github.com/bounswe/bounswe2026group11/backend/internal/application/profile"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
// ProfileRepository is the Postgres-backed implementation of profile.Repository.
type ProfileRepository struct {
pool *pgxpool.Pool
db execer
}
// NewProfileRepository returns a repository that executes queries against the given connection pool.
func NewProfileRepository(pool *pgxpool.Pool) *ProfileRepository {
return &ProfileRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
// GetProfile returns the combined app_user + profile data for the given user.
func (r *ProfileRepository) GetProfile(ctx context.Context, userID uuid.UUID) (*domain.UserProfile, error) {
row := r.pool.QueryRow(ctx, `
SELECT
u.id,
u.username,
u.email,
u.phone_number,
u.gender,
u.birth_date,
u.email_verified_at,
u.status,
u.default_location_address,
ST_Y(u.default_location_point::geometry) AS lat,
ST_X(u.default_location_point::geometry) AS lon,
p.display_name,
p.bio,
p.avatar_url,
us.final_score,
us.hosted_event_score,
COALESCE(us.hosted_event_rating_count, 0),
us.participant_score,
COALESCE(us.participant_rating_count, 0)
FROM app_user u
LEFT JOIN profile p ON p.user_id = u.id
LEFT JOIN user_score us ON us.user_id = u.id
WHERE u.id = $1
`, userID)
var (
up domain.UserProfile
phoneNumber pgtype.Text
gender pgtype.Text
birthDate pgtype.Date
emailVerifiedAt pgtype.Timestamptz
status pgtype.Text
defaultLocAddress pgtype.Text
lat pgtype.Float8
lon pgtype.Float8
displayName pgtype.Text
bio pgtype.Text
avatarURL pgtype.Text
finalScore pgtype.Float8
hostedEventScore pgtype.Float8
hostedEventRatingCount int
participantScore pgtype.Float8
participantRatingCount int
)
if err := row.Scan(
&up.ID,
&up.Username,
&up.Email,
&phoneNumber,
&gender,
&birthDate,
&emailVerifiedAt,
&status,
&defaultLocAddress,
&lat,
&lon,
&displayName,
&bio,
&avatarURL,
&finalScore,
&hostedEventScore,
&hostedEventRatingCount,
&participantScore,
&participantRatingCount,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get profile: %w", err)
}
up.PhoneNumber = textPtr(phoneNumber)
up.Gender = textPtr(gender)
up.BirthDate = datePtr(birthDate)
up.EmailVerified = emailVerifiedAt.Valid
up.Status = textValue(status)
up.DefaultLocationAddress = textPtr(defaultLocAddress)
if lat.Valid {
up.DefaultLocationLat = &lat.Float64
}
if lon.Valid {
up.DefaultLocationLon = &lon.Float64
}
up.DisplayName = textPtr(displayName)
up.Bio = textPtr(bio)
up.AvatarURL = textPtr(avatarURL)
if finalScore.Valid {
up.FinalScore = &finalScore.Float64
}
up.HostScore.RatingCount = hostedEventRatingCount
if hostedEventScore.Valid {
up.HostScore.Score = &hostedEventScore.Float64
}
up.ParticipantScore.RatingCount = participantRatingCount
if participantScore.Valid {
up.ParticipantScore.Score = &participantScore.Float64
}
return &up, nil
}
// GetHostedEvents returns a summary of all events created by the given user.
func (r *ProfileRepository) GetHostedEvents(ctx context.Context, userID uuid.UUID) ([]domain.EventSummary, error) {
rows, err := r.pool.Query(ctx, `
SELECT
e.id,
e.title,
e.start_time,
e.end_time,
e.status,
e.privacy_level,
ec.name AS category,
e.image_url,
e.approved_participant_count,
el.address
FROM event e
LEFT JOIN event_category ec ON ec.id = e.category_id
LEFT JOIN event_location el ON el.event_id = e.id
WHERE e.host_id = $1
ORDER BY e.start_time DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("get hosted events: %w", err)
}
defer rows.Close()
return scanEventSummaries(rows)
}
// GetUpcomingEvents returns events the user has an APPROVED participation in
// that are still ACTIVE or IN_PROGRESS.
func (r *ProfileRepository) GetUpcomingEvents(ctx context.Context, userID uuid.UUID) ([]domain.EventSummary, error) {
rows, err := r.pool.Query(ctx, `
SELECT
e.id,
e.title,
e.start_time,
e.end_time,
e.status,
e.privacy_level,
ec.name AS category,
e.image_url,
e.approved_participant_count,
el.address
FROM event e
JOIN participation p ON p.event_id = e.id
LEFT JOIN event_category ec ON ec.id = e.category_id
LEFT JOIN event_location el ON el.event_id = e.id
WHERE p.user_id = $1
AND p.status = $2
AND e.status IN ($3, $4)
ORDER BY e.start_time ASC
`, userID, domain.ParticipationStatusApproved, domain.EventStatusActive, domain.EventStatusInProgress)
if err != nil {
return nil, fmt.Errorf("get upcoming events: %w", err)
}
defer rows.Close()
return scanEventSummaries(rows)
}
// GetCompletedEvents returns events the user either completed as an APPROVED
// participant or left after the event had already started.
func (r *ProfileRepository) GetCompletedEvents(ctx context.Context, userID uuid.UUID) ([]domain.EventSummary, error) {
rows, err := r.pool.Query(ctx, `
SELECT
e.id,
e.title,
e.start_time,
e.end_time,
e.status,
e.privacy_level,
ec.name AS category,
e.image_url,
e.approved_participant_count,
el.address
FROM event e
JOIN participation p ON p.event_id = e.id
LEFT JOIN event_category ec ON ec.id = e.category_id
LEFT JOIN event_location el ON el.event_id = e.id
WHERE p.user_id = $1
AND (
p.status = $2
OR (p.status = $3 AND p.updated_at >= e.start_time)
)
AND e.status = $4
ORDER BY e.start_time DESC
`, userID, domain.ParticipationStatusApproved, domain.ParticipationStatusLeaved, domain.EventStatusCompleted)
if err != nil {
return nil, fmt.Errorf("get completed events: %w", err)
}
defer rows.Close()
return scanEventSummaries(rows)
}
// GetCanceledEvents returns events the user was part of (participation CANCELED)
// that are CANCELED, covering both host and participant views.
func (r *ProfileRepository) GetCanceledEvents(ctx context.Context, userID uuid.UUID) ([]domain.EventSummary, error) {
rows, err := r.pool.Query(ctx, `
SELECT
e.id,
e.title,
e.start_time,
e.end_time,
e.status,
e.privacy_level,
ec.name AS category,
e.image_url,
e.approved_participant_count,
el.address
FROM event e
JOIN participation p ON p.event_id = e.id
LEFT JOIN event_category ec ON ec.id = e.category_id
LEFT JOIN event_location el ON el.event_id = e.id
WHERE p.user_id = $1
AND p.status = $2
AND e.status = $3
ORDER BY e.start_time DESC
`, userID, domain.ParticipationStatusCanceled, domain.EventStatusCanceled)
if err != nil {
return nil, fmt.Errorf("get canceled events: %w", err)
}
defer rows.Close()
return scanEventSummaries(rows)
}
func scanEventSummaries(rows interface {
Next() bool
Scan(...any) error
Err() error
}) ([]domain.EventSummary, error) {
var events []domain.EventSummary
for rows.Next() {
var (
e domain.EventSummary
endTime pgtype.Timestamptz
category pgtype.Text
imageURL pgtype.Text
locationAddress pgtype.Text
)
if err := rows.Scan(&e.ID, &e.Title, &e.StartTime, &endTime, &e.Status, &e.PrivacyLevel,
&category, &imageURL, &e.ApprovedParticipantCount, &locationAddress); err != nil {
return nil, fmt.Errorf("scan event summary: %w", err)
}
if endTime.Valid {
e.EndTime = endTime.Time
}
e.Category = textPtr(category)
e.ImageURL = textPtr(imageURL)
e.LocationAddress = textPtr(locationAddress)
events = append(events, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate event summaries: %w", err)
}
if events == nil {
events = []domain.EventSummary{}
}
return events, nil
}
// UpdateProfile persists editable profile fields across app_user and profile tables.
func (r *ProfileRepository) UpdateProfile(ctx context.Context, params profileapp.UpdateProfileParams) error {
// Update app_user fields.
var locationExpr string
var userArgs []any
userArgs = append(userArgs, params.UserID)
setClauses := "updated_at = now()"
if params.PhoneNumber != nil {
setClauses += fmt.Sprintf(", phone_number = $%d", len(userArgs)+1)
userArgs = append(userArgs, *params.PhoneNumber)
}
if params.Gender != nil {
setClauses += fmt.Sprintf(", gender = $%d", len(userArgs)+1)
userArgs = append(userArgs, *params.Gender)
}
if params.BirthDate != nil {
setClauses += fmt.Sprintf(", birth_date = $%d", len(userArgs)+1)
userArgs = append(userArgs, *params.BirthDate)
}
if params.DefaultLocationAddress != nil {
setClauses += fmt.Sprintf(", default_location_address = $%d", len(userArgs)+1)
userArgs = append(userArgs, *params.DefaultLocationAddress)
}
if params.DefaultLocationLat != nil && params.DefaultLocationLon != nil {
locationExpr = fmt.Sprintf(", default_location_point = ST_SetSRID(ST_MakePoint($%d, $%d), 4326)::geography",
len(userArgs)+1, len(userArgs)+2)
userArgs = append(userArgs, *params.DefaultLocationLon, *params.DefaultLocationLat)
}
setClauses += locationExpr
if _, err := r.db.Exec(ctx,
fmt.Sprintf(`UPDATE app_user SET %s WHERE id = $1`, setClauses),
userArgs...,
); err != nil {
return fmt.Errorf("update app_user: %w", err)
}
// Update profile fields.
profileSet := "updated_at = now()"
var profileArgs []any
profileArgs = append(profileArgs, params.UserID)
if params.DisplayName != nil {
profileSet += fmt.Sprintf(", display_name = $%d", len(profileArgs)+1)
profileArgs = append(profileArgs, *params.DisplayName)
}
if params.Bio != nil {
profileSet += fmt.Sprintf(", bio = $%d", len(profileArgs)+1)
profileArgs = append(profileArgs, *params.Bio)
}
if params.AvatarURL != nil {
profileSet += fmt.Sprintf(", avatar_url = $%d", len(profileArgs)+1)
profileArgs = append(profileArgs, *params.AvatarURL)
}
if _, err := r.db.Exec(ctx,
fmt.Sprintf(`UPDATE profile SET %s WHERE user_id = $1`, profileSet),
profileArgs...,
); err != nil {
return fmt.Errorf("update profile: %w", err)
}
return nil
}
// GetAvatarVersion returns the current avatar version of the given user profile.
func (r *ProfileRepository) GetAvatarVersion(ctx context.Context, userID uuid.UUID) (int, error) {
var version int
err := r.pool.QueryRow(ctx, `
SELECT avatar_version
FROM profile
WHERE user_id = $1
`, userID).Scan(&version)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, domain.ErrNotFound
}
return 0, fmt.Errorf("get avatar version: %w", err)
}
return version, nil
}
// SetAvatarIfVersion updates the avatar URL only if the current version matches expectedVersion.
func (r *ProfileRepository) SetAvatarIfVersion(
ctx context.Context,
userID uuid.UUID,
expectedVersion, nextVersion int,
baseURL string,
updatedAt time.Time,
) (bool, error) {
tag, err := r.pool.Exec(ctx, `
UPDATE profile
SET avatar_url = $2,
avatar_version = $3,
updated_at = $4
WHERE user_id = $1
AND avatar_version = $5
`, userID, baseURL, nextVersion, updatedAt, expectedVersion)
if err != nil {
return false, fmt.Errorf("set avatar image: %w", err)
}
return tag.RowsAffected() == 1, nil
}
var _ imageuploadapp.ProfileRepository = (*ProfileRepository)(nil)
package postgres
import (
"context"
"errors"
"fmt"
ratingapp "github.com/bounswe/bounswe2026group11/backend/internal/application/rating"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
// RatingRepository is the Postgres-backed implementation of rating.Repository.
type RatingRepository struct {
pool *pgxpool.Pool
db execer
}
// NewRatingRepository returns a repository that executes queries against the given connection pool.
func NewRatingRepository(pool *pgxpool.Pool) *RatingRepository {
return &RatingRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
func (r *RatingRepository) GetEventRatingContext(
ctx context.Context,
eventID, participantUserID uuid.UUID,
) (*ratingapp.EventRatingContext, error) {
var (
status string
endTime pgtype.Timestamptz
ratingContext ratingapp.EventRatingContext
isRequestingHost bool
isApprovedParticipant bool
)
err := r.db.QueryRow(ctx, `
SELECT
e.id,
e.host_id,
e.status,
e.start_time,
e.end_time,
(e.host_id = $2) AS is_requesting_host,
EXISTS (
SELECT 1
FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $2
AND p.status = $3
) AS is_approved_participant
FROM event e
WHERE e.id = $1
`, eventID, participantUserID, domain.ParticipationStatusApproved).Scan(
&ratingContext.EventID,
&ratingContext.HostUserID,
&status,
&ratingContext.StartTime,
&endTime,
&isRequestingHost,
&isApprovedParticipant,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event rating context: %w", err)
}
ratingContext.Status = domain.EventStatus(status)
ratingContext.IsRequestingHost = isRequestingHost
ratingContext.IsApprovedParticipant = isApprovedParticipant
if endTime.Valid {
ratingContext.EndTime = &endTime.Time
}
return &ratingContext, nil
}
func (r *RatingRepository) UpsertEventRating(
ctx context.Context,
params ratingapp.UpsertEventRatingParams,
) (*domain.EventRating, error) {
row := r.db.QueryRow(ctx, `
INSERT INTO event_rating (participant_user_id, event_id, rating, message)
VALUES ($1, $2, $3, $4)
ON CONFLICT (participant_user_id, event_id)
DO UPDATE SET
rating = EXCLUDED.rating,
message = EXCLUDED.message,
updated_at = now()
RETURNING id, participant_user_id, event_id, rating, message, created_at, updated_at
`, params.ParticipantUserID, params.EventID, params.Rating, params.Message)
rating, err := scanEventRating(row)
if err != nil {
return nil, fmt.Errorf("upsert event rating: %w", err)
}
return rating, nil
}
func (r *RatingRepository) DeleteEventRating(ctx context.Context, eventID, participantUserID uuid.UUID) (bool, error) {
tag, err := r.db.Exec(ctx, `
DELETE FROM event_rating
WHERE event_id = $1
AND participant_user_id = $2
`, eventID, participantUserID)
if err != nil {
return false, fmt.Errorf("delete event rating: %w", err)
}
return tag.RowsAffected() == 1, nil
}
func (r *RatingRepository) GetParticipantRatingContext(
ctx context.Context,
eventID, hostUserID, participantUserID uuid.UUID,
) (*ratingapp.ParticipantRatingContext, error) {
var (
status string
endTime pgtype.Timestamptz
ratingContext ratingapp.ParticipantRatingContext
isApprovedParticipant bool
isRequestingHost bool
)
err := r.db.QueryRow(ctx, `
SELECT
e.id,
e.host_id,
e.status,
e.start_time,
e.end_time,
(e.host_id = $2) AS is_requesting_host,
EXISTS (
SELECT 1
FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $3
AND p.status = $4
) AS is_approved_participant
FROM event e
WHERE e.id = $1
`, eventID, hostUserID, participantUserID, domain.ParticipationStatusApproved).Scan(
&ratingContext.EventID,
&ratingContext.HostUserID,
&status,
&ratingContext.StartTime,
&endTime,
&isRequestingHost,
&isApprovedParticipant,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get participant rating context: %w", err)
}
ratingContext.ParticipantUserID = participantUserID
ratingContext.Status = domain.EventStatus(status)
ratingContext.IsRequestingHost = isRequestingHost
ratingContext.IsApprovedParticipant = isApprovedParticipant
if endTime.Valid {
ratingContext.EndTime = &endTime.Time
}
return &ratingContext, nil
}
func (r *RatingRepository) UpsertParticipantRating(
ctx context.Context,
params ratingapp.UpsertParticipantRatingParams,
) (*domain.ParticipantRating, error) {
row := r.db.QueryRow(ctx, `
INSERT INTO participant_rating (host_user_id, participant_user_id, event_id, rating, message)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (host_user_id, participant_user_id, event_id)
DO UPDATE SET
rating = EXCLUDED.rating,
message = EXCLUDED.message,
updated_at = now()
RETURNING id, host_user_id, participant_user_id, event_id, rating, message, created_at, updated_at
`, params.HostUserID, params.ParticipantUserID, params.EventID, params.Rating, params.Message)
rating, err := scanParticipantRating(row)
if err != nil {
return nil, fmt.Errorf("upsert participant rating: %w", err)
}
return rating, nil
}
func (r *RatingRepository) DeleteParticipantRating(
ctx context.Context,
eventID, hostUserID, participantUserID uuid.UUID,
) (bool, error) {
tag, err := r.db.Exec(ctx, `
DELETE FROM participant_rating
WHERE event_id = $1
AND host_user_id = $2
AND participant_user_id = $3
`, eventID, hostUserID, participantUserID)
if err != nil {
return false, fmt.Errorf("delete participant rating: %w", err)
}
return tag.RowsAffected() == 1, nil
}
func (r *RatingRepository) CalculateParticipantAggregate(
ctx context.Context,
userID uuid.UUID,
) (*ratingapp.ScoreAggregate, error) {
var (
average pgtype.Float8
count int
)
err := r.db.QueryRow(ctx, `
SELECT AVG(rating)::double precision, COUNT(*)
FROM participant_rating
WHERE participant_user_id = $1
`, userID).Scan(&average, &count)
if err != nil {
return nil, fmt.Errorf("calculate participant aggregate: %w", err)
}
result := &ratingapp.ScoreAggregate{Count: count}
if average.Valid {
result.Average = &average.Float64
}
return result, nil
}
func (r *RatingRepository) CalculateHostedEventAggregate(
ctx context.Context,
userID uuid.UUID,
) (*ratingapp.ScoreAggregate, error) {
var (
average pgtype.Float8
count int
)
err := r.db.QueryRow(ctx, `
SELECT AVG(er.rating)::double precision, COUNT(*)
FROM event_rating er
JOIN event e ON e.id = er.event_id
WHERE e.host_id = $1
`, userID).Scan(&average, &count)
if err != nil {
return nil, fmt.Errorf("calculate hosted event aggregate: %w", err)
}
result := &ratingapp.ScoreAggregate{Count: count}
if average.Valid {
result.Average = &average.Float64
}
return result, nil
}
func (r *RatingRepository) UpsertUserScore(ctx context.Context, params ratingapp.UpsertUserScoreParams) error {
_, err := r.db.Exec(ctx, `
INSERT INTO user_score (
user_id,
participant_score,
participant_rating_count,
hosted_event_score,
hosted_event_rating_count,
final_score
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id)
DO UPDATE SET
participant_score = EXCLUDED.participant_score,
participant_rating_count = EXCLUDED.participant_rating_count,
hosted_event_score = EXCLUDED.hosted_event_score,
hosted_event_rating_count = EXCLUDED.hosted_event_rating_count,
final_score = EXCLUDED.final_score,
updated_at = now()
`, params.UserID, params.ParticipantScore, params.ParticipantRatingCount, params.HostedEventScore, params.HostedEventRatingCount, params.FinalScore)
if err != nil {
return fmt.Errorf("upsert user score: %w", err)
}
return nil
}
func scanEventRating(row pgx.Row) (*domain.EventRating, error) {
var (
record domain.EventRating
message pgtype.Text
)
if err := row.Scan(
&record.ID,
&record.ParticipantUserID,
&record.EventID,
&record.Rating,
&message,
&record.CreatedAt,
&record.UpdatedAt,
); err != nil {
return nil, err
}
record.Message = textPtr(message)
return &record, nil
}
func scanParticipantRating(row pgx.Row) (*domain.ParticipantRating, error) {
var (
record domain.ParticipantRating
message pgtype.Text
)
if err := row.Scan(
&record.ID,
&record.HostUserID,
&record.ParticipantUserID,
&record.EventID,
&record.Rating,
&message,
&record.CreatedAt,
&record.UpdatedAt,
); err != nil {
return nil, err
}
record.Message = textPtr(message)
return &record, nil
}
package postgres
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// execer abstracts pgxpool.Pool and pgx.Tx so repository methods can execute
// against either the default pool or an ambient transaction.
type execer interface {
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
type txContextKey struct{}
type contextualRunner struct {
fallback execer
}
func (r contextualRunner) Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) {
return runnerFromContext(ctx, r.fallback).Exec(ctx, sql, arguments...)
}
func (r contextualRunner) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
return runnerFromContext(ctx, r.fallback).Query(ctx, sql, args...)
}
func (r contextualRunner) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
return runnerFromContext(ctx, r.fallback).QueryRow(ctx, sql, args...)
}
func withTx(ctx context.Context, tx pgx.Tx) context.Context {
return context.WithValue(ctx, txContextKey{}, tx)
}
func txFromContext(ctx context.Context) pgx.Tx {
tx, _ := ctx.Value(txContextKey{}).(pgx.Tx)
return tx
}
func runnerFromContext(ctx context.Context, fallback execer) execer {
if tx := txFromContext(ctx); tx != nil {
return tx
}
return fallback
}
package postgres
import (
"context"
"fmt"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type txStarter interface {
BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error)
}
// UnitOfWork is the Postgres-backed implementation of the application UoW port.
// Nested RunInTx calls reuse the ambient transaction from context, and the fixed
// transaction variant is intended for rollback-only integration harnesses.
type UnitOfWork struct {
starter txStarter
fixedTx pgx.Tx
}
var _ uow.UnitOfWork = (*UnitOfWork)(nil)
func NewUnitOfWork(pool *pgxpool.Pool) *UnitOfWork {
return &UnitOfWork{starter: pool}
}
func NewUnitOfWorkWithTx(pool *pgxpool.Pool, tx pgx.Tx) *UnitOfWork {
return &UnitOfWork{
starter: pool,
fixedTx: tx,
}
}
func (u *UnitOfWork) RunInTx(ctx context.Context, fn func(ctx context.Context) error) error {
if tx := txFromContext(ctx); tx != nil {
return fn(ctx)
}
if u.fixedTx != nil {
return fn(withTx(ctx, u.fixedTx))
}
tx, err := u.starter.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
txCtx := withTx(ctx, tx)
if err := fn(txCtx); err != nil {
_ = tx.Rollback(txCtx)
return err
}
if err := tx.Commit(txCtx); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
package ratelimit
import (
"sync"
"time"
)
// InMemoryRateLimiter implements auth.RateLimiter using a fixed-window
// counter stored in memory. Swap with a Redis-backed implementation for
// distributed deployments.
type InMemoryRateLimiter struct {
mu sync.Mutex
window time.Duration
limit int
buckets map[string]bucket
}
type bucket struct {
WindowStartedAt time.Time
Count int
}
// NewInMemoryRateLimiter creates a rate limiter with the given max requests (limit)
// per time window.
func NewInMemoryRateLimiter(limit int, window time.Duration) *InMemoryRateLimiter {
return &InMemoryRateLimiter{
window: window,
limit: limit,
buckets: make(map[string]bucket),
}
}
// Allow checks whether key is within its rate limit at time now. Returns false
// and a suggested retry-after duration if the limit has been reached.
func (l *InMemoryRateLimiter) Allow(key string, now time.Time) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
current := l.buckets[key]
// Start a new window if this is the first request or the previous window has elapsed.
if current.WindowStartedAt.IsZero() || now.Sub(current.WindowStartedAt) >= l.window {
current = bucket{WindowStartedAt: now, Count: 0}
}
// Reject if the request count has reached the limit within this window.
if current.Count >= l.limit {
retryAfter := current.WindowStartedAt.Add(l.window).Sub(now)
if retryAfter < 0 {
retryAfter = 0
}
l.buckets[key] = current
return false, retryAfter
}
current.Count++
l.buckets[key] = current
return true, 0
}
package security
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
// RefreshTokenManager implements auth.RefreshTokenManager.
type RefreshTokenManager struct {
ByteLength int
}
// NewToken generates a random refresh token, returning both the base64url-encoded
// plaintext (sent to the client) and its SHA-256 hash (stored in the database).
func (m RefreshTokenManager) NewToken() (string, string, error) {
size := m.ByteLength
if size <= 0 {
size = 32
}
tokenBytes := make([]byte, size)
if _, err := rand.Read(tokenBytes); err != nil {
return "", "", err
}
plain := base64.RawURLEncoding.EncodeToString(tokenBytes)
return plain, m.HashToken(plain), nil
}
// HashToken returns the hex-encoded SHA-256 digest of a plaintext refresh token.
func (m RefreshTokenManager) HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
package spaces
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
smithyhttp "github.com/aws/smithy-go/transport/http"
"github.com/bounswe/bounswe2026group11/backend/internal/application/imageupload"
)
// Config contains the secrets and endpoint settings required to talk to Spaces.
type Config struct {
AccessKey string
SecretKey string
Endpoint string
Bucket string
Region string
}
// Storage is the Spaces-backed implementation of imageupload.Storage.
type Storage struct {
client *s3.Client
presign *s3.PresignClient
bucket string
}
// NewStorage constructs a Spaces client using the AWS SDK v2 S3 client.
func NewStorage(cfg Config) *Storage {
awsCfg := aws.Config{
Region: strings.TrimSpace(cfg.Region),
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
}
client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) {
opts.UsePathStyle = false
opts.BaseEndpoint = aws.String(strings.TrimRight(strings.TrimSpace(cfg.Endpoint), "/"))
})
return &Storage{
client: client,
presign: s3.NewPresignClient(client),
bucket: strings.TrimSpace(cfg.Bucket),
}
}
// PresignPutObject returns a signed PUT request for a single object key.
func (s *Storage) PresignPutObject(
ctx context.Context,
key, contentType, cacheControl string,
expires time.Duration,
) (*imageupload.PresignedRequest, error) {
req, err := s.presign.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
ContentType: aws.String(contentType),
CacheControl: aws.String(cacheControl),
ACL: types.ObjectCannedACLPublicRead,
}, func(opts *s3.PresignOptions) {
opts.Expires = expires
})
if err != nil {
return nil, fmt.Errorf("presign put object %q: %w", key, err)
}
headers := map[string]string{
"Content-Type": contentType,
"Cache-Control": cacheControl,
"x-amz-acl": string(types.ObjectCannedACLPublicRead),
}
for name, values := range req.SignedHeader {
if strings.EqualFold(name, "Host") || len(values) == 0 {
continue
}
headers[normalizeHeaderName(name)] = values[0]
}
return &imageupload.PresignedRequest{
Method: req.Method,
URL: req.URL,
Headers: headers,
}, nil
}
func normalizeHeaderName(name string) string {
switch {
case strings.EqualFold(name, "Content-Type"):
return "Content-Type"
case strings.EqualFold(name, "Cache-Control"):
return "Cache-Control"
case strings.EqualFold(name, "x-amz-acl"):
return "x-amz-acl"
default:
return name
}
}
// ObjectExists checks whether the requested object key is present in Spaces.
func (s *Storage) ObjectExists(ctx context.Context, key string) (bool, error) {
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err == nil {
return true, nil
}
if isNotFound(err) {
return false, nil
}
return false, fmt.Errorf("head object %q: %w", key, err)
}
func isNotFound(err error) bool {
var responseErr *smithyhttp.ResponseError
if errors.As(err, &responseErr) && responseErr.HTTPStatusCode() == http.StatusNotFound {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
code := apiErr.ErrorCode()
return code == "NotFound" || code == "NoSuchKey"
}
return false
}
package auth_handler
import (
"strings"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/auth"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// AuthHandler groups HTTP handlers that delegate to the auth use-case port.
type AuthHandler struct {
service auth.UseCase
}
// NewAuthHandler creates a handler backed by the given auth use case.
func NewAuthHandler(service auth.UseCase) *AuthHandler {
return &AuthHandler{service: service}
}
// RegisterAuthRoutes mounts all authentication endpoints under /auth.
func RegisterAuthRoutes(router fiber.Router, handler *AuthHandler) {
group := router.Group("/auth")
group.Post("/register/email/request-otp", handler.RequestRegistrationOTP)
group.Post("/forgot-password/request-otp", handler.RequestPasswordResetOTP)
group.Post("/forgot-password/verify-otp", handler.VerifyPasswordResetOTP)
group.Post("/forgot-password/reset-password", handler.ResetPassword)
group.Post("/register/email/verify", handler.VerifyRegistrationOTP)
group.Post("/register/check-availability", handler.CheckAvailability)
group.Post("/login", handler.Login)
group.Post("/refresh", handler.Refresh)
group.Post("/logout", handler.Logout)
}
// RequestRegistrationOTP handles POST /auth/register/email/request-otp.
func (h *AuthHandler) RequestRegistrationOTP(c *fiber.Ctx) error {
var body requestOTPBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
err := h.service.RequestRegistrationOTP(c.UserContext(), auth.RequestOTPInput{
Email: body.Email,
})
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
"status": "accepted",
"message": "If the email can be registered, an OTP has been sent.",
})
}
// RequestPasswordResetOTP handles POST /auth/forgot-password/request-otp.
func (h *AuthHandler) RequestPasswordResetOTP(c *fiber.Ctx) error {
var body requestOTPBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
err := h.service.RequestPasswordResetOTP(c.UserContext(), auth.RequestOTPInput{
Email: body.Email,
})
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "ok",
"message": "If an account with that email exists, a password-reset OTP has been sent.",
})
}
// VerifyPasswordResetOTP handles POST /auth/forgot-password/verify-otp.
func (h *AuthHandler) VerifyPasswordResetOTP(c *fiber.Ctx) error {
var body verifyPasswordResetBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
grant, err := h.service.VerifyPasswordResetOTP(c.UserContext(), auth.VerifyPasswordResetInput{
Email: body.Email,
OTP: body.OTP,
})
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusOK).JSON(passwordResetGrantResponse{
Status: "ok",
ResetToken: grant.ResetToken,
ExpiresInSeconds: grant.ExpiresInSeconds,
})
}
// ResetPassword handles POST /auth/forgot-password/reset-password.
func (h *AuthHandler) ResetPassword(c *fiber.Ctx) error {
var body resetPasswordBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
if err := h.service.ResetPassword(c.UserContext(), auth.ResetPasswordInput{
Email: body.Email,
ResetToken: body.ResetToken,
NewPassword: body.NewPassword,
}); err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "ok",
"message": "Password has been reset.",
})
}
// CheckAvailability handles POST /auth/register/check-availability.
func (h *AuthHandler) CheckAvailability(c *fiber.Ctx) error {
var body checkAvailabilityBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
result, err := h.service.CheckAvailability(c.UserContext(), auth.CheckAvailabilityInput{
Username: body.Username,
Email: body.Email,
ClientKey: availabilityClientKey(c),
})
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"username": result.Username,
"email": result.Email,
})
}
// VerifyRegistrationOTP handles POST /auth/register/email/verify.
func (h *AuthHandler) VerifyRegistrationOTP(c *fiber.Ctx) error {
var body verifyRegistrationBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
session, err := h.service.VerifyRegistrationOTP(c.UserContext(), auth.VerifyRegistrationInput{
Email: body.Email,
OTP: body.OTP,
Username: body.Username,
Password: body.Password,
PhoneNumber: body.PhoneNumber,
Gender: body.Gender,
BirthDate: body.BirthDate,
DeviceInfo: userAgent(c),
})
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(toSessionResponse(session))
}
// Login handles POST /auth/login.
func (h *AuthHandler) Login(c *fiber.Ctx) error {
var body loginBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
session, err := h.service.Login(c.UserContext(), auth.LoginInput{
Username: body.Username,
Password: body.Password,
DeviceInfo: userAgent(c),
})
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusOK).JSON(toSessionResponse(session))
}
// Refresh handles POST /auth/refresh.
func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
var body refreshBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
session, err := h.service.Refresh(c.UserContext(), body.RefreshToken, userAgent(c))
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusOK).JSON(toSessionResponse(session))
}
// Logout handles POST /auth/logout.
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
var body refreshBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
if err := h.service.Logout(c.UserContext(), body.RefreshToken); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// toSessionResponse converts an auth.Session into the JSON response payload.
func toSessionResponse(s *auth.Session) sessionResponse {
return sessionResponse{
AccessToken: s.AccessToken,
RefreshToken: s.RefreshToken,
TokenType: s.TokenType,
ExpiresInSeconds: s.ExpiresInSeconds,
User: s.User,
}
}
// userAgent extracts the User-Agent header, returning nil if absent or empty.
func userAgent(c *fiber.Ctx) *string {
value := strings.TrimSpace(c.Get(fiber.HeaderUserAgent))
if value == "" {
return nil
}
return &value
}
func availabilityClientKey(c *fiber.Ctx) string {
return strings.TrimSpace(c.IP())
}
package category_handler
import (
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/category"
"github.com/gofiber/fiber/v2"
)
// CategoryHandler groups HTTP handlers that delegate to the category use-case port.
type CategoryHandler struct {
service category.UseCase
}
// NewCategoryHandler creates a category handler backed by the given use case.
func NewCategoryHandler(service category.UseCase) *CategoryHandler {
return &CategoryHandler{service: service}
}
// RegisterCategoryRoutes mounts all category endpoints under /categories.
func RegisterCategoryRoutes(router fiber.Router, handler *CategoryHandler) {
router.Get("/categories", handler.ListCategories)
}
// ListCategories handles GET /categories.
func (h *CategoryHandler) ListCategories(c *fiber.Ctx) error {
result, err := h.service.ListCategories(c.UserContext())
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
package event_handler
import (
"net/url"
"strconv"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/event"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// EventHandler groups HTTP handlers that delegate to the event use-case port.
type EventHandler struct {
service event.UseCase
}
// NewEventHandler creates an event handler backed by the given event use case.
func NewEventHandler(service event.UseCase) *EventHandler {
return &EventHandler{service: service}
}
// RegisterEventRoutes mounts all event endpoints under /events.
// The auth middleware is provided by the caller so that route protection
// decisions remain outside the handler. optionalAuth is used for read-only
// public endpoints so unauthenticated users can browse without a token.
func RegisterEventRoutes(router fiber.Router, handler *EventHandler, auth fiber.Handler, optionalAuth fiber.Handler) {
group := router.Group("/events")
group.Get("/", optionalAuth, handler.DiscoverEvents)
group.Get("/:id", optionalAuth, handler.GetEventDetail)
group.Get("/:id/host-context", auth, handler.GetEventHostContextSummary)
group.Get("/:id/participants", auth, handler.ListEventApprovedParticipants)
group.Get("/:id/join-requests", auth, handler.ListEventPendingJoinRequests)
group.Get("/:id/invitations", auth, handler.ListEventInvitations)
group.Post("/", auth, handler.CreateEvent)
group.Post("/:id/join", auth, handler.JoinEvent)
group.Patch("/:id/leave", auth, handler.LeaveEvent)
group.Post("/:id/join-request", auth, handler.RequestJoin)
group.Post("/:id/join-requests/:joinRequestId/approve", auth, handler.ApproveJoinRequest)
group.Post("/:id/join-requests/:joinRequestId/reject", auth, handler.RejectJoinRequest)
group.Patch("/:id/cancel", auth, handler.CancelEvent)
group.Patch("/:id/complete", auth, handler.CompleteEvent)
group.Post("/:id/favorite", auth, handler.AddFavorite)
group.Delete("/:id/favorite", auth, handler.RemoveFavorite)
}
// DiscoverEvents handles GET /events.
func (h *EventHandler) DiscoverEvents(c *fiber.Ctx) error {
input, errs := parseDiscoverEventsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
userID := callerID(c)
result, err := h.service.DiscoverEvents(c.UserContext(), userID, input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// GetEventDetail handles GET /events/:id.
func (h *EventHandler) GetEventDetail(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
userID := callerID(c)
result, err := h.service.GetEventDetail(c.UserContext(), userID, eventID)
if err != nil {
return httpapi.WriteError(c, err)
}
c.Set(fiber.HeaderCacheControl, "private, no-store")
c.Set(fiber.HeaderVary, fiber.HeaderAuthorization)
return c.JSON(result)
}
// GetEventHostContextSummary handles GET /events/:id/host-context.
func (h *EventHandler) GetEventHostContextSummary(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.GetEventHostContextSummary(c.UserContext(), claims.UserID, eventID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// ListEventApprovedParticipants handles GET /events/:id/participants.
func (h *EventHandler) ListEventApprovedParticipants(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
input, errs := parseEventCollectionInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
claims := httpapi.UserClaims(c)
result, err := h.service.ListEventApprovedParticipants(c.UserContext(), claims.UserID, eventID, input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// ListEventPendingJoinRequests handles GET /events/:id/join-requests.
func (h *EventHandler) ListEventPendingJoinRequests(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
input, errs := parseEventCollectionInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
claims := httpapi.UserClaims(c)
result, err := h.service.ListEventPendingJoinRequests(c.UserContext(), claims.UserID, eventID, input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// ListEventInvitations handles GET /events/:id/invitations.
func (h *EventHandler) ListEventInvitations(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
input, errs := parseEventCollectionInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
claims := httpapi.UserClaims(c)
result, err := h.service.ListEventInvitations(c.UserContext(), claims.UserID, eventID, input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// callerID returns the authenticated user's ID, or uuid.Nil for anonymous requests.
func callerID(c *fiber.Ctx) uuid.UUID {
if claims := httpapi.UserClaims(c); claims != nil {
return claims.UserID
}
return uuid.Nil
}
// CreateEvent handles POST /events.
func (h *EventHandler) CreateEvent(c *fiber.Ctx) error {
var body createEventBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
claims := httpapi.UserClaims(c)
input, errs := toCreateEventInput(body)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.CreateEvent(c.UserContext(), claims.UserID, input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// toCreateEventInput parses wire-format values and maps the request body to the
// application-level create-event DTO.
func toCreateEventInput(body createEventBody) (event.CreateEventInput, map[string]string) {
input := event.CreateEventInput{
Title: body.Title,
Description: body.Description,
ImageURL: body.ImageURL,
CategoryID: body.CategoryID,
Address: body.Address,
Lat: body.Lat,
Lon: body.Lon,
RoutePoints: toRoutePointInputs(body.RoutePoints),
Capacity: body.Capacity,
Tags: body.Tags,
Constraints: toConstraintInputs(body.Constraints),
MinimumAge: body.MinimumAge,
}
errs := make(map[string]string)
if body.LocationType != "" {
locationType, ok := domain.ParseEventLocationType(body.LocationType)
if !ok {
errs["location_type"] = "must be one of: POINT, ROUTE"
} else {
input.LocationType = locationType
}
}
if body.PrivacyLevel != "" {
privacyLevel, ok := domain.ParseEventPrivacyLevel(body.PrivacyLevel)
if !ok {
errs["privacy_level"] = "must be one of: PUBLIC, PROTECTED, PRIVATE"
} else {
input.PrivacyLevel = privacyLevel
}
}
if body.StartTime != "" {
startTime, err := time.Parse(time.RFC3339, body.StartTime)
if err != nil {
errs["start_time"] = "must be a valid RFC3339 date-time with timezone"
} else {
input.StartTime = startTime
}
}
if body.EndTime != nil {
endTime, err := time.Parse(time.RFC3339, *body.EndTime)
if err != nil {
errs["end_time"] = "must be a valid RFC3339 date-time with timezone"
} else {
input.EndTime = &endTime
}
}
if body.PreferredGender != nil {
gender, ok := domain.ParseEventParticipantGender(*body.PreferredGender)
if !ok {
errs["preferred_gender"] = "must be one of: MALE, FEMALE, OTHER"
} else {
input.PreferredGender = &gender
}
}
return input, errs
}
// toConstraintInputs converts the HTTP constraint bodies to service-level DTOs.
func toConstraintInputs(bodies []constraintBody) []event.ConstraintInput {
inputs := make([]event.ConstraintInput, len(bodies))
for i, b := range bodies {
inputs[i] = event.ConstraintInput{Type: b.Type, Info: b.Info}
}
return inputs
}
// toRoutePointInputs converts HTTP route point bodies to service-level DTOs.
func toRoutePointInputs(points []routePointBody) []event.RoutePointInput {
inputs := make([]event.RoutePointInput, len(points))
for i, p := range points {
inputs[i] = event.RoutePointInput{Lat: p.Lat, Lon: p.Lon}
}
return inputs
}
// JoinEvent handles POST /events/:id/join.
// Allows the authenticated user to join a PUBLIC event directly.
func (h *EventHandler) JoinEvent(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.JoinEvent(c.UserContext(), claims.UserID, eventID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// LeaveEvent handles PATCH /events/:id/leave.
// Allows the authenticated user to leave an event they previously joined.
func (h *EventHandler) LeaveEvent(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.LeaveEvent(c.UserContext(), claims.UserID, eventID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// RequestJoin handles POST /events/:id/join-request.
// Allows the authenticated user to submit a join request for a PROTECTED event.
func (h *EventHandler) RequestJoin(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
var body requestJoinBody
if len(c.Body()) > 0 {
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
}
claims := httpapi.UserClaims(c)
result, err := h.service.RequestJoin(c.UserContext(), claims.UserID, eventID, event.RequestJoinInput{
Message: body.Message,
})
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// ApproveJoinRequest handles POST /events/:id/join-requests/:joinRequestId/approve.
// Allows the authenticated host to approve a pending join request.
func (h *EventHandler) ApproveJoinRequest(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
joinRequestID, err := parseJoinRequestIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.ApproveJoinRequest(c.UserContext(), claims.UserID, eventID, joinRequestID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// RejectJoinRequest handles POST /events/:id/join-requests/:joinRequestId/reject.
// Allows the authenticated host to reject a pending join request.
func (h *EventHandler) RejectJoinRequest(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
joinRequestID, err := parseJoinRequestIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.RejectJoinRequest(c.UserContext(), claims.UserID, eventID, joinRequestID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// CancelEvent handles PATCH /events/:id/cancel.
// Transitions an ACTIVE event to CANCELED. Only the host may perform this.
func (h *EventHandler) CancelEvent(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.service.CancelEvent(c.UserContext(), claims.UserID, eventID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// CompleteEvent handles PATCH /events/:id/complete.
// Transitions an ACTIVE or IN_PROGRESS event to COMPLETED. Only the host may perform this.
func (h *EventHandler) CompleteEvent(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.service.CompleteEvent(c.UserContext(), claims.UserID, eventID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// AddFavorite handles POST /events/:id/favorite.
func (h *EventHandler) AddFavorite(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.service.AddFavorite(c.UserContext(), claims.UserID, eventID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// RemoveFavorite handles DELETE /events/:id/favorite.
func (h *EventHandler) RemoveFavorite(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.service.RemoveFavorite(c.UserContext(), claims.UserID, eventID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func parseEventIDParam(c *fiber.Ctx) (uuid.UUID, error) {
eventID, err := uuid.Parse(c.Params("id"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"id": "must be a valid UUID"})
}
return eventID, nil
}
func parseJoinRequestIDParam(c *fiber.Ctx) (uuid.UUID, error) {
joinRequestID, err := uuid.Parse(c.Params("joinRequestId"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"joinRequestId": "must be a valid UUID"})
}
return joinRequestID, nil
}
func parseDiscoverEventsInput(c *fiber.Ctx) (event.DiscoverEventsInput, map[string]string) {
values, err := url.ParseQuery(string(c.Context().URI().QueryString()))
if err != nil {
return event.DiscoverEventsInput{}, map[string]string{"query": "query string must be valid"}
}
input := event.DiscoverEventsInput{}
errs := make(map[string]string)
if lat, ok, msg := parseOptionalFloatQuery(c, "lat"); msg != "" {
errs["lat"] = msg
} else if ok {
input.Lat = &lat
}
if lon, ok, msg := parseOptionalFloatQuery(c, "lon"); msg != "" {
errs["lon"] = msg
} else if ok {
input.Lon = &lon
}
if radius, ok, msg := parseOptionalIntQuery(c, "radius_meters"); msg != "" {
errs["radius_meters"] = msg
} else if ok {
input.RadiusMeters = &radius
}
if limit, ok, msg := parseOptionalIntQuery(c, "limit"); msg != "" {
errs["limit"] = msg
} else if ok {
input.Limit = &limit
}
if rawQuery := strings.TrimSpace(c.Query("q")); rawQuery != "" {
input.Query = &rawQuery
}
if privacyLevels, msg := parsePrivacyLevels(values, "privacy_levels"); msg != "" {
errs["privacy_levels"] = msg
} else {
input.PrivacyLevels = privacyLevels
}
if categoryIDs, msg := parseCategoryIDs(values, "category_ids"); msg != "" {
errs["category_ids"] = msg
} else {
input.CategoryIDs = categoryIDs
}
tagNames := parseListQueryValues(values, "tag_names")
if len(tagNames) > 0 {
input.TagNames = tagNames
}
if startFrom, ok, msg := parseOptionalTimeQuery(c, "start_from"); msg != "" {
errs["start_from"] = msg
} else if ok {
input.StartFrom = &startFrom
}
if startTo, ok, msg := parseOptionalTimeQuery(c, "start_to"); msg != "" {
errs["start_to"] = msg
} else if ok {
input.StartTo = &startTo
}
if rawOnlyFavorited := strings.TrimSpace(c.Query("only_favorited")); rawOnlyFavorited != "" {
parsed, err := strconv.ParseBool(rawOnlyFavorited)
if err != nil {
errs["only_favorited"] = "only_favorited must be a boolean"
} else {
input.OnlyFavorited = parsed
}
}
if rawSortBy := strings.TrimSpace(c.Query("sort_by")); rawSortBy != "" {
sortBy, ok := domain.ParseEventDiscoverySort(rawSortBy)
if !ok {
errs["sort_by"] = "must be one of: START_TIME, DISTANCE, RELEVANCE"
} else {
input.SortBy = &sortBy
}
}
if rawCursor := strings.TrimSpace(c.Query("cursor")); rawCursor != "" {
input.Cursor = &rawCursor
}
return input, errs
}
func parseEventCollectionInput(c *fiber.Ctx) (event.ListEventCollectionInput, map[string]string) {
input := event.ListEventCollectionInput{}
errs := make(map[string]string)
if limit, ok, msg := parseOptionalIntQuery(c, "limit"); msg != "" {
errs["limit"] = msg
} else if ok {
input.Limit = &limit
}
if rawCursor := strings.TrimSpace(c.Query("cursor")); rawCursor != "" {
input.Cursor = &rawCursor
}
return input, errs
}
func parseOptionalFloatQuery(c *fiber.Ctx, key string) (float64, bool, string) {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return 0, false, ""
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, false, key + " must be a valid number"
}
return value, true, ""
}
func parseOptionalIntQuery(c *fiber.Ctx, key string) (int, bool, string) {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return 0, false, ""
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, false, key + " must be a valid integer"
}
return value, true, ""
}
func parseOptionalTimeQuery(c *fiber.Ctx, key string) (time.Time, bool, string) {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return time.Time{}, false, ""
}
value, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false, key + " must be a valid RFC3339 date-time with timezone"
}
return value, true, ""
}
func parseCategoryIDs(values url.Values, key string) ([]int, string) {
rawValues := parseListQueryValues(values, key)
if len(rawValues) == 0 {
return nil, ""
}
categoryIDs := make([]int, 0, len(rawValues))
for _, raw := range rawValues {
value, err := strconv.Atoi(raw)
if err != nil {
return nil, "category_ids must contain only integers"
}
categoryIDs = append(categoryIDs, value)
}
return categoryIDs, ""
}
func parsePrivacyLevels(values url.Values, key string) ([]domain.EventPrivacyLevel, string) {
rawValues := parseListQueryValues(values, key)
if len(rawValues) == 0 {
return nil, ""
}
levels := make([]domain.EventPrivacyLevel, 0, len(rawValues))
for _, raw := range rawValues {
switch raw {
case string(domain.PrivacyPublic):
levels = append(levels, domain.PrivacyPublic)
case string(domain.PrivacyProtected):
levels = append(levels, domain.PrivacyProtected)
default:
return nil, "privacy_levels must contain only: PUBLIC, PROTECTED"
}
}
return levels, ""
}
func parseListQueryValues(values url.Values, key string) []string {
rawValues := values[key]
if len(rawValues) == 0 {
return nil
}
items := make([]string, 0, len(rawValues))
for _, rawValue := range rawValues {
parts := strings.Split(rawValue, ",")
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
items = append(items, trimmed)
}
}
return items
}
package favorite_location_handler
import (
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
favoritelocationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/favorite_location"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// Handler groups HTTP handlers that delegate to the favorite-location use case.
type Handler struct {
service favoritelocationapp.UseCase
}
// NewHandler constructs a favorite-location handler.
func NewHandler(service favoritelocationapp.UseCase) *Handler {
return &Handler{service: service}
}
// RegisterRoutes mounts all favorite-location endpoints under /me.
func RegisterRoutes(router fiber.Router, handler *Handler, auth fiber.Handler) {
me := router.Group("/me", auth)
me.Get("/favorite-locations", handler.ListFavoriteLocations)
me.Post("/favorite-locations", handler.CreateFavoriteLocation)
me.Patch("/favorite-locations/:id", handler.UpdateFavoriteLocation)
me.Delete("/favorite-locations/:id", handler.DeleteFavoriteLocation)
}
// ListFavoriteLocations handles GET /me/favorite-locations.
func (h *Handler) ListFavoriteLocations(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.ListMyFavoriteLocations(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// CreateFavoriteLocation handles POST /me/favorite-locations.
func (h *Handler) CreateFavoriteLocation(c *fiber.Ctx) error {
var body createFavoriteLocationBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
input, errs := toCreateFavoriteLocationInput(body)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
claims := httpapi.UserClaims(c)
input.UserID = claims.UserID
result, err := h.service.CreateMyFavoriteLocation(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// UpdateFavoriteLocation handles PATCH /me/favorite-locations/:id.
func (h *Handler) UpdateFavoriteLocation(c *fiber.Ctx) error {
favoriteLocationID, err := parseFavoriteLocationID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
var body updateFavoriteLocationBody
if len(c.Body()) == 0 {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must not be empty"}))
}
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
claims := httpapi.UserClaims(c)
result, err := h.service.UpdateMyFavoriteLocation(c.UserContext(), favoritelocationapp.UpdateFavoriteLocationInput{
UserID: claims.UserID,
FavoriteLocationID: favoriteLocationID,
Name: body.Name,
Address: body.Address,
Lat: body.Lat,
Lon: body.Lon,
})
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// DeleteFavoriteLocation handles DELETE /me/favorite-locations/:id.
func (h *Handler) DeleteFavoriteLocation(c *fiber.Ctx) error {
favoriteLocationID, err := parseFavoriteLocationID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.service.DeleteMyFavoriteLocation(c.UserContext(), claims.UserID, favoriteLocationID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func toCreateFavoriteLocationInput(body createFavoriteLocationBody) (favoritelocationapp.CreateFavoriteLocationInput, map[string]string) {
input := favoritelocationapp.CreateFavoriteLocationInput{}
errs := make(map[string]string)
if body.Name == nil {
errs["name"] = "is required"
} else {
input.Name = *body.Name
}
if body.Address == nil {
errs["address"] = "is required"
} else {
input.Address = *body.Address
}
if body.Lat == nil {
errs["lat"] = "is required"
} else {
input.Lat = *body.Lat
}
if body.Lon == nil {
errs["lon"] = "is required"
} else {
input.Lon = *body.Lon
}
return input, errs
}
func parseFavoriteLocationID(c *fiber.Ctx) (uuid.UUID, error) {
favoriteLocationID, err := uuid.Parse(c.Params("id"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"id": "must be a valid UUID"})
}
return favoriteLocationID, nil
}
package httpapi
import (
"errors"
"log"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// ErrorEnvelope wraps all error responses in a consistent JSON structure: {"error": {...}}.
type ErrorEnvelope struct {
Error ErrorBody `json:"error"`
}
// ErrorBody is the inner payload of an error response, containing a machine-
// readable code, a human-readable message, and optional per-field details.
type ErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
}
// WriteError converts err into a JSON error response. Known AppErrors are
// serialized with their original status; unexpected errors produce a 500.
func WriteError(c *fiber.Ctx, err error) error {
if appErr, ok := errors.AsType[*domain.AppError](err); ok {
return c.Status(appErr.Status).JSON(ErrorEnvelope{
Error: ErrorBody{
Code: appErr.Code,
Message: appErr.Message,
Details: appErr.Details,
},
})
}
log.Printf("handler error: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(ErrorEnvelope{
Error: ErrorBody{
Code: "internal_server_error",
Message: "An unexpected error occurred.",
},
})
}
package image_upload_handler
import (
"strings"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/imageupload"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// Handler groups HTTP handlers that delegate to the image-upload use case.
type Handler struct {
service imageupload.UseCase
}
// NewHandler constructs an image upload handler.
func NewHandler(service imageupload.UseCase) *Handler {
return &Handler{service: service}
}
// RegisterRoutes mounts all image-upload endpoints under /me and /events.
func RegisterRoutes(router fiber.Router, handler *Handler, auth fiber.Handler) {
me := router.Group("/me", auth)
me.Post("/avatar/upload-url", handler.CreateProfileAvatarUpload)
me.Post("/avatar/confirm", handler.ConfirmProfileAvatarUpload)
events := router.Group("/events", auth)
events.Post("/:id/image/upload-url", handler.CreateEventImageUpload)
events.Post("/:id/image/confirm", handler.ConfirmEventImageUpload)
}
// CreateProfileAvatarUpload handles POST /me/avatar/upload-url.
func (h *Handler) CreateProfileAvatarUpload(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.CreateProfileAvatarUpload(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// ConfirmProfileAvatarUpload handles POST /me/avatar/confirm.
func (h *Handler) ConfirmProfileAvatarUpload(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
body, err := parseConfirmBody(c)
if err != nil {
return httpapi.WriteError(c, err)
}
if err := h.service.ConfirmProfileAvatarUpload(c.UserContext(), claims.UserID, body); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// CreateEventImageUpload handles POST /events/:id/image/upload-url.
func (h *Handler) CreateEventImageUpload(c *fiber.Ctx) error {
eventID, err := parseEventID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.CreateEventImageUpload(c.UserContext(), claims.UserID, eventID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// ConfirmEventImageUpload handles POST /events/:id/image/confirm.
func (h *Handler) ConfirmEventImageUpload(c *fiber.Ctx) error {
eventID, err := parseEventID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
body, err := parseConfirmBody(c)
if err != nil {
return httpapi.WriteError(c, err)
}
if err := h.service.ConfirmEventImageUpload(c.UserContext(), claims.UserID, eventID, body); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
type confirmBody struct {
ConfirmToken string `json:"confirm_token"`
}
func parseConfirmBody(c *fiber.Ctx) (imageupload.ConfirmUploadInput, error) {
var body confirmBody
if err := c.BodyParser(&body); err != nil {
return imageupload.ConfirmUploadInput{}, domain.ValidationError(map[string]string{"body": "must be valid JSON"})
}
if strings.TrimSpace(body.ConfirmToken) == "" {
return imageupload.ConfirmUploadInput{}, domain.ValidationError(map[string]string{"confirm_token": "confirm_token is required"})
}
return imageupload.ConfirmUploadInput{ConfirmToken: body.ConfirmToken}, nil
}
func parseEventID(c *fiber.Ctx) (uuid.UUID, error) {
eventID, err := uuid.Parse(c.Params("id"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"id": "must be a valid UUID"})
}
return eventID, nil
}
package httpapi
import (
"log/slog"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// contextKeyUserClaims is the key used to store AuthClaims in the Fiber context.
const contextKeyUserClaims = "user_claims"
// RequireAuth returns a middleware that validates the Bearer access token in the
// Authorization header. On success it stores the claims in the request context
// so downstream handlers can call UserClaims(c). On failure it returns 401.
func RequireAuth(verifier domain.TokenVerifier) fiber.Handler {
return func(c *fiber.Ctx) error {
header := c.Get(fiber.HeaderAuthorization)
token, ok := extractBearer(header)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(ErrorEnvelope{
Error: ErrorBody{
Code: "missing_token",
Message: "Authorization header with Bearer token is required.",
},
})
}
claims, err := verifier.VerifyAccessToken(token)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(ErrorEnvelope{
Error: ErrorBody{
Code: "invalid_token",
Message: "The access token is invalid or expired.",
},
})
}
c.Locals(contextKeyUserClaims, claims)
return c.Next()
}
}
// OptionalAuth returns a middleware that parses the Bearer token if present
// and stores the claims in the request context. If the header is absent the
// request proceeds unauthenticated (UserClaims will return nil). If a token
// is present but invalid the request is rejected with 401.
func OptionalAuth(verifier domain.TokenVerifier) fiber.Handler {
return func(c *fiber.Ctx) error {
header := c.Get(fiber.HeaderAuthorization)
token, ok := extractBearer(header)
if !ok {
return c.Next()
}
claims, err := verifier.VerifyAccessToken(token)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(ErrorEnvelope{
Error: ErrorBody{
Code: "invalid_token",
Message: "The access token is invalid or expired.",
},
})
}
c.Locals(contextKeyUserClaims, claims)
return c.Next()
}
}
// UserClaims retrieves the authenticated user's claims from the request context.
// Returns nil if RequireAuth middleware was not applied to the route.
func UserClaims(c *fiber.Ctx) *domain.AuthClaims {
claims, _ := c.Locals(contextKeyUserClaims).(*domain.AuthClaims)
return claims
}
// RequestLogger returns a middleware that logs the HTTP method, path, status
// code, and latency of every request using the structured logger.
func RequestLogger() fiber.Handler {
return func(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
slog.Info("request",
"method", c.Method(),
"path", c.Path(),
"status", c.Response().StatusCode(),
"latency", time.Since(start).String(),
)
return err
}
}
// extractBearer parses "Bearer <token>" from an Authorization header value.
func extractBearer(header string) (string, bool) {
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
return "", false
}
token := strings.TrimSpace(header[len(prefix):])
if token == "" {
return "", false
}
return token, true
}
package profile_handler
import (
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/event"
"github.com/bounswe/bounswe2026group11/backend/internal/application/profile"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// ProfileHandler groups HTTP handlers that delegate to the profile use-case port.
type ProfileHandler struct {
service profile.UseCase
eventService event.UseCase
}
// NewProfileHandler creates a profile handler backed by the given use cases.
func NewProfileHandler(service profile.UseCase, eventService event.UseCase) *ProfileHandler {
return &ProfileHandler{service: service, eventService: eventService}
}
// RegisterProfileRoutes mounts all profile endpoints under /me.
func RegisterProfileRoutes(router fiber.Router, handler *ProfileHandler, auth fiber.Handler) {
me := router.Group("/me", auth)
me.Get("", handler.GetMyProfile)
me.Patch("", handler.UpdateMyProfile)
me.Get("/events/hosted", handler.GetMyHostedEvents)
me.Get("/events/upcoming", handler.GetMyUpcomingEvents)
me.Get("/events/completed", handler.GetMyCompletedEvents)
me.Get("/events/canceled", handler.GetMyCanceledEvents)
me.Get("/favorites", handler.ListFavoriteEvents)
}
// GetMyProfile handles GET /me.
func (h *ProfileHandler) GetMyProfile(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.GetMyProfile(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// GetMyHostedEvents handles GET /me/events/hosted.
func (h *ProfileHandler) GetMyHostedEvents(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
events, err := h.service.GetMyHostedEvents(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(fiber.Map{"events": events})
}
// GetMyUpcomingEvents handles GET /me/events/upcoming.
func (h *ProfileHandler) GetMyUpcomingEvents(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
events, err := h.service.GetMyUpcomingEvents(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(fiber.Map{"events": events})
}
// GetMyCompletedEvents handles GET /me/events/completed.
func (h *ProfileHandler) GetMyCompletedEvents(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
events, err := h.service.GetMyCompletedEvents(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(fiber.Map{"events": events})
}
// GetMyCanceledEvents handles GET /me/events/canceled.
func (h *ProfileHandler) GetMyCanceledEvents(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
events, err := h.service.GetMyCanceledEvents(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(fiber.Map{"events": events})
}
// updateProfileBody is the request body for PATCH /me.
type updateProfileBody struct {
PhoneNumber *string `json:"phone_number"`
Gender *string `json:"gender"`
BirthDate *string `json:"birth_date"`
DefaultLocationAddress *string `json:"default_location_address"`
DefaultLocationLat *float64 `json:"default_location_lat"`
DefaultLocationLon *float64 `json:"default_location_lon"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
AvatarURL *string `json:"avatar_url"`
}
// UpdateMyProfile handles PATCH /me.
func (h *ProfileHandler) UpdateMyProfile(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
var body updateProfileBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
if err := h.service.UpdateMyProfile(c.UserContext(), profile.UpdateProfileInput{
UserID: claims.UserID,
PhoneNumber: body.PhoneNumber,
Gender: body.Gender,
BirthDate: body.BirthDate,
DefaultLocationAddress: body.DefaultLocationAddress,
DefaultLocationLat: body.DefaultLocationLat,
DefaultLocationLon: body.DefaultLocationLon,
DisplayName: body.DisplayName,
Bio: body.Bio,
AvatarURL: body.AvatarURL,
}); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// ListFavoriteEvents handles GET /me/favorites.
func (h *ProfileHandler) ListFavoriteEvents(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.eventService.ListFavoriteEvents(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
package rating_handler
import (
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
ratingapp "github.com/bounswe/bounswe2026group11/backend/internal/application/rating"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// RatingHandler groups HTTP handlers that delegate to the rating use-case port.
type RatingHandler struct {
service ratingapp.UseCase
}
// NewRatingHandler creates a rating handler backed by the given rating use case.
func NewRatingHandler(service ratingapp.UseCase) *RatingHandler {
return &RatingHandler{service: service}
}
// RegisterRatingRoutes mounts all rating endpoints under /events.
func RegisterRatingRoutes(router fiber.Router, handler *RatingHandler, auth fiber.Handler) {
group := router.Group("/events")
group.Put("/:id/rating", auth, handler.UpsertEventRating)
group.Delete("/:id/rating", auth, handler.DeleteEventRating)
group.Put("/:id/participants/:participantUserId/rating", auth, handler.UpsertParticipantRating)
group.Delete("/:id/participants/:participantUserId/rating", auth, handler.DeleteParticipantRating)
}
// UpsertEventRating handles PUT /events/:id/rating.
func (h *RatingHandler) UpsertEventRating(c *fiber.Ctx) error {
eventID, err := parseUUIDParam(c, "id")
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"id": "must be a valid UUID"}))
}
input, err := parseUpsertRatingBody(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, svcErr := h.service.UpsertEventRating(c.UserContext(), claims.UserID, eventID, input)
if svcErr != nil {
return httpapi.WriteError(c, svcErr)
}
return c.JSON(result)
}
// DeleteEventRating handles DELETE /events/:id/rating.
func (h *RatingHandler) DeleteEventRating(c *fiber.Ctx) error {
eventID, err := parseUUIDParam(c, "id")
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"id": "must be a valid UUID"}))
}
claims := httpapi.UserClaims(c)
if err := h.service.DeleteEventRating(c.UserContext(), claims.UserID, eventID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// UpsertParticipantRating handles PUT /events/:id/participants/:participantUserId/rating.
func (h *RatingHandler) UpsertParticipantRating(c *fiber.Ctx) error {
eventID, err := parseUUIDParam(c, "id")
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"id": "must be a valid UUID"}))
}
participantUserID, err := parseUUIDParam(c, "participantUserId")
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"participantUserId": "must be a valid UUID"}))
}
input, err := parseUpsertRatingBody(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, svcErr := h.service.UpsertParticipantRating(c.UserContext(), claims.UserID, eventID, participantUserID, input)
if svcErr != nil {
return httpapi.WriteError(c, svcErr)
}
return c.JSON(result)
}
// DeleteParticipantRating handles DELETE /events/:id/participants/:participantUserId/rating.
func (h *RatingHandler) DeleteParticipantRating(c *fiber.Ctx) error {
eventID, err := parseUUIDParam(c, "id")
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"id": "must be a valid UUID"}))
}
participantUserID, err := parseUUIDParam(c, "participantUserId")
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"participantUserId": "must be a valid UUID"}))
}
claims := httpapi.UserClaims(c)
if err := h.service.DeleteParticipantRating(c.UserContext(), claims.UserID, eventID, participantUserID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func parseUUIDParam(c *fiber.Ctx, name string) (uuid.UUID, error) {
return uuid.Parse(c.Params(name))
}
func parseUpsertRatingBody(c *fiber.Ctx) (ratingapp.UpsertRatingInput, error) {
var body upsertRatingBody
if err := c.BodyParser(&body); err != nil {
return ratingapp.UpsertRatingInput{}, domain.ValidationError(map[string]string{"body": "must be valid JSON"})
}
return ratingapp.UpsertRatingInput{
Rating: body.Rating,
Message: body.Message,
}, nil
}
package auth
import (
"errors"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
// mapRepoError extracts an AppError from a wrapped repository error so the
// HTTP layer can return the correct status code instead of a generic 500.
func mapRepoError(err error) error {
if appErr, ok := errors.AsType[*domain.AppError](err); ok {
return appErr
}
return err
}
func otpRateLimitKey(purpose, email string) string {
return purpose + ":" + email
}
func isAppErrorCode(err error, code string) bool {
if appErr, ok := errors.AsType[*domain.AppError](err); ok {
return appErr.Code == code
}
return false
}
func passwordResetTokenError() error {
return domain.AuthError(domain.ErrorCodeInvalidResetToken, "The password reset session is invalid or has expired.")
}
package auth
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service implements the authentication use cases: OTP-based registration,
// forgot-password flows, password login, refresh-token rotation, and logout.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
passwordHasher PasswordHasher
otpHasher PasswordHasher
tokenIssuer TokenIssuer
refreshTokens RefreshTokenManager
otpGenerator OTPCodeGenerator
mailer OTPMailer
otpRateLimiter RateLimiter
loginRateLimiter RateLimiter
availabilityRateLimiter RateLimiter
now func() time.Time
otpTTL time.Duration
otpMaxAttempts int
otpResendCooldown time.Duration
refreshTokenTTL time.Duration
maxSessionTTL time.Duration
}
var _ UseCase = (*Service)(nil)
// NewService constructs an auth Service with the given adapters and configuration.
func NewService(
repo Repository,
unitOfWork uow.UnitOfWork,
passwordHasher PasswordHasher,
otpHasher PasswordHasher,
tokenIssuer TokenIssuer,
refreshTokens RefreshTokenManager,
otpGenerator OTPCodeGenerator,
mailer OTPMailer,
otpRateLimiter RateLimiter,
loginRateLimiter RateLimiter,
availabilityRateLimiter RateLimiter,
cfg Config,
) *Service {
return &Service{
repo: repo,
unitOfWork: unitOfWork,
passwordHasher: passwordHasher,
otpHasher: otpHasher,
tokenIssuer: tokenIssuer,
refreshTokens: refreshTokens,
otpGenerator: otpGenerator,
mailer: mailer,
otpRateLimiter: otpRateLimiter,
loginRateLimiter: loginRateLimiter,
availabilityRateLimiter: availabilityRateLimiter,
now: time.Now,
otpTTL: cfg.OTPTTL,
otpMaxAttempts: cfg.OTPMaxAttempts,
otpResendCooldown: cfg.OTPResendCooldown,
refreshTokenTTL: cfg.RefreshTokenTTL,
maxSessionTTL: cfg.MaxSessionTTL,
}
}
// RequestRegistrationOTP generates an OTP code, stores its hash, and mails the
// plaintext code to the user. If the email is already registered, the method
// returns nil silently to avoid leaking account-existence information.
func (s *Service) RequestRegistrationOTP(ctx context.Context, input RequestOTPInput) error {
email, err := normalizeEmail(input.Email)
if err != nil {
return domain.ValidationError(map[string]string{"email": "must be a valid email address"})
}
now := s.now().UTC()
if allowed, _ := s.otpRateLimiter.Allow(otpRateLimitKey(domain.OTPPurposeRegistration, email), now); !allowed {
return domain.RateLimitedError("Too many requests. Try again later.")
}
existingUser, err := s.repo.GetUserByEmail(ctx, email)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("lookup user by email: %w", err)
}
if err == nil && existingUser != nil {
// Silently succeed to avoid revealing that a user with this email exists.
return nil
}
return s.sendEmailOTPChallenge(
ctx,
now,
nil,
email,
domain.OTPPurposeRegistration,
s.mailer.SendRegistrationOTP,
"send registration otp",
)
}
// RequestPasswordResetOTP generates an OTP for password reset if the account
// exists. Unknown emails, resend cooldowns, and rate-limited requests all
// return nil so the caller cannot infer account existence.
func (s *Service) RequestPasswordResetOTP(ctx context.Context, input RequestOTPInput) error {
email, err := normalizeEmail(input.Email)
if err != nil {
return domain.ValidationError(map[string]string{"email": "must be a valid email address"})
}
now := s.now().UTC()
if allowed, _ := s.otpRateLimiter.Allow(otpRateLimitKey(domain.OTPPurposePasswordReset, email), now); !allowed {
return nil
}
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil
}
return fmt.Errorf("lookup user by email: %w", err)
}
if err := s.sendEmailOTPChallenge(
ctx,
now,
&user.ID,
email,
domain.OTPPurposePasswordReset,
s.mailer.SendPasswordResetOTP,
"send password reset otp",
); err != nil {
if isAppErrorCode(err, domain.ErrorCodeRateLimited) {
return nil
}
return err
}
return nil
}
// VerifyPasswordResetOTP validates a password-reset OTP and returns a short-lived
// reset token that authorizes the final password change step.
func (s *Service) VerifyPasswordResetOTP(ctx context.Context, input VerifyPasswordResetInput) (*PasswordResetGrant, error) {
email, otp, appErr := validateEmailOTPInput(input.Email, input.OTP)
if appErr != nil {
return nil, appErr
}
now := s.now().UTC()
challenge, err := s.verifyOTPChallenge(ctx, now, email, domain.OTPPurposePasswordReset, otp)
if err != nil {
return nil, err
}
resetToken, resetTokenHash, err := s.refreshTokens.NewToken()
if err != nil {
return nil, fmt.Errorf("generate password reset token: %w", err)
}
expiresAt := now.Add(s.otpTTL)
err = s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
if err := s.repo.ConsumeOTPChallenge(ctx, challenge.ID, now); err != nil {
return fmt.Errorf("consume otp challenge: %w", err)
}
if _, err := s.repo.UpsertOTPChallenge(ctx, UpsertOTPChallengeParams{
UserID: challenge.UserID,
Channel: domain.OTPChannelEmail,
Destination: email,
Purpose: domain.OTPPurposePasswordResetGrant,
CodeHash: resetTokenHash,
ExpiresAt: expiresAt,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("store password reset grant: %w", err)
}
return nil
})
if err != nil {
return nil, mapRepoError(err)
}
return &PasswordResetGrant{
ResetToken: resetToken,
ExpiresInSeconds: int64(expiresAt.Sub(now) / time.Second),
}, nil
}
// ResetPassword finalizes a forgot-password flow using a previously issued
// password reset token and replaces the user's password hash.
func (s *Service) ResetPassword(ctx context.Context, input ResetPasswordInput) error {
email, resetToken, newPassword, appErr := validateResetPasswordInput(input)
if appErr != nil {
return appErr
}
now := s.now().UTC()
grant, err := s.repo.GetActiveOTPChallenge(ctx, email, domain.OTPPurposePasswordResetGrant)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return passwordResetTokenError()
}
return fmt.Errorf("lookup password reset grant: %w", err)
}
if grant.ConsumedAt != nil || now.After(grant.ExpiresAt.UTC()) {
return passwordResetTokenError()
}
if s.refreshTokens.HashToken(resetToken) != grant.CodeHash {
return passwordResetTokenError()
}
passwordHash, err := s.passwordHasher.Hash(newPassword)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
err = s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
userID, err := s.resolvePasswordResetUserID(ctx, s.repo, email, grant)
if err != nil {
return err
}
if err := s.repo.UpdatePassword(ctx, userID, passwordHash, now); err != nil {
if errors.Is(err, domain.ErrNotFound) {
return passwordResetTokenError()
}
return fmt.Errorf("update password: %w", err)
}
if err := s.repo.ConsumeOTPChallenge(ctx, grant.ID, now); err != nil {
return fmt.Errorf("consume password reset grant: %w", err)
}
return nil
})
if err != nil {
return mapRepoError(err)
}
return nil
}
// VerifyRegistrationOTP validates the OTP, creates the user and profile, marks
// the challenge as consumed, and issues a session — all within a single DB
// transaction to guarantee atomicity.
func (s *Service) VerifyRegistrationOTP(ctx context.Context, input VerifyRegistrationInput) (*Session, error) {
email, username, password, phoneNumber, gender, birthDate, otp, appErr := validateVerifyRegistrationInput(input)
if appErr != nil {
return nil, appErr
}
now := s.now().UTC()
challenge, err := s.verifyOTPChallenge(ctx, now, email, domain.OTPPurposeRegistration, otp)
if err != nil {
return nil, err
}
passwordHash, err := s.passwordHasher.Hash(password)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
var session *Session
err = s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
user, createErr := s.repo.CreateUser(ctx, CreateUserParams{
Username: username,
Email: email,
PhoneNumber: phoneNumber,
Gender: gender,
BirthDate: birthDate,
PasswordHash: passwordHash,
EmailVerifiedAt: now,
Status: domain.UserStatusActive,
})
if createErr != nil {
return createErr
}
if err := s.repo.CreateProfile(ctx, user.ID); err != nil {
return fmt.Errorf("create profile: %w", err)
}
if err := s.repo.ConsumeOTPChallenge(ctx, challenge.ID, now); err != nil {
return fmt.Errorf("consume otp challenge: %w", err)
}
sessionValue, err := s.issueSession(ctx, s.repo, *user, uuid.New(), input.DeviceInfo, now)
if err != nil {
return err
}
session = sessionValue
return nil
})
if err != nil {
return nil, mapRepoError(err)
}
return session, nil
}
// Login authenticates a user by username and password and returns a new session.
func (s *Service) Login(ctx context.Context, input LoginInput) (*Session, error) {
username, password, appErr := validateLoginInput(input)
if appErr != nil {
return nil, appErr
}
now := s.now().UTC()
if allowed, _ := s.loginRateLimiter.Allow(strings.ToLower(username), now); !allowed {
return nil, domain.RateLimitedError("Too many requests. Try again later.")
}
user, err := s.repo.GetUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.AuthError(domain.ErrorCodeInvalidCreds, "Invalid username or password.")
}
return nil, fmt.Errorf("lookup user by username: %w", err)
}
// Use a constant-time comparison via bcrypt; also reject users with no password set.
if user.PasswordHash == "" || s.passwordHasher.Compare(user.PasswordHash, password) != nil {
return nil, domain.AuthError(domain.ErrorCodeInvalidCreds, "Invalid username or password.")
}
var session *Session
err = s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
if err := s.repo.UpdateLastLogin(ctx, user.ID, now); err != nil {
return fmt.Errorf("update last login: %w", err)
}
sessionValue, err := s.issueSession(ctx, s.repo, *user, uuid.New(), input.DeviceInfo, now)
if err != nil {
return err
}
session = sessionValue
return nil
})
if err != nil {
return nil, err
}
return session, nil
}
// Refresh performs refresh-token rotation: it validates the current token,
// issues a new access + refresh pair, revokes the old token, and links the
// old token to its replacement. If a revoked token is replayed, the entire
// token family is revoked to mitigate token theft.
func (s *Service) Refresh(ctx context.Context, refreshToken string, deviceInfo *string) (*Session, error) {
refreshToken = strings.TrimSpace(refreshToken)
if refreshToken == "" {
return nil, domain.ValidationError(map[string]string{"refresh_token": "is required"})
}
now := s.now().UTC()
tokenHash := s.refreshTokens.HashToken(refreshToken)
var session *Session
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
current, err := s.repo.GetRefreshTokenByHash(ctx, tokenHash)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.AuthError(domain.ErrorCodeInvalidRefresh, "The refresh token is invalid or expired.")
}
return fmt.Errorf("lookup refresh token: %w", err)
}
// Reuse detection: if the token was already revoked, an attacker may
// have stolen it. Revoke the entire family as a precaution.
if current.RevokedAt != nil {
if err := s.repo.RevokeRefreshTokenFamily(ctx, current.FamilyID, now); err != nil {
return fmt.Errorf("revoke refresh token family: %w", err)
}
return domain.AuthError(domain.ErrorCodeRefreshReused, "The refresh token has already been used.")
}
if now.After(current.ExpiresAt.UTC()) {
return domain.AuthError(domain.ErrorCodeInvalidRefresh, "The refresh token is invalid or expired.")
}
user, err := s.repo.GetUserByID(ctx, current.UserID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.AuthError(domain.ErrorCodeInvalidRefresh, "The refresh token is invalid or expired.")
}
return fmt.Errorf("lookup user by id: %w", err)
}
familyStartedAt, err := s.repo.GetRefreshTokenFamilyCreatedAt(ctx, current.FamilyID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.AuthError(domain.ErrorCodeInvalidRefresh, "The refresh token is invalid or expired.")
}
return fmt.Errorf("lookup refresh token family start: %w", err)
}
// Enforce absolute session lifetime: no matter how many rotations
// occur, the session cannot exceed maxSessionTTL from the first login.
if !now.Before(familyStartedAt.UTC().Add(s.maxSessionTTL)) {
return domain.AuthError(domain.ErrorCodeInvalidRefresh, "The refresh token is invalid or expired.")
}
sessionValue, newToken, err := s.issueRotatedSession(ctx, s.repo, *user, current.FamilyID, familyStartedAt.UTC(), current.ID, deviceInfo, now)
if err != nil {
return err
}
if err := s.repo.RevokeRefreshToken(ctx, current.ID, now); err != nil {
return fmt.Errorf("revoke previous refresh token: %w", err)
}
if err := s.repo.SetRefreshTokenReplacement(ctx, current.ID, newToken.ID, now); err != nil {
return fmt.Errorf("set refresh token replacement: %w", err)
}
session = sessionValue
return nil
})
if err != nil {
return nil, err
}
return session, nil
}
// Logout revokes the presented refresh token, ending the session.
func (s *Service) Logout(ctx context.Context, refreshToken string) error {
refreshToken = strings.TrimSpace(refreshToken)
if refreshToken == "" {
return domain.ValidationError(map[string]string{"refresh_token": "is required"})
}
now := s.now().UTC()
tokenHash := s.refreshTokens.HashToken(refreshToken)
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
current, err := s.repo.GetRefreshTokenByHash(ctx, tokenHash)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.AuthError(domain.ErrorCodeInvalidRefresh, "The refresh token is invalid or expired.")
}
return fmt.Errorf("lookup refresh token: %w", err)
}
if current.RevokedAt != nil || now.After(current.ExpiresAt.UTC()) {
return domain.AuthError(domain.ErrorCodeInvalidRefresh, "The refresh token is invalid or expired.")
}
if err := s.repo.RevokeRefreshToken(ctx, current.ID, now); err != nil {
return fmt.Errorf("revoke refresh token: %w", err)
}
return nil
})
}
// CheckAvailability reports whether the given username and email are available
// for registration. Both fields are required.
func (s *Service) CheckAvailability(ctx context.Context, input CheckAvailabilityInput) (*CheckAvailabilityResult, error) {
email, username, appErr := validateRegistrationIdentity(input.Email, input.Username)
if appErr != nil {
return nil, appErr
}
now := s.now().UTC()
clientKey := strings.TrimSpace(input.ClientKey)
if clientKey == "" {
clientKey = "unknown"
}
if allowed, _ := s.availabilityRateLimiter.Allow(clientKey, now); !allowed {
return nil, domain.RateLimitedError("Too many requests. Try again later.")
}
result := &CheckAvailabilityResult{
Username: "AVAILABLE",
Email: "AVAILABLE",
}
if _, err := s.repo.GetUserByUsername(ctx, username); err == nil {
result.Username = "TAKEN"
} else if !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("lookup user by username: %w", err)
}
if _, err := s.repo.GetUserByEmail(ctx, email); err == nil {
result.Email = "TAKEN"
} else if !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("lookup user by email: %w", err)
}
return result, nil
}
// issueSession creates a brand-new session (access + refresh tokens) for a fresh login.
func (s *Service) issueSession(
ctx context.Context,
repo Repository,
user domain.User,
familyID uuid.UUID,
deviceInfo *string,
issuedAt time.Time,
) (*Session, error) {
session, _, err := s.issueRotatedSession(ctx, repo, user, familyID, issuedAt, uuid.Nil, deviceInfo, issuedAt)
return session, err
}
// issueRotatedSession creates a new access + refresh token pair during rotation.
func (s *Service) issueRotatedSession(
ctx context.Context,
repo Repository,
user domain.User,
familyID uuid.UUID,
familyStartedAt time.Time,
_ uuid.UUID,
deviceInfo *string,
issuedAt time.Time,
) (*Session, *domain.RefreshToken, error) {
accessToken, expiresInSeconds, err := s.tokenIssuer.IssueAccessToken(user, issuedAt)
if err != nil {
return nil, nil, fmt.Errorf("issue access token: %w", err)
}
plainRefreshToken, refreshHash, err := s.refreshTokens.NewToken()
if err != nil {
return nil, nil, fmt.Errorf("generate refresh token: %w", err)
}
refreshRecord, err := repo.CreateRefreshToken(ctx, CreateRefreshTokenParams{
UserID: user.ID,
FamilyID: familyID,
TokenHash: refreshHash,
CreatedAt: issuedAt,
ExpiresAt: s.refreshExpiry(issuedAt, familyStartedAt),
DeviceInfo: deviceInfo,
})
if err != nil {
return nil, nil, fmt.Errorf("create refresh token: %w", err)
}
return &Session{
AccessToken: accessToken,
RefreshToken: plainRefreshToken,
TokenType: "Bearer",
ExpiresInSeconds: expiresInSeconds,
User: user.Summary(),
}, refreshRecord, nil
}
// refreshExpiry returns the earlier of the per-token TTL and the absolute
// session deadline, preventing rotated tokens from extending beyond maxSessionTTL.
func (s *Service) refreshExpiry(issuedAt, familyStartedAt time.Time) time.Time {
refreshExpiresAt := issuedAt.Add(s.refreshTokenTTL)
absoluteExpiresAt := familyStartedAt.Add(s.maxSessionTTL)
if refreshExpiresAt.After(absoluteExpiresAt) {
return absoluteExpiresAt
}
return refreshExpiresAt
}
func (s *Service) sendEmailOTPChallenge(
ctx context.Context,
now time.Time,
userID *uuid.UUID,
email string,
purpose string,
send func(context.Context, OTPMailInput) error,
sendLabel string,
) error {
challenge, err := s.repo.GetActiveOTPChallenge(ctx, email, purpose)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("lookup otp challenge: %w", err)
}
if err == nil && challenge != nil && now.Before(challenge.CreatedAt.UTC().Add(s.otpResendCooldown)) {
// Enforce per-email cooldown to prevent OTP flooding.
return domain.RateLimitedError("Too many requests. Try again later.")
}
code := s.otpGenerator.NewCode()
codeHash, err := s.otpHasher.Hash(code)
if err != nil {
return fmt.Errorf("hash otp code: %w", err)
}
if _, err := s.repo.UpsertOTPChallenge(ctx, UpsertOTPChallengeParams{
UserID: userID,
Channel: domain.OTPChannelEmail,
Destination: email,
Purpose: purpose,
CodeHash: codeHash,
ExpiresAt: now.Add(s.otpTTL),
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("store otp challenge: %w", err)
}
if err := send(ctx, OTPMailInput{
Email: email,
Code: code,
ExpiresIn: s.otpTTL,
}); err != nil {
return fmt.Errorf("%s: %w", sendLabel, err)
}
return nil
}
func (s *Service) verifyOTPChallenge(
ctx context.Context,
now time.Time,
email string,
purpose string,
otp string,
) (*domain.OTPChallenge, error) {
challenge, err := s.repo.GetActiveOTPChallenge(ctx, email, purpose)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.AuthError(domain.ErrorCodeInvalidOTP, "The OTP is invalid or has expired.")
}
return nil, fmt.Errorf("lookup otp challenge: %w", err)
}
// Reject already-consumed or expired challenges before comparing the code.
if challenge.ConsumedAt != nil || now.After(challenge.ExpiresAt.UTC()) {
return nil, domain.AuthError(domain.ErrorCodeInvalidOTP, "The OTP is invalid or has expired.")
}
if challenge.AttemptCount >= s.otpMaxAttempts {
return nil, domain.AuthError(domain.ErrorCodeOTPExhausted, "The OTP can no longer be used. Request a new code.")
}
// On code mismatch, increment the attempt counter. If the max is reached,
// the challenge becomes permanently exhausted and the user must request a new OTP.
if err := s.otpHasher.Compare(challenge.CodeHash, otp); err != nil {
updatedChallenge, incErr := s.repo.IncrementOTPChallengeAttempts(ctx, challenge.ID, now)
if incErr != nil {
return nil, fmt.Errorf("increment otp attempts: %w", incErr)
}
if updatedChallenge.AttemptCount >= s.otpMaxAttempts {
return nil, domain.AuthError(domain.ErrorCodeOTPExhausted, "The OTP can no longer be used. Request a new code.")
}
return nil, domain.AuthError(domain.ErrorCodeInvalidOTP, "The OTP is invalid or has expired.")
}
return challenge, nil
}
func (s *Service) resolvePasswordResetUserID(
ctx context.Context,
repo Repository,
email string,
grant *domain.OTPChallenge,
) (uuid.UUID, error) {
if grant.UserID != nil {
return *grant.UserID, nil
}
user, err := repo.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return uuid.Nil, passwordResetTokenError()
}
return uuid.Nil, fmt.Errorf("lookup user by email: %w", err)
}
return user.ID, nil
}
package auth
import (
"net/mail"
"regexp"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
// Validation patterns for usernames (alphanumeric + underscore) and OTP codes (6 digits).
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
var otpPattern = regexp.MustCompile(`^[0-9]{6}$`)
// validateVerifyRegistrationInput normalizes and validates all registration fields,
// returning cleaned values or a combined validation error.
func validateVerifyRegistrationInput(input VerifyRegistrationInput) (email, username, password string, phoneNumber, gender *string, birthDate *time.Time, otp string, appErr *domain.AppError) {
email, username, appErr = validateRegistrationIdentity(input.Email, input.Username)
if appErr != nil {
return "", "", "", nil, nil, nil, "", appErr
}
password = input.Password
otp = strings.TrimSpace(input.OTP)
details := make(map[string]string)
if len(password) < 8 || len(password) > 128 {
details["password"] = "must be between 8 and 128 characters"
}
if !otpPattern.MatchString(otp) {
details["otp"] = "must be a 6-digit code"
}
phoneNumber = sanitizePhoneNumber(input.PhoneNumber, details)
gender = sanitizeGender(input.Gender, details)
birthDate = sanitizeBirthDate(input.BirthDate, details)
if len(details) > 0 {
return "", "", "", nil, nil, nil, "", domain.ValidationError(details)
}
return email, username, password, phoneNumber, gender, birthDate, otp, nil
}
func validateEmailOTPInput(rawEmail, rawOTP string) (email, otp string, appErr *domain.AppError) {
email, err := normalizeEmail(rawEmail)
otp = strings.TrimSpace(rawOTP)
details := make(map[string]string)
if err != nil {
details["email"] = "must be a valid email address"
}
if !otpPattern.MatchString(otp) {
details["otp"] = "must be a 6-digit code"
}
if len(details) > 0 {
return "", "", domain.ValidationError(details)
}
return email, otp, nil
}
func validateResetPasswordInput(input ResetPasswordInput) (email, resetToken, newPassword string, appErr *domain.AppError) {
email, err := normalizeEmail(input.Email)
resetToken = strings.TrimSpace(input.ResetToken)
newPassword = input.NewPassword
details := make(map[string]string)
if err != nil {
details["email"] = "must be a valid email address"
}
if len(resetToken) < 32 || len(resetToken) > 512 {
details["reset_token"] = "must be between 32 and 512 characters"
}
if len(newPassword) < 8 || len(newPassword) > 128 {
details["new_password"] = "must be between 8 and 128 characters"
}
if len(details) > 0 {
return "", "", "", domain.ValidationError(details)
}
return email, resetToken, newPassword, nil
}
// validateLoginInput normalizes and validates login credentials.
func validateLoginInput(input LoginInput) (username, password string, appErr *domain.AppError) {
username = strings.TrimSpace(input.Username)
password = input.Password
details := make(map[string]string)
if len(username) < 3 || len(username) > 32 {
details["username"] = "must be between 3 and 32 characters"
}
if len(password) < 8 || len(password) > 128 {
details["password"] = "must be between 8 and 128 characters"
}
if len(details) > 0 {
return "", "", domain.ValidationError(details)
}
return username, password, nil
}
func validateRegistrationIdentity(rawEmail, rawUsername string) (email, username string, appErr *domain.AppError) {
email, err := normalizeEmail(rawEmail)
username = strings.TrimSpace(rawUsername)
details := make(map[string]string)
if err != nil {
details["email"] = "must be a valid email address"
}
if len(username) < 3 || len(username) > 32 || !usernamePattern.MatchString(username) {
details["username"] = "must be 3-32 characters using letters, numbers, or underscores"
}
if len(details) > 0 {
return "", "", domain.ValidationError(details)
}
return email, username, nil
}
// normalizeEmail trims whitespace, lowercases, and parses an email address.
func normalizeEmail(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
addr, err := mail.ParseAddress(value)
if err != nil {
return "", err
}
return strings.ToLower(addr.Address), nil
}
func sanitizePhoneNumber(value *string, details map[string]string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
if len(trimmed) > 32 {
details["phone_number"] = "must be at most 32 characters"
return nil
}
return &trimmed
}
func sanitizeGender(value *string, details map[string]string) *string {
if value == nil {
return nil
}
upper := strings.ToUpper(strings.TrimSpace(*value))
if upper == "" {
return nil
}
switch upper {
case "MALE", "FEMALE", "OTHER", "PREFER_NOT_TO_SAY":
return &upper
default:
details["gender"] = "must be one of: MALE, FEMALE, OTHER, PREFER_NOT_TO_SAY"
return nil
}
}
func sanitizeBirthDate(value *string, details map[string]string) *time.Time {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
parsed, err := time.Parse("2006-01-02", trimmed)
if err != nil {
details["birth_date"] = "must be in YYYY-MM-DD format"
return nil
}
return &parsed
}
package category
import "context"
// Service owns category-specific application behavior.
type Service struct {
repo Repository
}
var _ UseCase = (*Service)(nil)
// NewService constructs a category service backed by its own repository.
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
// ListCategories returns all event categories ordered by id.
func (s *Service) ListCategories(ctx context.Context) (*ListCategoriesResult, error) {
categories, err := s.repo.ListCategories(ctx)
if err != nil {
return nil, err
}
items := make([]CategoryItem, len(categories))
for i, c := range categories {
items[i] = CategoryItem{ID: c.ID, Name: c.Name}
}
return &ListCategoriesResult{Items: items}, nil
}
package event
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/google/uuid"
)
func encodeEventCollectionCursor(cursor EventCollectionCursor) (string, error) {
raw, err := json.Marshal(cursor)
if err != nil {
return "", fmt.Errorf("marshal event collection cursor: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func decodeEventCollectionCursor(token string) (*EventCollectionCursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return nil, fmt.Errorf("decode event collection cursor: %w", err)
}
var cursor EventCollectionCursor
if err := json.Unmarshal(raw, &cursor); err != nil {
return nil, fmt.Errorf("unmarshal event collection cursor: %w", err)
}
if cursor.CreatedAt.IsZero() {
return nil, fmt.Errorf("cursor is missing created_at")
}
if cursor.EntityID == uuid.Nil {
return nil, fmt.Errorf("cursor is missing entity_id")
}
return &cursor, nil
}
package event
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
type discoverEventsFingerprintPayload struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
RadiusMeters int `json:"radius_meters"`
Query string `json:"query"`
PrivacyLevels []string `json:"privacy_levels,omitempty"`
CategoryIDs []int `json:"category_ids,omitempty"`
StartFrom *string `json:"start_from,omitempty"`
StartTo *string `json:"start_to,omitempty"`
TagNames []string `json:"tag_names,omitempty"`
OnlyFavorited bool `json:"only_favorited"`
}
// buildDiscoverEventsFilterFingerprint hashes the normalized filter set so a
// cursor cannot be reused with a different filter combination.
func buildDiscoverEventsFilterFingerprint(params DiscoverEventsParams) (string, error) {
payload := discoverEventsFingerprintPayload{
Lat: params.Origin.Lat,
Lon: params.Origin.Lon,
RadiusMeters: params.RadiusMeters,
Query: params.Query,
PrivacyLevels: toPrivacyLevelStrings(params.PrivacyLevels),
CategoryIDs: params.CategoryIDs,
TagNames: params.TagNames,
OnlyFavorited: params.OnlyFavorited,
}
if params.StartFrom != nil {
value := params.StartFrom.UTC().Format(timeLayoutCursor)
payload.StartFrom = &value
}
if params.StartTo != nil {
value := params.StartTo.UTC().Format(timeLayoutCursor)
payload.StartTo = &value
}
raw, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal discovery fingerprint: %w", err)
}
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:]), nil
}
const timeLayoutCursor = "2006-01-02T15:04:05.999999999Z07:00"
func encodeDiscoverEventsCursor(cursor DiscoverEventsCursor) (string, error) {
raw, err := json.Marshal(cursor)
if err != nil {
return "", fmt.Errorf("marshal discovery cursor: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func decodeDiscoverEventsCursor(token string) (*DiscoverEventsCursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return nil, fmt.Errorf("decode discovery cursor: %w", err)
}
var cursor DiscoverEventsCursor
if err := json.Unmarshal(raw, &cursor); err != nil {
return nil, fmt.Errorf("unmarshal discovery cursor: %w", err)
}
if _, ok := domain.ParseEventDiscoverySort(string(cursor.SortBy)); !ok {
return nil, fmt.Errorf("cursor contains unsupported sort mode")
}
if cursor.FilterFingerprint == "" {
return nil, fmt.Errorf("cursor is missing filter fingerprint")
}
if cursor.StartTime.IsZero() {
return nil, fmt.Errorf("cursor is missing start_time")
}
if cursor.EventID == uuid.Nil {
return nil, fmt.Errorf("cursor is missing event_id")
}
if cursor.SortBy == domain.EventDiscoverySortDistance && cursor.DistanceMeters == nil {
return nil, fmt.Errorf("cursor is missing distance_meters")
}
if cursor.SortBy == domain.EventDiscoverySortRelevance {
if cursor.DistanceMeters == nil {
return nil, fmt.Errorf("cursor is missing distance_meters")
}
if cursor.RelevanceScore == nil {
return nil, fmt.Errorf("cursor is missing relevance_score")
}
}
return &cursor, nil
}
func buildNextDiscoverEventsCursor(params DiscoverEventsParams, record DiscoverableEventRecord) (DiscoverEventsCursor, error) {
cursor := DiscoverEventsCursor{
SortBy: params.SortBy,
FilterFingerprint: params.FilterFingerprint,
StartTime: record.StartTime.UTC(),
EventID: record.ID,
}
switch params.SortBy {
case domain.EventDiscoverySortDistance:
cursor.DistanceMeters = &record.DistanceMeters
case domain.EventDiscoverySortRelevance:
if record.RelevanceScore == nil {
return DiscoverEventsCursor{}, fmt.Errorf("relevance cursor requires relevance score")
}
cursor.DistanceMeters = &record.DistanceMeters
cursor.RelevanceScore = record.RelevanceScore
}
return cursor, nil
}
func toPrivacyLevelStrings(levels []domain.EventPrivacyLevel) []string {
if len(levels) == 0 {
return nil
}
values := make([]string, len(levels))
for i, level := range levels {
values[i] = string(level)
}
return values
}
package event
import (
"sort"
"strings"
"time"
"unicode"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
const (
defaultDiscoverRadiusMeters = 10000
maxDiscoverRadiusMeters = 50000
defaultDiscoverLimit = 20
maxDiscoverLimit = 50
)
// normalizeAndValidateDiscoverEventsInput applies defaults, normalizes filters,
// and validates discovery-specific invariants before the repository is called.
func normalizeAndValidateDiscoverEventsInput(input DiscoverEventsInput) (DiscoverEventsParams, map[string]string) {
errs := make(map[string]string)
params := DiscoverEventsParams{
OnlyFavorited: input.OnlyFavorited,
}
if input.Lat == nil {
errs["lat"] = "lat is required"
} else if *input.Lat < -90 || *input.Lat > 90 {
errs["lat"] = "lat must be between -90 and 90"
} else {
params.Origin.Lat = *input.Lat
}
if input.Lon == nil {
errs["lon"] = "lon is required"
} else if *input.Lon < -180 || *input.Lon > 180 {
errs["lon"] = "lon must be between -180 and 180"
} else {
params.Origin.Lon = *input.Lon
}
params.RadiusMeters = defaultDiscoverRadiusMeters
if input.RadiusMeters != nil {
params.RadiusMeters = *input.RadiusMeters
}
if params.RadiusMeters <= 0 || params.RadiusMeters > maxDiscoverRadiusMeters {
errs["radius_meters"] = "radius_meters must be between 1 and 50000"
}
params.Limit = defaultDiscoverLimit
if input.Limit != nil {
params.Limit = *input.Limit
}
if params.Limit <= 0 || params.Limit > maxDiscoverLimit {
errs["limit"] = "limit must be between 1 and 50"
}
if input.Query != nil {
params.Query = strings.TrimSpace(*input.Query)
}
if params.Query != "" {
params.SearchTSQuery = buildPrefixTSQuery(params.Query)
if params.SearchTSQuery == "" {
errs["q"] = "q must contain at least one letter or number"
}
}
privacyLevels, privacyErr := normalizePrivacyLevels(input.PrivacyLevels)
if privacyErr != "" {
errs["privacy_levels"] = privacyErr
} else {
params.PrivacyLevels = privacyLevels
}
categoryIDs, categoryErr := normalizeCategoryIDs(input.CategoryIDs)
if categoryErr != "" {
errs["category_ids"] = categoryErr
} else {
params.CategoryIDs = categoryIDs
}
tagNames, tagErr := normalizeTagNames(input.TagNames)
if tagErr != "" {
errs["tag_names"] = tagErr
} else {
params.TagNames = tagNames
}
params.StartFrom = normalizeTime(input.StartFrom)
params.StartTo = normalizeTime(input.StartTo)
if params.StartFrom != nil && params.StartTo != nil && params.StartFrom.After(*params.StartTo) {
errs["start_from"] = "start_from must be before or equal to start_to"
}
if input.SortBy != nil {
params.SortBy = *input.SortBy
} else if params.Query != "" {
params.SortBy = domain.EventDiscoverySortRelevance
} else {
params.SortBy = domain.EventDiscoverySortStartTime
}
if _, ok := domain.ParseEventDiscoverySort(string(params.SortBy)); !ok {
errs["sort_by"] = "must be one of: START_TIME, DISTANCE, RELEVANCE"
}
if params.SortBy == domain.EventDiscoverySortRelevance && params.Query == "" {
errs["sort_by"] = "sort_by=RELEVANCE requires q"
}
if input.Cursor != nil {
params.CursorToken = strings.TrimSpace(*input.Cursor)
}
return params, errs
}
func normalizePrivacyLevels(levels []domain.EventPrivacyLevel) ([]domain.EventPrivacyLevel, string) {
if len(levels) == 0 {
return []domain.EventPrivacyLevel{
domain.PrivacyPublic,
domain.PrivacyProtected,
}, ""
}
seen := make(map[domain.EventPrivacyLevel]struct{}, len(levels))
normalized := make([]domain.EventPrivacyLevel, 0, len(levels))
for _, level := range levels {
if level != domain.PrivacyPublic && level != domain.PrivacyProtected {
return nil, "privacy_levels may only include PUBLIC or PROTECTED"
}
if _, ok := seen[level]; ok {
continue
}
seen[level] = struct{}{}
normalized = append(normalized, level)
}
sort.Slice(normalized, func(i, j int) bool {
return normalized[i] < normalized[j]
})
return normalized, ""
}
func normalizeCategoryIDs(categoryIDs []int) ([]int, string) {
if len(categoryIDs) == 0 {
return nil, ""
}
seen := make(map[int]struct{}, len(categoryIDs))
normalized := make([]int, 0, len(categoryIDs))
for _, categoryID := range categoryIDs {
if categoryID <= 0 {
return nil, "category_ids must contain only positive integers"
}
if _, ok := seen[categoryID]; ok {
continue
}
seen[categoryID] = struct{}{}
normalized = append(normalized, categoryID)
}
sort.Ints(normalized)
return normalized, ""
}
func normalizeTagNames(tagNames []string) ([]string, string) {
if len(tagNames) == 0 {
return nil, ""
}
seen := make(map[string]struct{}, len(tagNames))
normalized := make([]string, 0, len(tagNames))
for _, tagName := range tagNames {
trimmed := strings.TrimSpace(tagName)
if trimmed == "" {
return nil, "tag_names must not contain empty values"
}
lower := strings.ToLower(trimmed)
if _, ok := seen[lower]; ok {
continue
}
seen[lower] = struct{}{}
normalized = append(normalized, lower)
}
sort.Strings(normalized)
return normalized, ""
}
func normalizeTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
utc := value.UTC()
return &utc
}
func buildPrefixTSQuery(query string) string {
tokens := strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
if len(tokens) == 0 {
return ""
}
prefixed := make([]string, 0, len(tokens))
for _, token := range tokens {
if token == "" {
continue
}
prefixed = append(prefixed, token+":*")
}
return strings.Join(prefixed, " & ")
}
package event
import (
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// toCreateEventResult maps the created domain.Event to the API response shape.
func toCreateEventResult(e *domain.Event) *CreateEventResult {
return &CreateEventResult{
ID: e.ID.String(),
Title: e.Title,
PrivacyLevel: string(e.PrivacyLevel),
Status: string(e.Status),
StartTime: e.StartTime,
EndTime: e.EndTime,
CreatedAt: e.CreatedAt,
}
}
// toCreateEventParams maps a validated CreateEventInput to the repository params
// expected by the repository.
func toCreateEventParams(hostID uuid.UUID, input CreateEventInput) CreateEventParams {
constraints := make([]EventConstraintParams, len(input.Constraints))
for i, c := range input.Constraints {
constraints[i] = EventConstraintParams{
Type: strings.TrimSpace(c.Type),
Info: strings.TrimSpace(c.Info),
}
}
description := strings.TrimSpace(*input.Description)
params := CreateEventParams{
HostID: hostID,
Title: strings.TrimSpace(input.Title),
Description: description,
ImageURL: input.ImageURL,
CategoryID: *input.CategoryID,
StartTime: input.StartTime,
EndTime: input.EndTime,
PrivacyLevel: input.PrivacyLevel,
Capacity: input.Capacity,
MinimumAge: input.MinimumAge,
PreferredGender: input.PreferredGender,
LocationType: input.LocationType,
Address: input.Address,
Tags: input.Tags,
Constraints: constraints,
}
switch input.LocationType {
case domain.LocationPoint:
params.Point = &domain.GeoPoint{
Lat: *input.Lat,
Lon: *input.Lon,
}
case domain.LocationRoute:
params.RoutePoints = toDomainRoutePoints(input.RoutePoints)
}
return params
}
func toDiscoverableEventItem(record DiscoverableEventRecord) DiscoverableEventItem {
return DiscoverableEventItem{
ID: record.ID.String(),
Title: record.Title,
CategoryName: record.CategoryName,
ImageURL: record.ImageURL,
StartTime: record.StartTime,
Status: string(record.Status),
LocationAddress: record.LocationAddress,
PrivacyLevel: string(record.PrivacyLevel),
ApprovedParticipantCount: record.ApprovedParticipantCount,
FavoriteCount: record.FavoriteCount,
IsFavorited: record.IsFavorited,
HostScore: toEventHostScoreSummary(record.HostScore),
}
}
func toEventDetailResult(record *EventDetailRecord, now time.Time) *GetEventDetailResult {
ratingWindow := domain.NewRatingWindow(record.StartTime, record.EndTime)
isRatingWindowActive := ratingWindow.IsActive(now)
if record.Status == domain.EventStatusCanceled {
isRatingWindowActive = false
}
result := &GetEventDetailResult{
ID: record.ID.String(),
Title: record.Title,
Description: record.Description,
ImageURL: record.ImageURL,
PrivacyLevel: string(record.PrivacyLevel),
Status: string(record.Status),
StartTime: record.StartTime,
EndTime: record.EndTime,
Capacity: record.Capacity,
MinimumAge: record.MinimumAge,
ApprovedParticipantCount: record.ApprovedParticipantCount,
PendingParticipantCount: record.PendingParticipantCount,
FavoriteCount: record.FavoriteCount,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
Host: toEventDetailPerson(record.Host),
HostScore: toEventHostScoreSummary(record.HostScore),
Location: toEventDetailLocation(record.Location),
Tags: append([]string{}, record.Tags...),
Constraints: toEventDetailConstraints(record.Constraints),
RatingWindow: EventDetailRatingWindow{
OpensAt: ratingWindow.OpensAt,
ClosesAt: ratingWindow.ClosesAt,
IsActive: isRatingWindowActive,
},
ViewerEventRating: toEventDetailRating(record.ViewerEventRating),
ViewerContext: EventDetailViewerContext{
IsHost: record.ViewerContext.IsHost,
IsFavorited: record.ViewerContext.IsFavorited,
ParticipationStatus: string(record.ViewerContext.ParticipationStatus),
},
}
if record.Category != nil {
result.Category = &EventDetailCategory{
ID: record.Category.ID,
Name: record.Category.Name,
}
}
if record.PreferredGender != nil {
preferredGender := string(*record.PreferredGender)
result.PreferredGender = &preferredGender
}
return result
}
func toEventDetailLocation(record EventDetailLocationRecord) EventDetailLocation {
location := EventDetailLocation{
Type: string(record.Type),
Address: record.Address,
}
if record.Point != nil {
location.Point = &EventDetailPoint{
Lat: record.Point.Lat,
Lon: record.Point.Lon,
}
}
if len(record.RoutePoints) > 0 {
location.RoutePoints = make([]EventDetailPoint, len(record.RoutePoints))
for i, point := range record.RoutePoints {
location.RoutePoints[i] = EventDetailPoint{
Lat: point.Lat,
Lon: point.Lon,
}
}
}
return location
}
func toEventDetailConstraints(records []EventDetailConstraintRecord) []EventDetailConstraint {
constraints := make([]EventDetailConstraint, len(records))
for i, record := range records {
constraints[i] = EventDetailConstraint(record)
}
return constraints
}
func toEventDetailApprovedParticipants(records []EventDetailApprovedParticipantRecord) []EventDetailApprovedParticipant {
participants := make([]EventDetailApprovedParticipant, len(records))
for i, record := range records {
participants[i] = EventDetailApprovedParticipant{
ParticipationID: record.ParticipationID.String(),
Status: record.Status,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
HostRating: toEventDetailRating(record.HostRating),
User: toEventDetailHostContextUser(record.User),
}
}
return participants
}
func toEventDetailPendingJoinRequests(records []EventDetailPendingJoinRequestRecord) []EventDetailPendingJoinRequest {
requests := make([]EventDetailPendingJoinRequest, len(records))
for i, record := range records {
requests[i] = EventDetailPendingJoinRequest{
JoinRequestID: record.JoinRequestID.String(),
Status: record.Status,
Message: record.Message,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
User: toEventDetailHostContextUser(record.User),
}
}
return requests
}
func toEventDetailInvitations(records []EventDetailInvitationRecord) []EventDetailInvitation {
invitations := make([]EventDetailInvitation, len(records))
for i, record := range records {
invitations[i] = EventDetailInvitation{
InvitationID: record.InvitationID.String(),
Status: string(record.Status),
Message: record.Message,
ExpiresAt: record.ExpiresAt,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
User: toEventDetailHostContextUser(record.User),
}
}
return invitations
}
func toEventDetailPerson(record EventDetailPersonRecord) EventDetailPerson {
return EventDetailPerson{
ID: record.ID.String(),
Username: record.Username,
DisplayName: record.DisplayName,
AvatarURL: record.AvatarURL,
}
}
func toEventDetailHostContextUser(record EventDetailHostContextUserRecord) EventDetailHostContextUser {
return EventDetailHostContextUser{
ID: record.ID.String(),
Username: record.Username,
DisplayName: record.DisplayName,
AvatarURL: record.AvatarURL,
FinalScore: record.FinalScore,
RatingCount: record.RatingCount,
}
}
func toEventHostScoreSummary(record EventHostScoreSummaryRecord) EventHostScoreSummary {
return EventHostScoreSummary(record)
}
func toEventDetailRating(record *EventDetailRatingRecord) *EventDetailRating {
if record == nil {
return nil
}
return &EventDetailRating{
ID: record.ID.String(),
Rating: record.Rating,
Message: record.Message,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
}
}
func toEventHostContextSummary(record *EventHostContextSummaryRecord) *EventHostContextSummary {
if record == nil {
return nil
}
return &EventHostContextSummary{
ApprovedParticipantCount: record.ApprovedParticipantCount,
PendingJoinRequestCount: record.PendingJoinRequestCount,
InvitationCount: record.InvitationCount,
}
}
func toEventCollectionPageInfo(nextCursor *string, hasNext bool) EventCollectionPageInfo {
return EventCollectionPageInfo{
NextCursor: nextCursor,
HasNext: hasNext,
}
}
func toDomainRoutePoints(points []RoutePointInput) []domain.GeoPoint {
domainPoints := make([]domain.GeoPoint, len(points))
for i, point := range points {
domainPoints[i] = domain.GeoPoint{
Lat: *point.Lat,
Lon: *point.Lon,
}
}
return domainPoints
}
package event
import (
"context"
"errors"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/application/join_request"
"github.com/bounswe/bounswe2026group11/backend/internal/application/participation"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service implements the event use cases.
type Service struct {
eventRepo Repository
participationService participation.UseCase
joinRequestService join_request.UseCase
unitOfWork uow.UnitOfWork
now func() time.Time
}
var _ UseCase = (*Service)(nil)
const (
defaultEventCollectionLimit = 25
maxEventCollectionLimit = 50
)
// NewService constructs an event Service with its own repository and the
// cross-aggregate services it orchestrates.
func NewService(
eventRepo Repository,
participationService participation.UseCase,
joinRequestService join_request.UseCase,
unitOfWork uow.UnitOfWork,
) *Service {
return &Service{
eventRepo: eventRepo,
participationService: participationService,
joinRequestService: joinRequestService,
unitOfWork: unitOfWork,
now: time.Now,
}
}
// CreateEvent validates the input, then persists the event with its location,
// tags, and constraints in a single transaction.
func (s *Service) CreateEvent(ctx context.Context, hostID uuid.UUID, input CreateEventInput) (*CreateEventResult, error) {
errs := validateCreateEventInput(input)
if len(errs) > 0 {
return nil, domain.ValidationError(errs)
}
params := toCreateEventParams(hostID, input)
var created *domain.Event
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
created, err = s.eventRepo.CreateEvent(ctx, params)
return err
})
if err != nil {
return nil, err
}
return toCreateEventResult(created), nil
}
// DiscoverEvents returns nearby discoverable events using combined full-text,
// structured filters, and keyset pagination.
func (s *Service) DiscoverEvents(ctx context.Context, userID uuid.UUID, input DiscoverEventsInput) (*DiscoverEventsResult, error) {
params, errs := normalizeAndValidateDiscoverEventsInput(input)
if len(errs) > 0 {
return nil, domain.ValidationError(errs)
}
fingerprint, err := buildDiscoverEventsFilterFingerprint(params)
if err != nil {
return nil, err
}
params.FilterFingerprint = fingerprint
params.RepositoryFetchLimit = params.Limit + 1
if params.CursorToken != "" {
cursor, err := decodeDiscoverEventsCursor(params.CursorToken)
if err != nil {
return nil, domain.ValidationError(map[string]string{
"cursor": "cursor is invalid",
})
}
if cursor.SortBy != params.SortBy || cursor.FilterFingerprint != params.FilterFingerprint {
return nil, domain.ValidationError(map[string]string{
"cursor": "cursor does not match the active filters or sort order",
})
}
params.DecodedCursor = cursor
}
records, err := s.eventRepo.ListDiscoverableEvents(ctx, userID, params)
if err != nil {
return nil, err
}
hasNext := len(records) > params.Limit
if hasNext {
records = records[:params.Limit]
}
items := make([]DiscoverableEventItem, len(records))
for i, record := range records {
items[i] = toDiscoverableEventItem(record)
}
var nextCursor *string
if hasNext && len(records) > 0 {
cursor, err := buildNextDiscoverEventsCursor(params, records[len(records)-1])
if err != nil {
return nil, err
}
encoded, err := encodeDiscoverEventsCursor(cursor)
if err != nil {
return nil, err
}
nextCursor = &encoded
}
return &DiscoverEventsResult{
Items: items,
PageInfo: DiscoverEventsPageInfo{
NextCursor: nextCursor,
HasNext: hasNext,
},
}, nil
}
// GetEventDetail returns the maximum event detail payload visible to the
// authenticated user, enforcing event visibility rules in the repository read path.
func (s *Service) GetEventDetail(ctx context.Context, userID, eventID uuid.UUID) (*GetEventDetailResult, error) {
record, err := s.eventRepo.GetEventDetail(ctx, userID, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return nil, err
}
return toEventDetailResult(record, s.now().UTC()), nil
}
// GetEventHostContextSummary returns host-only management counters without
// loading the underlying collections.
func (s *Service) GetEventHostContextSummary(ctx context.Context, userID, eventID uuid.UUID) (*EventHostContextSummary, error) {
if _, err := s.requireEventHost(ctx, userID, eventID); err != nil {
return nil, err
}
record, err := s.eventRepo.GetEventHostContextSummary(ctx, eventID)
if err != nil {
return nil, err
}
return toEventHostContextSummary(record), nil
}
// ListEventApprovedParticipants returns the host-only approved-participant collection.
func (s *Service) ListEventApprovedParticipants(
ctx context.Context,
userID, eventID uuid.UUID,
input ListEventCollectionInput,
) (*ListEventApprovedParticipantsResult, error) {
if _, err := s.requireEventHost(ctx, userID, eventID); err != nil {
return nil, err
}
params, err := normalizeEventCollectionInput(input)
if err != nil {
return nil, err
}
records, nextCursor, hasNext, err := s.loadEventApprovedParticipantsPage(ctx, eventID, params)
if err != nil {
return nil, err
}
return &ListEventApprovedParticipantsResult{
Items: toEventDetailApprovedParticipants(records),
PageInfo: toEventCollectionPageInfo(nextCursor, hasNext),
}, nil
}
// ListEventPendingJoinRequests returns the host-only pending join-request collection.
func (s *Service) ListEventPendingJoinRequests(
ctx context.Context,
userID, eventID uuid.UUID,
input ListEventCollectionInput,
) (*ListEventPendingJoinRequestsResult, error) {
if _, err := s.requireEventHost(ctx, userID, eventID); err != nil {
return nil, err
}
params, err := normalizeEventCollectionInput(input)
if err != nil {
return nil, err
}
records, nextCursor, hasNext, err := s.loadEventPendingJoinRequestsPage(ctx, eventID, params)
if err != nil {
return nil, err
}
return &ListEventPendingJoinRequestsResult{
Items: toEventDetailPendingJoinRequests(records),
PageInfo: toEventCollectionPageInfo(nextCursor, hasNext),
}, nil
}
// ListEventInvitations returns the host-only invitation collection.
func (s *Service) ListEventInvitations(
ctx context.Context,
userID, eventID uuid.UUID,
input ListEventCollectionInput,
) (*ListEventInvitationsResult, error) {
if _, err := s.requireEventHost(ctx, userID, eventID); err != nil {
return nil, err
}
params, err := normalizeEventCollectionInput(input)
if err != nil {
return nil, err
}
records, nextCursor, hasNext, err := s.loadEventInvitationsPage(ctx, eventID, params)
if err != nil {
return nil, err
}
return &ListEventInvitationsResult{
Items: toEventDetailInvitations(records),
PageInfo: toEventCollectionPageInfo(nextCursor, hasNext),
}, nil
}
// JoinEvent allows a user to join a PUBLIC event directly. The resulting
// participation record has status APPROVED.
//
// Errors:
// - 404 event_not_found – event does not exist
// - 403 host_cannot_join – caller is the event host
// - 409 event_join_not_allowed – event is not PUBLIC
// - 409 capacity_exceeded – event has reached maximum capacity
// - 409 already_participating – caller already has a participation record
func (s *Service) JoinEvent(ctx context.Context, userID, eventID uuid.UUID) (*JoinEventResult, error) {
event, err := s.eventRepo.GetEventByID(ctx, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return nil, err
}
if event.HostID == userID {
return nil, domain.ForbiddenError(domain.ErrorCodeHostCannotJoin, "The event host cannot join their own event.")
}
if event.Status == domain.EventStatusCanceled || event.Status == domain.EventStatusCompleted {
return nil, domain.ConflictError(domain.ErrorCodeEventNotJoinable, "This event is no longer accepting participants.")
}
if event.PrivacyLevel != domain.PrivacyPublic {
return nil, domain.ConflictError(domain.ErrorCodeEventJoinNotAllowed, "Only PUBLIC events can be joined directly.")
}
if event.Capacity != nil && event.ApprovedParticipantCount >= *event.Capacity {
return nil, domain.ConflictError(domain.ErrorCodeCapacityExceeded, "This event has reached its maximum capacity.")
}
p, err := s.participationService.CreateApprovedParticipation(ctx, eventID, userID)
if err != nil {
return nil, err
}
return &JoinEventResult{
ParticipationID: p.ID.String(),
EventID: p.EventID.String(),
Status: p.Status,
CreatedAt: p.CreatedAt,
}, nil
}
// LeaveEvent allows an approved participant to leave an event before it ends.
// The event host cannot leave their own event.
func (s *Service) LeaveEvent(ctx context.Context, userID, eventID uuid.UUID) (*LeaveEventResult, error) {
event, err := s.eventRepo.GetEventByID(ctx, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return nil, err
}
if event.HostID == userID {
return nil, domain.ForbiddenError(domain.ErrorCodeHostCannotLeave, "The event host cannot leave their own event.")
}
if !canLeaveEvent(event, s.now().UTC()) {
return nil, domain.ConflictError(domain.ErrorCodeEventNotLeaveable, "This event can no longer be left.")
}
p, err := s.participationService.LeaveParticipation(ctx, eventID, userID)
if err != nil {
return nil, err
}
return &LeaveEventResult{
ParticipationID: p.ID.String(),
EventID: p.EventID.String(),
Status: p.Status,
UpdatedAt: p.UpdatedAt,
}, nil
}
// RequestJoin creates a join request for a PROTECTED event.
// The host must approve the request before the user becomes a participant.
//
// Errors:
// - 404 event_not_found – event does not exist
// - 403 host_cannot_join – caller is the event host
// - 409 event_join_not_allowed – event is not PROTECTED
// - 409 already_requested – caller already has a pending request
func (s *Service) RequestJoin(ctx context.Context, userID, eventID uuid.UUID, input RequestJoinInput) (*RequestJoinResult, error) {
event, err := s.eventRepo.GetEventByID(ctx, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return nil, err
}
if event.HostID == userID {
return nil, domain.ForbiddenError(domain.ErrorCodeHostCannotJoin, "The event host cannot request to join their own event.")
}
if event.Status == domain.EventStatusCanceled || event.Status == domain.EventStatusCompleted {
return nil, domain.ConflictError(domain.ErrorCodeEventNotJoinable, "This event is no longer accepting participants.")
}
if event.PrivacyLevel != domain.PrivacyProtected {
return nil, domain.ConflictError(domain.ErrorCodeEventJoinNotAllowed, "Only PROTECTED events accept join requests.")
}
jr, err := s.joinRequestService.CreatePendingJoinRequest(ctx, eventID, userID, event.HostID, join_request.CreatePendingJoinRequestInput{
Message: input.Message,
})
if err != nil {
return nil, err
}
return &RequestJoinResult{
JoinRequestID: jr.ID.String(),
EventID: jr.EventID.String(),
Status: string(domain.JoinRequestStatusPending),
CreatedAt: jr.CreatedAt,
}, nil
}
// ApproveJoinRequest allows the authenticated host to approve a pending join
// request for one of their events.
func (s *Service) ApproveJoinRequest(
ctx context.Context,
hostUserID, eventID, joinRequestID uuid.UUID,
) (*ApproveJoinRequestResult, error) {
event, err := s.eventRepo.GetEventByID(ctx, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return nil, err
}
if event.Status == domain.EventStatusCanceled || event.Status == domain.EventStatusCompleted {
return nil, domain.ConflictError(domain.ErrorCodeEventNotJoinable, "This event is no longer accepting participants.")
}
result, err := s.joinRequestService.ApproveJoinRequest(ctx, eventID, joinRequestID, hostUserID)
if err != nil {
return nil, err
}
return &ApproveJoinRequestResult{
JoinRequestID: result.JoinRequest.ID.String(),
EventID: result.JoinRequest.EventID.String(),
JoinRequestStatus: string(result.JoinRequest.Status),
ParticipationID: result.Participation.ID.String(),
ParticipationStatus: result.Participation.Status,
UpdatedAt: result.JoinRequest.UpdatedAt,
}, nil
}
// RejectJoinRequest allows the authenticated host to reject a pending join
// request for one of their events.
func (s *Service) RejectJoinRequest(
ctx context.Context,
hostUserID, eventID, joinRequestID uuid.UUID,
) (*RejectJoinRequestResult, error) {
result, err := s.joinRequestService.RejectJoinRequest(ctx, eventID, joinRequestID, hostUserID)
if err != nil {
return nil, err
}
return &RejectJoinRequestResult{
JoinRequestID: result.JoinRequest.ID.String(),
EventID: result.JoinRequest.EventID.String(),
Status: string(result.JoinRequest.Status),
UpdatedAt: result.JoinRequest.UpdatedAt,
CooldownEndsAt: result.CooldownEndsAt,
}, nil
}
// CancelEvent transitions an ACTIVE event to CANCELED. Only the event host may cancel.
func (s *Service) CancelEvent(ctx context.Context, userID, eventID uuid.UUID) error {
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
event, err := s.eventRepo.GetEventByID(ctx, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return err
}
if event.HostID != userID {
return domain.ForbiddenError(domain.ErrorCodeEventCancelNotAllowed, "Only the event host can cancel this event.")
}
if err := s.eventRepo.CancelEvent(ctx, eventID, event.ApprovedParticipantCount); err != nil {
if errors.Is(err, ErrEventNotCancelable) {
return domain.ConflictError(domain.ErrorCodeEventNotCancelable, "Only ACTIVE events can be canceled.")
}
return err
}
if err := s.participationService.CancelEventParticipations(ctx, eventID); err != nil {
return err
}
return nil
})
}
// CompleteEvent transitions an ACTIVE or IN_PROGRESS event to COMPLETED. Only the host may call this.
func (s *Service) CompleteEvent(ctx context.Context, userID, eventID uuid.UUID) error {
event, err := s.eventRepo.GetEventByID(ctx, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return err
}
if event.HostID != userID {
return domain.ForbiddenError(domain.ErrorCodeEventCompleteNotAllowed, "Only the event host can complete this event.")
}
if err := s.eventRepo.CompleteEvent(ctx, eventID); err != nil {
if errors.Is(err, ErrEventNotCompletable) {
return domain.ConflictError(domain.ErrorCodeEventNotCompletable, "The event cannot be completed because it is already CANCELED or COMPLETED.")
}
return err
}
return nil
}
// AddFavorite saves an event to the user's favorites list.
func (s *Service) AddFavorite(ctx context.Context, userID, eventID uuid.UUID) error {
return s.eventRepo.AddFavorite(ctx, userID, eventID)
}
// RemoveFavorite removes an event from the user's favorites list.
func (s *Service) RemoveFavorite(ctx context.Context, userID, eventID uuid.UUID) error {
return s.eventRepo.RemoveFavorite(ctx, userID, eventID)
}
// ListFavoriteEvents returns events the user has favorited, ordered by most recent.
func (s *Service) ListFavoriteEvents(ctx context.Context, userID uuid.UUID) (*FavoriteEventsResult, error) {
records, err := s.eventRepo.ListFavoriteEvents(ctx, userID)
if err != nil {
return nil, err
}
items := make([]FavoriteEventItem, len(records))
for i, r := range records {
items[i] = FavoriteEventItem{
ID: r.ID.String(),
Title: r.Title,
Category: r.CategoryName,
ImageURL: r.ImageURL,
Status: string(r.Status),
PrivacyLevel: string(r.PrivacyLevel),
LocationAddress: r.LocationAddress,
StartTime: r.StartTime,
EndTime: r.EndTime,
FavoritedAt: r.FavoritedAt,
}
}
return &FavoriteEventsResult{Items: items}, nil
}
func canLeaveEvent(event *domain.Event, now time.Time) bool {
if event.Status == domain.EventStatusCanceled || event.Status == domain.EventStatusCompleted {
return false
}
if event.EndTime != nil && !now.Before(*event.EndTime) {
return false
}
return true
}
func normalizeEventCollectionInput(input ListEventCollectionInput) (EventCollectionPageParams, error) {
params := EventCollectionPageParams{
Limit: defaultEventCollectionLimit,
}
if input.Limit != nil {
if *input.Limit < 1 || *input.Limit > maxEventCollectionLimit {
return EventCollectionPageParams{}, domain.ValidationError(map[string]string{
"limit": "limit must be between 1 and 50",
})
}
params.Limit = *input.Limit
}
if input.Cursor != nil {
params.CursorToken = *input.Cursor
}
params.RepositoryFetchLimit = params.Limit + 1
if params.CursorToken != "" {
cursor, err := decodeEventCollectionCursor(params.CursorToken)
if err != nil {
return EventCollectionPageParams{}, domain.ValidationError(map[string]string{
"cursor": "cursor is invalid",
})
}
params.DecodedCursor = cursor
}
return params, nil
}
func (s *Service) requireEventHost(ctx context.Context, userID, eventID uuid.UUID) (*domain.Event, error) {
event, err := s.eventRepo.GetEventByID(ctx, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return nil, err
}
if event.HostID != userID {
return nil, domain.ForbiddenError(
domain.ErrorCodeEventHostManagementNotAllowed,
"Only the event host can access this management resource.",
)
}
return event, nil
}
func (s *Service) loadEventApprovedParticipantsPage(
ctx context.Context,
eventID uuid.UUID,
params EventCollectionPageParams,
) ([]EventDetailApprovedParticipantRecord, *string, bool, error) {
records, err := s.eventRepo.ListEventApprovedParticipants(ctx, eventID, params)
if err != nil {
return nil, nil, false, err
}
hasNext := len(records) > params.Limit
if hasNext {
records = records[:params.Limit]
}
nextCursor, err := buildNextApprovedParticipantsCursor(records, hasNext)
if err != nil {
return nil, nil, false, err
}
return records, nextCursor, hasNext, nil
}
func (s *Service) loadEventPendingJoinRequestsPage(
ctx context.Context,
eventID uuid.UUID,
params EventCollectionPageParams,
) ([]EventDetailPendingJoinRequestRecord, *string, bool, error) {
records, err := s.eventRepo.ListEventPendingJoinRequests(ctx, eventID, params)
if err != nil {
return nil, nil, false, err
}
hasNext := len(records) > params.Limit
if hasNext {
records = records[:params.Limit]
}
nextCursor, err := buildNextPendingJoinRequestsCursor(records, hasNext)
if err != nil {
return nil, nil, false, err
}
return records, nextCursor, hasNext, nil
}
func (s *Service) loadEventInvitationsPage(
ctx context.Context,
eventID uuid.UUID,
params EventCollectionPageParams,
) ([]EventDetailInvitationRecord, *string, bool, error) {
records, err := s.eventRepo.ListEventInvitations(ctx, eventID, params)
if err != nil {
return nil, nil, false, err
}
hasNext := len(records) > params.Limit
if hasNext {
records = records[:params.Limit]
}
nextCursor, err := buildNextInvitationsCursor(records, hasNext)
if err != nil {
return nil, nil, false, err
}
return records, nextCursor, hasNext, nil
}
func buildNextApprovedParticipantsCursor(records []EventDetailApprovedParticipantRecord, hasNext bool) (*string, error) {
if !hasNext || len(records) == 0 {
return nil, nil
}
return encodeNextEventCollectionCursor(records[len(records)-1].CreatedAt, records[len(records)-1].ParticipationID)
}
func buildNextPendingJoinRequestsCursor(records []EventDetailPendingJoinRequestRecord, hasNext bool) (*string, error) {
if !hasNext || len(records) == 0 {
return nil, nil
}
return encodeNextEventCollectionCursor(records[len(records)-1].CreatedAt, records[len(records)-1].JoinRequestID)
}
func buildNextInvitationsCursor(records []EventDetailInvitationRecord, hasNext bool) (*string, error) {
if !hasNext || len(records) == 0 {
return nil, nil
}
return encodeNextEventCollectionCursor(records[len(records)-1].CreatedAt, records[len(records)-1].InvitationID)
}
func encodeNextEventCollectionCursor(createdAt time.Time, entityID uuid.UUID) (*string, error) {
encoded, err := encodeEventCollectionCursor(EventCollectionCursor{
CreatedAt: createdAt,
EntityID: entityID,
})
if err != nil {
return nil, err
}
return &encoded, nil
}
package event
import (
"strconv"
"strings"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
// validateCreateEventInput checks the application-level invariants for create-event
// requests after delivery adapters have parsed wire-specific values.
func validateCreateEventInput(input CreateEventInput) map[string]string {
errs := make(map[string]string)
if strings.TrimSpace(input.Title) == "" {
errs["title"] = "title is required"
}
if input.Description == nil || strings.TrimSpace(*input.Description) == "" {
errs["description"] = "description is required"
}
if input.CategoryID == nil {
errs["category_id"] = "category_id is required"
} else if *input.CategoryID <= 0 {
errs["category_id"] = "category_id must be a positive integer"
}
if _, ok := domain.ParseEventPrivacyLevel(string(input.PrivacyLevel)); !ok {
errs["privacy_level"] = "must be one of: PUBLIC, PROTECTED, PRIVATE"
}
if _, ok := domain.ParseEventLocationType(string(input.LocationType)); !ok {
errs["location_type"] = "must be one of: POINT, ROUTE"
} else {
validateLocation(input, errs)
}
if input.StartTime.IsZero() {
errs["start_time"] = "start_time is required"
}
if input.EndTime != nil && input.StartTime.IsZero() {
// Cannot validate ordering without a valid start time.
} else if input.EndTime != nil && !input.EndTime.After(input.StartTime) {
errs["end_time"] = "end_time must be after start_time"
}
validateTags(input.Tags, errs)
validateConstraints(input.Constraints, errs)
if input.PreferredGender != nil {
if _, ok := domain.ParseEventParticipantGender(string(*input.PreferredGender)); !ok {
errs["preferred_gender"] = "must be one of: MALE, FEMALE, OTHER"
}
}
if input.Capacity != nil && *input.Capacity <= 0 {
errs["capacity"] = "capacity must be a positive integer"
}
if input.MinimumAge != nil && (*input.MinimumAge < 0 || *input.MinimumAge > 120) {
errs["minimum_age"] = "minimum_age must be between 0 and 120"
}
return errs
}
// validateLocation checks the conditional geometry requirements for point and
// route events.
func validateLocation(input CreateEventInput, errs map[string]string) {
switch input.LocationType {
case domain.LocationPoint:
if input.Lat == nil {
errs["lat"] = "lat is required when location_type is POINT"
}
if input.Lon == nil {
errs["lon"] = "lon is required when location_type is POINT"
}
if len(input.RoutePoints) > 0 {
errs["route_points"] = "route_points must be omitted when location_type is POINT"
}
case domain.LocationRoute:
if input.Lat != nil {
errs["lat"] = "lat must be omitted when location_type is ROUTE"
}
if input.Lon != nil {
errs["lon"] = "lon must be omitted when location_type is ROUTE"
}
if len(input.RoutePoints) < domain.MinRoutePoints {
errs["route_points"] = "route_points must contain at least 2 points when location_type is ROUTE"
return
}
for i, point := range input.RoutePoints {
if point.Lat == nil {
errs["route_points["+strconv.Itoa(i)+"].lat"] = "lat is required"
}
if point.Lon == nil {
errs["route_points["+strconv.Itoa(i)+"].lon"] = "lon is required"
}
}
}
}
// validateTags checks tag count and individual tag lengths, writing any
// errors into errs.
func validateTags(tags []string, errs map[string]string) {
if len(tags) > domain.MaxEventTags {
errs["tags"] = "at most 5 tags are allowed"
return
}
for _, tag := range tags {
if strings.TrimSpace(tag) == "" {
errs["tags"] = "tags must not be empty"
return
}
if len([]rune(tag)) > domain.MaxTagLength {
errs["tags"] = "each tag must be at most 20 characters"
return
}
}
}
// validateConstraints checks that every constraint has a non-empty type and
// info, writing any errors into errs.
func validateConstraints(constraints []ConstraintInput, errs map[string]string) {
if len(constraints) > domain.MaxEventConstraints {
errs["constraints"] = "at most 5 constraints are allowed"
return
}
for i, c := range constraints {
if strings.TrimSpace(c.Type) == "" {
errs["constraints["+strconv.Itoa(i)+"].type"] = "type is required"
}
if strings.TrimSpace(c.Info) == "" {
errs["constraints["+strconv.Itoa(i)+"].info"] = "info is required"
}
}
}
package favorite_location
import (
"context"
"errors"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service owns favorite-location application behavior.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
}
var _ UseCase = (*Service)(nil)
// NewService constructs a favorite-location service backed by its repository.
func NewService(repo Repository, unitOfWork uow.UnitOfWork) *Service {
return &Service{
repo: repo,
unitOfWork: unitOfWork,
}
}
// ListMyFavoriteLocations returns the authenticated user's favorite locations.
func (s *Service) ListMyFavoriteLocations(ctx context.Context, userID uuid.UUID) (*ListFavoriteLocationsResult, error) {
locations, err := s.repo.ListByUserID(ctx, userID)
if err != nil {
return nil, err
}
items := make([]FavoriteLocationResult, len(locations))
for i, location := range locations {
items[i] = toFavoriteLocationResult(location)
}
return &ListFavoriteLocationsResult{Items: items}, nil
}
// CreateMyFavoriteLocation validates and persists a new favorite location for the authenticated user.
func (s *Service) CreateMyFavoriteLocation(ctx context.Context, input CreateFavoriteLocationInput) (*FavoriteLocationResult, error) {
validated, appErr := validateFavoriteLocationCandidate(favoriteLocationCandidate{
Name: input.Name,
Address: input.Address,
Lat: input.Lat,
Lon: input.Lon,
})
if appErr != nil {
return nil, appErr
}
var location *domain.FavoriteLocation
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
location, err = s.repo.Create(ctx, CreateFavoriteLocationParams{
UserID: input.UserID,
Name: validated.Name,
Address: validated.Address,
Lat: validated.Lat,
Lon: validated.Lon,
})
return err
})
if err != nil {
if errors.Is(err, ErrFavoriteLocationLimitExceeded) {
return nil, domain.ConflictError(
domain.ErrorCodeFavoriteLocationLimitExceeded,
"Users can save at most 3 favorite locations.",
)
}
return nil, err
}
result := toFavoriteLocationResult(*location)
return &result, nil
}
// UpdateMyFavoriteLocation applies a partial update to one of the authenticated user's favorite locations.
func (s *Service) UpdateMyFavoriteLocation(ctx context.Context, input UpdateFavoriteLocationInput) (*FavoriteLocationResult, error) {
current, err := s.repo.GetByIDForUser(ctx, input.UserID, input.FavoriteLocationID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, favoriteLocationNotFoundError()
}
return nil, err
}
candidate := favoriteLocationCandidate{
Name: current.Name,
Address: current.Address,
Lat: current.Point.Lat,
Lon: current.Point.Lon,
}
if input.Name != nil {
candidate.Name = *input.Name
}
if input.Address != nil {
candidate.Address = *input.Address
}
if input.Lat != nil {
candidate.Lat = *input.Lat
}
if input.Lon != nil {
candidate.Lon = *input.Lon
}
validated, appErr := validateFavoriteLocationCandidate(candidate)
if appErr != nil {
return nil, appErr
}
location, err := s.repo.Update(ctx, UpdateFavoriteLocationParams{
UserID: input.UserID,
FavoriteLocationID: input.FavoriteLocationID,
Name: validated.Name,
Address: validated.Address,
Lat: validated.Lat,
Lon: validated.Lon,
})
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, favoriteLocationNotFoundError()
}
return nil, err
}
result := toFavoriteLocationResult(*location)
return &result, nil
}
// DeleteMyFavoriteLocation removes one of the authenticated user's favorite locations.
func (s *Service) DeleteMyFavoriteLocation(ctx context.Context, userID, favoriteLocationID uuid.UUID) error {
err := s.repo.Delete(ctx, userID, favoriteLocationID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return favoriteLocationNotFoundError()
}
return err
}
return nil
}
func toFavoriteLocationResult(location domain.FavoriteLocation) FavoriteLocationResult {
return FavoriteLocationResult{
ID: location.ID.String(),
Name: location.Name,
Address: location.Address,
Lat: location.Point.Lat,
Lon: location.Point.Lon,
}
}
func favoriteLocationNotFoundError() *domain.AppError {
return domain.NotFoundError(
domain.ErrorCodeFavoriteLocationNotFound,
"The requested favorite location does not exist.",
)
}
package favorite_location
import (
"strings"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
type favoriteLocationCandidate struct {
Name string
Address string
Lat float64
Lon float64
}
func validateFavoriteLocationCandidate(candidate favoriteLocationCandidate) (*favoriteLocationCandidate, *domain.AppError) {
details := make(map[string]string)
name := strings.TrimSpace(candidate.Name)
switch {
case name == "":
details["name"] = "must not be empty"
case len(name) > maxFavoriteLocationNameLength:
details["name"] = "must be at most 64 characters"
}
address := strings.TrimSpace(candidate.Address)
switch {
case address == "":
details["address"] = "must not be empty"
case len(address) > maxFavoriteLocationAddressLength:
details["address"] = "must be at most 512 characters"
}
if candidate.Lat < -90 || candidate.Lat > 90 {
details["lat"] = "must be between -90 and 90"
}
if candidate.Lon < -180 || candidate.Lon > 180 {
details["lon"] = "must be between -180 and 180"
}
if len(details) > 0 {
return nil, domain.ValidationError(details)
}
return &favoriteLocationCandidate{
Name: name,
Address: address,
Lat: candidate.Lat,
Lon: candidate.Lon,
}, nil
}
package imageupload
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service owns image-upload application behavior for profile avatars and event images.
type Service struct {
profileRepo ProfileRepository
eventRepo EventRepository
storage Storage
tokens TokenManager
settings Settings
now func() time.Time
}
var _ UseCase = (*Service)(nil)
// NewService constructs an image upload service.
func NewService(
profileRepo ProfileRepository,
eventRepo EventRepository,
storage Storage,
tokens TokenManager,
settings Settings,
) *Service {
return &Service{
profileRepo: profileRepo,
eventRepo: eventRepo,
storage: storage,
tokens: tokens,
settings: Settings{
PresignTTL: settings.PresignTTL,
UploadCacheCtrl: strings.TrimSpace(settings.UploadCacheCtrl),
CDNBaseURL: strings.TrimRight(strings.TrimSpace(settings.CDNBaseURL), "/"),
},
now: time.Now,
}
}
// CreateProfileAvatarUpload prepares a versioned direct-upload flow for the authenticated user's avatar.
func (s *Service) CreateProfileAvatarUpload(ctx context.Context, userID uuid.UUID) (*CreateUploadResult, error) {
currentVersion, err := s.profileRepo.GetAvatarVersion(ctx, userID)
if err != nil {
return nil, err
}
uploadID := uuid.NewString()
return s.createUpload(ctx, uploadDescriptor{
Resource: ResourceProfileAvatar,
OwnerUserID: userID,
NextVersion: currentVersion + 1,
OriginalKey: fmt.Sprintf("profiles/%s/avatar/v%d-%s", userID, currentVersion+1, uploadID),
UploadID: uploadID,
CurrentEvent: nil,
})
}
// ConfirmProfileAvatarUpload verifies the uploaded objects and atomically updates the profile URL/version.
func (s *Service) ConfirmProfileAvatarUpload(ctx context.Context, userID uuid.UUID, input ConfirmUploadInput) error {
payload, err := s.verifyConfirmToken(input, ResourceProfileAvatar)
if err != nil {
return err
}
if payload.OwnerUserID != userID || payload.EventID != nil {
return invalidConfirmTokenError()
}
currentVersion, err := s.profileRepo.GetAvatarVersion(ctx, userID)
if err != nil {
return err
}
if currentVersion != payload.Version-1 {
return domain.ConflictError(
domain.ErrorCodeImageUploadVersionConflict,
"A newer avatar image upload has already been confirmed.",
)
}
if err := s.ensureUploadedObjectsExist(ctx, payload); err != nil {
return err
}
updated, err := s.profileRepo.SetAvatarIfVersion(
ctx,
userID,
currentVersion,
payload.Version,
payload.BaseURL,
s.now().UTC(),
)
if err != nil {
return err
}
if !updated {
return domain.ConflictError(
domain.ErrorCodeImageUploadVersionConflict,
"A newer avatar image upload has already been confirmed.",
)
}
return nil
}
// CreateEventImageUpload prepares a versioned direct-upload flow for an event cover image.
func (s *Service) CreateEventImageUpload(ctx context.Context, userID, eventID uuid.UUID) (*CreateUploadResult, error) {
state, err := s.eventRepo.GetEventImageState(ctx, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return nil, err
}
if state.HostID != userID {
return nil, domain.ForbiddenError(
domain.ErrorCodeImageUploadNotAllowed,
"Only the event host can upload the event image.",
)
}
uploadID := uuid.NewString()
return s.createUpload(ctx, uploadDescriptor{
Resource: ResourceEventImage,
OwnerUserID: userID,
NextVersion: state.CurrentVersion + 1,
OriginalKey: fmt.Sprintf("events/%s/cover/v%d-%s", eventID, state.CurrentVersion+1, uploadID),
UploadID: uploadID,
CurrentEvent: &eventID,
})
}
// ConfirmEventImageUpload verifies the uploaded objects and atomically updates the event URL/version.
func (s *Service) ConfirmEventImageUpload(ctx context.Context, userID, eventID uuid.UUID, input ConfirmUploadInput) error {
payload, err := s.verifyConfirmToken(input, ResourceEventImage)
if err != nil {
return err
}
if payload.OwnerUserID != userID || payload.EventID == nil || *payload.EventID != eventID {
return invalidConfirmTokenError()
}
state, err := s.eventRepo.GetEventImageState(ctx, eventID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return err
}
if state.HostID != userID {
return domain.ForbiddenError(
domain.ErrorCodeImageUploadNotAllowed,
"Only the event host can upload the event image.",
)
}
if state.CurrentVersion != payload.Version-1 {
return domain.ConflictError(
domain.ErrorCodeImageUploadVersionConflict,
"A newer event image upload has already been confirmed.",
)
}
if err := s.ensureUploadedObjectsExist(ctx, payload); err != nil {
return err
}
updated, err := s.eventRepo.SetEventImageIfVersion(
ctx,
eventID,
state.CurrentVersion,
payload.Version,
payload.BaseURL,
s.now().UTC(),
)
if err != nil {
return err
}
if !updated {
return domain.ConflictError(
domain.ErrorCodeImageUploadVersionConflict,
"A newer event image upload has already been confirmed.",
)
}
return nil
}
type uploadDescriptor struct {
Resource string
OwnerUserID uuid.UUID
NextVersion int
OriginalKey string
UploadID string
CurrentEvent *uuid.UUID
}
func (s *Service) createUpload(ctx context.Context, desc uploadDescriptor) (*CreateUploadResult, error) {
baseURL := s.settings.CDNBaseURL + "/" + desc.OriginalKey
smallKey := desc.OriginalKey + "-small"
originalUpload, err := s.storage.PresignPutObject(
ctx,
desc.OriginalKey,
JPEGContentType,
s.settings.UploadCacheCtrl,
s.settings.PresignTTL,
)
if err != nil {
return nil, err
}
smallUpload, err := s.storage.PresignPutObject(
ctx,
smallKey,
JPEGContentType,
s.settings.UploadCacheCtrl,
s.settings.PresignTTL,
)
if err != nil {
return nil, err
}
token, err := s.tokens.Sign(ConfirmTokenPayload{
Resource: desc.Resource,
OwnerUserID: desc.OwnerUserID,
EventID: desc.CurrentEvent,
Version: desc.NextVersion,
UploadID: desc.UploadID,
BaseURL: baseURL,
OriginalKey: desc.OriginalKey,
SmallKey: smallKey,
ExpiresAt: s.now().UTC().Add(s.settings.PresignTTL),
}, s.settings.PresignTTL)
if err != nil {
return nil, err
}
return &CreateUploadResult{
BaseURL: baseURL,
Version: desc.NextVersion,
ConfirmToken: token,
Uploads: []PresignedUpload{
{
Variant: VariantOriginal,
Method: originalUpload.Method,
URL: originalUpload.URL,
Headers: originalUpload.Headers,
},
{
Variant: VariantSmall,
Method: smallUpload.Method,
URL: smallUpload.URL,
Headers: smallUpload.Headers,
},
},
}, nil
}
func (s *Service) verifyConfirmToken(input ConfirmUploadInput, expectedResource string) (*ConfirmTokenPayload, error) {
token := strings.TrimSpace(input.ConfirmToken)
if token == "" {
return nil, invalidConfirmTokenError()
}
payload, err := s.tokens.Verify(token)
if err != nil {
return nil, invalidConfirmTokenError()
}
if payload.Resource != expectedResource {
return nil, invalidConfirmTokenError()
}
return payload, nil
}
func (s *Service) ensureUploadedObjectsExist(ctx context.Context, payload *ConfirmTokenPayload) error {
originalExists, err := s.storage.ObjectExists(ctx, payload.OriginalKey)
if err != nil {
return err
}
smallExists, err := s.storage.ObjectExists(ctx, payload.SmallKey)
if err != nil {
return err
}
if originalExists && smallExists {
return nil
}
return domain.ConflictError(
domain.ErrorCodeImageUploadIncomplete,
"Upload is incomplete. Upload both ORIGINAL and SMALL images before confirming.",
)
}
func invalidConfirmTokenError() *domain.AppError {
return domain.BadRequestError(
domain.ErrorCodeImageUploadTokenInvalid,
"The confirm token is invalid or expired.",
)
}
package join_request
import (
"context"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service owns join-request-specific application behavior.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
}
var _ UseCase = (*Service)(nil)
// NewService constructs a join request service backed by its own repository.
func NewService(repo Repository, unitOfWork uow.UnitOfWork) *Service {
return &Service{
repo: repo,
unitOfWork: unitOfWork,
}
}
// CreatePendingJoinRequest persists a PENDING join request for the given event,
// requesting user, and host.
func (s *Service) CreatePendingJoinRequest(
ctx context.Context,
eventID, userID, hostUserID uuid.UUID,
input CreatePendingJoinRequestInput,
) (*domain.JoinRequest, error) {
var result *domain.JoinRequest
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
result, err = s.repo.CreateJoinRequest(ctx, CreateJoinRequestParams{
EventID: eventID,
UserID: userID,
HostUserID: hostUserID,
Message: input.Message,
})
return err
})
if err != nil {
return nil, err
}
return result, nil
}
// ApproveJoinRequest transitions a pending join request to APPROVED and creates
// the participant's APPROVED participation row atomically.
func (s *Service) ApproveJoinRequest(
ctx context.Context,
eventID, joinRequestID, hostUserID uuid.UUID,
) (*ApproveJoinRequestResult, error) {
var result *ApproveJoinRequestResult
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
result, err = s.repo.ApproveJoinRequest(ctx, ApproveJoinRequestParams{
EventID: eventID,
JoinRequestID: joinRequestID,
HostUserID: hostUserID,
})
return err
})
if err != nil {
return nil, err
}
return result, nil
}
// RejectJoinRequest transitions a pending join request to REJECTED and returns
// the resulting cooldown end timestamp.
func (s *Service) RejectJoinRequest(
ctx context.Context,
eventID, joinRequestID, hostUserID uuid.UUID,
) (*RejectJoinRequestResult, error) {
var result *RejectJoinRequestResult
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
result, err = s.repo.RejectJoinRequest(ctx, RejectJoinRequestParams{
EventID: eventID,
JoinRequestID: joinRequestID,
HostUserID: hostUserID,
})
return err
})
if err != nil {
return nil, err
}
return result, nil
}
package participation
import (
"context"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service owns participation-specific application behavior.
type Service struct {
repo Repository
}
var _ UseCase = (*Service)(nil)
// NewService constructs a participation service backed by its own repository.
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
// CreateApprovedParticipation persists an APPROVED participation for the given
// event and user.
func (s *Service) CreateApprovedParticipation(ctx context.Context, eventID, userID uuid.UUID) (*domain.Participation, error) {
return s.repo.CreateParticipation(ctx, eventID, userID)
}
// LeaveParticipation marks an APPROVED participation as LEAVED for the given
// event and user.
func (s *Service) LeaveParticipation(ctx context.Context, eventID, userID uuid.UUID) (*domain.Participation, error) {
return s.repo.LeaveParticipation(ctx, eventID, userID)
}
// CancelEventParticipations marks all non-LEAVED participations for an event as CANCELED.
func (s *Service) CancelEventParticipations(ctx context.Context, eventID uuid.UUID) error {
return s.repo.CancelEventParticipations(ctx, eventID)
}
package profile
import (
"context"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service owns profile-specific application behavior.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
}
var _ UseCase = (*Service)(nil)
// NewService constructs a profile service backed by its own repository.
func NewService(repo Repository, unitOfWork uow.UnitOfWork) *Service {
return &Service{
repo: repo,
unitOfWork: unitOfWork,
}
}
// GetMyProfile returns the combined app_user + profile data for the given user.
func (s *Service) GetMyProfile(ctx context.Context, userID uuid.UUID) (*GetProfileResult, error) {
p, err := s.repo.GetProfile(ctx, userID)
if err != nil {
return nil, err
}
result := &GetProfileResult{
ID: p.ID.String(),
Username: p.Username,
Email: p.Email,
PhoneNumber: p.PhoneNumber,
Gender: p.Gender,
EmailVerified: p.EmailVerified,
Status: p.Status,
DefaultLocationAddress: p.DefaultLocationAddress,
DefaultLocationLat: p.DefaultLocationLat,
DefaultLocationLon: p.DefaultLocationLon,
DisplayName: p.DisplayName,
Bio: p.Bio,
AvatarURL: p.AvatarURL,
FinalScore: p.FinalScore,
HostScore: &HostScore{
Score: p.HostScore.Score,
RatingCount: p.HostScore.RatingCount,
},
ParticipantScore: &ParticipantScore{
Score: p.ParticipantScore.Score,
RatingCount: p.ParticipantScore.RatingCount,
},
}
if p.BirthDate != nil {
formatted := p.BirthDate.Format("2006-01-02")
result.BirthDate = &formatted
}
return result, nil
}
// GetMyHostedEvents returns events created by the user.
func (s *Service) GetMyHostedEvents(ctx context.Context, userID uuid.UUID) ([]EventSummary, error) {
events, err := s.repo.GetHostedEvents(ctx, userID)
if err != nil {
return nil, err
}
return toEventSummaries(events), nil
}
// GetMyUpcomingEvents returns events the user is still actively participating
// in with an APPROVED participation.
func (s *Service) GetMyUpcomingEvents(ctx context.Context, userID uuid.UUID) ([]EventSummary, error) {
events, err := s.repo.GetUpcomingEvents(ctx, userID)
if err != nil {
return nil, err
}
return toEventSummaries(events), nil
}
// GetMyCompletedEvents returns completed events the user either finished as an
// APPROVED participant or left after the event had already started.
func (s *Service) GetMyCompletedEvents(ctx context.Context, userID uuid.UUID) ([]EventSummary, error) {
events, err := s.repo.GetCompletedEvents(ctx, userID)
if err != nil {
return nil, err
}
return toEventSummaries(events), nil
}
// GetMyCanceledEvents returns events the user was part of (as host or participant)
// that have been CANCELED.
func (s *Service) GetMyCanceledEvents(ctx context.Context, userID uuid.UUID) ([]EventSummary, error) {
events, err := s.repo.GetCanceledEvents(ctx, userID)
if err != nil {
return nil, err
}
return toEventSummaries(events), nil
}
func toEventSummaries(events []domain.EventSummary) []EventSummary {
result := make([]EventSummary, len(events))
for i, e := range events {
result[i] = EventSummary{
ID: e.ID.String(),
Title: e.Title,
StartTime: e.StartTime.Format("2006-01-02T15:04:05Z07:00"),
EndTime: e.EndTime.Format("2006-01-02T15:04:05Z07:00"),
Status: e.Status,
PrivacyLevel: e.PrivacyLevel,
Category: e.Category,
ImageURL: e.ImageURL,
ParticipantsCount: e.ApprovedParticipantCount,
LocationAddress: e.LocationAddress,
}
}
return result
}
// UpdateMyProfile validates and persists the editable profile fields.
func (s *Service) UpdateMyProfile(ctx context.Context, input UpdateProfileInput) error {
validated, appErr := validateUpdateProfileInput(input)
if appErr != nil {
return appErr
}
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
return s.repo.UpdateProfile(ctx, UpdateProfileParams{
UserID: validated.UserID,
PhoneNumber: validated.PhoneNumber,
Gender: validated.Gender,
BirthDate: validated.BirthDate,
DefaultLocationAddress: validated.DefaultLocationAddress,
DefaultLocationLat: validated.DefaultLocationLat,
DefaultLocationLon: validated.DefaultLocationLon,
DisplayName: validated.DisplayName,
Bio: validated.Bio,
AvatarURL: validated.AvatarURL,
})
})
}
package profile
import (
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
func validateUpdateProfileInput(input UpdateProfileInput) (*UpdateProfileInput, *domain.AppError) {
details := make(map[string]string)
out := UpdateProfileInput{UserID: input.UserID}
if input.PhoneNumber != nil {
trimmed := strings.TrimSpace(*input.PhoneNumber)
if trimmed == "" {
out.PhoneNumber = nil
} else if len(trimmed) > 32 {
details["phone_number"] = "must be at most 32 characters"
} else {
out.PhoneNumber = &trimmed
}
}
if input.Gender != nil {
upper := strings.ToUpper(strings.TrimSpace(*input.Gender))
if upper == "" {
out.Gender = nil
} else {
switch upper {
case "MALE", "FEMALE", "OTHER", "PREFER_NOT_TO_SAY":
out.Gender = &upper
default:
details["gender"] = "must be one of: MALE, FEMALE, OTHER, PREFER_NOT_TO_SAY"
}
}
}
if input.BirthDate != nil {
trimmed := strings.TrimSpace(*input.BirthDate)
if trimmed == "" {
out.BirthDate = nil
} else {
if _, err := time.Parse("2006-01-02", trimmed); err != nil {
details["birth_date"] = "must be in YYYY-MM-DD format"
} else {
out.BirthDate = &trimmed
}
}
}
if input.DefaultLocationAddress != nil {
trimmed := strings.TrimSpace(*input.DefaultLocationAddress)
if trimmed == "" {
out.DefaultLocationAddress = nil
} else if len(trimmed) > 512 {
details["default_location_address"] = "must be at most 512 characters"
} else {
out.DefaultLocationAddress = &trimmed
}
}
if input.DefaultLocationLat != nil {
lat := *input.DefaultLocationLat
if lat < -90 || lat > 90 {
details["default_location_lat"] = "must be between -90 and 90"
} else {
out.DefaultLocationLat = &lat
}
}
if input.DefaultLocationLon != nil {
lon := *input.DefaultLocationLon
if lon < -180 || lon > 180 {
details["default_location_lon"] = "must be between -180 and 180"
} else {
out.DefaultLocationLon = &lon
}
}
if (input.DefaultLocationLat != nil) != (input.DefaultLocationLon != nil) {
details["default_location"] = "lat and lon must be provided together"
}
if input.DisplayName != nil {
trimmed := strings.TrimSpace(*input.DisplayName)
if trimmed == "" {
out.DisplayName = nil
} else if len(trimmed) > 64 {
details["display_name"] = "must be at most 64 characters"
} else {
out.DisplayName = &trimmed
}
}
if input.Bio != nil {
trimmed := strings.TrimSpace(*input.Bio)
if trimmed == "" {
out.Bio = nil
} else if len(trimmed) > 512 {
details["bio"] = "must be at most 512 characters"
} else {
out.Bio = &trimmed
}
}
if input.AvatarURL != nil {
trimmed := strings.TrimSpace(*input.AvatarURL)
if trimmed == "" {
out.AvatarURL = nil
} else if len(trimmed) > 512 {
details["avatar_url"] = "must be at most 512 characters"
} else {
out.AvatarURL = &trimmed
}
}
if len(details) > 0 {
return nil, domain.ValidationError(details)
}
return &out, nil
}
package rating
import (
"strings"
"time"
"unicode/utf8"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
func normalizeMessage(message *string) *string {
if message == nil {
return nil
}
trimmed := strings.TrimSpace(*message)
if trimmed == "" {
return nil
}
return &trimmed
}
func validateUpsertRatingInput(input UpsertRatingInput) map[string]string {
errs := make(map[string]string)
if input.Rating < domain.RatingMin || input.Rating > domain.RatingMax {
errs["rating"] = "rating must be between 1 and 5"
}
if input.Message != nil {
length := utf8.RuneCountInString(*input.Message)
if length < domain.RatingMessageMinLength || length > domain.RatingMessageMaxLength {
errs["message"] = "message must be between 10 and 100 characters when provided"
}
}
return errs
}
func toRatingResult(id string, ratingValue int, message *string, createdAt, updatedAt time.Time) *RatingResult {
return &RatingResult{
ID: id,
Rating: ratingValue,
Message: message,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}
func toEventRatingResult(record *domain.EventRating) *RatingResult {
return toRatingResult(
record.ID.String(),
record.Rating,
record.Message,
record.CreatedAt,
record.UpdatedAt,
)
}
func toParticipantRatingResult(record *domain.ParticipantRating) *RatingResult {
return toRatingResult(
record.ID.String(),
record.Rating,
record.Message,
record.CreatedAt,
record.UpdatedAt,
)
}
func calculateBayesianAverage(average *float64, count int, settings Settings) *float64 {
if average == nil || count == 0 {
return nil
}
m := float64(settings.BayesianM)
score := ((*average * float64(count)) + (settings.GlobalPrior * m)) / (float64(count) + m)
return &score
}
func calculateFinalScore(participantAggregate, hostedAggregate *ScoreAggregate, settings Settings) *float64 {
var (
participantBayesian = calculateBayesianAverage(participantAggregate.Average, participantAggregate.Count, settings)
hostedBayesian = calculateBayesianAverage(hostedAggregate.Average, hostedAggregate.Count, settings)
)
switch {
case participantBayesian == nil && hostedBayesian == nil:
return nil
case participantBayesian == nil:
return hostedBayesian
case hostedBayesian == nil:
return participantBayesian
default:
score := (0.6 * *hostedBayesian) + (0.4 * *participantBayesian)
return &score
}
}
package rating
import (
"context"
"errors"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service owns rating-specific application behavior.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
settings Settings
now func() time.Time
}
var _ UseCase = (*Service)(nil)
// NewService constructs a rating service backed by its own repository.
func NewService(repo Repository, unitOfWork uow.UnitOfWork, settings Settings) *Service {
return &Service{
repo: repo,
unitOfWork: unitOfWork,
settings: settings,
now: time.Now,
}
}
// UpsertEventRating creates or updates the caller's rating for an event.
func (s *Service) UpsertEventRating(
ctx context.Context,
participantUserID, eventID uuid.UUID,
input UpsertRatingInput,
) (*RatingResult, error) {
input.Message = normalizeMessage(input.Message)
if errs := validateUpsertRatingInput(input); len(errs) > 0 {
return nil, domain.ValidationError(errs)
}
var result *domain.EventRating
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
ratingContext, err := s.repo.GetEventRatingContext(ctx, eventID, participantUserID)
if err != nil {
return s.mapContextError(err)
}
if err := s.validateEventRatingContext(ratingContext); err != nil {
return err
}
result, err = s.repo.UpsertEventRating(ctx, UpsertEventRatingParams{
EventID: eventID,
ParticipantUserID: participantUserID,
Rating: input.Rating,
Message: input.Message,
})
if err != nil {
return err
}
return s.refreshUserScore(ctx, s.repo, ratingContext.HostUserID)
})
if err != nil {
return nil, err
}
return toEventRatingResult(result), nil
}
// DeleteEventRating hard deletes the caller's rating for an event.
func (s *Service) DeleteEventRating(ctx context.Context, participantUserID, eventID uuid.UUID) error {
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
ratingContext, err := s.repo.GetEventRatingContext(ctx, eventID, participantUserID)
if err != nil {
return s.mapContextError(err)
}
if err := s.validateEventRatingContext(ratingContext); err != nil {
return err
}
deleted, err := s.repo.DeleteEventRating(ctx, eventID, participantUserID)
if err != nil {
return err
}
if !deleted {
return domain.NotFoundError(domain.ErrorCodeEventRatingNotFound, "The requested event rating does not exist.")
}
return s.refreshUserScore(ctx, s.repo, ratingContext.HostUserID)
})
}
// UpsertParticipantRating creates or updates the host's rating for an approved participant.
func (s *Service) UpsertParticipantRating(
ctx context.Context,
hostUserID, eventID, participantUserID uuid.UUID,
input UpsertRatingInput,
) (*RatingResult, error) {
input.Message = normalizeMessage(input.Message)
if errs := validateUpsertRatingInput(input); len(errs) > 0 {
return nil, domain.ValidationError(errs)
}
var result *domain.ParticipantRating
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
ratingContext, err := s.repo.GetParticipantRatingContext(ctx, eventID, hostUserID, participantUserID)
if err != nil {
return s.mapContextError(err)
}
if err := s.validateParticipantRatingContext(hostUserID, participantUserID, ratingContext); err != nil {
return err
}
result, err = s.repo.UpsertParticipantRating(ctx, UpsertParticipantRatingParams{
EventID: eventID,
HostUserID: hostUserID,
ParticipantUserID: participantUserID,
Rating: input.Rating,
Message: input.Message,
})
if err != nil {
return err
}
return s.refreshUserScore(ctx, s.repo, participantUserID)
})
if err != nil {
return nil, err
}
return toParticipantRatingResult(result), nil
}
// DeleteParticipantRating hard deletes the host's rating for a participant.
func (s *Service) DeleteParticipantRating(
ctx context.Context,
hostUserID, eventID, participantUserID uuid.UUID,
) error {
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
ratingContext, err := s.repo.GetParticipantRatingContext(ctx, eventID, hostUserID, participantUserID)
if err != nil {
return s.mapContextError(err)
}
if err := s.validateParticipantRatingContext(hostUserID, participantUserID, ratingContext); err != nil {
return err
}
deleted, err := s.repo.DeleteParticipantRating(ctx, eventID, hostUserID, participantUserID)
if err != nil {
return err
}
if !deleted {
return domain.NotFoundError(domain.ErrorCodeParticipantRatingNotFound, "The requested participant rating does not exist.")
}
return s.refreshUserScore(ctx, s.repo, participantUserID)
})
}
func (s *Service) mapContextError(err error) error {
if errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return err
}
func (s *Service) validateEventRatingContext(ratingContext *EventRatingContext) error {
if ratingContext.IsRequestingHost {
return domain.ForbiddenError(domain.ErrorCodeHostCannotRateSelf, "The event host cannot rate their own event.")
}
if ratingContext.Status == domain.EventStatusCanceled {
return domain.ConflictError(domain.ErrorCodeRatingNotAllowed, "Ratings are not allowed for canceled events.")
}
if !ratingContext.IsApprovedParticipant {
return domain.ForbiddenError(domain.ErrorCodeRatingNotAllowed, "Only approved participants can rate this event.")
}
return s.validateWindow(ratingContext.StartTime, ratingContext.EndTime)
}
func (s *Service) validateParticipantRatingContext(
hostUserID, participantUserID uuid.UUID,
ratingContext *ParticipantRatingContext,
) error {
if hostUserID == participantUserID {
return domain.ForbiddenError(domain.ErrorCodeHostCannotRateSelf, "The event host cannot rate themselves.")
}
if !ratingContext.IsRequestingHost || ratingContext.HostUserID != hostUserID {
return domain.ForbiddenError(domain.ErrorCodeRatingNotAllowed, "Only the event host can rate participants for this event.")
}
if ratingContext.Status == domain.EventStatusCanceled {
return domain.ConflictError(domain.ErrorCodeRatingNotAllowed, "Ratings are not allowed for canceled events.")
}
if !ratingContext.IsApprovedParticipant {
return domain.ForbiddenError(domain.ErrorCodeRatingNotAllowed, "Only approved participants can be rated for this event.")
}
return s.validateWindow(ratingContext.StartTime, ratingContext.EndTime)
}
func (s *Service) validateWindow(startTime time.Time, endTime *time.Time) error {
window := domain.NewRatingWindow(startTime, endTime)
if !window.IsActive(s.now().UTC()) {
return domain.ConflictError(domain.ErrorCodeRatingWindowClosed, "Ratings can only be modified within 7 days after the event ends.")
}
return nil
}
func (s *Service) refreshUserScore(ctx context.Context, repo Repository, userID uuid.UUID) error {
participantAggregate, err := repo.CalculateParticipantAggregate(ctx, userID)
if err != nil {
return err
}
hostedAggregate, err := repo.CalculateHostedEventAggregate(ctx, userID)
if err != nil {
return err
}
return repo.UpsertUserScore(ctx, UpsertUserScoreParams{
UserID: userID,
ParticipantScore: participantAggregate.Average,
ParticipantRatingCount: participantAggregate.Count,
HostedEventScore: hostedAggregate.Average,
HostedEventRatingCount: hostedAggregate.Count,
FinalScore: calculateFinalScore(participantAggregate, hostedAggregate, s.settings),
})
}
package bootstrap
import (
"context"
"fmt"
"log"
"time"
emailadapter "github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/email"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/hasher"
jwtadapter "github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/jwt"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/otp"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/postgres"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/ratelimit"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/security"
spacesadapter "github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/spaces"
"github.com/bounswe/bounswe2026group11/backend/internal/application/auth"
"github.com/bounswe/bounswe2026group11/backend/internal/application/category"
emailapp "github.com/bounswe/bounswe2026group11/backend/internal/application/email"
"github.com/bounswe/bounswe2026group11/backend/internal/application/event"
favoritelocation "github.com/bounswe/bounswe2026group11/backend/internal/application/favorite_location"
"github.com/bounswe/bounswe2026group11/backend/internal/application/imageupload"
"github.com/bounswe/bounswe2026group11/backend/internal/application/join_request"
"github.com/bounswe/bounswe2026group11/backend/internal/application/participation"
"github.com/bounswe/bounswe2026group11/backend/internal/application/profile"
"github.com/bounswe/bounswe2026group11/backend/internal/application/rating"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/bounswe/bounswe2026group11/backend/internal/infrastructure/config"
"github.com/bounswe/bounswe2026group11/backend/internal/infrastructure/database"
"github.com/jackc/pgx/v5/pgxpool"
)
// Container is the backend composition root. It owns long-lived infrastructure
// dependencies and exposes application services to the delivery layer.
type Container struct {
Config *config.Config
DB *pgxpool.Pool
UnitOfWork uow.UnitOfWork
MailProvider emailapp.Provider
TokenIssuer auth.TokenIssuer
TokenVerifier domain.TokenVerifier
authRepo *postgres.AuthRepository
eventRepo *postgres.EventRepository
participationRepo *postgres.ParticipationRepository
joinRequestRepo *postgres.JoinRequestRepository
ratingRepo *postgres.RatingRepository
categoryRepo *postgres.CategoryRepository
profileRepo *postgres.ProfileRepository
favoriteLocationRepo *postgres.FavoriteLocationRepository
AuthService auth.UseCase
EventService event.UseCase
ParticipationService participation.UseCase
JoinRequestService join_request.UseCase
RatingService rating.UseCase
CategoryService category.UseCase
ProfileService profile.UseCase
FavoriteLocationService favoritelocation.UseCase
ImageUploadService imageupload.UseCase
// Extend with additional services as features are added
}
// New initializes infrastructure (config, database) and wires all application
// services. The returned Container must be closed when the application exits.
func New(ctx context.Context) (*Container, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
db, err := database.OpenDB(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
mailProvider, err := buildMailProvider(cfg)
if err != nil {
db.Close()
return nil, fmt.Errorf("build mail provider: %w", err)
}
spacesStorage := buildSpacesStorage(cfg)
container := &Container{
Config: cfg,
DB: db,
UnitOfWork: postgres.NewUnitOfWork(db),
MailProvider: mailProvider,
TokenIssuer: buildTokenIssuer(cfg),
TokenVerifier: buildTokenVerifier(cfg),
}
container.authRepo = postgres.NewAuthRepository(container.DB)
container.eventRepo = postgres.NewEventRepository(container.DB)
container.participationRepo = postgres.NewParticipationRepository(container.DB)
container.joinRequestRepo = postgres.NewJoinRequestRepository(container.DB)
container.ratingRepo = postgres.NewRatingRepository(container.DB)
container.categoryRepo = postgres.NewCategoryRepository(container.DB)
container.profileRepo = postgres.NewProfileRepository(container.DB)
container.favoriteLocationRepo = postgres.NewFavoriteLocationRepository(container.DB)
container.ParticipationService = newParticipationService(container)
container.JoinRequestService = newJoinRequestService(container)
container.RatingService = newRatingService(container)
container.AuthService = newAuthService(container)
container.EventService = newEventService(container)
container.CategoryService = newCategoryService(container)
container.ProfileService = newProfileService(container)
container.FavoriteLocationService = newFavoriteLocationService(container)
container.ImageUploadService = newImageUploadService(container, spacesStorage)
return container, nil
}
// StartEventExpiryJob immediately transitions event statuses
// (ACTIVE → IN_PROGRESS → COMPLETED), then repeats every interval until ctx
// is cancelled.
func (c *Container) StartEventExpiryJob(ctx context.Context, interval time.Duration) {
expire := func() {
if err := c.eventRepo.TransitionEventStatuses(ctx); err != nil {
log.Printf("event status transition job: %v", err)
}
}
expire()
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
expire()
case <-ctx.Done():
return
}
}
}()
}
// Close releases all long-lived resources (e.g. database connections).
func (c *Container) Close() {
if c == nil || c.DB == nil {
return
}
c.DB.Close()
}
// buildTokenIssuer constructs the JWT token issuer adapter.
func buildTokenIssuer(cfg *config.Config) jwtadapter.Issuer {
return jwtadapter.Issuer{
Secret: []byte(cfg.JWTSecret),
TTL: cfg.AccessTokenTTL,
}
}
// buildTokenVerifier constructs the JWT token verifier adapter.
func buildTokenVerifier(cfg *config.Config) jwtadapter.Verifier {
return jwtadapter.Verifier{
Secret: []byte(cfg.JWTSecret),
}
}
func buildMailProvider(cfg *config.Config) (emailapp.Provider, error) {
switch cfg.MailProvider {
case "mock":
return emailadapter.MockProvider{}, nil
case "resend":
return emailadapter.NewResendProvider(cfg.ResendClientAPIKey, cfg.MailDomain), nil
default:
return nil, fmt.Errorf("unsupported mail provider %q", cfg.MailProvider)
}
}
func buildSpacesStorage(cfg *config.Config) *spacesadapter.Storage {
return spacesadapter.NewStorage(spacesadapter.Config{
AccessKey: cfg.SpacesAccessKey,
SecretKey: cfg.SpacesSecretKey,
Endpoint: cfg.SpacesEndpoint,
Bucket: cfg.SpacesBucket,
Region: cfg.SpacesS3Region,
})
}
// newEventService wires the event use case with its driven adapters.
func newEventService(c *Container) event.UseCase {
return event.NewService(c.eventRepo, c.ParticipationService, c.JoinRequestService, c.UnitOfWork)
}
// newParticipationService wires the participation use-case service with its
// driven adapter.
func newParticipationService(c *Container) participation.UseCase {
return participation.NewService(c.participationRepo)
}
// newJoinRequestService wires the join request use-case service with its
// driven adapter.
func newJoinRequestService(c *Container) join_request.UseCase {
return join_request.NewService(c.joinRequestRepo, c.UnitOfWork)
}
// newCategoryService wires the category use-case service with its driven adapter.
func newCategoryService(c *Container) category.UseCase {
return category.NewService(c.categoryRepo)
}
// newProfileService wires the profile use-case service with its driven adapter.
func newProfileService(c *Container) profile.UseCase {
return profile.NewService(c.profileRepo, c.UnitOfWork)
}
// newFavoriteLocationService wires the favorite-location use-case service with its driven adapter.
func newFavoriteLocationService(c *Container) favoritelocation.UseCase {
return favoritelocation.NewService(c.favoriteLocationRepo, c.UnitOfWork)
}
func newImageUploadService(c *Container, storage *spacesadapter.Storage) imageupload.UseCase {
return imageupload.NewService(
c.profileRepo,
c.eventRepo,
storage,
jwtadapter.ImageUploadTokenManager{Secret: []byte(c.Config.JWTSecret)},
imageupload.Settings{
PresignTTL: c.Config.SpacesPresignTTL,
UploadCacheCtrl: c.Config.SpacesUploadCacheCtrl,
CDNBaseURL: c.Config.SpacesCDNBaseURL,
},
)
}
// newRatingService wires the rating use-case service with its driven adapter.
func newRatingService(c *Container) rating.UseCase {
return rating.NewService(c.ratingRepo, c.UnitOfWork, rating.Settings{
GlobalPrior: c.Config.RatingGlobalPrior,
BayesianM: c.Config.RatingBayesianM,
})
}
// newAuthService wires the auth use-case service with its driven adapters.
func newAuthService(c *Container) auth.UseCase {
return auth.NewService(
c.authRepo,
c.UnitOfWork,
hasher.BcryptHasher{},
hasher.BcryptHasher{},
c.TokenIssuer,
security.RefreshTokenManager{ByteLength: 32},
otp.CodeGenerator{},
emailadapter.NewAuthOTPMailer(c.MailProvider),
ratelimit.NewInMemoryRateLimiter(
c.Config.OTPRequestLimit,
c.Config.OTPRequestWindow,
),
ratelimit.NewInMemoryRateLimiter(
c.Config.LoginRateLimit,
c.Config.LoginRateWindow,
),
ratelimit.NewInMemoryRateLimiter(
c.Config.AvailabilityRateLimit,
c.Config.AvailabilityRateWindow,
),
auth.Config{
OTPTTL: c.Config.OTPTTL,
OTPMaxAttempts: c.Config.OTPMaxAttempts,
OTPResendCooldown: c.Config.OTPResendCooldown,
RefreshTokenTTL: c.Config.RefreshTokenTTL,
MaxSessionTTL: c.Config.MaxSessionTTL,
},
)
}
package domain
import (
"errors"
"fmt"
)
// HTTP status codes used by AppError to map domain errors to HTTP responses.
const (
StatusBadRequest = 400
StatusUnauthorized = 401
StatusForbidden = 403
StatusNotFound = 404
StatusConflict = 409
StatusTooManyRequests = 429
StatusInternalError = 500
)
// Machine-readable error codes returned in the JSON "error.code" field.
// Clients can switch on these codes to decide how to handle each error.
const (
ErrorCodeValidation = "validation_error"
ErrorCodeRateLimited = "rate_limited"
ErrorCodeInvalidOTP = "invalid_otp"
ErrorCodeOTPExhausted = "otp_attempts_exceeded"
ErrorCodeInvalidResetToken = "invalid_password_reset_token"
ErrorCodeInvalidCreds = "invalid_credentials" // #nosec G101 -- wire error code, not a secret
ErrorCodeInvalidRefresh = "invalid_refresh_token"
ErrorCodeRefreshReused = "refresh_token_reused"
ErrorCodeEmailExists = "email_already_exists"
ErrorCodeUsernameExists = "username_already_exists"
ErrorCodePhoneExists = "phone_number_already_exists"
ErrorCodeEventTitleExists = "event_title_already_exists"
ErrorCodeEventNotFound = "event_not_found"
ErrorCodeAlreadyParticipating = "already_participating"
ErrorCodeAlreadyRequested = "already_requested"
ErrorCodeEventJoinNotAllowed = "event_join_not_allowed"
ErrorCodeHostCannotJoin = "host_cannot_join"
ErrorCodeHostCannotLeave = "host_cannot_leave"
ErrorCodeCapacityExceeded = "capacity_exceeded"
ErrorCodeJoinRequestNotFound = "join_request_not_found"
ErrorCodeJoinRequestModerationNotAllowed = "join_request_moderation_not_allowed"
ErrorCodeJoinRequestStateInvalid = "join_request_state_invalid"
ErrorCodeJoinRequestCooldownActive = "join_request_cooldown_active"
ErrorCodeEventHostManagementNotAllowed = "event_host_management_not_allowed"
ErrorCodeEventCancelNotAllowed = "event_cancel_not_allowed"
ErrorCodeEventNotCancelable = "event_not_cancelable"
ErrorCodeEventCompleteNotAllowed = "event_complete_not_allowed"
ErrorCodeEventNotCompletable = "event_not_completable"
ErrorCodeEventCanceled = "event_canceled"
ErrorCodeEventNotJoinable = "event_not_joinable"
ErrorCodeEventLeaveNotAllowed = "event_leave_not_allowed"
ErrorCodeEventNotLeaveable = "event_not_leaveable"
ErrorCodeFavoriteLocationNotFound = "favorite_location_not_found"
ErrorCodeFavoriteLocationLimitExceeded = "favorite_location_limit_exceeded"
ErrorCodeImageUploadTokenInvalid = "image_upload_token_invalid"
ErrorCodeImageUploadNotAllowed = "image_upload_not_allowed"
ErrorCodeImageUploadIncomplete = "image_upload_incomplete"
ErrorCodeImageUploadVersionConflict = "image_upload_version_conflict"
)
// ErrNotFound is a sentinel error returned when a queried entity does not exist.
var ErrNotFound = errors.New("not found")
// AppError is the structured error type used across the application. It carries
// an HTTP status code, a machine-readable code, a human-readable message, and
// optional per-field validation details.
type AppError struct {
Code string
Message string
Status int
Details map[string]string
}
func (e *AppError) Error() string {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// ValidationError creates a 400 Bad Request error with per-field detail messages.
func ValidationError(details map[string]string) *AppError {
return &AppError{
Code: ErrorCodeValidation,
Message: "The request body contains invalid fields. See error.details for field-specific messages.",
Status: StatusBadRequest,
Details: details,
}
}
// BadRequestError creates a 400 Bad Request error for invalid request states
// that are not field-validation failures.
func BadRequestError(code, message string) *AppError {
return &AppError{
Code: code,
Message: message,
Status: StatusBadRequest,
}
}
// RateLimitedError creates a 429 Too Many Requests error.
func RateLimitedError(message string) *AppError {
return &AppError{
Code: ErrorCodeRateLimited,
Message: message,
Status: StatusTooManyRequests,
}
}
// ConflictError creates a 409 Conflict error for unique-constraint violations
// (e.g. duplicate email, username, or phone number).
func ConflictError(code, message string) *AppError {
return &AppError{
Code: code,
Message: message,
Status: StatusConflict,
}
}
// AuthError creates a 401 Unauthorized error for authentication failures
// (e.g. invalid credentials, expired OTP, revoked refresh token).
func AuthError(code, message string) *AppError {
return &AppError{
Code: code,
Message: message,
Status: StatusUnauthorized,
}
}
// ForbiddenError creates a 403 Forbidden error for operations the caller is
// not permitted to perform (e.g. host attempting to join their own event).
func ForbiddenError(code, message string) *AppError {
return &AppError{
Code: code,
Message: message,
Status: StatusForbidden,
}
}
// NotFoundError creates a 404 Not Found error for entities that do not exist.
func NotFoundError(code, message string) *AppError {
return &AppError{
Code: code,
Message: message,
Status: StatusNotFound,
}
}
package domain
import (
"time"
"github.com/google/uuid"
)
// EventPrivacyLevel defines who can discover and join an event.
type EventPrivacyLevel string
// EventLocationType defines how location geometry is represented.
type EventLocationType string
// EventParticipantGender defines optional participant preference filters.
type EventParticipantGender string
// EventStatus defines the lifecycle state of an event.
type EventStatus string
// EventDiscoverySort defines the supported event discovery ordering modes.
type EventDiscoverySort string
// EventCategory represents a predefined event category row.
type EventCategory struct {
ID int
Name string
}
// EventDetailParticipationStatus describes the authenticated viewer's relation
// to an event detail payload.
type EventDetailParticipationStatus string
// Accepted values for event fields.
const (
PrivacyPublic EventPrivacyLevel = "PUBLIC"
PrivacyProtected EventPrivacyLevel = "PROTECTED"
PrivacyPrivate EventPrivacyLevel = "PRIVATE"
LocationPoint EventLocationType = "POINT"
LocationRoute EventLocationType = "ROUTE"
GenderMale EventParticipantGender = "MALE"
GenderFemale EventParticipantGender = "FEMALE"
GenderOther EventParticipantGender = "OTHER"
EventStatusActive EventStatus = "ACTIVE"
EventStatusInProgress EventStatus = "IN_PROGRESS"
EventStatusCanceled EventStatus = "CANCELED"
EventStatusCompleted EventStatus = "COMPLETED"
EventDiscoverySortStartTime EventDiscoverySort = "START_TIME"
EventDiscoverySortDistance EventDiscoverySort = "DISTANCE"
EventDiscoverySortRelevance EventDiscoverySort = "RELEVANCE"
EventDetailParticipationStatusJoined EventDetailParticipationStatus = "JOINED"
EventDetailParticipationStatusLeaved EventDetailParticipationStatus = "LEAVED"
EventDetailParticipationStatusPending EventDetailParticipationStatus = "PENDING"
EventDetailParticipationStatusInvited EventDetailParticipationStatus = "INVITED"
EventDetailParticipationStatusNone EventDetailParticipationStatus = "NONE"
EventDetailParticipationStatusCanceled EventDetailParticipationStatus = "CANCELED"
MaxEventTags = 5
MaxEventConstraints = 5
MaxTagLength = 20
MinRoutePoints = 2
)
var eventPrivacyLevels = map[string]EventPrivacyLevel{
string(PrivacyPublic): PrivacyPublic,
string(PrivacyProtected): PrivacyProtected,
string(PrivacyPrivate): PrivacyPrivate,
}
var eventLocationTypes = map[string]EventLocationType{
string(LocationPoint): LocationPoint,
string(LocationRoute): LocationRoute,
}
var eventParticipantGenders = map[string]EventParticipantGender{
string(GenderMale): GenderMale,
string(GenderFemale): GenderFemale,
string(GenderOther): GenderOther,
}
var eventDiscoverySorts = map[string]EventDiscoverySort{
string(EventDiscoverySortStartTime): EventDiscoverySortStartTime,
string(EventDiscoverySortDistance): EventDiscoverySortDistance,
string(EventDiscoverySortRelevance): EventDiscoverySortRelevance,
}
// ParseEventPrivacyLevel converts a wire string to an EventPrivacyLevel.
func ParseEventPrivacyLevel(value string) (EventPrivacyLevel, bool) {
level, ok := eventPrivacyLevels[value]
return level, ok
}
// ParseEventLocationType converts a wire string to an EventLocationType.
func ParseEventLocationType(value string) (EventLocationType, bool) {
locationType, ok := eventLocationTypes[value]
return locationType, ok
}
// ParseEventParticipantGender converts a wire string to an EventParticipantGender.
func ParseEventParticipantGender(value string) (EventParticipantGender, bool) {
gender, ok := eventParticipantGenders[value]
return gender, ok
}
// ParseEventDiscoverySort converts a wire string to an EventDiscoverySort.
func ParseEventDiscoverySort(value string) (EventDiscoverySort, bool) {
sort, ok := eventDiscoverySorts[value]
return sort, ok
}
// GeoPoint is a single WGS84 coordinate used for event locations.
type GeoPoint struct {
Lat float64
Lon float64
}
// Event is the core event entity.
type Event struct {
ID uuid.UUID
HostID uuid.UUID
Title string
Description *string
ImageURL *string
CategoryID *int
StartTime time.Time
EndTime *time.Time
PrivacyLevel EventPrivacyLevel
Status EventStatus
Capacity *int
ApprovedParticipantCount int
MinimumAge *int
PreferredGender *EventParticipantGender
LocationType *EventLocationType
CreatedAt time.Time
UpdatedAt time.Time
}
package domain
import (
"time"
"github.com/google/uuid"
)
// JoinRequestStatus defines the lifecycle of a protected-event join request.
type JoinRequestStatus string
const (
JoinRequestStatusPending JoinRequestStatus = "PENDING"
JoinRequestStatusApproved JoinRequestStatus = "APPROVED"
JoinRequestStatusRejected JoinRequestStatus = "REJECTED"
JoinRequestCooldown = 72 * time.Hour
)
var joinRequestStatuses = map[string]JoinRequestStatus{
string(JoinRequestStatusPending): JoinRequestStatusPending,
string(JoinRequestStatusApproved): JoinRequestStatusApproved,
string(JoinRequestStatusRejected): JoinRequestStatusRejected,
}
// ParseJoinRequestStatus converts a wire string to a JoinRequestStatus.
func ParseJoinRequestStatus(value string) (JoinRequestStatus, bool) {
status, ok := joinRequestStatuses[value]
return status, ok
}
// JoinRequest records a pending request to join a protected event.
type JoinRequest struct {
ID uuid.UUID
EventID uuid.UUID
UserID uuid.UUID
ParticipationID *uuid.UUID
HostUserID uuid.UUID
Status JoinRequestStatus
Message *string
CreatedAt time.Time
UpdatedAt time.Time
}
package domain
import (
"time"
"github.com/google/uuid"
)
// ParticipationStatus defines the lifecycle state of an event participation row.
type ParticipationStatus string
const (
// ParticipationStatusApproved means the user is currently participating in the event.
ParticipationStatusApproved ParticipationStatus = "APPROVED"
// ParticipationStatusPending is reserved for flows that keep pending participation rows.
ParticipationStatusPending ParticipationStatus = "PENDING"
// ParticipationStatusCanceled marks a participation canceled because the event was canceled.
ParticipationStatusCanceled ParticipationStatus = "CANCELED"
// ParticipationStatusLeaved marks a participant who explicitly left the event.
ParticipationStatusLeaved ParticipationStatus = "LEAVED"
)
var participationStatuses = map[string]ParticipationStatus{
string(ParticipationStatusApproved): ParticipationStatusApproved,
string(ParticipationStatusPending): ParticipationStatusPending,
string(ParticipationStatusCanceled): ParticipationStatusCanceled,
string(ParticipationStatusLeaved): ParticipationStatusLeaved,
}
// ParseParticipationStatus converts a wire or persistence string into a domain status.
func ParseParticipationStatus(value string) (ParticipationStatus, bool) {
status, ok := participationStatuses[value]
return status, ok
}
// String returns the serialized wire value of the participation status.
func (s ParticipationStatus) String() string {
return string(s)
}
// Participation records a user's membership status in an event.
type Participation struct {
ID uuid.UUID
EventID uuid.UUID
UserID uuid.UUID
Status ParticipationStatus
CreatedAt time.Time
UpdatedAt time.Time
}
package domain
import (
"time"
"github.com/google/uuid"
)
const (
RatingMin = 1
RatingMax = 5
RatingMessageMinLength = 10
RatingMessageMaxLength = 100
RatingWindowDuration = 7 * 24 * time.Hour
)
// EventRating stores a participant's rating for an event.
type EventRating struct {
ID uuid.UUID
ParticipantUserID uuid.UUID
EventID uuid.UUID
Rating int
Message *string
CreatedAt time.Time
UpdatedAt time.Time
}
// ParticipantRating stores a host's rating for a participant in an event.
type ParticipantRating struct {
ID uuid.UUID
HostUserID uuid.UUID
ParticipantUserID uuid.UUID
EventID uuid.UUID
Rating int
Message *string
CreatedAt time.Time
UpdatedAt time.Time
}
// UserScore stores cached aggregate scores derived from rating tables.
type UserScore struct {
UserID uuid.UUID
ParticipantScore *float64
ParticipantRatingCount int
HostedEventScore *float64
HostedEventRatingCount int
FinalScore *float64
CreatedAt time.Time
UpdatedAt time.Time
}
// RatingWindow describes when rating mutations are allowed for an event.
type RatingWindow struct {
OpensAt time.Time
ClosesAt time.Time
}
// NewRatingWindow builds the allowed rating interval for an event.
func NewRatingWindow(startTime time.Time, endTime *time.Time) RatingWindow {
opensAt := startTime
if endTime != nil {
opensAt = *endTime
}
return RatingWindow{
OpensAt: opensAt,
ClosesAt: opensAt.Add(RatingWindowDuration),
}
}
// IsActive reports whether now falls inside the inclusive rating interval.
func (w RatingWindow) IsActive(now time.Time) bool {
return !now.Before(w.OpensAt) && !now.After(w.ClosesAt)
}
package domain
import (
"time"
"github.com/google/uuid"
)
// UserStatusActive is the default status assigned to newly registered users.
const UserStatusActive = "active"
// User is the core identity entity representing a registered account.
type User struct {
ID uuid.UUID
Username string
Email string
PhoneNumber *string
Gender *string
BirthDate *time.Time
PasswordHash string
EmailVerifiedAt *time.Time
LastLogin *time.Time
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
// UserSummary is a safe-to-serialize projection of User that omits sensitive
// fields like PasswordHash. It is returned in authentication responses.
type UserSummary struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PhoneNumber *string `json:"phone_number"`
EmailVerified bool `json:"email_verified"`
Status string `json:"status"`
Gender *string `json:"gender"`
BirthDate *string `json:"birth_date"`
}
// Summary converts a full User into a UserSummary, deriving EmailVerified from
// whether EmailVerifiedAt is set.
func (u User) Summary() UserSummary {
s := UserSummary{
ID: u.ID,
Username: u.Username,
Email: u.Email,
PhoneNumber: u.PhoneNumber,
EmailVerified: u.EmailVerifiedAt != nil,
Status: u.Status,
Gender: u.Gender,
}
if u.BirthDate != nil {
formatted := u.BirthDate.Format("2006-01-02")
s.BirthDate = &formatted
}
return s
}
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/spf13/viper"
)
const defaultAppEnv = "local"
var appEnvPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// Config holds application settings loaded from an environment-specific YAML
// file first, then from the repository-root .env for secrets, then from OS environment variables.
type Config struct {
AppPort int
DBHost string
DBPort int
DBName string
DBUser string
DBPassword string
JWTSecret string
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
MaxSessionTTL time.Duration
OTPTTL time.Duration
OTPMaxAttempts int
OTPResendCooldown time.Duration
OTPRequestLimit int
OTPRequestWindow time.Duration
LoginRateLimit int
LoginRateWindow time.Duration
AvailabilityRateLimit int
AvailabilityRateWindow time.Duration
MailProvider string
MailDomain string
ResendClientAPIKey string
RatingGlobalPrior float64
RatingBayesianM int
SpacesAccessKey string
SpacesSecretKey string
SpacesEndpoint string
SpacesBucket string
SpacesCDNBaseURL string
SpacesS3Region string
SpacesPresignTTL time.Duration
SpacesUploadCacheCtrl string
}
// Load reads configuration using the following precedence:
// 1. config/application.<APP_ENV>.yaml (or APP_CONFIG_FILE if set)
// 2. repository-root .env
// 3. OS environment variables
func Load() (*Config, error) {
v := viper.New()
appEnv := strings.TrimSpace(os.Getenv("APP_ENV"))
if appEnv == "" {
appEnv = defaultAppEnv
}
if err := loadBaseConfig(v, appEnv); err != nil {
return nil, err
}
if err := mergeDotEnv(v); err != nil {
return nil, err
}
v.AutomaticEnv()
bind := func(key, envVar string) {
_ = v.BindEnv(key, envVar)
}
bind("app_port", "APP_PORT")
bind("db_host", "DB_HOST")
bind("db_port", "DB_PORT")
bind("db_name", "DB_NAME")
bind("db_user", "DB_USER")
bind("db_password", "DB_PASSWORD")
bind("jwt_secret", "JWT_SECRET")
bind("access_token_ttl", "ACCESS_TOKEN_TTL")
bind("refresh_token_ttl", "REFRESH_TOKEN_TTL")
bind("max_session_ttl", "MAX_SESSION_TTL")
bind("otp_ttl", "OTP_TTL")
bind("otp_max_attempts", "OTP_MAX_ATTEMPTS")
bind("otp_resend_cooldown", "OTP_RESEND_COOLDOWN")
bind("otp_request_limit", "OTP_REQUEST_LIMIT")
bind("otp_request_window", "OTP_REQUEST_WINDOW")
bind("login_rate_limit", "LOGIN_RATE_LIMIT")
bind("login_rate_window", "LOGIN_RATE_WINDOW")
bind("availability_rate_limit", "AVAILABILITY_RATE_LIMIT")
bind("availability_rate_window", "AVAILABILITY_RATE_WINDOW")
bind("mail_provider", "MAIL_PROVIDER")
bind("mail_domain", "MAIL_DOMAIN")
bind("resend_client_api_key", "RESEND_CLIENT_API_KEY")
bind("rating_global_prior", "RATING_GLOBAL_PRIOR")
bind("rating_bayesian_m", "RATING_BAYESIAN_M")
bind("spaces_access_key", "SPACES_ACCESS_KEY")
bind("spaces_secret_key", "SPACES_SECRET_KEY")
bind("spaces_endpoint", "SPACES_ENDPOINT")
bind("spaces_bucket", "SPACES_BUCKET")
bind("spaces_cdn_base_url", "SPACES_CDN_BASE_URL")
bind("spaces_s3_region", "SPACES_S3_REGION")
bind("spaces_presign_ttl", "SPACES_PRESIGN_TTL")
bind("spaces_upload_cache_control", "SPACES_UPLOAD_CACHE_CONTROL")
cfg := &Config{
AppPort: v.GetInt("app_port"),
DBHost: strings.TrimSpace(v.GetString("db_host")),
DBPort: v.GetInt("db_port"),
DBName: strings.TrimSpace(v.GetString("db_name")),
DBUser: strings.TrimSpace(v.GetString("db_user")),
DBPassword: v.GetString("db_password"),
JWTSecret: strings.TrimSpace(v.GetString("jwt_secret")),
AccessTokenTTL: v.GetDuration("access_token_ttl"),
RefreshTokenTTL: v.GetDuration("refresh_token_ttl"),
MaxSessionTTL: v.GetDuration("max_session_ttl"),
OTPTTL: v.GetDuration("otp_ttl"),
OTPMaxAttempts: v.GetInt("otp_max_attempts"),
OTPResendCooldown: v.GetDuration("otp_resend_cooldown"),
OTPRequestLimit: v.GetInt("otp_request_limit"),
OTPRequestWindow: v.GetDuration("otp_request_window"),
LoginRateLimit: v.GetInt("login_rate_limit"),
LoginRateWindow: v.GetDuration("login_rate_window"),
AvailabilityRateLimit: v.GetInt("availability_rate_limit"),
AvailabilityRateWindow: v.GetDuration("availability_rate_window"),
MailProvider: strings.TrimSpace(v.GetString("mail_provider")),
MailDomain: strings.TrimSpace(v.GetString("mail_domain")),
ResendClientAPIKey: strings.TrimSpace(v.GetString("resend_client_api_key")),
RatingGlobalPrior: v.GetFloat64("rating_global_prior"),
RatingBayesianM: v.GetInt("rating_bayesian_m"),
SpacesAccessKey: strings.TrimSpace(v.GetString("spaces_access_key")),
SpacesSecretKey: strings.TrimSpace(v.GetString("spaces_secret_key")),
SpacesEndpoint: strings.TrimSpace(v.GetString("spaces_endpoint")),
SpacesBucket: strings.TrimSpace(v.GetString("spaces_bucket")),
SpacesCDNBaseURL: strings.TrimSpace(v.GetString("spaces_cdn_base_url")),
SpacesS3Region: strings.TrimSpace(v.GetString("spaces_s3_region")),
SpacesPresignTTL: v.GetDuration("spaces_presign_ttl"),
SpacesUploadCacheCtrl: strings.TrimSpace(v.GetString("spaces_upload_cache_control")),
}
if err := validate(v, cfg); err != nil {
return nil, err
}
return cfg, nil
}
// loadBaseConfig reads the environment-specific YAML file. It checks APP_CONFIG_FILE
// first, then probes a small set of supported backend working-directory layouts.
func loadBaseConfig(v *viper.Viper, appEnv string) error {
configFile := strings.TrimSpace(os.Getenv("APP_CONFIG_FILE"))
if configFile != "" {
v.SetConfigFile(configFile)
if err := v.ReadInConfig(); err != nil {
return fmt.Errorf("load APP_CONFIG_FILE %q: %w", configFile, err)
}
return nil
}
if !appEnvPattern.MatchString(appEnv) {
return fmt.Errorf("APP_ENV must contain only letters, numbers, underscore, or hyphen")
}
configName := fmt.Sprintf("application.%s.yaml", appEnv)
for _, candidate := range []string{
filepath.Join("config", configName),
filepath.Join("..", "config", configName),
filepath.Join("..", "..", "config", configName),
filepath.Join("backend", "config", configName),
} {
// #nosec G703 -- appEnv is validated above and candidate directories are fixed backend config locations.
if st, err := os.Stat(candidate); err == nil && !st.IsDir() {
v.SetConfigFile(candidate)
if err := v.ReadInConfig(); err != nil {
return fmt.Errorf("load %s: %w", candidate, err)
}
return nil
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("stat %s: %w", candidate, err)
}
}
return fmt.Errorf(
"base configuration missing: expected %s under config/, ../config/, ../../config/, or backend/config/ relative to the working directory",
configName,
)
}
// mergeDotEnv merges key=value pairs from the repository root .env file.
// Missing .env is silently ignored so containerized runs can rely on process env.
func mergeDotEnv(v *viper.Viper) error {
workingDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
repoRoot, err := findRepositoryRoot(workingDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("resolve repository root: %w", err)
}
envPath := filepath.Join(repoRoot, ".env")
if st, err := os.Stat(envPath); err == nil && !st.IsDir() {
v.SetConfigFile(envPath)
v.SetConfigType("env")
if err := v.MergeInConfig(); err != nil {
return fmt.Errorf("load %s: %w", envPath, err)
}
return nil
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("stat %s: %w", envPath, err)
}
return nil
}
func findRepositoryRoot(start string) (string, error) {
dir := filepath.Clean(start)
for {
agentsPath := filepath.Join(dir, "AGENTS.md")
backendPath := filepath.Join(dir, "backend")
if fileInfo, err := os.Stat(agentsPath); err == nil && !fileInfo.IsDir() {
if backendInfo, err := os.Stat(backendPath); err == nil && backendInfo.IsDir() {
return dir, nil
} else if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("stat %s: %w", backendPath, err)
}
} else if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("stat %s: %w", agentsPath, err)
}
parent := filepath.Dir(dir)
if parent == dir {
return "", os.ErrNotExist
}
dir = parent
}
}
// validate ensures all required config values are present and within valid ranges.
func validate(v *viper.Viper, c *Config) error {
missing := func(envVar string) error {
return fmt.Errorf("required configuration missing: set %s in the environment or in the repository-root .env file", envVar)
}
if !v.IsSet("db_host") && c.DBHost == "" {
return missing("DB_HOST")
}
if c.DBHost == "" {
return fmt.Errorf("DB_HOST is required and cannot be empty")
}
if !v.IsSet("db_name") && c.DBName == "" {
return missing("DB_NAME")
}
if c.DBName == "" {
return fmt.Errorf("DB_NAME is required and cannot be empty")
}
if !v.IsSet("db_user") && c.DBUser == "" {
return missing("DB_USER")
}
if c.DBUser == "" {
return fmt.Errorf("DB_USER is required and cannot be empty")
}
if !v.IsSet("jwt_secret") && c.JWTSecret == "" {
return missing("JWT_SECRET")
}
if c.JWTSecret == "" {
return fmt.Errorf("JWT_SECRET is required and cannot be empty")
}
if c.AppPort < 1 || c.AppPort > 65535 {
return fmt.Errorf("APP_PORT must be between 1 and 65535, got %d", c.AppPort)
}
if c.DBPort < 1 || c.DBPort > 65535 {
return fmt.Errorf("DB_PORT must be between 1 and 65535, got %d", c.DBPort)
}
if c.AccessTokenTTL <= 0 {
return fmt.Errorf("ACCESS_TOKEN_TTL must be greater than zero")
}
if c.RefreshTokenTTL <= 0 {
return fmt.Errorf("REFRESH_TOKEN_TTL must be greater than zero")
}
if c.MaxSessionTTL <= 0 {
return fmt.Errorf("MAX_SESSION_TTL must be greater than zero")
}
if c.MaxSessionTTL < c.RefreshTokenTTL {
return fmt.Errorf("MAX_SESSION_TTL must be greater than or equal to REFRESH_TOKEN_TTL")
}
if c.OTPTTL <= 0 {
return fmt.Errorf("OTP_TTL must be greater than zero")
}
if c.OTPMaxAttempts < 1 {
return fmt.Errorf("OTP_MAX_ATTEMPTS must be at least 1")
}
if c.OTPResendCooldown < 0 {
return fmt.Errorf("OTP_RESEND_COOLDOWN cannot be negative")
}
if c.OTPRequestLimit < 1 {
return fmt.Errorf("OTP_REQUEST_LIMIT must be at least 1")
}
if c.OTPRequestWindow <= 0 {
return fmt.Errorf("OTP_REQUEST_WINDOW must be greater than zero")
}
if c.LoginRateLimit < 1 {
return fmt.Errorf("LOGIN_RATE_LIMIT must be at least 1")
}
if c.LoginRateWindow <= 0 {
return fmt.Errorf("LOGIN_RATE_WINDOW must be greater than zero")
}
if c.AvailabilityRateLimit < 1 {
return fmt.Errorf("AVAILABILITY_RATE_LIMIT must be at least 1")
}
if c.AvailabilityRateWindow <= 0 {
return fmt.Errorf("AVAILABILITY_RATE_WINDOW must be greater than zero")
}
if c.MailProvider == "" {
return fmt.Errorf("MAIL_PROVIDER cannot be empty")
}
if c.MailProvider != "mock" && c.MailProvider != "resend" {
return fmt.Errorf("MAIL_PROVIDER must be \"mock\" or \"resend\", got %q", c.MailProvider)
}
if c.MailDomain == "" {
return fmt.Errorf("MAIL_DOMAIN is required and cannot be empty")
}
if c.MailProvider == "resend" {
if !v.IsSet("resend_client_api_key") && c.ResendClientAPIKey == "" {
return missing("RESEND_CLIENT_API_KEY")
}
if c.ResendClientAPIKey == "" {
return fmt.Errorf("RESEND_CLIENT_API_KEY is required and cannot be empty")
}
}
if c.RatingGlobalPrior < 1 || c.RatingGlobalPrior > 5 {
return fmt.Errorf("RATING_GLOBAL_PRIOR must be between 1 and 5")
}
if c.RatingBayesianM < 1 {
return fmt.Errorf("RATING_BAYESIAN_M must be at least 1")
}
if !v.IsSet("spaces_access_key") && c.SpacesAccessKey == "" {
return missing("SPACES_ACCESS_KEY")
}
if c.SpacesAccessKey == "" {
return fmt.Errorf("SPACES_ACCESS_KEY is required and cannot be empty")
}
if !v.IsSet("spaces_secret_key") && c.SpacesSecretKey == "" {
return missing("SPACES_SECRET_KEY")
}
if c.SpacesSecretKey == "" {
return fmt.Errorf("SPACES_SECRET_KEY is required and cannot be empty")
}
if !v.IsSet("spaces_endpoint") && c.SpacesEndpoint == "" {
return missing("SPACES_ENDPOINT")
}
if c.SpacesEndpoint == "" {
return fmt.Errorf("SPACES_ENDPOINT is required and cannot be empty")
}
if !v.IsSet("spaces_bucket") && c.SpacesBucket == "" {
return missing("SPACES_BUCKET")
}
if c.SpacesBucket == "" {
return fmt.Errorf("SPACES_BUCKET is required and cannot be empty")
}
if !v.IsSet("spaces_cdn_base_url") && c.SpacesCDNBaseURL == "" {
return missing("SPACES_CDN_BASE_URL")
}
if c.SpacesCDNBaseURL == "" {
return fmt.Errorf("SPACES_CDN_BASE_URL is required and cannot be empty")
}
if !v.IsSet("spaces_s3_region") && c.SpacesS3Region == "" {
return missing("SPACES_S3_REGION")
}
if c.SpacesS3Region == "" {
return fmt.Errorf("SPACES_S3_REGION is required and cannot be empty")
}
if c.SpacesPresignTTL <= 0 {
return fmt.Errorf("SPACES_PRESIGN_TTL must be greater than zero")
}
if c.SpacesUploadCacheCtrl == "" {
return fmt.Errorf("SPACES_UPLOAD_CACHE_CONTROL is required and cannot be empty")
}
return nil
}
package database
import (
"context"
"fmt"
"github.com/bounswe/bounswe2026group11/backend/internal/infrastructure/config"
"github.com/jackc/pgx/v5/pgxpool"
)
// OpenDB creates a pgx connection pool from the given config and verifies
// connectivity with a ping. The caller is responsible for closing the pool.
func OpenDB(ctx context.Context, cfg *config.Config) (*pgxpool.Pool, error) {
dsn := fmt.Sprintf(
"postgres://%s:%s@%s:%d/%s",
cfg.DBUser,
cfg.DBPassword,
cfg.DBHost,
cfg.DBPort,
cfg.DBName,
)
poolConfig, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse postgres config: %w", err)
}
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("open postgres pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return pool, nil
}
package server
import (
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/auth_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/category_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/event_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/favorite_location_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/image_upload_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/profile_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/rating_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/bootstrap"
"github.com/gofiber/fiber/v2"
)
// NewHTTP builds a Fiber application with all registered route groups and middleware.
func NewHTTP(container *bootstrap.Container) *fiber.App {
app := fiber.New(fiber.Config{
ProxyHeader: "X-Real-IP",
EnableTrustedProxyCheck: true,
TrustedProxies: []string{
"127.0.0.1",
"::1",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
},
})
app.Use(httpapi.RequestLogger())
registerHealthRoute(app)
// Auth routes
authHandler := auth_handler.NewAuthHandler(container.AuthService)
auth_handler.RegisterAuthRoutes(app, authHandler)
// Event routes
auth := httpapi.RequireAuth(container.TokenVerifier)
optionalAuth := httpapi.OptionalAuth(container.TokenVerifier)
eventHandler := event_handler.NewEventHandler(container.EventService)
event_handler.RegisterEventRoutes(app, eventHandler, auth, optionalAuth)
// Rating routes
ratingHandler := rating_handler.NewRatingHandler(container.RatingService)
rating_handler.RegisterRatingRoutes(app, ratingHandler, auth)
// Category routes (public, no auth required)
categoryHandler := category_handler.NewCategoryHandler(container.CategoryService)
category_handler.RegisterCategoryRoutes(app, categoryHandler)
// Profile routes (authenticated)
profileHandler := profile_handler.NewProfileHandler(container.ProfileService, container.EventService)
profile_handler.RegisterProfileRoutes(app, profileHandler, auth)
// Favorite location routes (authenticated)
favoriteLocationHandler := favorite_location_handler.NewHandler(container.FavoriteLocationService)
favorite_location_handler.RegisterRoutes(app, favoriteLocationHandler, auth)
// Direct image upload routes (authenticated)
imageUploadHandler := image_upload_handler.NewHandler(container.ImageUploadService)
image_upload_handler.RegisterRoutes(app, imageUploadHandler, auth)
return app
}
// registerHealthRoute adds GET /health, used by load balancers and container
// orchestrators to verify the server is ready to accept traffic.
func registerHealthRoute(app *fiber.App) {
app.Get("/health", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
}