// Package main is the entrypoint for the Social Event Mapper backend server.
// It initializes the dependency container and starts the HTTP server.
package main
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/bootstrap"
"github.com/bounswe/bounswe2026group11/backend/internal/infrastructure/observability"
"github.com/bounswe/bounswe2026group11/backend/internal/server"
)
func main() {
ctx := context.Background()
observability.ConfigureLogger(nil)
observabilityRuntime, err := observability.Setup(ctx, os.Getenv("APP_ENV"))
if err != nil {
slog.Error("observability bootstrap failed", "error", err)
os.Exit(1)
}
if observabilityRuntime != nil {
observability.ConfigureLogger(observabilityRuntime.LoggerProvider())
defer func() {
if err := observabilityRuntime.Shutdown(ctx); err != nil {
slog.Error("observability shutdown failed", "error", err)
}
}()
}
container, err := bootstrap.New(ctx)
if err != nil {
slog.Error("bootstrap failed", "error", err)
os.Exit(1)
}
defer container.Close()
container.StartEventExpiryJob(ctx, 1*time.Minute)
if err := container.RunBadgeBackfill(ctx); err != nil {
slog.Error("badge backfill failed", "error", err)
}
container.StartNotificationRetentionJob(ctx, 24*time.Hour)
app := server.NewHTTP(container)
addr := fmt.Sprintf(":%d", container.Config.AppPort)
if err := app.Listen(addr); err != nil {
slog.Error("server stopped", "error", err, "addr", addr)
os.Exit(1)
}
}
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/slog"
"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(ctx context.Context, message emailapp.Message) error {
to := strings.TrimSpace(message.To)
if to == "" {
return fmt.Errorf("mock email provider: recipient is required")
}
slog.InfoContext(ctx, "mock email provider delivery",
"from_local_part", strings.TrimSpace(message.FromLocalPart),
"to", to,
"subject", strings.TrimSpace(message.Subject),
"text", 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 firebasepush
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
notificationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/notification"
)
const (
fcmScope = "https://www.googleapis.com/auth/firebase.messaging"
defaultTokenURI = "https://oauth2.googleapis.com/token" // #nosec G101 -- public Google OAuth endpoint, not a credential.
tokenRefreshSkew = time.Minute
fcmEndpointTemplate = "https://fcm.googleapis.com/v1/projects/%s/messages:send"
)
// MockSender accepts messages without contacting Firebase. It is used for local
// development and tests where push delivery should not leave the process.
type MockSender struct{}
func (MockSender) Send(context.Context, notificationapp.PushSendMessage) (*notificationapp.PushSendResult, error) {
return ¬ificationapp.PushSendResult{}, nil
}
// Sender sends push notifications through Firebase Cloud Messaging HTTP v1.
type Sender struct {
client *http.Client
tokenURI string
projectID string
clientEmail string
privateKey *rsa.PrivateKey
now func() time.Time
tokenMu sync.Mutex
accessToken string
tokenExpires time.Time
}
type serviceAccount struct {
ProjectID string `json:"project_id"`
ClientEmail string `json:"client_email"`
PrivateKey string `json:"private_key"`
TokenURI string `json:"token_uri"`
}
func NewSender(_ context.Context, credentialsFile, credentialsJSONBase64 string) (*Sender, error) {
credentials, err := loadServiceAccount(credentialsFile, credentialsJSONBase64)
if err != nil {
return nil, err
}
privateKey, err := parsePrivateKey(credentials.PrivateKey)
if err != nil {
return nil, err
}
tokenURI := strings.TrimSpace(credentials.TokenURI)
if tokenURI == "" {
tokenURI = defaultTokenURI
}
return &Sender{
client: http.DefaultClient,
tokenURI: tokenURI,
projectID: strings.TrimSpace(credentials.ProjectID),
clientEmail: strings.TrimSpace(credentials.ClientEmail),
privateKey: privateKey,
now: time.Now,
}, nil
}
func (s *Sender) Send(ctx context.Context, message notificationapp.PushSendMessage) (*notificationapp.PushSendResult, error) {
accessToken, err := s.validAccessToken(ctx)
if err != nil {
return nil, err
}
data := map[string]string{}
for key, value := range message.Data {
data[key] = value
}
if message.DeepLink != nil && strings.TrimSpace(*message.DeepLink) != "" {
data["deep_link"] = strings.TrimSpace(*message.DeepLink)
}
notificationPayload := map[string]string{
"title": message.Title,
"body": message.Body,
}
if message.ImageURL != nil && strings.TrimSpace(*message.ImageURL) != "" {
notificationPayload["image"] = strings.TrimSpace(*message.ImageURL)
}
payload := map[string]any{
"message": map[string]any{
"token": message.Token,
"notification": notificationPayload,
"data": data,
},
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal fcm payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf(fcmEndpointTemplate, s.projectID), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build fcm request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send fcm request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return ¬ificationapp.PushSendResult{}, nil
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
return ¬ificationapp.PushSendResult{InvalidToken: isInvalidTokenResponse(resp.StatusCode, responseBody)}, fmt.Errorf("fcm send failed: status=%d", resp.StatusCode)
}
func (s *Sender) validAccessToken(ctx context.Context) (string, error) {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
now := s.now().UTC()
if s.accessToken != "" && now.Before(s.tokenExpires.Add(-tokenRefreshSkew)) {
return s.accessToken, nil
}
token, expiresAt, err := s.fetchAccessToken(ctx, now)
if err != nil {
slog.ErrorContext(ctx, "firebase access token refresh failed",
"operation", "notification.push.firebase_token",
"project_id", s.projectID,
"error", err,
)
return "", err
}
s.accessToken = token
s.tokenExpires = expiresAt
return token, nil
}
func (s *Sender) fetchAccessToken(ctx context.Context, now time.Time) (string, time.Time, error) {
assertion, err := s.signedJWT(now)
if err != nil {
return "", time.Time{}, err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.tokenURI, strings.NewReader(form.Encode()))
if err != nil {
return "", time.Time{}, fmt.Errorf("build firebase token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return "", time.Time{}, fmt.Errorf("request firebase access token: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var response struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
Error string `json:"error"`
Description string `json:"error_description"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 8192)).Decode(&response); err != nil {
return "", time.Time{}, fmt.Errorf("decode firebase token response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", time.Time{}, fmt.Errorf("firebase token request failed: status=%d error=%s", resp.StatusCode, response.Error)
}
if response.AccessToken == "" || response.ExpiresIn <= 0 {
return "", time.Time{}, errors.New("firebase token response missing access token or expiry")
}
return response.AccessToken, now.Add(time.Duration(response.ExpiresIn) * time.Second), nil
}
func (s *Sender) signedJWT(now time.Time) (string, error) {
header, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
if err != nil {
return "", fmt.Errorf("marshal jwt header: %w", err)
}
claims, err := json.Marshal(map[string]any{
"iss": s.clientEmail,
"scope": fcmScope,
"aud": s.tokenURI,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
})
if err != nil {
return "", fmt.Errorf("marshal jwt claims: %w", err)
}
unsigned := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(claims)
digest := sha256.Sum256([]byte(unsigned))
signature, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("sign firebase jwt: %w", err)
}
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
func loadServiceAccount(credentialsFile, credentialsJSONBase64 string) (*serviceAccount, error) {
credentialsFile = strings.TrimSpace(credentialsFile)
credentialsJSONBase64 = strings.TrimSpace(credentialsJSONBase64)
if credentialsFile == "" && credentialsJSONBase64 == "" {
return nil, errors.New("firebase credentials are required")
}
if credentialsFile != "" && credentialsJSONBase64 != "" {
return nil, errors.New("set either FIREBASE_CREDENTIALS_FILE or FIREBASE_SERVICE_ACCOUNT_JSON_BASE64, not both")
}
var raw []byte
var err error
if credentialsFile != "" {
raw, err = os.ReadFile(credentialsFile) // #nosec G304 -- path is explicit deployment configuration.
if err != nil {
return nil, fmt.Errorf("read firebase credentials file: %w", err)
}
} else {
raw, err = base64.StdEncoding.DecodeString(credentialsJSONBase64)
if err != nil {
return nil, fmt.Errorf("decode FIREBASE_SERVICE_ACCOUNT_JSON_BASE64: %w", err)
}
}
var credentials serviceAccount
if err := json.Unmarshal(raw, &credentials); err != nil {
return nil, fmt.Errorf("parse firebase service account json: %w", err)
}
if strings.TrimSpace(credentials.ProjectID) == "" {
return nil, errors.New("firebase service account missing project_id")
}
if strings.TrimSpace(credentials.ClientEmail) == "" {
return nil, errors.New("firebase service account missing client_email")
}
if strings.TrimSpace(credentials.PrivateKey) == "" {
return nil, errors.New("firebase service account missing private_key")
}
return &credentials, nil
}
func parsePrivateKey(value string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(value))
if block == nil {
return nil, errors.New("firebase private key must be PEM encoded")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse firebase private key: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("firebase private key must be RSA")
}
return rsaKey, nil
}
func isInvalidTokenResponse(statusCode int, body []byte) bool {
if statusCode == http.StatusNotFound {
return true
}
var parsed struct {
Error struct {
Status string `json:"status"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return false
}
status := strings.ToUpper(parsed.Error.Status)
message := strings.ToLower(parsed.Error.Message)
return status == "NOT_FOUND" ||
(status == "INVALID_ARGUMENT" && strings.Contains(message, "token"))
}
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,
// email, and role 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,
"role": string(user.Role),
"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 (
"crypto/sha256"
"encoding/hex"
"fmt"
ticketapp "github.com/bounswe/bounswe2026group11/backend/internal/application/ticket"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type ticketTokenClaims struct {
TicketID string `json:"ticket_id"`
ParticipationID string `json:"participation_id"`
EventID string `json:"event_id"`
UserID string `json:"user_id"`
Version int `json:"version"`
gojwt.RegisteredClaims
}
// TicketTokenManager signs and verifies short-lived QR ticket tokens.
type TicketTokenManager struct {
Secret []byte
}
var _ ticketapp.QRTokenManager = (*TicketTokenManager)(nil)
// Issue creates a signed HS256 token from ticket QR claims.
func (m TicketTokenManager) Issue(claims ticketapp.QRTokenClaims) (string, error) {
tokenClaims := ticketTokenClaims{
TicketID: claims.TicketID.String(),
ParticipationID: claims.ParticipationID.String(),
EventID: claims.EventID.String(),
UserID: claims.UserID.String(),
Version: claims.Version,
RegisteredClaims: gojwt.RegisteredClaims{
IssuedAt: gojwt.NewNumericDate(claims.IssuedAt),
ExpiresAt: gojwt.NewNumericDate(claims.ExpiresAt),
},
}
token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, tokenClaims)
signed, err := token.SignedString(m.Secret)
if err != nil {
return "", err
}
return signed, nil
}
// Verify parses and validates a signed ticket QR token.
func (m TicketTokenManager) Verify(tokenString string) (*ticketapp.QRTokenClaims, error) {
claims := &ticketTokenClaims{}
token, err := gojwt.ParseWithClaims(tokenString, claims, func(token *gojwt.Token) (any, error) {
if token.Method != gojwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return m.Secret, nil
}, gojwt.WithExpirationRequired(), gojwt.WithIssuedAt())
if err != nil || !token.Valid {
return nil, fmt.Errorf("invalid ticket token: %w", err)
}
ticketID, err := uuid.Parse(claims.TicketID)
if err != nil {
return nil, fmt.Errorf("invalid ticket_id claim: %w", err)
}
participationID, err := uuid.Parse(claims.ParticipationID)
if err != nil {
return nil, fmt.Errorf("invalid participation_id claim: %w", err)
}
eventID, err := uuid.Parse(claims.EventID)
if err != nil {
return nil, fmt.Errorf("invalid event_id claim: %w", err)
}
userID, err := uuid.Parse(claims.UserID)
if err != nil {
return nil, fmt.Errorf("invalid user_id claim: %w", err)
}
if claims.ExpiresAt == nil || claims.IssuedAt == nil {
return nil, fmt.Errorf("ticket token is missing time claims")
}
return &ticketapp.QRTokenClaims{
TicketID: ticketID,
ParticipationID: participationID,
EventID: eventID,
UserID: userID,
Version: claims.Version,
IssuedAt: claims.IssuedAt.UTC(),
ExpiresAt: claims.ExpiresAt.UTC(),
}, nil
}
// Hash returns a deterministic SHA-256 hash of the plaintext signed token.
func (m TicketTokenManager) Hash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
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)
roleValue, _ := claims["role"].(string)
role, ok := domain.ParseUserRole(roleValue)
if !ok {
return nil, fmt.Errorf("invalid role claim")
}
return &domain.AuthClaims{
UserID: userID,
Username: username,
Email: email,
Role: role,
}, 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"
"encoding/json"
"errors"
"fmt"
"strings"
adminapp "github.com/bounswe/bounswe2026group11/backend/internal/application/admin"
"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"
)
// AdminRepository is the Postgres-backed implementation of admin.Repository.
type AdminRepository struct {
pool *pgxpool.Pool
db execer
}
var _ adminapp.Repository = (*AdminRepository)(nil)
// NewAdminRepository returns a repository that executes read-only admin queries.
func NewAdminRepository(pool *pgxpool.Pool) *AdminRepository {
return &AdminRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
func (r *AdminRepository) ListUsers(ctx context.Context, input adminapp.ListUsersInput) (*adminapp.ListUsersResult, error) {
args := []any{}
where := []string{"TRUE"}
add := func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
}
if input.Query != nil {
placeholder := add("%" + *input.Query + "%")
where = append(where, "(username ILIKE "+placeholder+" OR email ILIKE "+placeholder+" OR phone_number ILIKE "+placeholder+")")
}
if input.Status != nil {
where = append(where, "status = "+add(input.Status.String()))
}
if input.Role != nil {
where = append(where, "role = "+add(input.Role.String()))
}
if input.CreatedFrom != nil {
where = append(where, "created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "created_at <= "+add(*input.CreatedTo))
}
limitPlaceholder := add(input.Limit)
offsetPlaceholder := add(input.Offset)
query := `
SELECT id, username, email, phone_number, email_verified_at IS NOT NULL, last_login, status, role, created_at, updated_at, COUNT(*) OVER()
FROM app_user
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY created_at DESC, id DESC
LIMIT ` + limitPlaceholder + ` OFFSET ` + offsetPlaceholder
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("admin list users: %w", err)
}
defer rows.Close()
items := []adminapp.AdminUserItem{}
totalCount := 0
for rows.Next() {
var item adminapp.AdminUserItem
var phone pgtype.Text
var lastLogin pgtype.Timestamptz
var status string
var role string
if err := rows.Scan(
&item.ID,
&item.Username,
&item.Email,
&phone,
&item.EmailVerified,
&lastLogin,
&status,
&role,
&item.CreatedAt,
&item.UpdatedAt,
&totalCount,
); err != nil {
return nil, fmt.Errorf("admin scan user: %w", err)
}
item.PhoneNumber = textPtr(phone)
item.LastLogin = timestamptzPtr(lastLogin)
item.Status = status
item.Role = role
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list users rows: %w", err)
}
return &adminapp.ListUsersResult{Items: items, PageMeta: pageMeta(input.PageInput, totalCount, len(items))}, nil
}
func (r *AdminRepository) ListEvents(ctx context.Context, input adminapp.ListEventsInput) (*adminapp.ListEventsResult, error) {
args := []any{}
where := []string{"TRUE"}
add := func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
}
if input.Query != nil {
placeholder := add("%" + *input.Query + "%")
where = append(where, "(e.title ILIKE "+placeholder+" OR e.description ILIKE "+placeholder+" OR u.username ILIKE "+placeholder+")")
}
if input.HostID != nil {
where = append(where, "e.host_id = "+add(*input.HostID))
}
if input.CategoryID != nil {
where = append(where, "e.category_id = "+add(*input.CategoryID))
}
if input.PrivacyLevel != nil {
where = append(where, "e.privacy_level = "+add(string(*input.PrivacyLevel)))
}
if input.Status != nil {
where = append(where, "e.status = "+add(string(*input.Status)))
}
if input.StartFrom != nil {
where = append(where, "e.start_time >= "+add(*input.StartFrom))
}
if input.StartTo != nil {
where = append(where, "e.start_time <= "+add(*input.StartTo))
}
limitPlaceholder := add(input.Limit)
offsetPlaceholder := add(input.Offset)
query := `
SELECT e.id, e.host_id, u.username, e.title, e.category_id, c.name, e.start_time, e.end_time,
e.privacy_level, e.status, e.capacity, e.approved_participant_count, e.pending_participant_count,
e.created_at, e.updated_at, COUNT(*) OVER()
FROM event e
JOIN app_user u ON u.id = e.host_id
LEFT JOIN event_category c ON c.id = e.category_id
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY e.created_at DESC, e.id DESC
LIMIT ` + limitPlaceholder + ` OFFSET ` + offsetPlaceholder
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("admin list events: %w", err)
}
defer rows.Close()
items := []adminapp.AdminEventItem{}
totalCount := 0
for rows.Next() {
var item adminapp.AdminEventItem
var categoryID pgtype.Int4
var categoryName pgtype.Text
var endTime pgtype.Timestamptz
var capacity pgtype.Int4
if err := rows.Scan(
&item.ID,
&item.HostID,
&item.HostUsername,
&item.Title,
&categoryID,
&categoryName,
&item.StartTime,
&endTime,
&item.PrivacyLevel,
&item.Status,
&capacity,
&item.ApprovedParticipantCount,
&item.PendingParticipantCount,
&item.CreatedAt,
&item.UpdatedAt,
&totalCount,
); err != nil {
return nil, fmt.Errorf("admin scan event: %w", err)
}
item.CategoryID = intPtr(categoryID)
item.CategoryName = textPtr(categoryName)
item.EndTime = timestamptzPtr(endTime)
item.Capacity = intPtr(capacity)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list events rows: %w", err)
}
return &adminapp.ListEventsResult{Items: items, PageMeta: pageMeta(input.PageInput, totalCount, len(items))}, nil
}
func (r *AdminRepository) ListParticipations(ctx context.Context, input adminapp.ListParticipationsInput) (*adminapp.ListParticipationsResult, error) {
args := []any{}
where := []string{"TRUE"}
add := func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
}
if input.Query != nil {
placeholder := add("%" + *input.Query + "%")
where = append(where, "(e.title ILIKE "+placeholder+" OR u.username ILIKE "+placeholder+" OR u.email ILIKE "+placeholder+")")
}
if input.Status != nil {
where = append(where, "p.status = "+add(input.Status.String()))
}
if input.EventID != nil {
where = append(where, "p.event_id = "+add(*input.EventID))
}
if input.UserID != nil {
where = append(where, "p.user_id = "+add(*input.UserID))
}
if input.CreatedFrom != nil {
where = append(where, "p.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "p.created_at <= "+add(*input.CreatedTo))
}
limitPlaceholder := add(input.Limit)
offsetPlaceholder := add(input.Offset)
query := `
SELECT p.id, p.event_id, e.title, p.user_id, u.username, u.email, p.status, p.reconfirmed_at,
p.created_at, p.updated_at, COUNT(*) OVER()
FROM participation p
JOIN event e ON e.id = p.event_id
JOIN app_user u ON u.id = p.user_id
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY p.created_at DESC, p.id DESC
LIMIT ` + limitPlaceholder + ` OFFSET ` + offsetPlaceholder
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("admin list participations: %w", err)
}
defer rows.Close()
items := []adminapp.AdminParticipationItem{}
totalCount := 0
for rows.Next() {
var item adminapp.AdminParticipationItem
var reconfirmedAt pgtype.Timestamptz
if err := rows.Scan(
&item.ID,
&item.EventID,
&item.EventTitle,
&item.UserID,
&item.Username,
&item.UserEmail,
&item.Status,
&reconfirmedAt,
&item.CreatedAt,
&item.UpdatedAt,
&totalCount,
); err != nil {
return nil, fmt.Errorf("admin scan participation: %w", err)
}
item.ReconfirmedAt = timestamptzPtr(reconfirmedAt)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list participations rows: %w", err)
}
return &adminapp.ListParticipationsResult{Items: items, PageMeta: pageMeta(input.PageInput, totalCount, len(items))}, nil
}
func (r *AdminRepository) ListTickets(ctx context.Context, input adminapp.ListTicketsInput) (*adminapp.ListTicketsResult, error) {
args := []any{}
where := []string{"TRUE"}
add := func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
}
if input.Query != nil {
placeholder := add("%" + *input.Query + "%")
where = append(where, "(e.title ILIKE "+placeholder+" OR u.username ILIKE "+placeholder+" OR u.email ILIKE "+placeholder+")")
}
if input.Status != nil {
where = append(where, "t.status = "+add(input.Status.String()))
}
if input.EventID != nil {
where = append(where, "p.event_id = "+add(*input.EventID))
}
if input.UserID != nil {
where = append(where, "p.user_id = "+add(*input.UserID))
}
if input.ParticipationID != nil {
where = append(where, "t.participation_id = "+add(*input.ParticipationID))
}
if input.CreatedFrom != nil {
where = append(where, "t.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "t.created_at <= "+add(*input.CreatedTo))
}
limitPlaceholder := add(input.Limit)
offsetPlaceholder := add(input.Offset)
query := `
SELECT t.id, t.participation_id, p.event_id, e.title, p.user_id, u.username, u.email,
t.status, t.expires_at, t.used_at, t.canceled_at, t.created_at, t.updated_at, COUNT(*) OVER()
FROM ticket t
JOIN participation p ON p.id = t.participation_id
JOIN event e ON e.id = p.event_id
JOIN app_user u ON u.id = p.user_id
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY t.created_at DESC, t.id DESC
LIMIT ` + limitPlaceholder + ` OFFSET ` + offsetPlaceholder
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("admin list tickets: %w", err)
}
defer rows.Close()
items := []adminapp.AdminTicketItem{}
totalCount := 0
for rows.Next() {
var item adminapp.AdminTicketItem
var usedAt pgtype.Timestamptz
var canceledAt pgtype.Timestamptz
if err := rows.Scan(
&item.ID,
&item.ParticipationID,
&item.EventID,
&item.EventTitle,
&item.UserID,
&item.Username,
&item.UserEmail,
&item.Status,
&item.ExpiresAt,
&usedAt,
&canceledAt,
&item.CreatedAt,
&item.UpdatedAt,
&totalCount,
); err != nil {
return nil, fmt.Errorf("admin scan ticket: %w", err)
}
item.UsedAt = timestamptzPtr(usedAt)
item.CanceledAt = timestamptzPtr(canceledAt)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list tickets rows: %w", err)
}
return &adminapp.ListTicketsResult{Items: items, PageMeta: pageMeta(input.PageInput, totalCount, len(items))}, nil
}
func (r *AdminRepository) ListNotifications(ctx context.Context, input adminapp.ListNotificationsInput) (*adminapp.ListNotificationsResult, error) {
args := []any{}
where := []string{"TRUE"}
add := func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
}
if input.Query != nil {
placeholder := add("%" + *input.Query + "%")
where = append(where, "(n.title ILIKE "+placeholder+" OR n.body ILIKE "+placeholder+" OR u.username ILIKE "+placeholder+" OR u.email ILIKE "+placeholder+")")
}
if input.UserID != nil {
where = append(where, "n.receiver_user_id = "+add(*input.UserID))
}
if input.EventID != nil {
where = append(where, "n.event_id = "+add(*input.EventID))
}
if input.Type != nil {
where = append(where, "n.type = "+add(*input.Type))
}
if input.IsRead != nil {
where = append(where, "n.is_read = "+add(*input.IsRead))
}
if input.CreatedFrom != nil {
where = append(where, "n.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "n.created_at <= "+add(*input.CreatedTo))
}
limitPlaceholder := add(input.Limit)
offsetPlaceholder := add(input.Offset)
query := `
SELECT n.id, n.receiver_user_id, u.username, u.email, n.event_id, e.title, n.title, n.type,
n.body, n.deep_link, n.data, n.is_read, n.read_at, n.deleted_at,
COUNT(a.id) FILTER (WHERE a.method = 'SSE' AND a.status = 'SENT') AS sse_sent_count,
COUNT(a.id) FILTER (WHERE a.method = 'FCM' AND a.status = 'SENT') AS push_sent_count,
COUNT(a.id) FILTER (WHERE a.method = 'FCM' AND a.status = 'FAILED') AS push_failed_count,
n.created_at, n.updated_at, COUNT(*) OVER()
FROM notification n
JOIN app_user u ON u.id = n.receiver_user_id
LEFT JOIN event e ON e.id = n.event_id
LEFT JOIN notification_delivery_attempt a ON a.notification_id = n.id
WHERE ` + strings.Join(where, " AND ") + `
GROUP BY n.id, u.username, u.email, e.title
ORDER BY n.created_at DESC, n.id DESC
LIMIT ` + limitPlaceholder + ` OFFSET ` + offsetPlaceholder
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("admin list notifications: %w", err)
}
defer rows.Close()
items := []adminapp.AdminNotificationItem{}
totalCount := 0
for rows.Next() {
var (
item adminapp.AdminNotificationItem
eventID pgtype.UUID
eventTitle pgtype.Text
typ pgtype.Text
deepLink pgtype.Text
data []byte
readAt pgtype.Timestamptz
deletedAt pgtype.Timestamptz
)
if err := rows.Scan(
&item.ID,
&item.ReceiverUserID,
&item.Username,
&item.UserEmail,
&eventID,
&eventTitle,
&item.Title,
&typ,
&item.Body,
&deepLink,
&data,
&item.IsRead,
&readAt,
&deletedAt,
&item.SSESentCount,
&item.PushSentCount,
&item.PushFailedCount,
&item.CreatedAt,
&item.UpdatedAt,
&totalCount,
); err != nil {
return nil, fmt.Errorf("admin scan notification: %w", err)
}
item.EventID = uuidPtr(eventID)
item.EventTitle = textPtr(eventTitle)
item.Type = textPtr(typ)
item.DeepLink = textPtr(deepLink)
item.ReadAt = timestamptzPtr(readAt)
item.DeletedAt = timestamptzPtr(deletedAt)
item.Data = map[string]string{}
if len(data) > 0 {
if err := json.Unmarshal(data, &item.Data); err != nil {
return nil, fmt.Errorf("admin unmarshal notification data: %w", err)
}
}
if item.Data == nil {
item.Data = map[string]string{}
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list notifications rows: %w", err)
}
return &adminapp.ListNotificationsResult{Items: items, PageMeta: pageMeta(input.PageInput, totalCount, len(items))}, nil
}
func (r *AdminRepository) ListEventReports(ctx context.Context, input adminapp.ListEventReportsInput) (*adminapp.ListEventReportsResult, error) {
args := []any{}
where := []string{"TRUE"}
add := func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
}
if input.Query != nil {
placeholder := add("%" + *input.Query + "%")
where = append(where, "(e.title ILIKE "+placeholder+" OR u.username ILIKE "+placeholder+" OR u.email ILIKE "+placeholder+" OR er.message ILIKE "+placeholder+")")
}
if input.Status != nil {
where = append(where, "er.status = "+add(input.Status.String()))
}
if input.ReportCategory != nil {
where = append(where, "er.report_category = "+add(string(*input.ReportCategory)))
}
if input.EventID != nil {
where = append(where, "er.event_id = "+add(*input.EventID))
}
if input.ReporterUserID != nil {
where = append(where, "er.reporter_user_id = "+add(*input.ReporterUserID))
}
if input.CreatedFrom != nil {
where = append(where, "er.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "er.created_at <= "+add(*input.CreatedTo))
}
limitPlaceholder := add(input.Limit)
offsetPlaceholder := add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT er.id, er.event_id, e.title, er.reporter_user_id, u.username, u.email,
er.report_category, er.message, er.image_url, er.status, er.created_at, er.updated_at, COUNT(*) OVER()
FROM event_report er
LEFT JOIN event e ON e.id = er.event_id
LEFT JOIN app_user u ON u.id = er.reporter_user_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY er.created_at DESC, er.id DESC
LIMIT `+limitPlaceholder+` OFFSET `+offsetPlaceholder, args...)
if err != nil {
return nil, fmt.Errorf("admin list event reports: %w", err)
}
defer rows.Close()
items := []adminapp.AdminEventReportItem{}
totalCount := 0
for rows.Next() {
item, err := scanAdminEventReport(rows, &totalCount)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list event reports rows: %w", err)
}
return &adminapp.ListEventReportsResult{Items: items, PageMeta: pageMeta(input.PageInput, totalCount, len(items))}, nil
}
func (r *AdminRepository) ListCategories(ctx context.Context) (*adminapp.ListCategoriesResult, error) {
rows, err := r.db.Query(ctx, `
SELECT id, name, created_at, updated_at
FROM event_category
ORDER BY id ASC
`)
if err != nil {
return nil, fmt.Errorf("admin list categories: %w", err)
}
defer rows.Close()
items := []adminapp.AdminCategoryItem{}
for rows.Next() {
var item adminapp.AdminCategoryItem
if err := rows.Scan(&item.ID, &item.Name, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, fmt.Errorf("admin scan category: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list categories rows: %w", err)
}
return &adminapp.ListCategoriesResult{Items: items}, nil
}
func (r *AdminRepository) CreateCategory(ctx context.Context, name string) (*adminapp.AdminCategoryItem, error) {
var item adminapp.AdminCategoryItem
err := r.db.QueryRow(ctx, `
INSERT INTO event_category (name)
VALUES ($1)
RETURNING id, name, created_at, updated_at
`, name).Scan(&item.ID, &item.Name, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
if isUniqueViolation(err) {
return nil, domain.ConflictError("category_already_exists", "The category already exists.")
}
return nil, fmt.Errorf("admin create category: %w", err)
}
return &item, nil
}
func (r *AdminRepository) DeleteCategory(ctx context.Context, categoryID int) error {
tag, err := r.db.Exec(ctx, `DELETE FROM event_category WHERE id = $1`, categoryID)
if err != nil {
if isForeignKeyViolation(err) {
return domain.ConflictError("category_in_use", "This category is used by one or more events.")
}
return fmt.Errorf("admin delete category: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.NotFoundError("category_not_found", "The requested category does not exist.")
}
return nil
}
func (r *AdminRepository) UpdateEventReportStatus(ctx context.Context, reportID uuid.UUID, status domain.EventReportStatus) (*adminapp.AdminEventReportItem, error) {
item, err := scanAdminEventReportRow(r.db.QueryRow(ctx, `
WITH er AS (
UPDATE event_report
SET status = $2, updated_at = NOW()
WHERE id = $1
RETURNING id, event_id, reporter_user_id, report_category, message, image_url, status, created_at, updated_at
)
SELECT er.id, er.event_id, e.title, er.reporter_user_id, u.username, u.email,
er.report_category, er.message, er.image_url, er.status, er.created_at, er.updated_at
FROM er
LEFT JOIN event e ON e.id = er.event_id
LEFT JOIN app_user u ON u.id = er.reporter_user_id
`, reportID, status))
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError("event_report_not_found", "The requested event report does not exist.")
}
return nil, err
}
return item, nil
}
func (r *AdminRepository) UpdateEventStatus(ctx context.Context, eventID uuid.UUID, status domain.EventStatus) (*adminapp.AdminEventItem, error) {
item, err := scanAdminEventRow(r.db.QueryRow(ctx, `
WITH e AS (
UPDATE event
SET status = $2, updated_at = NOW()
WHERE id = $1
RETURNING id, host_id, title, category_id, start_time, end_time, privacy_level, status,
capacity, approved_participant_count, pending_participant_count, created_at, updated_at
)
SELECT e.id, e.host_id, u.username, e.title, e.category_id, c.name, e.start_time, e.end_time,
e.privacy_level, e.status, e.capacity, e.approved_participant_count, e.pending_participant_count,
e.created_at, e.updated_at
FROM e
JOIN app_user u ON u.id = e.host_id
LEFT JOIN event_category c ON c.id = e.category_id
`, eventID, status))
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 item, nil
}
func (r *AdminRepository) CancelEvent(ctx context.Context, eventID uuid.UUID) (bool, error) {
var current string
err := r.db.QueryRow(ctx, `SELECT status FROM event WHERE id = $1 FOR UPDATE`, eventID).Scan(¤t)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return false, fmt.Errorf("admin lock event: %w", err)
}
if current == string(domain.EventStatusCanceled) {
return true, nil
}
if _, err := r.db.Exec(ctx, `UPDATE event SET status = $2, updated_at = NOW() WHERE id = $1`, eventID, domain.EventStatusCanceled); err != nil {
return false, fmt.Errorf("admin cancel event: %w", err)
}
return false, nil
}
func (r *AdminRepository) CancelEventParticipations(ctx context.Context, eventID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE participation
SET status = $2, updated_at = NOW()
WHERE event_id = $1
AND status IN ($3, $4)
`, eventID, domain.ParticipationStatusCanceled, domain.ParticipationStatusApproved, domain.ParticipationStatusPending)
if err != nil {
return fmt.Errorf("admin cancel event participations: %w", err)
}
return nil
}
func (r *AdminRepository) CancelPendingInvitationsForEvent(ctx context.Context, eventID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE invitation SET status = $2, updated_at = NOW()
WHERE event_id = $1 AND status = $3
`, eventID, domain.InvitationStatusCanceled, domain.InvitationStatusPending)
return wrapExecErr(err, "admin cancel event invitations")
}
func (r *AdminRepository) CancelPendingJoinRequestsForEvent(ctx context.Context, eventID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE join_request SET status = $2, updated_at = NOW()
WHERE event_id = $1 AND status = $3
`, eventID, domain.JoinRequestStatusCanceled, domain.JoinRequestStatusPending)
return wrapExecErr(err, "admin cancel event join requests")
}
func (r *AdminRepository) GetUserStatus(ctx context.Context, userID uuid.UUID, forUpdate bool) (*domain.UserStatus, error) {
query := `SELECT status FROM app_user WHERE id = $1`
if forUpdate {
query += ` FOR UPDATE`
}
var raw string
err := r.db.QueryRow(ctx, query, userID).Scan(&raw)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("admin get user status: %w", err)
}
status, ok := domain.ParseUserStatus(raw)
if !ok {
return nil, fmt.Errorf("admin get user status: unknown user status %q", raw)
}
return &status, nil
}
func (r *AdminRepository) DeactivateUser(ctx context.Context, userID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `UPDATE app_user SET status = $2, updated_at = NOW() WHERE id = $1`, userID, domain.UserStatusDeactivated)
if err != nil {
return fmt.Errorf("admin deactivate user: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.NotFoundError(domain.ErrorCodeUserNotFound, "The requested user does not exist.")
}
return nil
}
func (r *AdminRepository) RevokeRefreshTokensForUser(ctx context.Context, userID uuid.UUID) error {
_, err := r.db.Exec(ctx, `UPDATE refresh_token SET revoked_at = COALESCE(revoked_at, NOW()), updated_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID)
return wrapExecErr(err, "admin revoke refresh tokens")
}
func (r *AdminRepository) RevokePushDevicesForUser(ctx context.Context, userID uuid.UUID) error {
_, err := r.db.Exec(ctx, `UPDATE user_push_device SET revoked_at = COALESCE(revoked_at, NOW()), updated_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID)
return wrapExecErr(err, "admin revoke push devices")
}
func (r *AdminRepository) ListHostedCancelableEventIDs(ctx context.Context, hostID uuid.UUID) ([]uuid.UUID, error) {
rows, err := r.db.Query(ctx, `SELECT id FROM event WHERE host_id = $1 AND status IN ($2, $3) ORDER BY created_at ASC FOR UPDATE`, hostID, domain.EventStatusActive, domain.EventStatusInProgress)
if err != nil {
return nil, fmt.Errorf("admin list hosted cancelable events: %w", err)
}
defer rows.Close()
ids := []uuid.UUID{}
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("admin scan hosted event id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list hosted event ids rows: %w", err)
}
return ids, nil
}
func (r *AdminRepository) CancelUserParticipations(ctx context.Context, userID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE participation p
SET status = $2, updated_at = NOW()
FROM event e
WHERE e.id = p.event_id
AND p.user_id = $1
AND e.host_id <> $1
AND p.status IN ($3, $4)
`, userID, domain.ParticipationStatusCanceled, domain.ParticipationStatusApproved, domain.ParticipationStatusPending)
return wrapExecErr(err, "admin cancel user participations")
}
func (r *AdminRepository) CancelUserTickets(ctx context.Context, userID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE ticket t
SET status = $2,
canceled_at = COALESCE(t.canceled_at, NOW()),
updated_at = NOW()
FROM participation p
JOIN event e ON e.id = p.event_id
WHERE p.id = t.participation_id
AND p.user_id = $1
AND e.host_id <> $1
AND t.status IN ($3, $4)
`, userID, domain.TicketStatusCanceled, domain.TicketStatusActive, domain.TicketStatusPending)
return wrapExecErr(err, "admin cancel user tickets")
}
func (r *AdminRepository) CancelPendingInvitationsForUser(ctx context.Context, userID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE invitation SET status = $2, updated_at = NOW()
WHERE status = $3 AND (host_id = $1 OR invited_user_id = $1)
`, userID, domain.InvitationStatusCanceled, domain.InvitationStatusPending)
return wrapExecErr(err, "admin cancel user invitations")
}
func (r *AdminRepository) CancelPendingJoinRequestsForUser(ctx context.Context, userID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE join_request SET status = $2, updated_at = NOW()
WHERE status = $3 AND (user_id = $1 OR host_user_id = $1)
`, userID, domain.JoinRequestStatusCanceled, domain.JoinRequestStatusPending)
return wrapExecErr(err, "admin cancel user join requests")
}
func (r *AdminRepository) ListInvitations(ctx context.Context, input adminapp.ListInvitationsInput) (*adminapp.ListInvitationsResult, error) {
args, where := []any{}, []string{"TRUE"}
add := func(value any) string { args = append(args, value); return fmt.Sprintf("$%d", len(args)) }
if input.Query != nil {
p := add("%" + *input.Query + "%")
where = append(where, "(e.title ILIKE "+p+" OR hu.username ILIKE "+p+" OR iu.username ILIKE "+p+" OR iu.email ILIKE "+p+")")
}
if input.Status != nil {
where = append(where, "i.status = "+add(input.Status.String()))
}
if input.EventID != nil {
where = append(where, "i.event_id = "+add(*input.EventID))
}
if input.HostID != nil {
where = append(where, "i.host_id = "+add(*input.HostID))
}
if input.InvitedUserID != nil {
where = append(where, "i.invited_user_id = "+add(*input.InvitedUserID))
}
if input.CreatedFrom != nil {
where = append(where, "i.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "i.created_at <= "+add(*input.CreatedTo))
}
limit, offset := add(input.Limit), add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT i.id, i.event_id, e.title, i.host_id, hu.username, i.invited_user_id, iu.username, iu.email,
i.status, i.message, i.expires_at, i.created_at, i.updated_at, COUNT(*) OVER()
FROM invitation i
JOIN event e ON e.id = i.event_id
JOIN app_user hu ON hu.id = i.host_id
JOIN app_user iu ON iu.id = i.invited_user_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY i.created_at DESC, i.id DESC
LIMIT `+limit+` OFFSET `+offset, args...)
if err != nil {
return nil, fmt.Errorf("admin list invitations: %w", err)
}
defer rows.Close()
items, total := []adminapp.AdminInvitationItem{}, 0
for rows.Next() {
var item adminapp.AdminInvitationItem
var message pgtype.Text
var expiresAt pgtype.Timestamptz
if err := rows.Scan(&item.ID, &item.EventID, &item.EventTitle, &item.HostID, &item.HostUsername, &item.InvitedUserID, &item.InvitedUsername, &item.InvitedEmail, &item.Status, &message, &expiresAt, &item.CreatedAt, &item.UpdatedAt, &total); err != nil {
return nil, fmt.Errorf("admin scan invitation: %w", err)
}
item.Message = textPtr(message)
item.ExpiresAt = timestamptzPtr(expiresAt)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list invitations rows: %w", err)
}
return &adminapp.ListInvitationsResult{Items: items, PageMeta: pageMeta(input.PageInput, total, len(items))}, nil
}
func (r *AdminRepository) UpdateInvitationStatus(ctx context.Context, invitationID uuid.UUID, status domain.InvitationStatus) (*adminapp.AdminInvitationItem, error) {
tag, err := r.db.Exec(ctx, `UPDATE invitation SET status = $2, updated_at = NOW() WHERE id = $1 AND status = $3`, invitationID, status, domain.InvitationStatusPending)
if err != nil {
return nil, fmt.Errorf("admin update invitation status: %w", err)
}
if tag.RowsAffected() == 0 {
return nil, domain.NotFoundError(domain.ErrorCodeInvitationNotFound, "The requested pending invitation does not exist.")
}
var item adminapp.AdminInvitationItem
var message pgtype.Text
var expiresAt pgtype.Timestamptz
err = r.db.QueryRow(ctx, `
SELECT i.id, i.event_id, e.title, i.host_id, hu.username, i.invited_user_id, iu.username, iu.email,
i.status, i.message, i.expires_at, i.created_at, i.updated_at
FROM invitation i
JOIN event e ON e.id = i.event_id
JOIN app_user hu ON hu.id = i.host_id
JOIN app_user iu ON iu.id = i.invited_user_id
WHERE i.id = $1
`, invitationID).Scan(&item.ID, &item.EventID, &item.EventTitle, &item.HostID, &item.HostUsername, &item.InvitedUserID, &item.InvitedUsername, &item.InvitedEmail, &item.Status, &message, &expiresAt, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("admin load updated invitation: %w", err)
}
item.Message = textPtr(message)
item.ExpiresAt = timestamptzPtr(expiresAt)
return &item, nil
}
func (r *AdminRepository) ListJoinRequests(ctx context.Context, input adminapp.ListJoinRequestsInput) (*adminapp.ListJoinRequestsResult, error) {
args, where := []any{}, []string{"TRUE"}
add := func(value any) string { args = append(args, value); return fmt.Sprintf("$%d", len(args)) }
if input.Query != nil {
p := add("%" + *input.Query + "%")
where = append(where, "(e.title ILIKE "+p+" OR u.username ILIKE "+p+" OR u.email ILIKE "+p+" OR hu.username ILIKE "+p+")")
}
if input.Status != nil {
where = append(where, "jr.status = "+add(input.Status.String()))
}
if input.EventID != nil {
where = append(where, "jr.event_id = "+add(*input.EventID))
}
if input.UserID != nil {
where = append(where, "jr.user_id = "+add(*input.UserID))
}
if input.HostUserID != nil {
where = append(where, "jr.host_user_id = "+add(*input.HostUserID))
}
if input.CreatedFrom != nil {
where = append(where, "jr.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "jr.created_at <= "+add(*input.CreatedTo))
}
limit, offset := add(input.Limit), add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT jr.id, jr.event_id, e.title, jr.user_id, u.username, u.email, jr.host_user_id, hu.username,
jr.status, jr.message, jr.image_url, jr.created_at, jr.updated_at, COUNT(*) OVER()
FROM join_request jr
JOIN event e ON e.id = jr.event_id
JOIN app_user u ON u.id = jr.user_id
JOIN app_user hu ON hu.id = jr.host_user_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY jr.created_at DESC, jr.id DESC
LIMIT `+limit+` OFFSET `+offset, args...)
if err != nil {
return nil, fmt.Errorf("admin list join requests: %w", err)
}
defer rows.Close()
items, total := []adminapp.AdminJoinRequestItem{}, 0
for rows.Next() {
var item adminapp.AdminJoinRequestItem
var message, imageURL pgtype.Text
if err := rows.Scan(&item.ID, &item.EventID, &item.EventTitle, &item.UserID, &item.Username, &item.UserEmail, &item.HostUserID, &item.HostUsername, &item.Status, &message, &imageURL, &item.CreatedAt, &item.UpdatedAt, &total); err != nil {
return nil, fmt.Errorf("admin scan join request: %w", err)
}
item.Message = textPtr(message)
item.ImageURL = textPtr(imageURL)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list join requests rows: %w", err)
}
return &adminapp.ListJoinRequestsResult{Items: items, PageMeta: pageMeta(input.PageInput, total, len(items))}, nil
}
func (r *AdminRepository) UpdateJoinRequestStatus(ctx context.Context, joinRequestID uuid.UUID, status domain.JoinRequestStatus) (*adminapp.AdminJoinRequestItem, error) {
tag, err := r.db.Exec(ctx, `UPDATE join_request SET status = $2, updated_at = NOW() WHERE id = $1 AND status = $3`, joinRequestID, status, domain.JoinRequestStatusPending)
if err != nil {
return nil, fmt.Errorf("admin update join request status: %w", err)
}
if tag.RowsAffected() == 0 {
return nil, domain.NotFoundError(domain.ErrorCodeJoinRequestNotFound, "The requested pending join request does not exist.")
}
var item adminapp.AdminJoinRequestItem
var message, imageURL pgtype.Text
err = r.db.QueryRow(ctx, `
SELECT jr.id, jr.event_id, e.title, jr.user_id, u.username, u.email, jr.host_user_id, hu.username,
jr.status, jr.message, jr.image_url, jr.created_at, jr.updated_at
FROM join_request jr
JOIN event e ON e.id = jr.event_id
JOIN app_user u ON u.id = jr.user_id
JOIN app_user hu ON hu.id = jr.host_user_id
WHERE jr.id = $1
`, joinRequestID).Scan(&item.ID, &item.EventID, &item.EventTitle, &item.UserID, &item.Username, &item.UserEmail, &item.HostUserID, &item.HostUsername, &item.Status, &message, &imageURL, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("admin load updated join request: %w", err)
}
item.Message = textPtr(message)
item.ImageURL = textPtr(imageURL)
return &item, nil
}
func (r *AdminRepository) ListComments(ctx context.Context, input adminapp.ListCommentsInput) (*adminapp.ListCommentsResult, error) {
args, where := []any{}, []string{"TRUE"}
add := func(value any) string { args = append(args, value); return fmt.Sprintf("$%d", len(args)) }
if input.Query != nil {
p := add("%" + *input.Query + "%")
where = append(where, "(ec.message ILIKE "+p+" OR e.title ILIKE "+p+" OR u.username ILIKE "+p+" OR u.email ILIKE "+p+")")
}
if input.EventID != nil {
where = append(where, "ec.event_id = "+add(*input.EventID))
}
if input.UserID != nil {
where = append(where, "ec.user_id = "+add(*input.UserID))
}
if input.Type != nil {
where = append(where, "ec.comment_type = "+add(*input.Type))
}
if input.CreatedFrom != nil {
where = append(where, "ec.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "ec.created_at <= "+add(*input.CreatedTo))
}
limit, offset := add(input.Limit), add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT ec.id, ec.event_id, e.title, ec.user_id, u.username, u.email, ec.comment_type, ec.parent_id, ec.message, ec.created_at, ec.updated_at, COUNT(*) OVER()
FROM event_comment ec JOIN event e ON e.id = ec.event_id JOIN app_user u ON u.id = ec.user_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY ec.created_at DESC, ec.id DESC LIMIT `+limit+` OFFSET `+offset, args...)
if err != nil {
return nil, fmt.Errorf("admin list comments: %w", err)
}
defer rows.Close()
items, total := []adminapp.AdminCommentItem{}, 0
for rows.Next() {
var item adminapp.AdminCommentItem
var parentID pgtype.UUID
if err := rows.Scan(&item.ID, &item.EventID, &item.EventTitle, &item.UserID, &item.Username, &item.UserEmail, &item.Type, &parentID, &item.Message, &item.CreatedAt, &item.UpdatedAt, &total); err != nil {
return nil, fmt.Errorf("admin scan comment: %w", err)
}
item.ParentID = uuidPtr(parentID)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list comments rows: %w", err)
}
return &adminapp.ListCommentsResult{Items: items, PageMeta: pageMeta(input.PageInput, total, len(items))}, nil
}
func (r *AdminRepository) DeleteComment(ctx context.Context, commentID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `DELETE FROM event_comment WHERE id = $1`, commentID)
if err != nil {
return fmt.Errorf("admin delete comment: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.NotFoundError("comment_not_found", "The requested comment does not exist.")
}
return nil
}
func (r *AdminRepository) ListEventRatings(ctx context.Context, input adminapp.ListEventRatingsInput) (*adminapp.ListEventRatingsResult, error) {
args, where := []any{}, []string{"TRUE"}
add := func(value any) string { args = append(args, value); return fmt.Sprintf("$%d", len(args)) }
if input.EventID != nil {
where = append(where, "er.event_id = "+add(*input.EventID))
}
if input.UserID != nil {
where = append(where, "er.participant_user_id = "+add(*input.UserID))
}
if input.CreatedFrom != nil {
where = append(where, "er.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "er.created_at <= "+add(*input.CreatedTo))
}
limit, offset := add(input.Limit), add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT er.id, er.event_id, e.title, er.participant_user_id, u.username, u.email, er.rating::double precision, er.created_at, er.updated_at, COUNT(*) OVER()
FROM event_rating er JOIN event e ON e.id = er.event_id JOIN app_user u ON u.id = er.participant_user_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY er.created_at DESC, er.id DESC LIMIT `+limit+` OFFSET `+offset, args...)
if err != nil {
return nil, fmt.Errorf("admin list event ratings: %w", err)
}
defer rows.Close()
items, total := []adminapp.AdminEventRatingItem{}, 0
for rows.Next() {
var item adminapp.AdminEventRatingItem
if err := rows.Scan(&item.ID, &item.EventID, &item.EventTitle, &item.ParticipantUserID, &item.Username, &item.UserEmail, &item.Score, &item.CreatedAt, &item.UpdatedAt, &total); err != nil {
return nil, fmt.Errorf("admin scan event rating: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list event ratings rows: %w", err)
}
return &adminapp.ListEventRatingsResult{Items: items, PageMeta: pageMeta(input.PageInput, total, len(items))}, nil
}
func (r *AdminRepository) ListParticipantRatings(ctx context.Context, input adminapp.ListParticipantRatingsInput) (*adminapp.ListParticipantRatingsResult, error) {
args, where := []any{}, []string{"TRUE"}
add := func(value any) string { args = append(args, value); return fmt.Sprintf("$%d", len(args)) }
if input.EventID != nil {
where = append(where, "pr.event_id = "+add(*input.EventID))
}
if input.HostID != nil {
where = append(where, "pr.host_user_id = "+add(*input.HostID))
}
if input.UserID != nil {
where = append(where, "pr.participant_user_id = "+add(*input.UserID))
}
if input.CreatedFrom != nil {
where = append(where, "pr.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "pr.created_at <= "+add(*input.CreatedTo))
}
limit, offset := add(input.Limit), add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT pr.id, pr.event_id, e.title, pr.host_user_id, hu.username, pr.participant_user_id, pu.username,
pr.rating::double precision, pr.created_at, pr.updated_at, COUNT(*) OVER()
FROM participant_rating pr
JOIN event e ON e.id = pr.event_id
JOIN app_user hu ON hu.id = pr.host_user_id
JOIN app_user pu ON pu.id = pr.participant_user_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY pr.created_at DESC, pr.id DESC LIMIT `+limit+` OFFSET `+offset, args...)
if err != nil {
return nil, fmt.Errorf("admin list participant ratings: %w", err)
}
defer rows.Close()
items, total := []adminapp.AdminParticipantRatingItem{}, 0
for rows.Next() {
var item adminapp.AdminParticipantRatingItem
if err := rows.Scan(&item.ID, &item.EventID, &item.EventTitle, &item.HostUserID, &item.HostUsername, &item.ParticipantUserID, &item.ParticipantUsername, &item.Score, &item.CreatedAt, &item.UpdatedAt, &total); err != nil {
return nil, fmt.Errorf("admin scan participant rating: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list participant ratings rows: %w", err)
}
return &adminapp.ListParticipantRatingsResult{Items: items, PageMeta: pageMeta(input.PageInput, total, len(items))}, nil
}
func (r *AdminRepository) DeleteEventRating(ctx context.Context, ratingID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `DELETE FROM event_rating WHERE id = $1`, ratingID)
if err != nil {
return fmt.Errorf("admin delete event rating: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.NotFoundError("rating_not_found", "The requested rating does not exist.")
}
return nil
}
func (r *AdminRepository) DeleteParticipantRating(ctx context.Context, ratingID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `DELETE FROM participant_rating WHERE id = $1`, ratingID)
if err != nil {
return fmt.Errorf("admin delete participant rating: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.NotFoundError("rating_not_found", "The requested rating does not exist.")
}
return nil
}
func (r *AdminRepository) ListFavoriteEvents(ctx context.Context, input adminapp.ListFavoriteEventsInput) (*adminapp.ListFavoriteEventsResult, error) {
args, where := []any{}, []string{"TRUE"}
add := func(value any) string { args = append(args, value); return fmt.Sprintf("$%d", len(args)) }
if input.UserID != nil {
where = append(where, "fe.user_id = "+add(*input.UserID))
}
if input.EventID != nil {
where = append(where, "fe.event_id = "+add(*input.EventID))
}
if input.CreatedFrom != nil {
where = append(where, "fe.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "fe.created_at <= "+add(*input.CreatedTo))
}
limit, offset := add(input.Limit), add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT fe.id, fe.user_id, u.username, u.email, fe.event_id, e.title, fe.created_at, fe.updated_at, COUNT(*) OVER()
FROM favorite_event fe JOIN app_user u ON u.id = fe.user_id JOIN event e ON e.id = fe.event_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY fe.created_at DESC, fe.id DESC LIMIT `+limit+` OFFSET `+offset, args...)
if err != nil {
return nil, fmt.Errorf("admin list favorite events: %w", err)
}
defer rows.Close()
items, total := []adminapp.AdminFavoriteEventItem{}, 0
for rows.Next() {
var item adminapp.AdminFavoriteEventItem
if err := rows.Scan(&item.ID, &item.UserID, &item.Username, &item.UserEmail, &item.EventID, &item.EventTitle, &item.CreatedAt, &item.UpdatedAt, &total); err != nil {
return nil, fmt.Errorf("admin scan favorite event: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list favorite events rows: %w", err)
}
return &adminapp.ListFavoriteEventsResult{Items: items, PageMeta: pageMeta(input.PageInput, total, len(items))}, nil
}
func (r *AdminRepository) ListFavoriteLocations(ctx context.Context, input adminapp.ListFavoriteLocationsInput) (*adminapp.ListFavoriteLocationsResult, error) {
args, where := []any{}, []string{"TRUE"}
add := func(value any) string { args = append(args, value); return fmt.Sprintf("$%d", len(args)) }
if input.UserID != nil {
where = append(where, "fl.user_id = "+add(*input.UserID))
}
if input.Query != nil {
p := add("%" + *input.Query + "%")
where = append(where, "(fl.name ILIKE "+p+" OR fl.address ILIKE "+p+" OR u.username ILIKE "+p+" OR u.email ILIKE "+p+")")
}
if input.CreatedFrom != nil {
where = append(where, "fl.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "fl.created_at <= "+add(*input.CreatedTo))
}
limit, offset := add(input.Limit), add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT fl.id, fl.user_id, u.username, u.email, fl.name, fl.address, fl.created_at, fl.updated_at, COUNT(*) OVER()
FROM favorite_location fl JOIN app_user u ON u.id = fl.user_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY fl.created_at DESC, fl.id DESC LIMIT `+limit+` OFFSET `+offset, args...)
if err != nil {
return nil, fmt.Errorf("admin list favorite locations: %w", err)
}
defer rows.Close()
items, total := []adminapp.AdminFavoriteLocationItem{}, 0
for rows.Next() {
var item adminapp.AdminFavoriteLocationItem
var name, address pgtype.Text
if err := rows.Scan(&item.ID, &item.UserID, &item.Username, &item.UserEmail, &name, &address, &item.CreatedAt, &item.UpdatedAt, &total); err != nil {
return nil, fmt.Errorf("admin scan favorite location: %w", err)
}
item.Name = textPtr(name)
item.Address = textPtr(address)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list favorite locations rows: %w", err)
}
return &adminapp.ListFavoriteLocationsResult{Items: items, PageMeta: pageMeta(input.PageInput, total, len(items))}, nil
}
func (r *AdminRepository) ListUserBadges(ctx context.Context, input adminapp.ListUserBadgesInput) (*adminapp.ListUserBadgesResult, error) {
args, where := []any{}, []string{"TRUE"}
add := func(value any) string { args = append(args, value); return fmt.Sprintf("$%d", len(args)) }
if input.UserID != nil {
where = append(where, "ub.user_id = "+add(*input.UserID))
}
if input.Query != nil {
p := add("%" + *input.Query + "%")
where = append(where, "(b.name ILIKE "+p+" OR b.slug ILIKE "+p+" OR u.username ILIKE "+p+" OR u.email ILIKE "+p+")")
}
limit, offset := add(input.Limit), add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT ub.user_id, u.username, u.email, b.id, b.slug, b.name, b.category, ub.earned_at, COUNT(*) OVER()
FROM user_badge ub JOIN app_user u ON u.id = ub.user_id JOIN badge b ON b.id = ub.badge_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY ub.earned_at DESC, ub.user_id DESC LIMIT `+limit+` OFFSET `+offset, args...)
if err != nil {
return nil, fmt.Errorf("admin list user badges: %w", err)
}
defer rows.Close()
items, total := []adminapp.AdminUserBadgeItem{}, 0
for rows.Next() {
var item adminapp.AdminUserBadgeItem
if err := rows.Scan(&item.UserID, &item.Username, &item.UserEmail, &item.BadgeID, &item.BadgeSlug, &item.BadgeName, &item.BadgeCategory, &item.EarnedAt, &total); err != nil {
return nil, fmt.Errorf("admin scan user badge: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list user badges rows: %w", err)
}
return &adminapp.ListUserBadgesResult{Items: items, PageMeta: pageMeta(input.PageInput, total, len(items))}, nil
}
func (r *AdminRepository) ListPushDevices(ctx context.Context, input adminapp.ListPushDevicesInput) (*adminapp.ListPushDevicesResult, error) {
args, where := []any{}, []string{"TRUE"}
add := func(value any) string { args = append(args, value); return fmt.Sprintf("$%d", len(args)) }
if input.UserID != nil {
where = append(where, "d.user_id = "+add(*input.UserID))
}
if input.Platform != nil {
where = append(where, "d.platform = "+add(*input.Platform))
}
if input.Active != nil {
if *input.Active {
where = append(where, "d.revoked_at IS NULL")
} else {
where = append(where, "d.revoked_at IS NOT NULL")
}
}
if input.CreatedFrom != nil {
where = append(where, "d.created_at >= "+add(*input.CreatedFrom))
}
if input.CreatedTo != nil {
where = append(where, "d.created_at <= "+add(*input.CreatedTo))
}
limit, offset := add(input.Limit), add(input.Offset)
rows, err := r.db.Query(ctx, `
SELECT d.id, d.user_id, u.username, u.email, d.installation_id, d.platform, d.last_seen_at, d.revoked_at, d.created_at, d.updated_at, COUNT(*) OVER()
FROM user_push_device d JOIN app_user u ON u.id = d.user_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY d.last_seen_at DESC, d.id DESC LIMIT `+limit+` OFFSET `+offset, args...)
if err != nil {
return nil, fmt.Errorf("admin list push devices: %w", err)
}
defer rows.Close()
items, total := []adminapp.AdminPushDeviceItem{}, 0
for rows.Next() {
var item adminapp.AdminPushDeviceItem
var revokedAt pgtype.Timestamptz
if err := rows.Scan(&item.ID, &item.UserID, &item.Username, &item.UserEmail, &item.InstallationID, &item.Platform, &item.LastSeenAt, &revokedAt, &item.CreatedAt, &item.UpdatedAt, &total); err != nil {
return nil, fmt.Errorf("admin scan push device: %w", err)
}
item.RevokedAt = timestamptzPtr(revokedAt)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("admin list push devices rows: %w", err)
}
return &adminapp.ListPushDevicesResult{Items: items, PageMeta: pageMeta(input.PageInput, total, len(items))}, nil
}
func (r *AdminRepository) RevokePushDevice(ctx context.Context, deviceID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `UPDATE user_push_device SET revoked_at = COALESCE(revoked_at, NOW()), updated_at = NOW() WHERE id = $1`, deviceID)
if err != nil {
return fmt.Errorf("admin revoke push device: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.NotFoundError("push_device_not_found", "The requested push device does not exist.")
}
return nil
}
func (r *AdminRepository) CountExistingUsers(ctx context.Context, userIDs []uuid.UUID) (int, error) {
if len(userIDs) == 0 {
return 0, nil
}
var count int
if err := r.db.QueryRow(ctx, `
SELECT COUNT(*)
FROM app_user
WHERE id = ANY($1)
`, userIDs).Scan(&count); err != nil {
return 0, fmt.Errorf("admin count existing users: %w", err)
}
return count, nil
}
func (r *AdminRepository) GetEventState(ctx context.Context, eventID uuid.UUID, forUpdate bool) (*adminapp.AdminEventState, error) {
query := `
SELECT id, privacy_level, capacity, approved_participant_count, pending_participant_count
FROM event
WHERE id = $1
`
if forUpdate {
query += ` FOR UPDATE`
}
var (
state adminapp.AdminEventState
privacyLevel string
capacity pgtype.Int4
)
err := r.db.QueryRow(ctx, query, eventID).Scan(&state.ID, &privacyLevel, &capacity, &state.ApprovedParticipantCount, &state.PendingParticipantCount)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("admin get event state: %w", err)
}
parsed, ok := domain.ParseEventPrivacyLevel(privacyLevel)
if !ok {
return nil, fmt.Errorf("admin get event state: unknown privacy level %q", privacyLevel)
}
state.PrivacyLevel = parsed
if capacity.Valid {
state.Capacity = new(int)
*state.Capacity = int(capacity.Int32)
}
return &state, nil
}
func (r *AdminRepository) CreateManualParticipation(ctx context.Context, eventID, userID uuid.UUID, status domain.ParticipationStatus) (*domain.Participation, error) {
existing, err := loadParticipation(ctx, r.db, eventID, userID, true)
if err != nil {
return nil, fmt.Errorf("admin load existing participation: %w", err)
}
if existing != nil && (existing.Status == domain.ParticipationStatusApproved || existing.Status == domain.ParticipationStatusPending) {
return nil, domain.ConflictError(domain.ErrorCodeAlreadyParticipating, "The user already has an active participation for this event.")
}
if existing != nil {
participation, err := scanParticipation(r.db.QueryRow(ctx, `
UPDATE participation
SET status = $3,
reconfirmed_at = NULL,
last_confirmed_event_version = CASE
WHEN $3 = $4 THEN (SELECT version_no FROM event WHERE id = $1)
ELSE last_confirmed_event_version
END,
updated_at = NOW()
WHERE event_id = $1
AND user_id = $2
RETURNING id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
`, eventID, userID, status, domain.ParticipationStatusApproved), eventID, userID, "admin reactivate participation")
if err != nil {
return nil, err
}
return participation, nil
}
participation, err := scanParticipation(r.db.QueryRow(ctx, `
INSERT INTO participation (event_id, user_id, status, last_confirmed_event_version)
VALUES (
$1,
$2,
$3,
CASE WHEN $3 = $4 THEN (SELECT version_no FROM event WHERE id = $1) ELSE NULL END
)
RETURNING id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
`, eventID, userID, status, domain.ParticipationStatusApproved), eventID, userID, "admin create participation")
if err != nil {
return nil, mapAdminParticipationMutationError(err)
}
if participation == nil {
return nil, fmt.Errorf("admin create participation: no row returned")
}
return participation, nil
}
func (r *AdminRepository) GetParticipationByID(ctx context.Context, participationID uuid.UUID, forUpdate bool) (*domain.Participation, error) {
query := `
SELECT id, event_id, user_id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
FROM participation
WHERE id = $1
`
if forUpdate {
query += ` FOR UPDATE`
}
return scanAdminParticipation(r.db.QueryRow(ctx, query, participationID), "admin get participation")
}
func (r *AdminRepository) CancelParticipation(ctx context.Context, participationID uuid.UUID) (*domain.Participation, bool, error) {
existing, err := r.GetParticipationByID(ctx, participationID, true)
if err != nil {
return nil, false, err
}
if existing == nil {
return nil, false, domain.NotFoundError(domain.ErrorCodeParticipationNotFound, "The requested participation does not exist.")
}
if existing.Status == domain.ParticipationStatusCanceled {
return existing, true, nil
}
participation, err := scanAdminParticipation(r.db.QueryRow(ctx, `
UPDATE participation
SET status = $2,
updated_at = NOW()
WHERE id = $1
RETURNING id, event_id, user_id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
`, participationID, domain.ParticipationStatusCanceled), "admin cancel participation")
if err != nil {
return nil, false, err
}
return participation, false, nil
}
func scanAdminParticipation(row pgx.Row, operation string) (*domain.Participation, error) {
var (
participation domain.Participation
status string
reconfirmedAt pgtype.Timestamptz
lastConfirmedVersion pgtype.Int4
)
err := row.Scan(
&participation.ID,
&participation.EventID,
&participation.UserID,
&status,
&reconfirmedAt,
&lastConfirmedVersion,
&participation.CreatedAt,
&participation.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("%s: %w", operation, err)
}
parsed, ok := domain.ParseParticipationStatus(status)
if !ok {
return nil, fmt.Errorf("%s: unknown participation status %q", operation, status)
}
participation.Status = parsed
participation.ReconfirmedAt = timestamptzPtr(reconfirmedAt)
if lastConfirmedVersion.Valid {
value := int(lastConfirmedVersion.Int32)
participation.LastConfirmedEventVersion = &value
}
return &participation, nil
}
func mapAdminParticipationMutationError(err error) error {
if err == nil {
return nil
}
if strings.Contains(err.Error(), "fk_participation_event") {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
if strings.Contains(err.Error(), "fk_participation_user") {
return domain.NotFoundError(domain.ErrorCodeUserNotFound, "The requested user does not exist.")
}
return err
}
func pageMeta(page adminapp.PageInput, totalCount, itemCount int) adminapp.PageMeta {
return adminapp.PageMeta{
Limit: page.Limit,
Offset: page.Offset,
TotalCount: totalCount,
HasNext: page.Offset+itemCount < totalCount,
}
}
func intPtr(value pgtype.Int4) *int {
if !value.Valid {
return nil
}
converted := int(value.Int32)
return &converted
}
func scanAdminEventReport(rows pgx.Rows, totalCount *int) (*adminapp.AdminEventReportItem, error) {
var item adminapp.AdminEventReportItem
var eventTitle, reporterUsername, reporterEmail, imageURL pgtype.Text
if err := rows.Scan(
&item.ID,
&item.EventID,
&eventTitle,
&item.ReporterUserID,
&reporterUsername,
&reporterEmail,
&item.ReportCategory,
&item.Message,
&imageURL,
&item.Status,
&item.CreatedAt,
&item.UpdatedAt,
totalCount,
); err != nil {
return nil, fmt.Errorf("admin scan event report: %w", err)
}
item.EventTitle = textPtr(eventTitle)
item.ReporterUsername = textPtr(reporterUsername)
item.ReporterEmail = textPtr(reporterEmail)
item.ImageURL = textPtr(imageURL)
return &item, nil
}
func scanAdminEventReportRow(row pgx.Row) (*adminapp.AdminEventReportItem, error) {
var item adminapp.AdminEventReportItem
var eventTitle, reporterUsername, reporterEmail, imageURL pgtype.Text
err := row.Scan(
&item.ID,
&item.EventID,
&eventTitle,
&item.ReporterUserID,
&reporterUsername,
&reporterEmail,
&item.ReportCategory,
&item.Message,
&imageURL,
&item.Status,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("admin scan event report row: %w", err)
}
item.EventTitle = textPtr(eventTitle)
item.ReporterUsername = textPtr(reporterUsername)
item.ReporterEmail = textPtr(reporterEmail)
item.ImageURL = textPtr(imageURL)
return &item, nil
}
func scanAdminEventRow(row pgx.Row) (*adminapp.AdminEventItem, error) {
var item adminapp.AdminEventItem
var categoryID pgtype.Int4
var categoryName pgtype.Text
var endTime pgtype.Timestamptz
var capacity pgtype.Int4
err := row.Scan(
&item.ID,
&item.HostID,
&item.HostUsername,
&item.Title,
&categoryID,
&categoryName,
&item.StartTime,
&endTime,
&item.PrivacyLevel,
&item.Status,
&capacity,
&item.ApprovedParticipantCount,
&item.PendingParticipantCount,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("admin scan event row: %w", err)
}
item.CategoryID = intPtr(categoryID)
item.CategoryName = textPtr(categoryName)
item.EndTime = timestamptzPtr(endTime)
item.Capacity = intPtr(capacity)
return &item, nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
func isForeignKeyViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23503"
}
func wrapExecErr(err error, operation string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", operation, err)
}
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, role, 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, role, 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, role, 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, role)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, username, email, phone_number, gender, birth_date, password_hash, email_verified_at, last_login, status, role, created_at, updated_at
`, params.Username, params.Email, params.PhoneNumber, params.Gender, params.BirthDate, params.PasswordHash, params.EmailVerifiedAt, params.Status, domain.UserRoleUser)
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"
badgeapp "github.com/bounswe/bounswe2026group11/backend/internal/application/badge"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
// BadgeRepository is the Postgres-backed implementation of badge.Repository.
type BadgeRepository struct {
pool *pgxpool.Pool
db execer
}
// NewBadgeRepository returns a repository that executes queries against the given connection pool.
func NewBadgeRepository(pool *pgxpool.Pool) *BadgeRepository {
return &BadgeRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
// ListAllBadges returns the full badge catalog ordered by sort_order.
func (r *BadgeRepository) ListAllBadges(ctx context.Context) ([]domain.Badge, error) {
rows, err := r.db.Query(ctx, `
SELECT id, slug, name, description, icon_url, category, sort_order
FROM badge
ORDER BY sort_order ASC, id ASC
`)
if err != nil {
return nil, fmt.Errorf("list badges: %w", err)
}
defer rows.Close()
var badges []domain.Badge
for rows.Next() {
b, err := scanBadge(rows)
if err != nil {
return nil, err
}
badges = append(badges, b)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate badges: %w", err)
}
if badges == nil {
badges = []domain.Badge{}
}
return badges, nil
}
// ListUserBadges returns the badges earned by the given user joined against the catalog.
func (r *BadgeRepository) ListUserBadges(ctx context.Context, userID uuid.UUID) ([]domain.UserBadge, error) {
rows, err := r.db.Query(ctx, `
SELECT
ub.user_id,
ub.badge_id,
ub.earned_at,
b.id,
b.slug,
b.name,
b.description,
b.icon_url,
b.category,
b.sort_order
FROM user_badge ub
JOIN badge b ON b.id = ub.badge_id
WHERE ub.user_id = $1
ORDER BY ub.earned_at DESC, b.sort_order ASC
`, userID)
if err != nil {
return nil, fmt.Errorf("list user badges: %w", err)
}
defer rows.Close()
var earned []domain.UserBadge
for rows.Next() {
var (
ub domain.UserBadge
def domain.Badge
iconURL pgtype.Text
slug string
category string
)
if err := rows.Scan(
&ub.UserID, &ub.BadgeID, &ub.EarnedAt,
&def.ID, &slug, &def.Name, &def.Description, &iconURL, &category, &def.SortOrder,
); err != nil {
return nil, fmt.Errorf("scan user badge: %w", err)
}
def.Slug = domain.BadgeSlug(slug)
def.Category = domain.BadgeCategory(category)
def.IconURL = textPtr(iconURL)
ub.Slug = def.Slug
ub.Definition = def
earned = append(earned, ub)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate user badges: %w", err)
}
if earned == nil {
earned = []domain.UserBadge{}
}
return earned, nil
}
// AwardBadge inserts a (user_id, badge_id) row using ON CONFLICT DO NOTHING so
// repeated evaluation calls never raise duplicate-key errors. Returns true
// when a new badge row was actually written for the user.
func (r *BadgeRepository) AwardBadge(ctx context.Context, userID uuid.UUID, slug domain.BadgeSlug) (bool, error) {
tag, err := r.db.Exec(ctx, `
INSERT INTO user_badge (user_id, badge_id)
SELECT $1, b.id
FROM badge b
WHERE b.slug = $2
ON CONFLICT (user_id, badge_id) DO NOTHING
`, userID, string(slug))
if err != nil {
return false, fmt.Errorf("award badge: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// ParticipationStats returns the participant-side metrics required by the
// participation badge rules.
func (r *BadgeRepository) ParticipationStats(ctx context.Context, userID uuid.UUID) (badgeapp.ParticipationStatsRecord, error) {
var record badgeapp.ParticipationStatsRecord
err := r.db.QueryRow(ctx, `
SELECT
COUNT(*) AS completed_event_count,
COUNT(DISTINCT e.category_id) FILTER (WHERE e.category_id IS NOT NULL) AS distinct_categories
FROM participation p
JOIN event e ON e.id = p.event_id
WHERE p.user_id = $1
AND p.status = $2
AND e.status = $3
`, userID, domain.ParticipationStatusApproved, domain.EventStatusCompleted).Scan(
&record.CompletedEventCount,
&record.DistinctCategoriesCount,
)
if err != nil {
return record, fmt.Errorf("participation stats: %w", err)
}
return record, nil
}
// HostStats returns the host-side metrics required by the hosting badge rules.
func (r *BadgeRepository) HostStats(ctx context.Context, hostID uuid.UUID) (badgeapp.HostStatsRecord, error) {
var (
record badgeapp.HostStatsRecord
hostScore pgtype.Float8
)
err := r.db.QueryRow(ctx, `
SELECT
(SELECT COUNT(*) FROM event e WHERE e.host_id = $1 AND e.status = $2) AS completed_hosted_count,
us.hosted_event_score,
COALESCE(us.hosted_event_rating_count, 0) AS hosted_event_rating_count
FROM app_user u
LEFT JOIN user_score us ON us.user_id = u.id
WHERE u.id = $1
`, hostID, domain.EventStatusCompleted).Scan(
&record.CompletedHostedEventCount,
&hostScore,
&record.HostRatingCount,
)
if err != nil {
return record, fmt.Errorf("host stats: %w", err)
}
if hostScore.Valid {
record.HostScore = &hostScore.Float64
}
return record, nil
}
// FavoriteLocationCount returns the total number of favorite locations saved
// by the given user.
func (r *BadgeRepository) FavoriteLocationCount(ctx context.Context, userID uuid.UUID) (int, error) {
var count int
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM favorite_location WHERE user_id = $1
`, userID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("favorite location count: %w", err)
}
return count, nil
}
// ListParticipationBadgeCandidateUserIDs returns the distinct users whose
// existing completed participations may qualify them for participation badges.
func (r *BadgeRepository) ListParticipationBadgeCandidateUserIDs(ctx context.Context) ([]uuid.UUID, error) {
rows, err := r.db.Query(ctx, `
SELECT DISTINCT p.user_id
FROM participation p
JOIN event e ON e.id = p.event_id
WHERE p.status = $1
AND e.status = $2
ORDER BY p.user_id ASC
`, domain.ParticipationStatusApproved, domain.EventStatusCompleted)
if err != nil {
return nil, fmt.Errorf("list participation badge candidate user ids: %w", err)
}
defer rows.Close()
return scanUUIDRows(rows)
}
// ListHostBadgeCandidateUserIDs returns the distinct hosts whose completed
// events may qualify them for hosting badges.
func (r *BadgeRepository) ListHostBadgeCandidateUserIDs(ctx context.Context) ([]uuid.UUID, error) {
rows, err := r.db.Query(ctx, `
SELECT DISTINCT host_id
FROM event
WHERE status = $1
ORDER BY host_id ASC
`, domain.EventStatusCompleted)
if err != nil {
return nil, fmt.Errorf("list host badge candidate user ids: %w", err)
}
defer rows.Close()
return scanUUIDRows(rows)
}
// ListFavoriteLocationBadgeCandidateUserIDs returns the distinct users with at
// least one saved favorite location that may qualify them for social badges.
func (r *BadgeRepository) ListFavoriteLocationBadgeCandidateUserIDs(ctx context.Context) ([]uuid.UUID, error) {
rows, err := r.db.Query(ctx, `
SELECT DISTINCT user_id
FROM favorite_location
ORDER BY user_id ASC
`)
if err != nil {
return nil, fmt.Errorf("list favorite-location badge candidate user ids: %w", err)
}
defer rows.Close()
return scanUUIDRows(rows)
}
func scanBadge(rows interface {
Scan(...any) error
}) (domain.Badge, error) {
var (
b domain.Badge
iconURL pgtype.Text
slug string
category string
)
if err := rows.Scan(&b.ID, &slug, &b.Name, &b.Description, &iconURL, &category, &b.SortOrder); err != nil {
return domain.Badge{}, fmt.Errorf("scan badge: %w", err)
}
b.Slug = domain.BadgeSlug(slug)
b.Category = domain.BadgeCategory(category)
b.IconURL = textPtr(iconURL)
return b, nil
}
func scanUUIDRows(rows interface {
Next() bool
Scan(...any) error
Err() error
}) ([]uuid.UUID, error) {
var ids []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan uuid row: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate uuid rows: %w", err)
}
if ids == nil {
ids = []uuid.UUID{}
}
return ids, nil
}
var _ badgeapp.Repository = (*BadgeRepository)(nil)
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"
commentapp "github.com/bounswe/bounswe2026group11/backend/internal/application/comment"
"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"
)
// CommentRepository is the Postgres-backed implementation of comment.Repository.
type CommentRepository struct {
pool *pgxpool.Pool
db execer
}
// NewCommentRepository returns a repository that executes queries against the
// given connection pool.
func NewCommentRepository(pool *pgxpool.Pool) *CommentRepository {
return &CommentRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
var _ commentapp.Repository = (*CommentRepository)(nil)
const (
commentCursorBefore = "<"
commentCursorAfter = ">"
)
func (r *CommentRepository) GetEventCommentContext(ctx context.Context, eventID uuid.UUID, viewerUserID *uuid.UUID) (*commentapp.EventCommentContext, error) {
var (
record commentapp.EventCommentContext
privacyLevel string
status string
viewer pgtype.UUID
isVisible bool
isHost bool
isApprovedParticipant bool
isQualifyingParticipant bool
)
if viewerUserID != nil {
viewer = pgtype.UUID{Bytes: *viewerUserID, Valid: true}
}
err := r.db.QueryRow(ctx, `
SELECT
e.id,
e.host_id,
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.privacy_level IN ($2, $3)
OR ($4::uuid IS NOT NULL AND e.host_id = $4)
OR EXISTS (
SELECT 1 FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $4
AND p.status IN ($5, $6, $7)
)
OR EXISTS (
SELECT 1 FROM invitation inv
WHERE inv.event_id = e.id
AND inv.invited_user_id = $4
AND inv.status IN ($8, $9)
)
) AS is_visible,
($4::uuid IS NOT NULL AND e.host_id = $4) AS is_host,
EXISTS (
SELECT 1 FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $4
AND p.status = $5
) AS is_approved_participant,
EXISTS (
SELECT 1 FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $4
AND (
p.status = $5
OR (p.status = $6 AND p.updated_at >= e.start_time)
)
) AS is_qualifying_participant
FROM event e
WHERE e.id = $1
`,
eventID,
domain.PrivacyPublic,
domain.PrivacyProtected,
viewer,
domain.ParticipationStatusApproved,
domain.ParticipationStatusLeaved,
domain.ParticipationStatusCanceled,
string(domain.InvitationStatusAccepted),
string(domain.InvitationStatusPending),
).Scan(
&record.EventID,
&record.HostUserID,
&privacyLevel,
&status,
&record.StartTime,
&isVisible,
&isHost,
&isApprovedParticipant,
&isQualifyingParticipant,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event comment context: %w", err)
}
record.PrivacyLevel = domain.EventPrivacyLevel(privacyLevel)
record.Status = domain.EventStatus(status)
record.IsVisible = isVisible
record.IsHost = isHost
record.IsApprovedParticipant = isApprovedParticipant
record.IsQualifyingParticipant = isQualifyingParticipant
return &record, nil
}
func (r *CommentRepository) GetDiscussionParentContext(ctx context.Context, eventID, parentID uuid.UUID) (*commentapp.DiscussionParentContext, error) {
var (
record commentapp.DiscussionParentContext
commentType string
parent pgtype.UUID
)
err := r.db.QueryRow(ctx, `
SELECT id, event_id, comment_type, parent_id
FROM event_comment
WHERE id = $1
AND event_id = $2
`, parentID, eventID).Scan(&record.ID, &record.EventID, &commentType, &parent)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get discussion parent context: %w", err)
}
record.Type = domain.CommentType(commentType)
record.ParentID = uuidPtr(parent)
return &record, nil
}
func (r *CommentRepository) ListTopLevelComments(ctx context.Context, eventID uuid.UUID, params commentapp.ListCommentsParams) ([]commentapp.CommentRecord, error) {
args := []any{eventID, string(params.CommentType)}
cursorClause := buildCommentCursorClause(params, &args, "ec.created_at", "ec.id", commentCursorBefore)
args = append(args, params.RepositoryFetchLimit)
rows, err := r.db.Query(ctx, fmt.Sprintf(`
SELECT
ec.id,
ec.event_id,
ec.user_id,
u.username,
pr.display_name,
pr.avatar_url,
ec.comment_type,
ec.message,
ec.parent_id,
ec.rating,
ec.image_url,
ec.likes_count,
ec.reply_count,
ec.created_at,
ec.updated_at
FROM event_comment ec
JOIN app_user u ON u.id = ec.user_id
LEFT JOIN profile pr ON pr.user_id = u.id
WHERE ec.event_id = $1
AND ec.comment_type = $2
AND ec.parent_id IS NULL
%s
ORDER BY ec.created_at DESC, ec.id DESC
LIMIT $%d
`, cursorClause, len(args)), args...)
if err != nil {
return nil, fmt.Errorf("list top-level comments: %w", err)
}
defer rows.Close()
return scanCommentRecords(rows, "list top-level comments")
}
func (r *CommentRepository) ListReplies(ctx context.Context, eventID, parentID uuid.UUID, params commentapp.ListCommentsParams) ([]commentapp.CommentRecord, error) {
args := []any{eventID, parentID, string(domain.CommentTypeDiscussion)}
cursorClause := buildCommentCursorClause(params, &args, "ec.created_at", "ec.id", commentCursorAfter)
args = append(args, params.RepositoryFetchLimit)
rows, err := r.db.Query(ctx, fmt.Sprintf(`
SELECT
ec.id,
ec.event_id,
ec.user_id,
u.username,
pr.display_name,
pr.avatar_url,
ec.comment_type,
ec.message,
ec.parent_id,
ec.rating,
ec.image_url,
ec.likes_count,
ec.reply_count,
ec.created_at,
ec.updated_at
FROM event_comment ec
JOIN app_user u ON u.id = ec.user_id
LEFT JOIN profile pr ON pr.user_id = u.id
WHERE ec.event_id = $1
AND ec.parent_id = $2
AND ec.comment_type = $3
%s
ORDER BY ec.created_at ASC, ec.id ASC
LIMIT $%d
`, cursorClause, len(args)), args...)
if err != nil {
return nil, fmt.Errorf("list comment replies: %w", err)
}
defer rows.Close()
return scanCommentRecords(rows, "list comment replies")
}
func (r *CommentRepository) CreateDiscussionComment(ctx context.Context, params commentapp.CreateDiscussionCommentParams) (*commentapp.CommentRecord, error) {
return scanCommentRecord(r.db.QueryRow(ctx, `
WITH inserted AS (
INSERT INTO event_comment (event_id, user_id, comment_type, message, parent_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, event_id, user_id, comment_type, message, parent_id, rating, image_url, likes_count, reply_count, created_at, updated_at
)
SELECT
i.id,
i.event_id,
i.user_id,
u.username,
pr.display_name,
pr.avatar_url,
i.comment_type,
i.message,
i.parent_id,
i.rating,
i.image_url,
i.likes_count,
i.reply_count,
i.created_at,
i.updated_at
FROM inserted i
JOIN app_user u ON u.id = i.user_id
LEFT JOIN profile pr ON pr.user_id = u.id
`, params.EventID, params.UserID, string(domain.CommentTypeDiscussion), params.Message, params.ParentID), "create discussion comment")
}
func (r *CommentRepository) UpsertReviewComment(ctx context.Context, params commentapp.UpsertReviewCommentParams) (*commentapp.CommentRecord, error) {
return scanCommentRecord(r.db.QueryRow(ctx, `
WITH upserted AS (
INSERT INTO event_comment (event_id, user_id, comment_type, message, rating, image_url)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (event_id, user_id) WHERE comment_type = 'REVIEW'
DO UPDATE SET
message = EXCLUDED.message,
rating = EXCLUDED.rating,
image_url = COALESCE(EXCLUDED.image_url, event_comment.image_url),
updated_at = now()
RETURNING id, event_id, user_id, comment_type, message, parent_id, rating, image_url, likes_count, reply_count, created_at, updated_at
)
SELECT
uo.id,
uo.event_id,
uo.user_id,
u.username,
pr.display_name,
pr.avatar_url,
uo.comment_type,
uo.message,
uo.parent_id,
uo.rating,
uo.image_url,
uo.likes_count,
uo.reply_count,
uo.created_at,
uo.updated_at
FROM upserted uo
JOIN app_user u ON u.id = uo.user_id
LEFT JOIN profile pr ON pr.user_id = u.id
`, params.EventID, params.UserID, string(domain.CommentTypeReview), params.Message, params.Rating, params.ImageURL), "upsert review comment")
}
func (r *CommentRepository) DeleteReviewComment(ctx context.Context, eventID, userID uuid.UUID) (bool, error) {
tag, err := r.db.Exec(ctx, `
DELETE FROM event_comment
WHERE event_id = $1
AND user_id = $2
AND comment_type = $3
`, eventID, userID, string(domain.CommentTypeReview))
if err != nil {
return false, fmt.Errorf("delete review comment: %w", err)
}
return tag.RowsAffected() == 1, nil
}
func buildCommentCursorClause(params commentapp.ListCommentsParams, args *[]any, createdAtColumn, idColumn, comparator string) string {
if params.DecodedCursor == nil {
return ""
}
*args = append(*args, params.DecodedCursor.CreatedAt, params.DecodedCursor.CommentID)
createdAtIndex := len(*args) - 1
idIndex := len(*args)
return fmt.Sprintf("AND (%s, %s) %s ($%d, $%d)", createdAtColumn, idColumn, comparator, createdAtIndex, idIndex)
}
func scanCommentRecords(rows pgx.Rows, operation string) ([]commentapp.CommentRecord, error) {
records := make([]commentapp.CommentRecord, 0)
for rows.Next() {
record, err := scanCommentRecord(rows, operation)
if err != nil {
return nil, err
}
records = append(records, *record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("%s rows: %w", operation, err)
}
return records, nil
}
func scanCommentRecord(row pgx.Row, operation string) (*commentapp.CommentRecord, error) {
var (
record commentapp.CommentRecord
commentType string
parentID pgtype.UUID
rating pgtype.Int4
imageURL pgtype.Text
displayName pgtype.Text
avatarURL pgtype.Text
)
if err := row.Scan(
&record.ID,
&record.EventID,
&record.User.ID,
&record.User.Username,
&displayName,
&avatarURL,
&commentType,
&record.Message,
&parentID,
&rating,
&imageURL,
&record.LikesCount,
&record.ReplyCount,
&record.CreatedAt,
&record.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
record.Type = domain.CommentType(commentType)
record.ParentID = uuidPtr(parentID)
if rating.Valid {
value := int(rating.Int32)
record.Rating = &value
}
record.ImageURL = textPtr(imageURL)
record.User.DisplayName = textPtr(displayName)
record.User.AvatarURL = textPtr(avatarURL)
return &record, nil
}
package postgres
import (
"context"
"encoding/json"
"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
}
// GetEventEditSnapshot loads and locks the event state needed to evaluate an edit.
func (r *EventRepository) GetEventEditSnapshot(ctx context.Context, eventID uuid.UUID) (*eventapp.EventEditSnapshot, error) {
var (
event domain.Event
description pgtype.Text
imageURL pgtype.Text
categoryID pgtype.Int4
endTime pgtype.Timestamptz
privacyLevel string
status string
capacity pgtype.Int4
minimumAge pgtype.Int4
preferredGender pgtype.Text
locationType pgtype.Text
versionNo int
)
err := r.db.QueryRow(ctx, `
SELECT id, host_id, title, description, image_url, category_id,
start_time, end_time, privacy_level, status, capacity,
approved_participant_count, pending_participant_count,
minimum_age, preferred_gender, location_type,
version_no, created_at, updated_at
FROM event
WHERE id = $1
FOR UPDATE
`, eventID).Scan(
&event.ID,
&event.HostID,
&event.Title,
&description,
&imageURL,
&categoryID,
&event.StartTime,
&endTime,
&privacyLevel,
&status,
&capacity,
&event.ApprovedParticipantCount,
&event.PendingParticipantCount,
&minimumAge,
&preferredGender,
&locationType,
&versionNo,
&event.CreatedAt,
&event.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event edit snapshot: %w", err)
}
if description.Valid {
event.Description = &description.String
}
event.PrivacyLevel = domain.EventPrivacyLevel(privacyLevel)
event.Status = domain.EventStatus(status)
event.VersionNo = versionNo
if imageURL.Valid {
event.ImageURL = &imageURL.String
}
if categoryID.Valid {
event.CategoryID = new(int)
*event.CategoryID = int(categoryID.Int32)
}
if endTime.Valid {
event.EndTime = &endTime.Time
}
if capacity.Valid {
event.Capacity = new(int)
*event.Capacity = int(capacity.Int32)
}
if minimumAge.Valid {
event.MinimumAge = new(int)
*event.MinimumAge = int(minimumAge.Int32)
}
if preferredGender.Valid {
gender := domain.EventParticipantGender(preferredGender.String)
event.PreferredGender = &gender
}
if locationType.Valid {
locType := domain.EventLocationType(locationType.String)
event.LocationType = &locType
} else {
locType := domain.LocationPoint
event.LocationType = &locType
}
location, err := r.loadEventEditLocation(ctx, eventID, *event.LocationType)
if err != nil {
return nil, err
}
constraints, err := r.loadEventEditConstraints(ctx, eventID)
if err != nil {
return nil, err
}
return &eventapp.EventEditSnapshot{
Event: event,
VersionNo: versionNo,
Location: location,
Constraints: constraints,
}, nil
}
// UpdateEvent persists a fully merged event edit. Related location and
// constraint replacements are expected to run inside the caller's transaction.
func (r *EventRepository) UpdateEvent(ctx context.Context, params eventapp.UpdateEventParams) (*domain.Event, error) {
updated, err := updateEventRow(ctx, r.db, params)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, eventapp.ErrEventNotEditable
}
return nil, mapEventUpdateError(err)
}
if params.LocationChanged {
if _, err := r.db.Exec(ctx, `DELETE FROM event_location WHERE event_id = $1`, params.EventID); err != nil {
return nil, fmt.Errorf("delete event location: %w", err)
}
if err := insertEventLocation(ctx, r.db, params.EventID, params.Address, params.LocationType, params.Point, params.RoutePoints); err != nil {
return nil, err
}
}
if params.ConstraintsChanged {
if _, err := r.db.Exec(ctx, `DELETE FROM event_constraint WHERE event_id = $1`, params.EventID); err != nil {
return nil, fmt.Errorf("delete event constraints: %w", err)
}
if err := insertEventConstraints(ctx, r.db, params.EventID, params.Constraints); err != nil {
return nil, err
}
}
return updated, nil
}
// CreateEventHistorySnapshot stores an immutable snapshot of the current event
// detail state for one event version.
func (r *EventRepository) CreateEventHistorySnapshot(
ctx context.Context,
eventID uuid.UUID,
versionNo int,
changedFields []string,
createdByUserID uuid.UUID,
) error {
tag, err := r.db.Exec(ctx, `
WITH event_snapshot AS (
SELECT
e.id,
e.host_id,
e.title,
e.description,
e.image_url,
e.category_id,
e.start_time,
e.end_time,
e.privacy_level,
e.status,
e.capacity,
e.minimum_age,
e.preferred_gender,
e.location_type,
e.child_friendly,
e.family_oriented,
e.updated_at,
CASE
WHEN ec.id IS NULL THEN NULL
ELSE jsonb_build_object('id', ec.id, 'name', ec.name)
END AS category,
CASE
WHEN e.location_type = 'ROUTE' THEN jsonb_build_object(
'type', e.location_type,
'address', el.address,
'route_points', (
SELECT COALESCE(
jsonb_agg(jsonb_build_object('lat', ST_Y(dp.geom), 'lon', ST_X(dp.geom)) ORDER BY dp.path),
'[]'::jsonb
)
FROM ST_DumpPoints(el.geom::geometry) AS dp
)
)
ELSE jsonb_build_object(
'type', e.location_type,
'address', el.address,
'point', jsonb_build_object('lat', ST_Y(el.geom::geometry), 'lon', ST_X(el.geom::geometry)),
'route_points', '[]'::jsonb
)
END AS location,
(
SELECT COALESCE(jsonb_agg(et.name ORDER BY et.name), '[]'::jsonb)
FROM event_tag et
WHERE et.event_id = e.id
) AS tags,
(
SELECT COALESCE(
jsonb_agg(jsonb_build_object('type', ect.constraint_type, 'info', ect.constraint_info) ORDER BY ect.created_at, ect.id),
'[]'::jsonb
)
FROM event_constraint ect
WHERE ect.event_id = e.id
) AS constraints
FROM event e
JOIN event_location el ON el.event_id = e.id
LEFT JOIN event_category ec ON ec.id = e.category_id
WHERE e.id = $1
)
INSERT INTO event_history (
event_id, host_id, title, category_id, description, start_time,
end_time, privacy_level, status, capacity, minimum_age,
preferred_gender, location_type, version_no, snapshot,
changed_fields, created_by_user_id, event_updated_at, created_at, updated_at
)
SELECT
id,
host_id,
title,
category_id,
description,
start_time,
end_time,
privacy_level,
status,
capacity,
minimum_age,
preferred_gender,
location_type,
$2,
jsonb_build_object(
'title', title,
'description', description,
'image_url', image_url,
'privacy_level', privacy_level,
'status', status,
'start_time', start_time,
'end_time', end_time,
'capacity', capacity,
'minimum_age', minimum_age,
'preferred_gender', preferred_gender,
'child_friendly', child_friendly,
'family_oriented', family_oriented,
'category', category,
'location', location,
'tags', tags,
'constraints', constraints
),
COALESCE($3::text[], '{}'::text[]),
NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid),
updated_at,
NOW(),
NOW()
FROM event_snapshot
`, eventID, versionNo, changedFields, createdByUserID)
if err != nil {
return fmt.Errorf("create event history snapshot: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
// GetEventHistorySnapshot loads one stored event version.
func (r *EventRepository) GetEventHistorySnapshot(
ctx context.Context,
eventID uuid.UUID,
versionNo int,
) (*eventapp.EventHistorySnapshotRecord, error) {
record, err := scanEventHistorySnapshot(r.db.QueryRow(ctx, `
SELECT event_id, version_no, changed_fields, snapshot, event_updated_at
FROM event_history
WHERE event_id = $1
AND version_no = $2
`, eventID, versionNo))
if err != nil {
return nil, err
}
return record, nil
}
// GetLatestEventHistorySnapshot loads the highest stored event version.
func (r *EventRepository) GetLatestEventHistorySnapshot(
ctx context.Context,
eventID uuid.UUID,
) (*eventapp.EventHistorySnapshotRecord, error) {
record, err := scanEventHistorySnapshot(r.db.QueryRow(ctx, `
SELECT event_id, version_no, changed_fields, snapshot, event_updated_at
FROM event_history
WHERE event_id = $1
ORDER BY version_no DESC
LIMIT 1
`, eventID))
if err != nil {
return nil, err
}
return record, nil
}
func scanEventHistorySnapshot(row pgx.Row) (*eventapp.EventHistorySnapshotRecord, error) {
var (
record eventapp.EventHistorySnapshotRecord
snapshotRaw []byte
)
if err := row.Scan(
&record.EventID,
&record.VersionNo,
&record.ChangedFields,
&snapshotRaw,
&record.EventUpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("scan event history snapshot: %w", err)
}
if err := json.Unmarshal(snapshotRaw, &record.Snapshot); err != nil {
return nil, fmt.Errorf("decode event history snapshot: %w", err)
}
if record.Snapshot.Tags == nil {
record.Snapshot.Tags = []string{}
}
if record.Snapshot.Constraints == nil {
record.Snapshot.Constraints = []eventapp.EventDetailConstraintRecord{}
}
if record.Snapshot.Location.RoutePoints == nil {
record.Snapshot.Location.RoutePoints = []eventapp.EventDetailPoint{}
}
return &record, 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
versionNo int
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,
child_friendly, family_oriented
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12, $13,
$14, $15
)
RETURNING id, version_no, 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),
params.ChildFriendly, params.FamilyOriented,
).Scan(&id, &versionNo, &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,
VersionNo: versionNo,
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
}
func updateEventRow(ctx context.Context, db execer, params eventapp.UpdateEventParams) (*domain.Event, error) {
var (
event domain.Event
description pgtype.Text
imageURL pgtype.Text
categoryID pgtype.Int4
endTime pgtype.Timestamptz
capacity pgtype.Int4
privacyLevel string
status string
locationType string
)
err := db.QueryRow(ctx, `
UPDATE event
SET title = $2,
description = $3,
category_id = $4,
start_time = $5,
end_time = $6,
capacity = $7,
location_type = $8,
version_no = version_no + 1,
updated_at = NOW()
WHERE id = $1
AND status = $9
RETURNING id, host_id, version_no, title, description, image_url, category_id,
start_time, end_time, privacy_level, status, capacity,
approved_participant_count, pending_participant_count,
location_type, created_at, updated_at
`,
params.EventID,
params.Title,
params.Description,
params.CategoryID,
params.StartTime,
params.EndTime,
params.Capacity,
string(params.LocationType),
string(domain.EventStatusActive),
).Scan(
&event.ID,
&event.HostID,
&event.VersionNo,
&event.Title,
&description,
&imageURL,
&categoryID,
&event.StartTime,
&endTime,
&privacyLevel,
&status,
&capacity,
&event.ApprovedParticipantCount,
&event.PendingParticipantCount,
&locationType,
&event.CreatedAt,
&event.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("update event: %w", err)
}
if description.Valid {
event.Description = &description.String
}
if imageURL.Valid {
event.ImageURL = &imageURL.String
}
if categoryID.Valid {
event.CategoryID = new(int)
*event.CategoryID = int(categoryID.Int32)
}
if endTime.Valid {
event.EndTime = &endTime.Time
}
if capacity.Valid {
event.Capacity = new(int)
*event.Capacity = int(capacity.Int32)
}
event.PrivacyLevel = domain.EventPrivacyLevel(privacyLevel)
event.Status = domain.EventStatus(status)
locType := domain.EventLocationType(locationType)
event.LocationType = &locType
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, last_confirmed_event_version, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6)
`, event.ID, event.HostID, domain.ParticipationStatusApproved, event.VersionNo, 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
}
func mapEventUpdateError(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 (r *EventRepository) loadEventEditLocation(
ctx context.Context,
eventID uuid.UUID,
locationType domain.EventLocationType,
) (eventapp.EventDetailLocationRecord, error) {
location := eventapp.EventDetailLocationRecord{
Type: locationType,
RoutePoints: []domain.GeoPoint{},
}
var address pgtype.Text
switch locationType {
case domain.LocationPoint:
var point domain.GeoPoint
err := r.db.QueryRow(ctx, `
SELECT address, ST_Y(geom::geometry), ST_X(geom::geometry)
FROM event_location
WHERE event_id = $1
`, eventID).Scan(&address, &point.Lat, &point.Lon)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return location, domain.ErrNotFound
}
return location, fmt.Errorf("load event edit point: %w", err)
}
location.Point = &point
case domain.LocationRoute:
err := r.db.QueryRow(ctx, `
SELECT address
FROM event_location
WHERE event_id = $1
`, eventID).Scan(&address)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return location, domain.ErrNotFound
}
return location, fmt.Errorf("load event edit route: %w", err)
}
rows, err := r.db.Query(ctx, `
SELECT ST_Y(dp.geom), ST_X(dp.geom)
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 edit 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 edit route point: %w", err)
}
location.RoutePoints = append(location.RoutePoints, point)
}
if err := rows.Err(); err != nil {
return location, fmt.Errorf("iterate event edit route points: %w", err)
}
default:
return location, nil
}
if address.Valid {
location.Address = &address.String
}
return location, nil
}
func (r *EventRepository) loadEventEditConstraints(ctx context.Context, eventID uuid.UUID) ([]eventapp.EventDetailConstraintRecord, error) {
rows, err := r.db.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 edit constraints: %w", err)
}
defer rows.Close()
constraints := []eventapp.EventDetailConstraintRecord{}
for rows.Next() {
var (
constraintType string
constraintInfo pgtype.Text
)
if err := rows.Scan(&constraintType, &constraintInfo); err != nil {
return nil, fmt.Errorf("scan event edit 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 edit constraints: %w", err)
}
return constraints, 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),
))
}
// Discover-side age filter applies to the event's configured minimum
// participant age itself. This lets clients intentionally narrow to
// adult-only / stricter events without changing the viewer-eligibility
// policy that still applies below.
if params.MinimumAge != nil {
filters = append(filters, fmt.Sprintf("e.minimum_age IS NOT NULL AND e.minimum_age >= %s", addArg(*params.MinimumAge)))
}
if params.OnlyFavorited {
filters = append(filters, "fav.event_id IS NOT NULL")
}
if params.OnlyChildFriendly {
filters = append(filters, "e.child_friendly = true")
}
if params.OnlyFamilyOriented {
filters = append(filters, "e.family_oriented = true")
}
// Eligibility filter (issue #501).
//
// Anonymous viewer: strict — only events with NO age/gender restriction
// are returned. We don't know who the caller is, so we cannot claim
// they satisfy any restriction.
//
// Authenticated viewer: generous — events whose restriction the viewer
// satisfies are returned, AND events whose restriction is on a
// dimension the viewer hasn't filled in (e.g. event requires age 18+
// but viewer's birth_date is NULL) are still returned. Joining will
// reject with profile_incomplete (existing #467 behavior); we don't
// silently hide the event from incomplete profiles. The age check uses
// EXTRACT(YEAR FROM AGE($now, viewer.birth_date)) which matches the
// year-floor + month/day adjustment of domain.HasMinimumAge so SQL and
// the join-flow Go path can never drift apart.
viewerJoinClause := ""
if params.AnonymousViewer {
filters = append(filters,
"e.minimum_age IS NULL",
"e.preferred_gender IS NULL",
)
} else {
viewerJoinClause = fmt.Sprintf("LEFT JOIN app_user viewer ON viewer.id = %s", userPlaceholder)
nowPlaceholder := addArg(params.Now)
filters = append(filters, fmt.Sprintf(
"(e.minimum_age IS NULL OR viewer.birth_date IS NULL OR EXTRACT(YEAR FROM AGE(%s, viewer.birth_date)) >= e.minimum_age)",
nowPlaceholder,
))
filters = append(filters,
"(e.preferred_gender IS NULL OR viewer.gender IS NULL OR e.preferred_gender = viewer.gender)",
)
}
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,
CASE WHEN e.location_type = 'POINT' THEN ST_Y(el.geom::geometry) ELSE ST_Y(ST_StartPoint(el.geom::geometry)) END AS location_lat,
CASE WHEN e.location_type = 'POINT' THEN ST_X(el.geom::geometry) ELSE ST_X(ST_StartPoint(el.geom::geometry)) END AS location_lon,
e.privacy_level,
e.approved_participant_count,
e.favorite_count,
(fav.event_id IS NOT NULL) AS is_favorited,
e.child_friendly,
e.family_oriented,
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
%s
WHERE %s
)
SELECT
id,
title,
category_name,
image_url,
start_time,
status,
location_address,
location_lat,
location_lon,
privacy_level,
approved_participant_count,
favorite_count,
is_favorited,
child_friendly,
family_oriented,
host_final_score,
host_rating_count,
distance_meters,
relevance_score
FROM base
%s
ORDER BY %s
LIMIT %s
`, distanceExpr, relevanceExpr, userPlaceholder, viewerJoinClause, 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
locationLat pgtype.Float8
locationLon pgtype.Float8
privacyLevel string
approvedParticipantCount int
favoriteCount int
isFavorited bool
childFriendly bool
familyOriented bool
hostFinalScore pgtype.Float8
hostRatingCount int
distanceMeters float64
relevanceScore pgtype.Float8
)
if err := rows.Scan(
&id,
&title,
&categoryName,
&imageURL,
&startTime,
&eventStatus,
&locationAddress,
&locationLat,
&locationLon,
&privacyLevel,
&approvedParticipantCount,
&favoriteCount,
&isFavorited,
&childFriendly,
&familyOriented,
&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,
ChildFriendly: childFriendly,
FamilyOriented: familyOriented,
HostScore: eventapp.EventHostScoreSummaryRecord{
HostedEventRatingCount: hostRatingCount,
},
DistanceMeters: distanceMeters,
}
if imageURL.Valid {
record.ImageURL = &imageURL.String
}
if locationAddress.Valid {
record.LocationAddress = &locationAddress.String
}
if locationLat.Valid {
record.LocationLat = &locationLat.Float64
}
if locationLon.Valid {
record.LocationLon = &locationLon.Float64
}
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
versionNo int
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
childFriendly bool
familyOriented bool
isHost bool
isFavorited bool
participationStatus pgtype.Text
joinRequestStatus pgtype.Text
invitationStatus pgtype.Text
lastConfirmedVersion pgtype.Int4
)
err := r.db.QueryRow(ctx, `
SELECT
e.id,
e.version_no,
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.child_friendly,
e.family_oriented,
(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 NULL ELSE p.status END AS participation_status,
CASE WHEN e.host_id = $2 THEN NULL ELSE jr.status END AS join_request_status,
CASE WHEN e.host_id = $2 THEN NULL ELSE inv.status END AS invitation_status,
CASE WHEN e.host_id = $2 THEN NULL ELSE p.last_confirmed_event_version END AS last_confirmed_event_version
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
LEFT JOIN participation p ON p.event_id = e.id AND p.user_id = $2
LEFT JOIN join_request jr ON jr.event_id = e.id AND jr.user_id = $2
LEFT JOIN invitation inv ON inv.event_id = e.id AND inv.invited_user_id = $2
WHERE e.id = $1
AND (
e.privacy_level IN ($3, $4)
OR e.host_id = $2
OR p.status IN ($5, $6, $7, $8)
OR inv.status IN ($9, $10)
)
`,
eventID,
userID,
string(domain.PrivacyPublic),
string(domain.PrivacyProtected),
domain.ParticipationStatusApproved,
domain.ParticipationStatusLeaved,
domain.ParticipationStatusCanceled,
domain.ParticipationStatusPending,
string(domain.InvitationStatusAccepted),
string(domain.InvitationStatusPending),
).Scan(
&id,
&versionNo,
&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,
&childFriendly,
&familyOriented,
&isHost,
&isFavorited,
&participationStatus,
&joinRequestStatus,
&invitationStatus,
&lastConfirmedVersion,
)
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,
VersionNo: versionNo,
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,
LatestEventVersion: versionNo,
},
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
}
if participationStatus.Valid {
status := domain.ParticipationStatus(participationStatus.String)
record.ViewerContext.ParticipationStatus = &status
}
if joinRequestStatus.Valid {
status := domain.JoinRequestStatus(joinRequestStatus.String)
record.ViewerContext.JoinRequestStatus = &status
}
if invitationStatus.Valid {
status := domain.InvitationStatus(invitationStatus.String)
record.ViewerContext.InvitationStatus = &status
}
if lastConfirmedVersion.Valid {
version := int(lastConfirmedVersion.Int32)
record.ViewerContext.LastConfirmedEventVersion = &version
}
record.ChildFriendly = childFriendly
record.FamilyOriented = familyOriented
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_comment
WHERE event_id = $1
AND user_id = $2
AND comment_type = $3
`, eventID, participantUserID, string(domain.CommentTypeReview)).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) {
status := params.Status
if status == "" {
status = domain.ParticipationStatusApproved
}
args := []any{eventID, status}
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.image_url,
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
imageURL 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,
&imageURL,
&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 imageURL.Valid {
request.ImageURL = &imageURL.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
versionNo int
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
pendingCount 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, version_no, title, description, image_url, category_id,
start_time, end_time, privacy_level, status, capacity,
approved_participant_count, pending_participant_count, minimum_age, preferred_gender,
location_type, created_at, updated_at
FROM event
WHERE id = $1
`, eventID).Scan(
&id, &hostID, &versionNo, &title, &description, &imageURL, &categoryID,
&startTime, &endTime, &privacyLevel, &status, &capacity,
&approvedCount, &pendingCount, &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,
VersionNo: versionNo,
Title: title,
PrivacyLevel: domain.EventPrivacyLevel(privacyLevel),
Status: domain.EventStatus(status),
StartTime: startTime,
ApprovedParticipantCount: approvedCount,
PendingParticipantCount: pendingCount,
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
}
// GetRequesterForJoin loads the minimal user fields required to enforce
// participation eligibility checks (age, gender).
func (r *EventRepository) GetRequesterForJoin(ctx context.Context, userID uuid.UUID) (*domain.User, error) {
const query = `SELECT id, gender, birth_date FROM app_user WHERE id = $1`
var (
id uuid.UUID
gender pgtype.Text
birthDate pgtype.Date
)
err := r.pool.QueryRow(ctx, query, userID).Scan(&id, &gender, &birthDate)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get requester for join: %w", err)
}
user := &domain.User{ID: id}
if gender.Valid {
g := gender.String
user.Gender = &g
}
if birthDate.Valid {
t := birthDate.Time
user.BirthDate = &t
}
return user, 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)
// GetEventJoinRequestImageState loads the event state needed to authorize
// join-request image uploads.
func (r *EventRepository) GetEventJoinRequestImageState(ctx context.Context, eventID uuid.UUID) (*imageuploadapp.EventJoinRequestImageState, error) {
var state imageuploadapp.EventJoinRequestImageState
err := r.db.QueryRow(ctx, `
SELECT
e.id,
e.host_id,
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.privacy_level
FROM event e
WHERE e.id = $1
`, eventID).Scan(
&state.EventID,
&state.HostID,
&state.Status,
&state.PrivacyLevel,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event join request image state: %w", err)
}
return &state, nil
}
// GetEventReviewImageState loads the event and caller relation needed to
// authorize review image uploads.
func (r *EventRepository) GetEventReviewImageState(ctx context.Context, eventID, userID uuid.UUID) (*imageuploadapp.EventReviewImageState, error) {
var (
state imageuploadapp.EventReviewImageState
status string
privacyLevel string
)
err := r.db.QueryRow(ctx, `
SELECT
e.id,
e.host_id,
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.privacy_level,
EXISTS (
SELECT 1
FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $2
AND (
p.status = $3
OR (p.status = $4 AND p.updated_at >= e.start_time)
)
) AS is_qualifying_participant
FROM event e
WHERE e.id = $1
`, eventID, userID, domain.ParticipationStatusApproved, domain.ParticipationStatusLeaved).Scan(
&state.EventID,
&state.HostID,
&status,
&privacyLevel,
&state.IsQualifyingParticipant,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event review image state: %w", err)
}
state.Status = status
state.PrivacyLevel = privacyLevel
return &state, nil
}
// GetEventReportImageState loads event state needed to authorize report image uploads.
func (r *EventRepository) GetEventReportImageState(ctx context.Context, eventID uuid.UUID) (*imageuploadapp.EventReportImageState, error) {
var (
state imageuploadapp.EventReportImageState
status string
)
err := r.db.QueryRow(ctx, `
SELECT
e.id,
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
FROM event e
WHERE e.id = $1
`, eventID).Scan(&state.EventID, &status)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event report image state: %w", err)
}
state.Status = status
return &state, 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.db.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) ([]eventapp.EventStatusTransitionRecord, error) {
rows, err := r.db.Query(ctx, `
WITH transitioned AS (
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())
)
RETURNING id, host_id, status
),
expired_tickets AS (
UPDATE ticket t
SET status = 'EXPIRED',
updated_at = NOW()
FROM participation p
JOIN transitioned e ON e.id = p.event_id
WHERE t.participation_id = p.id
AND e.status = 'COMPLETED'
AND t.status IN ('ACTIVE', 'PENDING')
RETURNING 1
)
SELECT id, host_id, status FROM transitioned
`)
if err != nil {
return nil, fmt.Errorf("transition event statuses: %w", err)
}
defer rows.Close()
records := []eventapp.EventStatusTransitionRecord{}
for rows.Next() {
var (
eventID uuid.UUID
hostID uuid.UUID
status string
)
if err := rows.Scan(&eventID, &hostID, &status); err != nil {
return nil, fmt.Errorf("scan transitioned event status: %w", err)
}
records = append(records, eventapp.EventStatusTransitionRecord{
EventID: eventID,
HostID: hostID,
Status: domain.EventStatus(status),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate transitioned event statuses: %w", err)
}
return records, nil
}
// 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"
eventreportapp "github.com/bounswe/bounswe2026group11/backend/internal/application/eventreport"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// EventReportRepository is the Postgres-backed implementation of eventreport.Repository.
type EventReportRepository struct {
pool *pgxpool.Pool
db execer
}
// NewEventReportRepository returns a repository that executes queries against the given pool.
func NewEventReportRepository(pool *pgxpool.Pool) *EventReportRepository {
return &EventReportRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
var _ eventreportapp.Repository = (*EventReportRepository)(nil)
// GetEventReportContext loads event state needed before creating a report.
func (r *EventReportRepository) GetEventReportContext(ctx context.Context, eventID uuid.UUID) (*eventreportapp.EventReportContext, error) {
var (
record eventreportapp.EventReportContext
status string
)
err := r.db.QueryRow(ctx, `
SELECT
e.id,
e.host_id,
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
FROM event e
WHERE e.id = $1
`, eventID).Scan(&record.EventID, &record.HostID, &status)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get event report context: %w", err)
}
record.Status = domain.EventStatus(status)
return &record, nil
}
// CreateEventReport inserts a user-submitted event report.
func (r *EventReportRepository) CreateEventReport(ctx context.Context, params eventreportapp.CreateEventReportParams) (*eventreportapp.EventReportRecord, error) {
var (
record eventreportapp.EventReportRecord
category string
status string
)
err := r.db.QueryRow(ctx, `
INSERT INTO event_report (
event_id,
reporter_user_id,
report_category,
message,
image_url,
status
) VALUES (
$1, $2, $3, $4, $5, $6
)
RETURNING id, event_id, reporter_user_id, report_category, message, image_url, status, created_at, updated_at
`,
params.EventID,
params.ReporterUserID,
string(params.Category),
params.Message,
params.ImageURL,
string(domain.EventReportStatusPending),
).Scan(
&record.ID,
&record.EventID,
&record.ReporterUserID,
&category,
&record.Message,
&record.ImageURL,
&status,
&record.CreatedAt,
&record.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("create event report: %w", err)
}
record.Category = domain.EventReportCategory(category)
record.Status = domain.EventReportStatus(status)
return &record, nil
}
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
role string
)
if err := row.Scan(
&user.ID,
&user.Username,
&user.Email,
&phoneNumber,
&gender,
&birthDate,
&passwordHash,
&emailVerifiedAt,
&lastLogin,
&status,
&role,
&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 = domain.UserStatus(textValue(status))
user.Role = domain.UserRole(role)
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"
"regexp"
"time"
invitationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/invitation"
"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"
)
type InvitationRepository struct {
pool *pgxpool.Pool
db execer
}
var _ invitationapp.Repository = (*InvitationRepository)(nil)
var invitationUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
func NewInvitationRepository(pool *pgxpool.Pool) *InvitationRepository {
return &InvitationRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
func (r *InvitationRepository) CreateInvitations(
ctx context.Context,
params invitationapp.CreateInvitationsParams,
) (*invitationapp.CreateInvitationsRecord, error) {
event, err := r.loadInvitationEventState(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.HostID {
return nil, domain.ForbiddenError(domain.ErrorCodeEventHostManagementNotAllowed, "Only the event host can manage invitations.")
}
if event.PrivacyLevel != domain.PrivacyPrivate {
return nil, domain.ConflictError(domain.ErrorCodeInvitationNotAllowed, "Only PRIVATE events support host invitations.")
}
if event.Status == domain.EventStatusCanceled || event.Status == domain.EventStatusCompleted {
return nil, domain.ConflictError(domain.ErrorCodeEventNotJoinable, "This event is no longer accepting participants.")
}
result := &invitationapp.CreateInvitationsRecord{
SuccessfulInvitations: []invitationapp.CreatedInvitationRecord{},
InvalidUsernames: []string{},
Failed: []invitationapp.InvitationFailureRecord{},
}
validForLookup, invalidByShape := partitionValidUsernames(params.Usernames)
result.InvalidUsernames = append(result.InvalidUsernames, invalidByShape...)
orderedUsernames, duplicates := uniqueUsernames(validForLookup)
for _, username := range duplicates {
result.Failed = append(result.Failed, invitationapp.InvitationFailureRecord{
Username: username,
Code: invitationapp.FailureDuplicateUsername,
})
}
users, err := r.loadUsersByUsername(ctx, orderedUsernames)
if err != nil {
return nil, err
}
userIDs := make([]uuid.UUID, 0, len(users))
for _, username := range orderedUsernames {
user, ok := users[username]
if !ok {
result.InvalidUsernames = append(result.InvalidUsernames, username)
continue
}
userIDs = append(userIDs, user.ID)
}
existingInvitations, err := r.loadInvitationsByEventAndUsers(ctx, params.EventID, userIDs)
if err != nil {
return nil, err
}
participations, err := r.loadParticipationsByEventAndUsers(ctx, params.EventID, userIDs)
if err != nil {
return nil, err
}
for _, username := range orderedUsernames {
user, ok := users[username]
if !ok {
continue
}
failureCode := invitationFailureForUser(event, user.ID, existingInvitations[user.ID], participations[user.ID], params.Now)
if failureCode != "" {
result.Failed = append(result.Failed, invitationapp.InvitationFailureRecord{Username: username, Code: failureCode})
continue
}
var invitation *domain.Invitation
if existing := existingInvitations[user.ID]; existing != nil {
invitation, err = r.reactivateInvitation(ctx, existing.ID, params.Message)
} else {
invitation, err = r.insertInvitation(ctx, params.EventID, params.HostID, user.ID, params.Message)
}
if err != nil {
return nil, err
}
result.SuccessfulInvitations = append(result.SuccessfulInvitations, invitationapp.CreatedInvitationRecord{
Invitation: invitation,
Username: username,
})
}
return result, nil
}
func (r *InvitationRepository) ListReceivedPendingInvitations(
ctx context.Context,
userID uuid.UUID,
) ([]invitationapp.ReceivedInvitationRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT
inv.id,
inv.status,
inv.message,
inv.expires_at,
inv.created_at,
inv.updated_at,
e.id,
e.title,
e.image_url,
e.start_time,
e.end_time,
e.status,
e.privacy_level,
e.approved_participant_count,
host.id,
host.username,
hp.display_name,
hp.avatar_url
FROM invitation inv
JOIN event e ON e.id = inv.event_id
JOIN app_user host ON host.id = inv.host_id
LEFT JOIN profile hp ON hp.user_id = host.id
WHERE inv.invited_user_id = $1
AND inv.status = $2
AND e.privacy_level = $3
AND e.status IN ($4, $5)
ORDER BY inv.created_at ASC, inv.id ASC
`, userID, domain.InvitationStatusPending, domain.PrivacyPrivate, domain.EventStatusActive, domain.EventStatusInProgress)
if err != nil {
return nil, fmt.Errorf("list received invitations: %w", err)
}
defer rows.Close()
records := make([]invitationapp.ReceivedInvitationRecord, 0)
for rows.Next() {
record, err := scanReceivedInvitation(rows)
if err != nil {
return nil, err
}
records = append(records, *record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate received invitations: %w", err)
}
return records, nil
}
// ListReceivedPastInvitations returns ACCEPTED+DECLINED+EXPIRED invitations for the
// given user against PRIVATE events. The query intentionally omits an
// event-status filter (the past bucket should still surface invitations
// for events that have since ended). Pagination is keyset on
// (updated_at, id) DESC; (FetchLimit) is set to (limit + 1) so callers can
// detect whether another page exists without a count query.
func (r *InvitationRepository) ListReceivedPastInvitations(
ctx context.Context,
userID uuid.UUID,
params invitationapp.ListPastInvitationsParams,
) ([]invitationapp.ReceivedInvitationRecord, error) {
const baseSelect = `
SELECT
inv.id,
inv.status,
inv.message,
inv.expires_at,
inv.created_at,
inv.updated_at,
e.id,
e.title,
e.image_url,
e.start_time,
e.end_time,
e.status,
e.privacy_level,
e.approved_participant_count,
host.id,
host.username,
hp.display_name,
hp.avatar_url
FROM invitation inv
JOIN event e ON e.id = inv.event_id
JOIN app_user host ON host.id = inv.host_id
LEFT JOIN profile hp ON hp.user_id = host.id
WHERE inv.invited_user_id = $1
AND inv.status = ANY($2::text[])
AND e.privacy_level = $3
`
args := []any{
userID,
[]string{
string(domain.InvitationStatusAccepted),
string(domain.InvitationStatusDeclined),
string(domain.InvitationStatusExpired),
},
domain.PrivacyPrivate,
}
query := baseSelect
if params.Cursor != nil {
// Strict-less-than tuple comparison preserves DESC ordering and
// ensures the cursor row itself is excluded from the next page.
query += " AND (inv.updated_at, inv.id) < ($4, $5)"
args = append(args, params.Cursor.UpdatedAt, params.Cursor.InvitationID)
}
query += " ORDER BY inv.updated_at DESC, inv.id DESC"
query += fmt.Sprintf(" LIMIT $%d", len(args)+1)
args = append(args, params.FetchLimit)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list past received invitations: %w", err)
}
defer rows.Close()
records := make([]invitationapp.ReceivedInvitationRecord, 0)
for rows.Next() {
record, err := scanReceivedInvitation(rows)
if err != nil {
return nil, err
}
records = append(records, *record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate past received invitations: %w", err)
}
return records, nil
}
// GetReceivedInvitation returns a single invitation owned by the recipient
// regardless of status, used by the modal-fetch flow that opens from a
// notification. Filters: invitation id, invited_user_id, PRIVATE event.
// Returns domain.ErrNotFound when no row matches all three filters so the
// API can respond with a 404 that does not leak the row's existence.
func (r *InvitationRepository) GetReceivedInvitation(
ctx context.Context,
userID, invitationID uuid.UUID,
) (*invitationapp.ReceivedInvitationRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT
inv.id,
inv.status,
inv.message,
inv.expires_at,
inv.created_at,
inv.updated_at,
e.id,
e.title,
e.image_url,
e.start_time,
e.end_time,
e.status,
e.privacy_level,
e.approved_participant_count,
host.id,
host.username,
hp.display_name,
hp.avatar_url
FROM invitation inv
JOIN event e ON e.id = inv.event_id
JOIN app_user host ON host.id = inv.host_id
LEFT JOIN profile hp ON hp.user_id = host.id
WHERE inv.id = $1
AND inv.invited_user_id = $2
AND e.privacy_level = $3
LIMIT 1
`, invitationID, userID, domain.PrivacyPrivate)
if err != nil {
return nil, fmt.Errorf("get received invitation: %w", err)
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("get received invitation: %w", err)
}
return nil, domain.ErrNotFound
}
record, err := scanReceivedInvitation(rows)
if err != nil {
return nil, fmt.Errorf("scan received invitation: %w", err)
}
return record, nil
}
func (r *InvitationRepository) AcceptInvitation(
ctx context.Context,
userID, invitationID uuid.UUID,
) (*invitationapp.AcceptInvitationRecord, error) {
invitation, err := r.loadInvitationByIDForUser(ctx, invitationID, userID, true)
if err != nil {
return nil, err
}
if invitation == nil {
return nil, domain.NotFoundError(domain.ErrorCodeInvitationNotFound, "The requested invitation does not exist.")
}
if invitation.Status != domain.InvitationStatusPending {
return nil, domain.ConflictError(domain.ErrorCodeInvitationStateInvalid, "Only PENDING invitations can be accepted.")
}
event, err := r.loadInvitationEventState(ctx, invitation.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.Status == domain.EventStatusCanceled || event.Status == domain.EventStatusCompleted {
return nil, domain.ConflictError(domain.ErrorCodeEventNotJoinable, "This event is no longer accepting participants.")
}
if event.Capacity != nil && event.ApprovedParticipantCount+event.PendingParticipantCount >= *event.Capacity {
return nil, domain.ConflictError(domain.ErrorCodeCapacityExceeded, "This event has reached its maximum capacity.")
}
user, err := r.loadUserForEligibility(ctx, userID)
if err != nil {
return nil, err
}
if appErr := domain.CheckParticipationEligibility(user, event, time.Now().UTC()); appErr != nil {
return nil, appErr
}
participation, err := r.insertOrReactivateApprovedParticipation(ctx, event, userID)
if err != nil {
return nil, err
}
updatedInvitation, err := r.updateInvitationStatus(ctx, invitationID, domain.InvitationStatusAccepted)
if err != nil {
return nil, err
}
return &invitationapp.AcceptInvitationRecord{
Invitation: updatedInvitation,
Participation: participation,
}, nil
}
func (r *InvitationRepository) DeclineInvitation(
ctx context.Context,
userID, invitationID uuid.UUID,
) (*domain.Invitation, error) {
invitation, err := r.loadInvitationByIDForUser(ctx, invitationID, userID, true)
if err != nil {
return nil, err
}
if invitation == nil {
return nil, domain.NotFoundError(domain.ErrorCodeInvitationNotFound, "The requested invitation does not exist.")
}
if invitation.Status != domain.InvitationStatusPending {
return nil, domain.ConflictError(domain.ErrorCodeInvitationStateInvalid, "Only PENDING invitations can be declined.")
}
return r.updateInvitationStatus(ctx, invitationID, domain.InvitationStatusDeclined)
}
func (r *InvitationRepository) GetInvitationNotificationContext(
ctx context.Context,
invitationID uuid.UUID,
) (*invitationapp.InvitationNotificationContext, error) {
var (
record invitationapp.InvitationNotificationContext
eventImageURL pgtype.Text
hostDisplayName pgtype.Text
invitedDisplayName pgtype.Text
)
err := r.db.QueryRow(ctx, `
SELECT
inv.id,
inv.event_id,
e.title,
e.image_url,
e.start_time,
inv.host_id,
host.username,
host_profile.display_name,
inv.invited_user_id,
invited.username,
invited_profile.display_name
FROM invitation inv
JOIN event e ON e.id = inv.event_id
JOIN app_user host ON host.id = inv.host_id
LEFT JOIN profile host_profile ON host_profile.user_id = host.id
JOIN app_user invited ON invited.id = inv.invited_user_id
LEFT JOIN profile invited_profile ON invited_profile.user_id = invited.id
WHERE inv.id = $1
`, invitationID).Scan(
&record.InvitationID,
&record.EventID,
&record.EventTitle,
&eventImageURL,
&record.EventStartTime,
&record.HostUserID,
&record.HostUsername,
&hostDisplayName,
&record.InvitedUserID,
&record.InvitedUsername,
&invitedDisplayName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.NotFoundError(domain.ErrorCodeInvitationNotFound, "The requested invitation does not exist.")
}
return nil, fmt.Errorf("load invitation notification context: %w", err)
}
record.EventImageURL = textPtr(eventImageURL)
record.HostDisplayName = textPtr(hostDisplayName)
record.InvitedDisplayName = textPtr(invitedDisplayName)
return &record, nil
}
func uniqueUsernames(usernames []string) ([]string, []string) {
seen := make(map[string]struct{}, len(usernames))
ordered := make([]string, 0, len(usernames))
duplicates := make([]string, 0)
for _, username := range usernames {
if _, ok := seen[username]; ok {
duplicates = append(duplicates, username)
continue
}
seen[username] = struct{}{}
ordered = append(ordered, username)
}
return ordered, duplicates
}
func partitionValidUsernames(usernames []string) ([]string, []string) {
valid := make([]string, 0, len(usernames))
invalid := make([]string, 0)
for _, username := range usernames {
if len(username) < 3 || len(username) > 32 || !invitationUsernamePattern.MatchString(username) {
invalid = append(invalid, username)
continue
}
valid = append(valid, username)
}
return valid, invalid
}
func invitationFailureForUser(
event *domain.Event,
userID uuid.UUID,
existing *domain.Invitation,
participation *domain.Participation,
now time.Time,
) string {
if userID == event.HostID {
return invitationapp.FailureHostUser
}
if event.Capacity != nil && event.ApprovedParticipantCount+event.PendingParticipantCount >= *event.Capacity {
return invitationapp.FailureCapacityExceeded
}
if participation != nil && !canReactivateLeavedParticipation(participation, event.StartTime) {
return invitationapp.FailureAlreadyParticipating
}
if existing == nil {
return ""
}
switch existing.Status {
case domain.InvitationStatusPending, domain.InvitationStatusAccepted:
return invitationapp.FailureAlreadyInvited
case domain.InvitationStatusDeclined:
if now.Before(existing.UpdatedAt.Add(domain.InvitationDeclineCooldown)) {
return invitationapp.FailureDeclineCooldown
}
return ""
case domain.InvitationStatusExpired:
return ""
case domain.InvitationStatusCanceled:
return ""
default:
return invitationapp.FailureAlreadyInvited
}
}
func (r *InvitationRepository) loadInvitationEventState(
ctx context.Context,
eventID uuid.UUID,
forUpdate bool,
) (*domain.Event, error) {
query := `
SELECT host_id, privacy_level, status, capacity, approved_participant_count, pending_participant_count,
start_time, minimum_age, preferred_gender, version_no
FROM event
WHERE id = $1
`
if forUpdate {
query += ` FOR UPDATE`
}
var (
hostID uuid.UUID
privacyLevel string
status string
capacity pgtype.Int4
approvedCount int
pendingCount int
startTime time.Time
minimumAge pgtype.Int4
preferredGender pgtype.Text
versionNo int
)
err := r.db.QueryRow(ctx, query, eventID).Scan(
&hostID,
&privacyLevel,
&status,
&capacity,
&approvedCount,
&pendingCount,
&startTime,
&minimumAge,
&preferredGender,
&versionNo,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("load invitation event state: %w", err)
}
event := &domain.Event{
ID: eventID,
HostID: hostID,
PrivacyLevel: domain.EventPrivacyLevel(privacyLevel),
Status: domain.EventStatus(status),
ApprovedParticipantCount: approvedCount,
PendingParticipantCount: pendingCount,
StartTime: startTime,
VersionNo: versionNo,
}
if capacity.Valid {
value := int(capacity.Int32)
event.Capacity = &value
}
if minimumAge.Valid {
value := int(minimumAge.Int32)
event.MinimumAge = &value
}
if preferredGender.Valid {
value := domain.EventParticipantGender(preferredGender.String)
event.PreferredGender = &value
}
return event, nil
}
type invitationUser struct {
ID uuid.UUID
Username string
}
func (r *InvitationRepository) loadUsersByUsername(
ctx context.Context,
usernames []string,
) (map[string]invitationUser, error) {
if len(usernames) == 0 {
return map[string]invitationUser{}, nil
}
rows, err := r.db.Query(ctx, `
SELECT id, username
FROM app_user
WHERE username = ANY($1)
`, usernames)
if err != nil {
return nil, fmt.Errorf("load invitation users: %w", err)
}
defer rows.Close()
users := make(map[string]invitationUser, len(usernames))
for rows.Next() {
var user invitationUser
if err := rows.Scan(&user.ID, &user.Username); err != nil {
return nil, fmt.Errorf("scan invitation user: %w", err)
}
users[user.Username] = user
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate invitation users: %w", err)
}
return users, nil
}
func (r *InvitationRepository) loadInvitationsByEventAndUsers(
ctx context.Context,
eventID uuid.UUID,
userIDs []uuid.UUID,
) (map[uuid.UUID]*domain.Invitation, error) {
if len(userIDs) == 0 {
return map[uuid.UUID]*domain.Invitation{}, nil
}
rows, err := r.db.Query(ctx, `
SELECT id, event_id, host_id, invited_user_id, status, message, expires_at, created_at, updated_at
FROM invitation
WHERE event_id = $1
AND invited_user_id = ANY($2)
FOR UPDATE
`, eventID, userIDs)
if err != nil {
return nil, fmt.Errorf("load invitations by users: %w", err)
}
defer rows.Close()
invitations := make(map[uuid.UUID]*domain.Invitation, len(userIDs))
for rows.Next() {
invitation, err := scanInvitation(rows)
if err != nil {
return nil, err
}
invitations[invitation.InvitedUserID] = invitation
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate invitations by users: %w", err)
}
return invitations, nil
}
func (r *InvitationRepository) loadParticipationsByEventAndUsers(
ctx context.Context,
eventID uuid.UUID,
userIDs []uuid.UUID,
) (map[uuid.UUID]*domain.Participation, error) {
if len(userIDs) == 0 {
return map[uuid.UUID]*domain.Participation{}, nil
}
rows, err := r.db.Query(ctx, `
SELECT id, user_id, status, created_at, updated_at
FROM participation
WHERE event_id = $1
AND user_id = ANY($2)
FOR UPDATE
`, eventID, userIDs)
if err != nil {
return nil, fmt.Errorf("load invitation participations: %w", err)
}
defer rows.Close()
participations := make(map[uuid.UUID]*domain.Participation, len(userIDs))
for rows.Next() {
var (
id uuid.UUID
userID uuid.UUID
status string
createdAt time.Time
updatedAt time.Time
)
if err := rows.Scan(&id, &userID, &status, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("scan invitation participation: %w", err)
}
parsedStatus, ok := domain.ParseParticipationStatus(status)
if !ok {
return nil, fmt.Errorf("unknown participation status %q", status)
}
participations[userID] = &domain.Participation{
ID: id,
EventID: eventID,
UserID: userID,
Status: parsedStatus,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate invitation participations: %w", err)
}
return participations, nil
}
func (r *InvitationRepository) insertInvitation(
ctx context.Context,
eventID, hostID, invitedUserID uuid.UUID,
message *string,
) (*domain.Invitation, error) {
invitation, err := scanInvitation(r.db.QueryRow(ctx, `
INSERT INTO invitation (event_id, host_id, invited_user_id, status, message)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, event_id, host_id, invited_user_id, status, message, expires_at, created_at, updated_at
`, eventID, hostID, invitedUserID, domain.InvitationStatusPending, message))
if err != nil {
return nil, fmt.Errorf("insert invitation: %w", err)
}
return invitation, nil
}
func (r *InvitationRepository) reactivateInvitation(
ctx context.Context,
invitationID uuid.UUID,
message *string,
) (*domain.Invitation, error) {
invitation, err := scanInvitation(r.db.QueryRow(ctx, `
UPDATE invitation
SET status = $2,
message = $3,
expires_at = NULL,
created_at = NOW(),
updated_at = NOW()
WHERE id = $1
RETURNING id, event_id, host_id, invited_user_id, status, message, expires_at, created_at, updated_at
`, invitationID, domain.InvitationStatusPending, message))
if err != nil {
return nil, fmt.Errorf("reactivate invitation: %w", err)
}
return invitation, nil
}
func (r *InvitationRepository) loadInvitationByIDForUser(
ctx context.Context,
invitationID, userID uuid.UUID,
forUpdate bool,
) (*domain.Invitation, error) {
query := `
SELECT id, event_id, host_id, invited_user_id, status, message, expires_at, created_at, updated_at
FROM invitation
WHERE id = $1
AND invited_user_id = $2
`
if forUpdate {
query += ` FOR UPDATE`
}
invitation, err := scanInvitation(r.db.QueryRow(ctx, query, invitationID, userID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return invitation, nil
}
func (r *InvitationRepository) loadInvitationByIDForHost(
ctx context.Context,
invitationID, hostID uuid.UUID,
forUpdate bool,
) (*domain.Invitation, error) {
query := `
SELECT id, event_id, host_id, invited_user_id, status, message, expires_at, created_at, updated_at
FROM invitation
WHERE id = $1
AND host_id = $2
`
if forUpdate {
query += ` FOR UPDATE`
}
invitation, err := scanInvitation(r.db.QueryRow(ctx, query, invitationID, hostID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return invitation, nil
}
func (r *InvitationRepository) RevokeInvitation(
ctx context.Context,
params invitationapp.RevokeInvitationParams,
) (*domain.Invitation, error) {
event, err := r.loadInvitationEventState(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.HostID {
return nil, domain.ForbiddenError(domain.ErrorCodeEventHostManagementNotAllowed, "Only the event host can manage invitations.")
}
invitation, err := r.loadInvitationByIDForHost(ctx, params.InvitationID, params.HostID, true)
if err != nil {
return nil, err
}
if invitation == nil {
return nil, domain.NotFoundError(domain.ErrorCodeInvitationNotFound, "The requested invitation does not exist.")
}
if invitation.Status != domain.InvitationStatusPending {
return nil, domain.ConflictError(domain.ErrorCodeInvitationStateInvalid, "Only PENDING invitations can be canceled.")
}
return r.updateInvitationStatus(ctx, params.InvitationID, domain.InvitationStatusCanceled)
}
func (r *InvitationRepository) updateInvitationStatus(
ctx context.Context,
invitationID uuid.UUID,
status domain.InvitationStatus,
) (*domain.Invitation, error) {
invitation, err := scanInvitation(r.db.QueryRow(ctx, `
UPDATE invitation
SET status = $2,
updated_at = NOW()
WHERE id = $1
RETURNING id, event_id, host_id, invited_user_id, status, message, expires_at, created_at, updated_at
`, invitationID, status))
if err != nil {
return nil, fmt.Errorf("update invitation status: %w", err)
}
return invitation, nil
}
func (r *InvitationRepository) loadUserForEligibility(ctx context.Context, userID uuid.UUID) (*domain.User, error) {
var (
userIDValue uuid.UUID
username string
email string
gender pgtype.Text
birthDate pgtype.Date
)
err := r.db.QueryRow(ctx, `
SELECT id, username, email, gender, birth_date
FROM app_user
WHERE id = $1
`, userID).Scan(&userIDValue, &username, &email, &gender, &birthDate)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.NotFoundError(domain.ErrorCodeUserNotFound, "The requested user does not exist.")
}
return nil, fmt.Errorf("load invitation user eligibility: %w", err)
}
user := &domain.User{
ID: userIDValue,
Username: username,
Email: email,
}
if gender.Valid {
user.Gender = &gender.String
}
if birthDate.Valid {
value := birthDate.Time
user.BirthDate = &value
}
return user, nil
}
func (r *InvitationRepository) 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,
reconfirmed_at = NULL,
last_confirmed_event_version = $6,
created_at = NOW(),
updated_at = NOW()
WHERE event_id = $1
AND user_id = $2
AND status = $4
AND updated_at < $5
RETURNING id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
),
inserted AS (
INSERT INTO participation (event_id, user_id, status, last_confirmed_event_version)
SELECT $1, $2, $3, $6
WHERE NOT EXISTS (SELECT 1 FROM reactivated)
ON CONFLICT ON CONSTRAINT uq_event_user DO NOTHING
RETURNING id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
)
SELECT id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
FROM reactivated
UNION ALL
SELECT id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
FROM inserted
LIMIT 1
`, event.ID, userID, domain.ParticipationStatusApproved, domain.ParticipationStatusLeaved, event.StartTime, event.VersionNo), event.ID, userID, "accept invitation 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,
"You are already participating in this event.",
"You cannot join this event after leaving once it has started.",
)
}
return nil, fmt.Errorf("accept invitation participation: no row returned and no existing participation found")
}
func scanInvitation(row pgx.Row) (*domain.Invitation, error) {
var (
id uuid.UUID
eventID uuid.UUID
hostID uuid.UUID
invitedUserID uuid.UUID
status string
message pgtype.Text
expiresAt pgtype.Timestamptz
createdAt time.Time
updatedAt time.Time
)
err := row.Scan(&id, &eventID, &hostID, &invitedUserID, &status, &message, &expiresAt, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
parsedStatus, ok := domain.ParseInvitationStatus(status)
if !ok {
return nil, fmt.Errorf("unknown invitation status %q", status)
}
invitation := &domain.Invitation{
ID: id,
EventID: eventID,
HostID: hostID,
InvitedUserID: invitedUserID,
Status: parsedStatus,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
if message.Valid {
invitation.Message = &message.String
}
if expiresAt.Valid {
invitation.ExpiresAt = &expiresAt.Time
}
return invitation, nil
}
func scanReceivedInvitation(rows pgx.Rows) (*invitationapp.ReceivedInvitationRecord, error) {
var (
record invitationapp.ReceivedInvitationRecord
status string
message pgtype.Text
expiresAt pgtype.Timestamptz
imageURL pgtype.Text
endTime pgtype.Timestamptz
eventStatus string
privacyLevel string
hostDisplayName pgtype.Text
hostAvatarURL pgtype.Text
)
if err := rows.Scan(
&record.InvitationID,
&status,
&message,
&expiresAt,
&record.CreatedAt,
&record.UpdatedAt,
&record.Event.ID,
&record.Event.Title,
&imageURL,
&record.Event.StartTime,
&endTime,
&eventStatus,
&privacyLevel,
&record.Event.ApprovedParticipantCount,
&record.Host.ID,
&record.Host.Username,
&hostDisplayName,
&hostAvatarURL,
); err != nil {
return nil, fmt.Errorf("scan received invitation: %w", err)
}
parsedStatus, ok := domain.ParseInvitationStatus(status)
if !ok {
return nil, fmt.Errorf("unknown invitation status %q", status)
}
record.Status = parsedStatus
record.Event.Status = domain.EventStatus(eventStatus)
record.Event.PrivacyLevel = domain.EventPrivacyLevel(privacyLevel)
if message.Valid {
record.Message = &message.String
}
if expiresAt.Valid {
record.ExpiresAt = &expiresAt.Time
}
if imageURL.Valid {
record.Event.ImageURL = &imageURL.String
}
if endTime.Valid {
record.Event.EndTime = &endTime.Time
}
if hostDisplayName.Valid {
record.Host.DisplayName = &hostDisplayName.String
}
if hostAvatarURL.Valid {
record.Host.AvatarURL = &hostAvatarURL.String
}
return &record, nil
}
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.PendingParticipantCount >= *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
}
// CancelJoinRequestByUser transitions the caller's own PENDING join request to CANCELED.
func (r *JoinRequestRepository) CancelJoinRequestByUser(
ctx context.Context,
params joinrequestapp.CancelJoinRequestByUserParams,
) (*domain.JoinRequest, error) {
request, err := r.loadJoinRequestByEventAndUser(ctx, params.EventID, params.UserID, true)
if err != nil {
return nil, err
}
if request == nil {
return nil, domain.NotFoundError(domain.ErrorCodeJoinRequestNotFound, "You do not have a join request for this event.")
}
if request.Status != domain.JoinRequestStatusPending {
return nil, domain.ConflictError(domain.ErrorCodeJoinRequestStateInvalid, "Only PENDING join requests can be canceled.")
}
return r.updateJoinRequestStatus(ctx, request.ID, domain.JoinRequestStatusCanceled, nil)
}
func (r *JoinRequestRepository) GetNotificationContext(
ctx context.Context,
joinRequestID uuid.UUID,
) (*joinrequestapp.NotificationContext, error) {
var (
record joinrequestapp.NotificationContext
eventImageURL pgtype.Text
hostDisplayName pgtype.Text
requesterDisplayName pgtype.Text
)
err := r.db.QueryRow(ctx, `
SELECT
jr.id,
jr.event_id,
e.title,
e.image_url,
e.start_time,
jr.host_user_id,
host.username,
host_profile.display_name,
jr.user_id,
requester.username,
requester_profile.display_name
FROM join_request jr
JOIN event e ON e.id = jr.event_id
JOIN app_user host ON host.id = jr.host_user_id
LEFT JOIN profile host_profile ON host_profile.user_id = host.id
JOIN app_user requester ON requester.id = jr.user_id
LEFT JOIN profile requester_profile ON requester_profile.user_id = requester.id
WHERE jr.id = $1
`, joinRequestID).Scan(
&record.JoinRequestID,
&record.EventID,
&record.EventTitle,
&eventImageURL,
&record.EventStartTime,
&record.HostUserID,
&record.HostUsername,
&hostDisplayName,
&record.RequesterUserID,
&record.RequesterUsername,
&requesterDisplayName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.NotFoundError(domain.ErrorCodeJoinRequestNotFound, "The requested join request does not exist.")
}
return nil, fmt.Errorf("load join request notification context: %w", err)
}
record.EventImageURL = textPtr(eventImageURL)
record.HostDisplayName = textPtr(hostDisplayName)
record.RequesterDisplayName = textPtr(requesterDisplayName)
return &record, 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, params.ImageURL)
}
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, params.ImageURL)
case domain.JoinRequestStatusCanceled:
return r.reactivateJoinRequest(ctx, existing.ID, params.HostUserID, params.Message, params.ImageURL)
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, pending_participant_count, start_time, version_no
FROM event
WHERE id = $1
`
if forUpdate {
query += ` FOR UPDATE`
}
var (
hostID uuid.UUID
privacyLevel string
capacity pgtype.Int4
approvedCount int
pendingCount int
startTime time.Time
versionNo int
)
err := r.db.QueryRow(ctx, query, eventID).Scan(&hostID, &privacyLevel, &capacity, &approvedCount, &pendingCount, &startTime, &versionNo)
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,
PendingParticipantCount: pendingCount,
StartTime: startTime,
VersionNo: versionNo,
}
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, image_url, 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, image_url, 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, image_url)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, event_id, user_id, participation_id, host_user_id, status, message, image_url, created_at, updated_at
`, params.EventID, params.UserID, params.HostUserID, domain.JoinRequestStatusPending, params.Message, params.ImageURL)
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,
imageURL *string,
) (*domain.JoinRequest, error) {
row := r.db.QueryRow(ctx, `
UPDATE join_request
SET host_user_id = $2,
status = $3,
participation_id = NULL,
message = $4,
image_url = $5,
created_at = now(),
updated_at = now()
WHERE id = $1
RETURNING id, event_id, user_id, participation_id, host_user_id, status, message, image_url, created_at, updated_at
`, joinRequestID, hostUserID, domain.JoinRequestStatusPending, message, imageURL)
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,
reconfirmed_at = NULL,
last_confirmed_event_version = $6,
created_at = NOW(),
updated_at = NOW()
WHERE event_id = $1
AND user_id = $2
AND status = $4
AND updated_at < $5
RETURNING id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
),
inserted AS (
INSERT INTO participation (event_id, user_id, status, last_confirmed_event_version)
SELECT $1, $2, $3, $6
WHERE NOT EXISTS (SELECT 1 FROM reactivated)
ON CONFLICT ON CONSTRAINT uq_event_user DO NOTHING
RETURNING id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
)
SELECT id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
FROM reactivated
UNION ALL
SELECT id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
FROM inserted
LIMIT 1
`, event.ID, userID, domain.ParticipationStatusApproved, domain.ParticipationStatusLeaved, event.StartTime, event.VersionNo), 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, image_url, 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
imageURL pgtype.Text
createdAt time.Time
updatedAt time.Time
)
err := row.Scan(
&requestID,
&eventID,
&userID,
&participationID,
&hostUserID,
&status,
&message,
&imageURL,
&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
}
if imageURL.Valid {
request.ImageURL = &imageURL.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"
"encoding/json"
"errors"
"fmt"
"time"
notificationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/notification"
"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"
)
// NotificationRepository is the Postgres-backed implementation of notification.Repository.
type NotificationRepository struct {
pool *pgxpool.Pool
db execer
}
func NewNotificationRepository(pool *pgxpool.Pool) *NotificationRepository {
return &NotificationRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
func NewNotificationRepositoryWithTx(pool *pgxpool.Pool, tx pgx.Tx) *NotificationRepository {
return &NotificationRepository{
pool: pool,
db: contextualRunner{fallback: tx},
}
}
// GetLocale returns the recipient's persisted locale preference. Used by
// the notification service to translate keyed text per recipient.
func (r *NotificationRepository) GetLocale(ctx context.Context, userID uuid.UUID) (string, error) {
var locale string
if err := r.db.QueryRow(ctx, `SELECT locale FROM app_user WHERE id = $1`, userID).Scan(&locale); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", domain.ErrNotFound
}
return "", fmt.Errorf("get locale: %w", err)
}
return locale, nil
}
func (r *NotificationRepository) LockUser(ctx context.Context, userID uuid.UUID) error {
var lockedID uuid.UUID
if err := r.db.QueryRow(ctx, `
SELECT id
FROM app_user
WHERE id = $1
FOR UPDATE
`, userID).Scan(&lockedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrNotFound
}
return fmt.Errorf("lock user: %w", err)
}
return nil
}
func (r *NotificationRepository) UpsertDevice(ctx context.Context, params notificationapp.RegisterDeviceParams) (*domain.PushDevice, error) {
device, err := scanPushDevice(r.db.QueryRow(ctx, `
WITH revoked_token AS (
UPDATE user_push_device
SET revoked_at = $6,
updated_at = $6
WHERE fcm_token = $4
AND revoked_at IS NULL
AND NOT (user_id = $1 AND installation_id = $2)
)
INSERT INTO user_push_device (
user_id,
installation_id,
platform,
fcm_token,
device_info,
last_seen_at,
revoked_at,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, NULL, $6, $6)
ON CONFLICT (user_id, installation_id) WHERE revoked_at IS NULL
DO UPDATE SET
platform = EXCLUDED.platform,
fcm_token = EXCLUDED.fcm_token,
device_info = EXCLUDED.device_info,
last_seen_at = EXCLUDED.last_seen_at,
revoked_at = NULL,
updated_at = EXCLUDED.updated_at
RETURNING id, user_id, installation_id, platform, fcm_token, device_info, last_seen_at, revoked_at, created_at, updated_at
`, params.UserID, params.InstallationID, params.Platform, params.FCMToken, params.DeviceInfo, params.LastSeenAt))
if err != nil {
return nil, err
}
return device, nil
}
func (r *NotificationRepository) CountActiveDevices(ctx context.Context, userID uuid.UUID) (int, error) {
var count int
if err := r.db.QueryRow(ctx, `
SELECT COUNT(*)
FROM user_push_device
WHERE user_id = $1
AND revoked_at IS NULL
`, userID).Scan(&count); err != nil {
return 0, fmt.Errorf("count active push devices: %w", err)
}
return count, nil
}
func (r *NotificationRepository) RevokeOldestActiveDevices(ctx context.Context, userID uuid.UUID, maxActive int, revokedAt time.Time) (int, error) {
tag, err := r.db.Exec(ctx, `
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY last_seen_at DESC, updated_at DESC, id DESC) AS keep_rank
FROM user_push_device
WHERE user_id = $1
AND revoked_at IS NULL
)
UPDATE user_push_device d
SET revoked_at = $3,
updated_at = $3
FROM ranked
WHERE d.id = ranked.id
AND ranked.keep_rank > $2
AND d.revoked_at IS NULL
`, userID, maxActive, revokedAt)
if err != nil {
return 0, fmt.Errorf("revoke oldest active push devices: %w", err)
}
return int(tag.RowsAffected()), nil
}
func (r *NotificationRepository) RevokeDevice(ctx context.Context, userID, installationID uuid.UUID, revokedAt time.Time) (bool, error) {
tag, err := r.db.Exec(ctx, `
UPDATE user_push_device
SET revoked_at = $3,
updated_at = $3
WHERE user_id = $1
AND installation_id = $2
AND revoked_at IS NULL
`, userID, installationID, revokedAt)
if err != nil {
return false, fmt.Errorf("revoke push device: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (r *NotificationRepository) RevokeDeviceByID(ctx context.Context, deviceID uuid.UUID, revokedAt time.Time) error {
if _, err := r.db.Exec(ctx, `
UPDATE user_push_device
SET revoked_at = $2,
updated_at = $2
WHERE id = $1
AND revoked_at IS NULL
`, deviceID, revokedAt); err != nil {
return fmt.Errorf("revoke push device by id: %w", err)
}
return nil
}
func (r *NotificationRepository) ListActiveDevicesForUsers(ctx context.Context, userIDs []uuid.UUID) ([]domain.PushDevice, error) {
if len(userIDs) == 0 {
return []domain.PushDevice{}, nil
}
rows, err := r.db.Query(ctx, `
SELECT id, user_id, installation_id, platform, fcm_token, device_info, last_seen_at, revoked_at, created_at, updated_at
FROM user_push_device
WHERE user_id = ANY($1)
AND revoked_at IS NULL
ORDER BY user_id ASC, last_seen_at DESC, id ASC
`, userIDs)
if err != nil {
return nil, fmt.Errorf("list active push devices: %w", err)
}
defer rows.Close()
devices := []domain.PushDevice{}
for rows.Next() {
device, err := scanPushDevice(rows)
if err != nil {
return nil, fmt.Errorf("scan push device: %w", err)
}
devices = append(devices, *device)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate push devices: %w", err)
}
return devices, nil
}
func (r *NotificationRepository) CreateNotificationIfAbsent(ctx context.Context, params notificationapp.CreateNotificationParams) (*notificationapp.CreateNotificationResult, error) {
data, err := marshalNotificationData(params.Data)
if err != nil {
return nil, err
}
var created bool
notification, err := scanNotificationWithCreated(r.db.QueryRow(ctx, `
WITH inserted AS (
INSERT INTO notification (
event_id,
receiver_user_id,
title,
type,
body,
is_read,
deep_link,
image_url,
data,
idempotency_key,
read_at,
deleted_at,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, FALSE, $6, $7, $8, $9, NULL, NULL, $10, $10)
ON CONFLICT (receiver_user_id, idempotency_key) DO NOTHING
RETURNING id, event_id, receiver_user_id, title, type, body, is_read, read_at, deleted_at,
deep_link, image_url, data, idempotency_key, created_at, updated_at, TRUE AS created
)
SELECT id, event_id, receiver_user_id, title, type, body, is_read, read_at, deleted_at,
deep_link, image_url, data, idempotency_key, created_at, updated_at, created
FROM inserted
UNION ALL
SELECT id, event_id, receiver_user_id, title, type, body, is_read, read_at, deleted_at,
deep_link, image_url, data, idempotency_key, created_at, updated_at, FALSE AS created
FROM notification
WHERE receiver_user_id = $2
AND idempotency_key = $9
LIMIT 1
`, params.EventID, params.UserID, params.Title, params.Type, params.Body, params.DeepLink, params.ImageURL, data, params.IdempotencyKey, params.CreatedAt), &created)
if err != nil {
return nil, fmt.Errorf("insert notification: %w", err)
}
return ¬ificationapp.CreateNotificationResult{Notification: *notification, Created: created}, nil
}
func (r *NotificationRepository) ListNotifications(ctx context.Context, params notificationapp.ListNotificationsParams) ([]domain.Notification, error) {
var (
cursorCreatedAt *time.Time
cursorID *uuid.UUID
)
if params.DecodedCursor != nil {
cursorCreatedAt = ¶ms.DecodedCursor.CreatedAt
cursorID = ¶ms.DecodedCursor.NotificationID
}
rows, err := r.db.Query(ctx, `
SELECT id, event_id, receiver_user_id, title, type, body, is_read, read_at, deleted_at,
deep_link, image_url, data, idempotency_key, created_at, updated_at
FROM notification
WHERE receiver_user_id = $1
AND deleted_at IS NULL
AND created_at >= $2
AND ($4 = FALSE OR is_read = FALSE)
AND ($5::timestamptz IS NULL OR (created_at, id) < ($5::timestamptz, $6::uuid))
ORDER BY created_at DESC, id DESC
LIMIT $3
`, params.UserID, params.VisibleAfter, params.RepositoryFetchLimit, params.OnlyUnread, cursorCreatedAt, cursorID)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
notifications := []domain.Notification{}
for rows.Next() {
notification, err := scanNotification(rows)
if err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
notifications = append(notifications, *notification)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate notifications: %w", err)
}
return notifications, nil
}
func (r *NotificationRepository) CountUnreadNotifications(ctx context.Context, userID uuid.UUID, visibleAfter time.Time) (int, error) {
var count int
if err := r.db.QueryRow(ctx, `
SELECT COUNT(*)
FROM notification
WHERE receiver_user_id = $1
AND deleted_at IS NULL
AND is_read = FALSE
AND created_at >= $2
`, userID, visibleAfter).Scan(&count); err != nil {
return 0, fmt.Errorf("count unread notifications: %w", err)
}
return count, nil
}
func (r *NotificationRepository) MarkNotificationRead(ctx context.Context, userID, notificationID uuid.UUID, readAt, visibleAfter time.Time) (bool, error) {
tag, err := r.db.Exec(ctx, `
UPDATE notification
SET is_read = TRUE,
read_at = COALESCE(read_at, $3),
updated_at = $3
WHERE receiver_user_id = $1
AND id = $2
AND deleted_at IS NULL
AND created_at >= $4
`, userID, notificationID, readAt, visibleAfter)
if err != nil {
return false, fmt.Errorf("mark notification read: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (r *NotificationRepository) MarkAllNotificationsRead(ctx context.Context, userID uuid.UUID, readAt, visibleAfter time.Time) (int, error) {
tag, err := r.db.Exec(ctx, `
UPDATE notification
SET is_read = TRUE,
read_at = COALESCE(read_at, $2),
updated_at = $2
WHERE receiver_user_id = $1
AND deleted_at IS NULL
AND is_read = FALSE
AND created_at >= $3
`, userID, readAt, visibleAfter)
if err != nil {
return 0, fmt.Errorf("mark all notifications read: %w", err)
}
return int(tag.RowsAffected()), nil
}
func (r *NotificationRepository) SoftDeleteNotification(ctx context.Context, userID, notificationID uuid.UUID, deletedAt, visibleAfter time.Time) error {
if _, err := r.db.Exec(ctx, `
UPDATE notification
SET deleted_at = COALESCE(deleted_at, $3),
updated_at = $3
WHERE receiver_user_id = $1
AND id = $2
AND deleted_at IS NULL
AND created_at >= $4
`, userID, notificationID, deletedAt, visibleAfter); err != nil {
return fmt.Errorf("soft delete notification: %w", err)
}
return nil
}
func (r *NotificationRepository) SoftDeleteAllNotifications(ctx context.Context, userID uuid.UUID, deletedAt, visibleAfter time.Time) error {
if _, err := r.db.Exec(ctx, `
UPDATE notification
SET deleted_at = COALESCE(deleted_at, $2),
updated_at = $2
WHERE receiver_user_id = $1
AND deleted_at IS NULL
AND created_at >= $3
`, userID, deletedAt, visibleAfter); err != nil {
return fmt.Errorf("soft delete all notifications: %w", err)
}
return nil
}
func (r *NotificationRepository) DeleteExpiredNotifications(ctx context.Context, cutoff time.Time) (int, error) {
tag, err := r.db.Exec(ctx, `
DELETE FROM notification
WHERE created_at < $1
`, cutoff)
if err != nil {
return 0, fmt.Errorf("delete expired notifications: %w", err)
}
return int(tag.RowsAffected()), nil
}
func (r *NotificationRepository) CreateDeliveryAttempt(ctx context.Context, params notificationapp.CreateDeliveryAttemptParams) error {
if _, err := r.db.Exec(ctx, `
INSERT INTO notification_delivery_attempt (
notification_id,
receiver_user_id,
method,
status,
push_device_id,
error_summary,
sent_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, params.NotificationID, params.UserID, params.Method, params.Status, params.PushDeviceID, params.ErrorSummary, params.SentAt); err != nil {
return fmt.Errorf("insert notification delivery attempt: %w", err)
}
return nil
}
func scanPushDevice(row pgx.Row) (*domain.PushDevice, error) {
var (
device domain.PushDevice
platform string
deviceInfo pgtype.Text
revokedAt pgtype.Timestamptz
)
if err := row.Scan(
&device.ID,
&device.UserID,
&device.InstallationID,
&platform,
&device.FCMToken,
&deviceInfo,
&device.LastSeenAt,
&revokedAt,
&device.CreatedAt,
&device.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
parsedPlatform, ok := domain.ParsePushDevicePlatform(platform)
if !ok {
return nil, fmt.Errorf("push device %s has invalid platform %q", device.ID, platform)
}
device.Platform = parsedPlatform
device.DeviceInfo = textPtr(deviceInfo)
device.RevokedAt = timestamptzPtr(revokedAt)
return &device, nil
}
func scanNotification(row pgx.Row) (*domain.Notification, error) {
return scanNotificationWithCreated(row, nil)
}
func scanNotificationWithCreated(row pgx.Row, created *bool) (*domain.Notification, error) {
var (
notification domain.Notification
eventID pgtype.UUID
typ pgtype.Text
readAt pgtype.Timestamptz
deletedAt pgtype.Timestamptz
deepLink pgtype.Text
imageURL pgtype.Text
data []byte
)
dest := []any{
¬ification.ID,
&eventID,
¬ification.ReceiverUserID,
¬ification.Title,
&typ,
¬ification.Body,
¬ification.IsRead,
&readAt,
&deletedAt,
&deepLink,
&imageURL,
&data,
¬ification.IdempotencyKey,
¬ification.CreatedAt,
¬ification.UpdatedAt,
}
if created != nil {
dest = append(dest, created)
}
if err := row.Scan(dest...); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
notification.EventID = uuidPtr(eventID)
notification.Type = textPtr(typ)
notification.ReadAt = timestamptzPtr(readAt)
notification.DeletedAt = timestamptzPtr(deletedAt)
notification.DeepLink = textPtr(deepLink)
notification.ImageURL = textPtr(imageURL)
notification.Data = map[string]string{}
if len(data) > 0 {
if err := json.Unmarshal(data, ¬ification.Data); err != nil {
return nil, fmt.Errorf("unmarshal notification data: %w", err)
}
}
if notification.Data == nil {
notification.Data = map[string]string{}
}
return ¬ification, nil
}
func marshalNotificationData(data map[string]string) ([]byte, error) {
if data == nil {
data = map[string]string{}
}
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshal notification data: %w", err)
}
return raw, nil
}
var _ notificationapp.Repository = (*NotificationRepository)(nil)
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"
)
func loadParticipation(
ctx context.Context,
db execer,
eventID, userID uuid.UUID,
forUpdate bool,
) (*domain.Participation, error) {
query := `
SELECT id, status, reconfirmed_at, last_confirmed_event_version, 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
reconfirmedAt pgtype.Timestamptz
lastConfirmedVersion pgtype.Int4
createdAt time.Time
updatedAt time.Time
)
err := row.Scan(&id, &status, &reconfirmedAt, &lastConfirmedVersion, &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)
}
participation := &domain.Participation{
ID: id,
EventID: eventID,
UserID: userID,
Status: parsedStatus,
ReconfirmedAt: timestamptzPtr(reconfirmedAt),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
if lastConfirmedVersion.Valid {
value := int(lastConfirmedVersion.Int32)
participation.LastConfirmedEventVersion = &value
}
return participation, 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, version_no
FROM event
WHERE id = $1
AND host_id <> $2
AND privacy_level = $3
AND (capacity IS NULL OR approved_participant_count + pending_participant_count < capacity)
),
reactivated AS (
UPDATE participation
SET status = $4,
reconfirmed_at = NULL,
last_confirmed_event_version = (SELECT version_no FROM joinable_event),
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, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
),
inserted AS (
INSERT INTO participation (event_id, user_id, status, last_confirmed_event_version)
SELECT id, $2, $4, version_no
FROM joinable_event
WHERE NOT EXISTS (SELECT 1 FROM reactivated)
ON CONFLICT ON CONSTRAINT uq_event_user DO NOTHING
RETURNING id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
)
SELECT id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
FROM reactivated
UNION ALL
SELECT id, status, reconfirmed_at, last_confirmed_event_version, 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 or PENDING 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 IN ($4, $5)
RETURNING id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
`, eventID, userID, domain.ParticipationStatusLeaved, domain.ParticipationStatusApproved, domain.ParticipationStatusPending), 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.PendingParticipantCount >= *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 or pending 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
pendingCount int
startTime time.Time
)
err := r.db.QueryRow(ctx, `
SELECT host_id, privacy_level, capacity, approved_participant_count, pending_participant_count, start_time
FROM event
WHERE id = $1
`, eventID).Scan(&hostID, &privacyLevel, &capacity, &approvedCount, &pendingCount, &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,
PendingParticipantCount: pendingCount,
StartTime: startTime,
}
if capacity.Valid {
event.Capacity = new(int(capacity.Int32))
}
return event, nil
}
// ListApprovedParticipantUserIDs returns the user IDs of every APPROVED
// participation for the given event. Used by post-completion badge evaluation
// fan-out.
func (r *ParticipationRepository) ListApprovedParticipantUserIDs(ctx context.Context, eventID uuid.UUID) ([]uuid.UUID, error) {
rows, err := r.db.Query(ctx, `
SELECT user_id
FROM participation
WHERE event_id = $1 AND status = $2
`, eventID, domain.ParticipationStatusApproved)
if err != nil {
return nil, fmt.Errorf("list approved participant user ids: %w", err)
}
defer rows.Close()
var userIDs []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan approved participant user id: %w", err)
}
userIDs = append(userIDs, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("approved participant user ids rows: %w", err)
}
return userIDs, nil
}
// CancelEventParticipations transitions every non-LEAVED participation for the
// event to CANCELED, preserving historical leave records. It returns the user
// IDs of every participation that was transitioned so callers can fan out
// notifications without a second query.
func (r *ParticipationRepository) CancelEventParticipations(ctx context.Context, eventID uuid.UUID) ([]uuid.UUID, error) {
rows, err := r.db.Query(ctx, `
UPDATE participation
SET status = $1, updated_at = NOW()
WHERE event_id = $2
AND status <> $3
RETURNING user_id
`, domain.ParticipationStatusCanceled, eventID, domain.ParticipationStatusLeaved)
if err != nil {
return nil, fmt.Errorf("cancel event participations: %w", err)
}
defer rows.Close()
var userIDs []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan cancelled participant: %w", err)
}
userIDs = append(userIDs, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("cancel event participations rows: %w", err)
}
return userIDs, nil
}
// MarkApprovedParticipationsPending moves approved non-host participants to
// PENDING for event-change reconfirmation and returns transitioned user IDs.
func (r *ParticipationRepository) MarkApprovedParticipationsPending(ctx context.Context, eventID, hostUserID uuid.UUID) ([]uuid.UUID, error) {
rows, err := r.db.Query(ctx, `
UPDATE participation
SET status = $1,
reconfirmed_at = NULL,
updated_at = NOW()
WHERE event_id = $2
AND user_id <> $3
AND status = $4
RETURNING user_id
`, domain.ParticipationStatusPending, eventID, hostUserID, domain.ParticipationStatusApproved)
if err != nil {
return nil, fmt.Errorf("mark approved participations pending: %w", err)
}
defer rows.Close()
userIDs := []uuid.UUID{}
for rows.Next() {
var userID uuid.UUID
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan pending participant user: %w", err)
}
userIDs = append(userIDs, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending participant users: %w", err)
}
return userIDs, nil
}
// ReconfirmParticipation transitions one pending participant back to APPROVED
// and records the event version they accepted.
func (r *ParticipationRepository) ReconfirmParticipation(ctx context.Context, eventID, userID uuid.UUID, eventVersion int) (*domain.Participation, error) {
participation, err := scanParticipation(r.db.QueryRow(ctx, `
UPDATE participation
SET status = $4,
reconfirmed_at = NOW(),
last_confirmed_event_version = $3,
updated_at = NOW()
WHERE event_id = $1
AND user_id = $2
AND status = $5
RETURNING id, status, reconfirmed_at, last_confirmed_event_version, created_at, updated_at
`, eventID, userID, eventVersion, domain.ParticipationStatusApproved, domain.ParticipationStatusPending), eventID, userID, "reconfirm participation")
if err != nil {
return nil, err
}
if participation == nil {
return nil, domain.ConflictError(domain.ErrorCodeParticipationReconfirmNotAllowed, "Only PENDING participations can be reconfirmed.")
}
return participation, nil
}
// ApprovePendingParticipationsForEvent auto-approves pending participants when
// an event starts without setting reconfirmation metadata.
func (r *ParticipationRepository) ApprovePendingParticipationsForEvent(ctx context.Context, eventID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE participation
SET status = $2,
updated_at = NOW()
WHERE event_id = $1
AND status = $3
`, eventID, domain.ParticipationStatusApproved, domain.ParticipationStatusPending)
if err != nil {
return fmt.Errorf("approve pending participations for event: %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.locale,
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
locale 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,
&locale,
&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.Locale = textValue(locale)
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
}
// GetPublicProfile returns the public-safe projection for another user's profile.
func (r *ProfileRepository) GetPublicProfile(ctx context.Context, userID uuid.UUID) (*domain.PublicUserProfile, error) {
row := r.db.QueryRow(ctx, `
SELECT
u.id,
u.username,
p.display_name,
p.avatar_url,
p.bio,
us.final_score,
COALESCE(us.hosted_event_rating_count, 0),
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 (
profileRecord domain.PublicUserProfile
displayName pgtype.Text
avatarURL pgtype.Text
bio pgtype.Text
finalScore pgtype.Float8
hostRatingCount int
participantRatingCount int
)
if err := row.Scan(
&profileRecord.UserID,
&profileRecord.Username,
&displayName,
&avatarURL,
&bio,
&finalScore,
&hostRatingCount,
&participantRatingCount,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get public profile: %w", err)
}
profileRecord.DisplayName = textPtr(displayName)
profileRecord.AvatarURL = textPtr(avatarURL)
profileRecord.Bio = textPtr(bio)
if finalScore.Valid {
profileRecord.FinalScore = &finalScore.Float64
}
profileRecord.HostRatingCount = hostRatingCount
profileRecord.ParticipantRatingCount = participantRatingCount
return &profileRecord, 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)
}
// ListEquipment returns the user's equipment entries ordered newest-first.
func (r *ProfileRepository) ListEquipment(ctx context.Context, userID uuid.UUID) ([]domain.ProfileEquipment, error) {
rows, err := r.db.Query(ctx, `
SELECT id, user_id, name, description, image_url, created_at, updated_at
FROM profile_equipment
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("list profile equipment: %w", err)
}
defer rows.Close()
items := make([]domain.ProfileEquipment, 0)
for rows.Next() {
item, err := scanProfileEquipment(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate profile equipment: %w", err)
}
return items, nil
}
// GetEquipmentByID returns one equipment item by its identifier.
func (r *ProfileRepository) GetEquipmentByID(ctx context.Context, equipmentID uuid.UUID) (*domain.ProfileEquipment, error) {
row := r.db.QueryRow(ctx, `
SELECT id, user_id, name, description, image_url, created_at, updated_at
FROM profile_equipment
WHERE id = $1
`, equipmentID)
item, err := scanProfileEquipment(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get profile equipment: %w", err)
}
return item, nil
}
// CreateEquipment inserts one equipment item for the given user.
func (r *ProfileRepository) CreateEquipment(ctx context.Context, params profileapp.CreateEquipmentParams) (*domain.ProfileEquipment, error) {
row := r.db.QueryRow(ctx, `
INSERT INTO profile_equipment (user_id, name, description, image_url)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, name, description, image_url, created_at, updated_at
`, params.UserID, params.Name, params.Description, params.ImageURL)
item, err := scanProfileEquipment(row)
if err != nil {
return nil, fmt.Errorf("create profile equipment: %w", err)
}
return item, nil
}
// UpdateEquipment updates one equipment item identified by id.
func (r *ProfileRepository) UpdateEquipment(ctx context.Context, params profileapp.UpdateEquipmentParams) (*domain.ProfileEquipment, error) {
setClauses := "updated_at = now()"
args := []any{params.EquipmentID}
if params.Name != nil {
setClauses += fmt.Sprintf(", name = $%d", len(args)+1)
args = append(args, *params.Name)
}
if params.Description != nil {
setClauses += fmt.Sprintf(", description = $%d", len(args)+1)
args = append(args, *params.Description)
}
if params.ImageURL != nil {
setClauses += fmt.Sprintf(", image_url = $%d", len(args)+1)
args = append(args, *params.ImageURL)
}
row := r.db.QueryRow(ctx, fmt.Sprintf(`
UPDATE profile_equipment
SET %s
WHERE id = $1
RETURNING id, user_id, name, description, image_url, created_at, updated_at
`, setClauses), args...)
item, err := scanProfileEquipment(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("update profile equipment: %w", err)
}
return item, nil
}
// DeleteEquipment deletes one equipment item by id.
func (r *ProfileRepository) DeleteEquipment(ctx context.Context, equipmentID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `DELETE FROM profile_equipment WHERE id = $1`, equipmentID)
if err != nil {
return fmt.Errorf("delete profile equipment: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
// ListShowcaseImages returns the user's showcase images ordered newest-first.
func (r *ProfileRepository) ListShowcaseImages(ctx context.Context, userID uuid.UUID) ([]domain.ProfileShowcaseImage, error) {
rows, err := r.db.Query(ctx, `
SELECT id, user_id, image_url, created_at, updated_at
FROM profile_showcase_image
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("list showcase images: %w", err)
}
defer rows.Close()
items := make([]domain.ProfileShowcaseImage, 0)
for rows.Next() {
item, err := scanProfileShowcaseImage(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate showcase images: %w", err)
}
return items, nil
}
// GetShowcaseImageByID returns one showcase image by its identifier.
func (r *ProfileRepository) GetShowcaseImageByID(ctx context.Context, showcaseImageID uuid.UUID) (*domain.ProfileShowcaseImage, error) {
row := r.db.QueryRow(ctx, `
SELECT id, user_id, image_url, created_at, updated_at
FROM profile_showcase_image
WHERE id = $1
`, showcaseImageID)
item, err := scanProfileShowcaseImage(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("get showcase image: %w", err)
}
return item, nil
}
// CreateShowcaseImage inserts a new showcase image row for the given user.
func (r *ProfileRepository) CreateShowcaseImage(ctx context.Context, userID uuid.UUID, imageURL string) (*domain.ProfileShowcaseImage, error) {
row := r.db.QueryRow(ctx, `
INSERT INTO profile_showcase_image (user_id, image_url)
VALUES ($1, $2)
RETURNING id, user_id, image_url, created_at, updated_at
`, userID, imageURL)
item, err := scanProfileShowcaseImage(row)
if err != nil {
return nil, fmt.Errorf("create showcase image: %w", err)
}
return item, nil
}
// DeleteShowcaseImage deletes one showcase image by id.
func (r *ProfileRepository) DeleteShowcaseImage(ctx context.Context, showcaseImageID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `DELETE FROM profile_showcase_image WHERE id = $1`, showcaseImageID)
if err != nil {
return fmt.Errorf("delete showcase image: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
// SearchUsers returns lightweight user summaries ordered for username picker relevance.
func (r *ProfileRepository) SearchUsers(ctx context.Context, query string, limit int) ([]profileapp.UserSearchRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT u.id, u.username, p.display_name, p.avatar_url
FROM app_user u
LEFT JOIN profile p ON p.user_id = u.id
WHERE u.username ILIKE '%' || $1 || '%'
ORDER BY
CASE WHEN u.username = $1 THEN 0 WHEN u.username ILIKE $1 || '%' THEN 1 ELSE 2 END,
u.username ASC
LIMIT $2
`, query, limit)
if err != nil {
return nil, fmt.Errorf("search users: %w", err)
}
defer rows.Close()
records := make([]profileapp.UserSearchRecord, 0)
for rows.Next() {
var (
record profileapp.UserSearchRecord
displayName pgtype.Text
avatarURL pgtype.Text
)
if err := rows.Scan(&record.ID, &record.Username, &displayName, &avatarURL); err != nil {
return nil, fmt.Errorf("scan user search result: %w", err)
}
record.DisplayName = textPtr(displayName)
record.AvatarURL = textPtr(avatarURL)
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate user search results: %w", err)
}
return records, nil
}
func scanProfileEquipment(row interface{ Scan(...any) error }) (*domain.ProfileEquipment, error) {
var (
item domain.ProfileEquipment
description pgtype.Text
imageURL pgtype.Text
)
if err := row.Scan(
&item.ID,
&item.UserID,
&item.Name,
&description,
&imageURL,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
item.Description = textPtr(description)
item.ImageURL = textPtr(imageURL)
return &item, nil
}
func scanProfileShowcaseImage(row interface{ Scan(...any) error }) (*domain.ProfileShowcaseImage, error) {
var item domain.ProfileShowcaseImage
if err := row.Scan(
&item.ID,
&item.UserID,
&item.ImageURL,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
return &item, nil
}
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.Locale != nil {
setClauses += fmt.Sprintf(", locale = $%d", len(userArgs)+1)
userArgs = append(userArgs, *params.Locale)
}
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
}
// GetLocale returns the persisted locale preference for the given user.
// Returns domain.ErrNotFound when the user does not exist.
func (r *ProfileRepository) GetLocale(ctx context.Context, userID uuid.UUID) (string, error) {
var locale string
err := r.pool.QueryRow(ctx, `SELECT locale FROM app_user WHERE id = $1`, userID).Scan(&locale)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", domain.ErrNotFound
}
return "", fmt.Errorf("get locale: %w", err)
}
return locale, nil
}
// GetPasswordHash returns the bcrypt hash stored for the given user.
func (r *ProfileRepository) GetPasswordHash(ctx context.Context, userID uuid.UUID) (string, error) {
var hash string
err := r.pool.QueryRow(ctx, `SELECT password_hash FROM app_user WHERE id = $1`, userID).Scan(&hash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", domain.ErrNotFound
}
return "", fmt.Errorf("get password hash: %w", err)
}
return hash, nil
}
// UpdatePasswordHash replaces the stored password hash for the given user.
func (r *ProfileRepository) UpdatePasswordHash(ctx context.Context, userID uuid.UUID, newHash string) error {
_, err := r.db.Exec(ctx,
`UPDATE app_user SET password_hash = $2, updated_at = now() WHERE id = $1`,
userID, newHash,
)
if err != nil {
return fmt.Errorf("update password hash: %w", err)
}
return 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
privacyLevel string
endTime pgtype.Timestamptz
ratingContext ratingapp.EventRatingContext
isRequestingHost bool
isApprovedParticipant bool
isQualifyingParticipant bool
)
err := r.db.QueryRow(ctx, `
SELECT
e.id,
e.host_id,
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.privacy_level,
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,
EXISTS (
SELECT 1
FROM participation p
WHERE p.event_id = e.id
AND p.user_id = $2
AND (
p.status = $3
OR (p.status = $4 AND p.updated_at >= e.start_time)
)
) AS is_qualifying_participant
FROM event e
WHERE e.id = $1
`, eventID, participantUserID, domain.ParticipationStatusApproved, domain.ParticipationStatusLeaved).Scan(
&ratingContext.EventID,
&ratingContext.HostUserID,
&status,
&privacyLevel,
&ratingContext.StartTime,
&endTime,
&isRequestingHost,
&isApprovedParticipant,
&isQualifyingParticipant,
)
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.PrivacyLevel = domain.EventPrivacyLevel(privacyLevel)
ratingContext.IsRequestingHost = isRequestingHost
ratingContext.IsApprovedParticipant = isApprovedParticipant
ratingContext.IsQualifyingParticipant = isQualifyingParticipant
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_comment (user_id, event_id, comment_type, rating, message)
VALUES ($1, $2, $3, $4, COALESCE($5, 'Rated this event.'))
ON CONFLICT (event_id, user_id) WHERE comment_type = 'REVIEW'
DO UPDATE SET
rating = EXCLUDED.rating,
message = EXCLUDED.message,
updated_at = now()
RETURNING id, user_id, event_id, rating, message, created_at, updated_at
`, params.ParticipantUserID, params.EventID, string(domain.CommentTypeReview), 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)
}
if tag.RowsAffected() == 1 {
return true, nil
}
tag, err = r.db.Exec(ctx, `
DELETE FROM event_comment
WHERE event_id = $1
AND user_id = $2
AND comment_type = $3
`, eventID, participantUserID, string(domain.CommentTypeReview))
if err != nil {
return false, fmt.Errorf("delete review comment 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_comment er
JOIN event e ON e.id = er.event_id
WHERE e.host_id = $1
AND er.comment_type = $2
`, userID, string(domain.CommentTypeReview)).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"
"errors"
"fmt"
"time"
ticketapp "github.com/bounswe/bounswe2026group11/backend/internal/application/ticket"
"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"
)
// TicketRepository is the Postgres-backed implementation of ticket.Repository.
type TicketRepository struct {
pool *pgxpool.Pool
db execer
}
var _ ticketapp.Repository = (*TicketRepository)(nil)
// NewTicketRepository returns a repository that executes queries against the given connection pool.
func NewTicketRepository(pool *pgxpool.Pool) *TicketRepository {
return &TicketRepository{
pool: pool,
db: contextualRunner{fallback: pool},
}
}
// CreateTicketForParticipation creates or reactivates the ticket for an approved participation.
func (r *TicketRepository) CreateTicketForParticipation(ctx context.Context, participationID uuid.UUID, status domain.TicketStatus) (*domain.Ticket, error) {
row := r.db.QueryRow(ctx, `
WITH participation_event AS (
SELECT p.id AS participation_id,
COALESCE(e.end_time, e.start_time + INTERVAL '60 days') AS expires_at
FROM participation p
JOIN event e ON e.id = p.event_id
WHERE p.id = $1
),
existing AS (
SELECT t.id
FROM ticket t
JOIN participation_event pe ON pe.participation_id = t.participation_id
ORDER BY
CASE WHEN t.status IN ($3, $4) THEN 0 ELSE 1 END,
t.updated_at DESC,
t.created_at DESC
LIMIT 1
FOR UPDATE OF t
),
reactivated AS (
UPDATE ticket t
SET status = $2,
qr_token_version = 0,
last_issued_qr_token_hash = NULL,
expires_at = pe.expires_at,
used_at = NULL,
canceled_at = NULL,
updated_at = NOW()
FROM participation_event pe
WHERE t.participation_id = pe.participation_id
AND t.id IN (SELECT id FROM existing)
RETURNING t.id, t.participation_id, t.status, t.qr_token_version, t.last_issued_qr_token_hash,
t.expires_at, t.used_at, t.canceled_at, t.created_at, t.updated_at
),
inserted AS (
INSERT INTO ticket (participation_id, status, expires_at)
SELECT participation_id, $2, expires_at
FROM participation_event
WHERE NOT EXISTS (SELECT 1 FROM reactivated)
ON CONFLICT DO NOTHING
RETURNING id, participation_id, status, qr_token_version, last_issued_qr_token_hash,
expires_at, used_at, canceled_at, created_at, updated_at
)
SELECT id, participation_id, status, qr_token_version, last_issued_qr_token_hash,
expires_at, used_at, canceled_at, created_at, updated_at
FROM reactivated
UNION ALL
SELECT id, participation_id, status, qr_token_version, last_issued_qr_token_hash,
expires_at, used_at, canceled_at, created_at, updated_at
FROM inserted
LIMIT 1
`, participationID, status, domain.TicketStatusActive, domain.TicketStatusPending)
ticket, err := scanTicket(row)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeTicketNotFound, "The linked participation does not exist.")
}
return nil, fmt.Errorf("create ticket for participation: %w", err)
}
return ticket, nil
}
// CancelTicketForParticipation cancels a non-terminal ticket for the participation.
func (r *TicketRepository) CancelTicketForParticipation(ctx context.Context, participationID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE ticket
SET status = $2,
canceled_at = COALESCE(canceled_at, NOW()),
updated_at = NOW()
WHERE participation_id = $1
AND status IN ($3, $4)
`, participationID, domain.TicketStatusCanceled, domain.TicketStatusActive, domain.TicketStatusPending)
if err != nil {
return fmt.Errorf("cancel ticket for participation: %w", err)
}
return nil
}
// CancelTicketsForEvent cancels all non-terminal tickets for an event.
func (r *TicketRepository) CancelTicketsForEvent(ctx context.Context, eventID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE ticket t
SET status = $2,
canceled_at = COALESCE(t.canceled_at, NOW()),
updated_at = NOW()
FROM participation p
WHERE p.id = t.participation_id
AND p.event_id = $1
AND t.status IN ($3, $4)
`, eventID, domain.TicketStatusCanceled, domain.TicketStatusActive, domain.TicketStatusPending)
if err != nil {
return fmt.Errorf("cancel tickets for event: %w", err)
}
return nil
}
// ExpireTicketsForEvent expires all unused non-terminal tickets for an event.
func (r *TicketRepository) ExpireTicketsForEvent(ctx context.Context, eventID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE ticket t
SET status = $2,
updated_at = NOW()
FROM participation p
WHERE p.id = t.participation_id
AND p.event_id = $1
AND t.status IN ($3, $4)
`, eventID, domain.TicketStatusExpired, domain.TicketStatusActive, domain.TicketStatusPending)
if err != nil {
return fmt.Errorf("expire tickets for event: %w", err)
}
return nil
}
// MarkTicketsPendingForEvent moves active tickets for pending participants to
// PENDING so QR access is blocked until reconfirmation or event start.
func (r *TicketRepository) MarkTicketsPendingForEvent(ctx context.Context, eventID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE ticket t
SET status = $2,
updated_at = NOW()
FROM participation p
WHERE p.id = t.participation_id
AND p.event_id = $1
AND p.status = $3
AND t.status = $4
`, eventID, domain.TicketStatusPending, domain.ParticipationStatusPending, domain.TicketStatusActive)
if err != nil {
return fmt.Errorf("mark tickets pending for event: %w", err)
}
return nil
}
// ActivatePendingTicketsForEvent restores pending tickets for approved
// participants.
func (r *TicketRepository) ActivatePendingTicketsForEvent(ctx context.Context, eventID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE ticket t
SET status = $2,
updated_at = NOW()
FROM participation p
WHERE p.id = t.participation_id
AND p.event_id = $1
AND p.status = $3
AND t.status = $4
`, eventID, domain.TicketStatusActive, domain.ParticipationStatusApproved, domain.TicketStatusPending)
if err != nil {
return fmt.Errorf("activate pending tickets for event: %w", err)
}
return nil
}
// ListTicketsByUser returns ticket summaries for the given user.
func (r *TicketRepository) ListTicketsByUser(ctx context.Context, userID uuid.UUID) ([]ticketapp.TicketRecord, error) {
rows, err := r.db.Query(ctx, ticketRecordSelect(`
WHERE user_id = $1
ORDER BY start_time ASC, created_at ASC
`, "NULL::double precision", "NULL::double precision"), userID)
if err != nil {
return nil, fmt.Errorf("list tickets by user: %w", err)
}
defer rows.Close()
records := []ticketapp.TicketRecord{}
for rows.Next() {
record, err := scanTicketRecord(rows)
if err != nil {
return nil, err
}
records = append(records, *record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list tickets by user rows: %w", err)
}
return records, nil
}
// GetTicketDetail returns a ticket detail projection when the ticket belongs to the user.
func (r *TicketRepository) GetTicketDetail(ctx context.Context, userID, ticketID uuid.UUID) (*ticketapp.TicketRecord, error) {
record, err := scanTicketRecord(r.db.QueryRow(ctx, ticketRecordSelect(`
WHERE user_id = $1
AND id = $2
`, "NULL::double precision", "NULL::double precision"), userID, ticketID))
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, nil
}
return nil, err
}
return record, nil
}
// GetTicketAccessForUser returns ticket state plus distance from the caller's provided coordinates.
func (r *TicketRepository) GetTicketAccessForUser(ctx context.Context, userID, ticketID uuid.UUID, lat, lon float64, forUpdate bool) (*ticketapp.TicketAccessRecord, error) {
_ = forUpdate
record, err := scanTicketAccessRecord(r.db.QueryRow(ctx, ticketRecordSelect(`
WHERE user_id = $1
AND id = $2
`, "$3::double precision", "$4::double precision"), userID, ticketID, lat, lon))
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, nil
}
return nil, err
}
return record, nil
}
// StoreIssuedToken stores only the hash and monotonically increasing token version.
func (r *TicketRepository) StoreIssuedToken(ctx context.Context, ticketID uuid.UUID, version int, tokenHash string) error {
tag, err := r.db.Exec(ctx, `
UPDATE ticket
SET qr_token_version = $2,
last_issued_qr_token_hash = $3,
updated_at = NOW()
WHERE id = $1
`, ticketID, version, tokenHash)
if err != nil {
return fmt.Errorf("store issued ticket token: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.NotFoundError(domain.ErrorCodeTicketNotFound, "The requested ticket does not exist.")
}
return nil
}
// GetTicketForScan returns locked ticket state for host scan validation.
func (r *TicketRepository) GetTicketForScan(ctx context.Context, eventID, ticketID uuid.UUID, forUpdate bool) (*ticketapp.TicketScanRecord, error) {
query := `
SELECT t.id, t.participation_id, t.status, t.qr_token_version, t.last_issued_qr_token_hash,
t.expires_at, t.used_at, t.canceled_at, t.created_at, t.updated_at,
p.id, p.event_id, p.user_id, p.status, p.created_at, p.updated_at,
e.id, e.status, e.privacy_level, e.host_id
FROM ticket t
JOIN participation p ON p.id = t.participation_id
JOIN event e ON e.id = p.event_id
WHERE t.id = $1
AND e.id = $2
`
if forUpdate {
query += ` FOR UPDATE OF t`
}
record, err := scanTicketScanRecord(r.db.QueryRow(ctx, query, ticketID, eventID))
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, nil
}
return nil, err
}
return record, nil
}
// MarkTicketUsed marks an ACTIVE ticket as USED.
func (r *TicketRepository) MarkTicketUsed(ctx context.Context, ticketID uuid.UUID) (*domain.Ticket, error) {
ticket, err := scanTicket(r.db.QueryRow(ctx, `
UPDATE ticket
SET status = $2,
used_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND status = $3
RETURNING id, participation_id, status, qr_token_version, last_issued_qr_token_hash,
expires_at, used_at, canceled_at, created_at, updated_at
`, ticketID, domain.TicketStatusUsed, domain.TicketStatusActive))
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.ConflictError(domain.ErrorCodeTicketScanRejected, "The ticket can no longer be used.")
}
return nil, fmt.Errorf("mark ticket used: %w", err)
}
return ticket, nil
}
func ticketRecordSelect(whereClause, latExpr, lonExpr string) string {
return `
WITH ticket_base AS (
SELECT t.id, t.participation_id, t.status, t.qr_token_version, t.last_issued_qr_token_hash,
t.expires_at, t.used_at, t.canceled_at, t.created_at, t.updated_at,
p.event_id, p.user_id, p.status AS participation_status, p.created_at AS participation_created_at, p.updated_at AS participation_updated_at,
e.title, e.status AS event_status, e.privacy_level, e.start_time, e.end_time, e.location_type,
el.address,
CASE
WHEN e.location_type = 'ROUTE' THEN ST_StartPoint(el.geom::geometry)
ELSE el.geom::geometry
END AS anchor_geom
FROM ticket t
JOIN participation p ON p.id = t.participation_id
JOIN event e ON e.id = p.event_id
JOIN event_location el ON el.event_id = e.id
)
SELECT id, participation_id, status, qr_token_version, last_issued_qr_token_hash,
expires_at, used_at, canceled_at, created_at, updated_at,
event_id, user_id, participation_status, participation_created_at, participation_updated_at,
title, event_status, privacy_level, start_time, end_time, location_type, address,
ST_Y(anchor_geom) AS anchor_lat,
ST_X(anchor_geom) AS anchor_lon,
CASE
WHEN ` + latExpr + ` IS NULL OR ` + lonExpr + ` IS NULL THEN 0::double precision
ELSE ST_Distance(anchor_geom::geography, ST_SetSRID(ST_MakePoint(` + lonExpr + `, ` + latExpr + `), 4326)::geography)
END AS distance_meters
FROM ticket_base
` + whereClause
}
func scanTicket(row pgx.Row) (*domain.Ticket, error) {
var (
ticket domain.Ticket
status string
tokenHash pgtype.Text
usedAt pgtype.Timestamptz
canceledAt pgtype.Timestamptz
)
if err := row.Scan(
&ticket.ID,
&ticket.ParticipationID,
&status,
&ticket.QRTokenVersion,
&tokenHash,
&ticket.ExpiresAt,
&usedAt,
&canceledAt,
&ticket.CreatedAt,
&ticket.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
parsedStatus, ok := domain.ParseTicketStatus(status)
if !ok {
return nil, fmt.Errorf("unknown ticket status %q", status)
}
ticket.Status = parsedStatus
ticket.LastIssuedQRTokenHash = textPtr(tokenHash)
ticket.UsedAt = timestamptzPtr(usedAt)
ticket.CanceledAt = timestamptzPtr(canceledAt)
return &ticket, nil
}
func scanTicketRecord(row pgx.Row) (*ticketapp.TicketRecord, error) {
accessRecord, err := scanTicketAccessRecord(row)
if err != nil {
return nil, err
}
return &accessRecord.TicketRecord, nil
}
func scanTicketAccessRecord(row pgx.Row) (*ticketapp.TicketAccessRecord, error) {
var (
ticket domain.Ticket
ticketStatus string
tokenHash pgtype.Text
usedAt pgtype.Timestamptz
canceledAt pgtype.Timestamptz
eventID uuid.UUID
userID uuid.UUID
participationStatus string
participationCreatedAt time.Time
participationUpdatedAt time.Time
eventTitle string
eventStatus string
privacyLevel string
startTime time.Time
endTime pgtype.Timestamptz
locationType string
address pgtype.Text
anchorLat float64
anchorLon float64
distanceMeters float64
)
err := row.Scan(
&ticket.ID,
&ticket.ParticipationID,
&ticketStatus,
&ticket.QRTokenVersion,
&tokenHash,
&ticket.ExpiresAt,
&usedAt,
&canceledAt,
&ticket.CreatedAt,
&ticket.UpdatedAt,
&eventID,
&userID,
&participationStatus,
&participationCreatedAt,
&participationUpdatedAt,
&eventTitle,
&eventStatus,
&privacyLevel,
&startTime,
&endTime,
&locationType,
&address,
&anchorLat,
&anchorLon,
&distanceMeters,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("scan ticket record: %w", err)
}
parsedTicketStatus, ok := domain.ParseTicketStatus(ticketStatus)
if !ok {
return nil, fmt.Errorf("unknown ticket status %q", ticketStatus)
}
parsedParticipationStatus, ok := domain.ParseParticipationStatus(participationStatus)
if !ok {
return nil, fmt.Errorf("unknown participation status %q", participationStatus)
}
parsedEventStatus := domain.EventStatus(eventStatus)
parsedPrivacyLevel, ok := domain.ParseEventPrivacyLevel(privacyLevel)
if !ok {
return nil, fmt.Errorf("unknown event privacy level %q", privacyLevel)
}
parsedLocationType, ok := domain.ParseEventLocationType(locationType)
if !ok {
return nil, fmt.Errorf("unknown event location type %q", locationType)
}
ticket.Status = parsedTicketStatus
ticket.LastIssuedQRTokenHash = textPtr(tokenHash)
ticket.UsedAt = timestamptzPtr(usedAt)
ticket.CanceledAt = timestamptzPtr(canceledAt)
record := &ticketapp.TicketAccessRecord{
TicketRecord: ticketapp.TicketRecord{
Ticket: ticket,
Participation: domain.Participation{
ID: ticket.ParticipationID,
EventID: eventID,
UserID: userID,
Status: parsedParticipationStatus,
CreatedAt: participationCreatedAt,
UpdatedAt: participationUpdatedAt,
},
EventID: eventID,
EventTitle: eventTitle,
EventStatus: parsedEventStatus,
PrivacyLevel: parsedPrivacyLevel,
StartTime: startTime,
LocationType: parsedLocationType,
Address: textPtr(address),
Anchor: domain.GeoPoint{
Lat: anchorLat,
Lon: anchorLon,
},
},
UserID: userID,
DistanceMeters: distanceMeters,
}
if endTime.Valid {
record.EndTime = &endTime.Time
}
return record, nil
}
func scanTicketScanRecord(row pgx.Row) (*ticketapp.TicketScanRecord, error) {
var (
ticket domain.Ticket
ticketStatus string
tokenHash pgtype.Text
usedAt pgtype.Timestamptz
canceledAt pgtype.Timestamptz
participation domain.Participation
participationStatus string
eventID uuid.UUID
eventStatus string
privacyLevel string
hostID uuid.UUID
)
err := row.Scan(
&ticket.ID,
&ticket.ParticipationID,
&ticketStatus,
&ticket.QRTokenVersion,
&tokenHash,
&ticket.ExpiresAt,
&usedAt,
&canceledAt,
&ticket.CreatedAt,
&ticket.UpdatedAt,
&participation.ID,
&participation.EventID,
&participation.UserID,
&participationStatus,
&participation.CreatedAt,
&participation.UpdatedAt,
&eventID,
&eventStatus,
&privacyLevel,
&hostID,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("scan ticket scan record: %w", err)
}
parsedTicketStatus, ok := domain.ParseTicketStatus(ticketStatus)
if !ok {
return nil, fmt.Errorf("unknown ticket status %q", ticketStatus)
}
parsedParticipationStatus, ok := domain.ParseParticipationStatus(participationStatus)
if !ok {
return nil, fmt.Errorf("unknown participation status %q", participationStatus)
}
parsedPrivacyLevel, ok := domain.ParseEventPrivacyLevel(privacyLevel)
if !ok {
return nil, fmt.Errorf("unknown event privacy level %q", privacyLevel)
}
ticket.Status = parsedTicketStatus
ticket.LastIssuedQRTokenHash = textPtr(tokenHash)
ticket.UsedAt = timestamptzPtr(usedAt)
ticket.CanceledAt = timestamptzPtr(canceledAt)
participation.Status = parsedParticipationStatus
return &ticketapp.TicketScanRecord{
Ticket: ticket,
Participation: participation,
EventID: eventID,
EventStatus: domain.EventStatus(eventStatus),
PrivacyLevel: parsedPrivacyLevel,
HostID: hostID,
UserID: participation.UserID,
}, nil
}
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 admin_handler
import (
"log/slog"
"strconv"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/admin"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// Handler groups web-only admin backoffice handlers.
type Handler struct {
service admin.UseCase
}
// NewHandler creates an admin handler backed by the given use case.
func NewHandler(service admin.UseCase) *Handler {
return &Handler{service: service}
}
// RegisterRoutes mounts admin-only read endpoints.
func RegisterRoutes(router fiber.Router, handler *Handler, adminAuth fiber.Handler) {
group := router.Group("/admin", adminAuth)
group.Get("/users", handler.ListUsers)
group.Post("/users/:user_id/deactivate", handler.DeactivateUser)
group.Get("/events", handler.ListEvents)
group.Patch("/events/:event_id/status", handler.UpdateEventStatus)
group.Post("/events/:event_id/cancel", handler.CancelEvent)
group.Get("/event-reports", handler.ListEventReports)
group.Patch("/event-reports/:report_id/status", handler.UpdateEventReportStatus)
group.Get("/categories", handler.ListCategories)
group.Post("/categories", handler.CreateCategory)
group.Delete("/categories/:category_id", handler.DeleteCategory)
group.Get("/participations", handler.ListParticipations)
group.Post("/participations", handler.CreateParticipation)
group.Post("/participations/:participation_id/cancel", handler.CancelParticipation)
group.Get("/tickets", handler.ListTickets)
group.Get("/notifications", handler.ListNotifications)
group.Post("/notifications", handler.CreateNotification)
group.Get("/invitations", handler.ListInvitations)
group.Patch("/invitations/:invitation_id/status", handler.UpdateInvitationStatus)
group.Get("/join-requests", handler.ListJoinRequests)
group.Patch("/join-requests/:join_request_id/status", handler.UpdateJoinRequestStatus)
group.Get("/comments", handler.ListComments)
group.Delete("/comments/:comment_id", handler.DeleteComment)
group.Get("/ratings/events", handler.ListEventRatings)
group.Delete("/ratings/events/:rating_id", handler.DeleteEventRating)
group.Get("/ratings/participants", handler.ListParticipantRatings)
group.Delete("/ratings/participants/:rating_id", handler.DeleteParticipantRating)
group.Get("/favorites/events", handler.ListFavoriteEvents)
group.Get("/favorites/locations", handler.ListFavoriteLocations)
group.Get("/badges/users", handler.ListUserBadges)
group.Get("/push-devices", handler.ListPushDevices)
group.Post("/push-devices/:device_id/revoke", handler.RevokePushDevice)
}
func (h *Handler) ListUsers(c *fiber.Ctx) error {
input, errs := parseListUsersInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListUsers(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
logAdminList(c, "admin.users.list", len(result.Items), result.PageMeta, summarizeUsers(input))
return c.JSON(result)
}
func (h *Handler) ListEvents(c *fiber.Ctx) error {
input, errs := parseListEventsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListEvents(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
logAdminList(c, "admin.events.list", len(result.Items), result.PageMeta, summarizeEvents(input))
return c.JSON(result)
}
func (h *Handler) ListParticipations(c *fiber.Ctx) error {
input, errs := parseListParticipationsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListParticipations(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
logAdminList(c, "admin.participations.list", len(result.Items), result.PageMeta, summarizeParticipations(input))
return c.JSON(result)
}
func (h *Handler) ListTickets(c *fiber.Ctx) error {
input, errs := parseListTicketsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListTickets(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
logAdminList(c, "admin.tickets.list", len(result.Items), result.PageMeta, summarizeTickets(input))
return c.JSON(result)
}
func (h *Handler) ListNotifications(c *fiber.Ctx) error {
input, errs := parseListNotificationsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListNotifications(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
logAdminList(c, "admin.notifications.list", len(result.Items), result.PageMeta, summarizeNotifications(input))
return c.JSON(result)
}
func (h *Handler) ListEventReports(c *fiber.Ctx) error {
input, errs := parseListEventReportsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListEventReports(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
logAdminList(c, "admin.event_reports.list", len(result.Items), result.PageMeta, summarizeEventReports(input))
return c.JSON(result)
}
func (h *Handler) ListCategories(c *fiber.Ctx) error {
result, err := h.service.ListCategories(c.UserContext())
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) CreateCategory(c *fiber.Ctx) error {
input, errs := parseCreateCategoryInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.CreateCategory(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
func (h *Handler) DeleteCategory(c *fiber.Ctx) error {
input, errs := parseDeleteCategoryInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
if err := h.service.DeleteCategory(c.UserContext(), input); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *Handler) UpdateEventReportStatus(c *fiber.Ctx) error {
input, errs := parseUpdateEventReportStatusInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.UpdateEventReportStatus(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) UpdateEventStatus(c *fiber.Ctx) error {
input, errs := parseUpdateEventStatusInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.UpdateEventStatus(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) CancelEvent(c *fiber.Ctx) error {
input, errs := parseCancelEventInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.CancelEvent(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) DeactivateUser(c *fiber.Ctx) error {
input, errs := parseDeactivateUserInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.DeactivateUser(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) CreateNotification(c *fiber.Ctx) error {
input, errs := parseCreateNotificationInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.SendCustomNotification(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
func (h *Handler) CreateParticipation(c *fiber.Ctx) error {
input, errs := parseCreateParticipationInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.CreateManualParticipation(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
func (h *Handler) CancelParticipation(c *fiber.Ctx) error {
input, errs := parseCancelParticipationInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.CancelParticipation(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) ListInvitations(c *fiber.Ctx) error {
input, errs := parseListInvitationsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListInvitations(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) UpdateInvitationStatus(c *fiber.Ctx) error {
input, errs := parseUpdateInvitationStatusInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.UpdateInvitationStatus(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) ListJoinRequests(c *fiber.Ctx) error {
input, errs := parseListJoinRequestsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListJoinRequests(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) UpdateJoinRequestStatus(c *fiber.Ctx) error {
input, errs := parseUpdateJoinRequestStatusInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.UpdateJoinRequestStatus(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) ListComments(c *fiber.Ctx) error {
input, errs := parseListCommentsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListComments(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) DeleteComment(c *fiber.Ctx) error {
input, errs := parseDeleteCommentInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
if err := h.service.DeleteComment(c.UserContext(), input); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *Handler) ListEventRatings(c *fiber.Ctx) error {
input, errs := parseListEventRatingsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListEventRatings(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) DeleteEventRating(c *fiber.Ctx) error {
input, errs := parseDeleteRatingInput(c, "rating_id")
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
if err := h.service.DeleteEventRating(c.UserContext(), input); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *Handler) ListParticipantRatings(c *fiber.Ctx) error {
input, errs := parseListParticipantRatingsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListParticipantRatings(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) DeleteParticipantRating(c *fiber.Ctx) error {
input, errs := parseDeleteRatingInput(c, "rating_id")
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
if err := h.service.DeleteParticipantRating(c.UserContext(), input); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *Handler) ListFavoriteEvents(c *fiber.Ctx) error {
input, errs := parseListFavoriteEventsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListFavoriteEvents(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) ListFavoriteLocations(c *fiber.Ctx) error {
input, errs := parseListFavoriteLocationsInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListFavoriteLocations(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) ListUserBadges(c *fiber.Ctx) error {
input, errs := parseListUserBadgesInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListUserBadges(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) ListPushDevices(c *fiber.Ctx) error {
input, errs := parseListPushDevicesInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
result, err := h.service.ListPushDevices(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
func (h *Handler) RevokePushDevice(c *fiber.Ctx) error {
input, errs := parseRevokePushDeviceInput(c)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
if err := h.service.RevokePushDevice(c.UserContext(), input); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
type createNotificationRequest struct {
UserIDs []string `json:"user_ids"`
DeliveryMode string `json:"delivery_mode"`
Title string `json:"title"`
Body string `json:"body"`
Type *string `json:"type"`
DeepLink *string `json:"deep_link"`
EventID *string `json:"event_id"`
Data map[string]string `json:"data"`
IdempotencyKey *string `json:"idempotency_key"`
}
type createParticipationRequest struct {
EventID string `json:"event_id"`
UserID string `json:"user_id"`
Status *string `json:"status"`
Reason *string `json:"reason"`
}
type cancelParticipationRequest struct {
Reason *string `json:"reason"`
}
type reasonRequest struct {
Reason *string `json:"reason"`
}
type statusRequest struct {
Status *string `json:"status"`
Reason *string `json:"reason"`
}
type createCategoryRequest struct {
Name string `json:"name"`
}
func parseCreateNotificationInput(c *fiber.Ctx) (admin.SendCustomNotificationInput, map[string]string) {
var body createNotificationRequest
errs := map[string]string{}
if err := c.BodyParser(&body); err != nil {
errs["body"] = "must be a valid JSON object"
return admin.SendCustomNotificationInput{}, errs
}
input := admin.SendCustomNotificationInput{
AdminUserID: httpapi.UserClaims(c).UserID,
Title: body.Title,
Body: body.Body,
Type: optionalTrimmedPtr(body.Type),
DeepLink: optionalTrimmedPtr(body.DeepLink),
Data: body.Data,
IdempotencyKey: optionalTrimmedPtr(body.IdempotencyKey),
}
if len(body.UserIDs) == 0 {
errs["user_ids"] = "must contain at least one user id"
} else {
input.UserIDs = make([]uuid.UUID, 0, len(body.UserIDs))
for _, raw := range body.UserIDs {
value, err := uuid.Parse(strings.TrimSpace(raw))
if err != nil {
errs["user_ids"] = "must contain only valid UUIDs"
break
}
if value == uuid.Nil {
errs["user_ids"] = "must contain only valid UUIDs"
break
}
input.UserIDs = append(input.UserIDs, value)
}
}
mode, ok := domain.ParseNotificationDeliveryMode(body.DeliveryMode)
if !ok {
errs["delivery_mode"] = "must be one of: IN_APP, PUSH, BOTH"
} else {
input.DeliveryMode = mode
}
if strings.TrimSpace(body.Title) == "" {
errs["title"] = "is required"
}
if strings.TrimSpace(body.Body) == "" {
errs["body"] = "is required"
}
if body.EventID != nil && strings.TrimSpace(*body.EventID) != "" {
eventID, err := uuid.Parse(strings.TrimSpace(*body.EventID))
if err != nil || eventID == uuid.Nil {
errs["event_id"] = "must be a valid UUID"
} else {
input.EventID = &eventID
}
}
return input, errs
}
func parseCreateParticipationInput(c *fiber.Ctx) (admin.CreateManualParticipationInput, map[string]string) {
var body createParticipationRequest
errs := map[string]string{}
if err := c.BodyParser(&body); err != nil {
errs["body"] = "must be a valid JSON object"
return admin.CreateManualParticipationInput{}, errs
}
input := admin.CreateManualParticipationInput{
AdminUserID: httpapi.UserClaims(c).UserID,
Status: domain.ParticipationStatusApproved,
Reason: optionalTrimmedPtr(body.Reason),
}
eventID, err := uuid.Parse(strings.TrimSpace(body.EventID))
if err != nil || eventID == uuid.Nil {
errs["event_id"] = "must be a valid UUID"
} else {
input.EventID = eventID
}
userID, err := uuid.Parse(strings.TrimSpace(body.UserID))
if err != nil || userID == uuid.Nil {
errs["user_id"] = "must be a valid UUID"
} else {
input.UserID = userID
}
if body.Status != nil && strings.TrimSpace(*body.Status) != "" {
status, ok := domain.ParseParticipationStatus(strings.TrimSpace(*body.Status))
if !ok || (status != domain.ParticipationStatusApproved && status != domain.ParticipationStatusPending) {
errs["status"] = "must be one of: APPROVED, PENDING"
} else {
input.Status = status
}
}
return input, errs
}
func parseCancelParticipationInput(c *fiber.Ctx) (admin.CancelParticipationInput, map[string]string) {
errs := map[string]string{}
participationID, err := uuid.Parse(strings.TrimSpace(c.Params("participation_id")))
if err != nil || participationID == uuid.Nil {
errs["participation_id"] = "must be a valid UUID"
}
var body cancelParticipationRequest
if len(c.Body()) > 0 {
if err := c.BodyParser(&body); err != nil {
errs["body"] = "must be a valid JSON object"
}
}
return admin.CancelParticipationInput{
AdminUserID: httpapi.UserClaims(c).UserID,
ParticipationID: participationID,
Reason: optionalTrimmedPtr(body.Reason),
}, errs
}
func parseCreateCategoryInput(c *fiber.Ctx) (admin.CreateCategoryInput, map[string]string) {
var body createCategoryRequest
errs := map[string]string{}
if err := c.BodyParser(&body); err != nil {
errs["body"] = "must be a valid JSON object"
}
return admin.CreateCategoryInput{AdminUserID: httpapi.UserClaims(c).UserID, Name: body.Name}, errs
}
func parseDeleteCategoryInput(c *fiber.Ctx) (admin.DeleteCategoryInput, map[string]string) {
errs := map[string]string{}
id, err := strconv.Atoi(strings.TrimSpace(c.Params("category_id")))
if err != nil || id <= 0 {
errs["category_id"] = "must be a positive integer"
}
return admin.DeleteCategoryInput{AdminUserID: httpapi.UserClaims(c).UserID, CategoryID: id}, errs
}
func parseUpdateEventReportStatusInput(c *fiber.Ctx) (admin.UpdateEventReportStatusInput, map[string]string) {
errs := map[string]string{}
reportID := parsePathUUID(c, "report_id", errs)
body := parseStatusBody(c, errs)
var status domain.EventReportStatus
if body.Status == nil {
errs["status"] = "is required"
} else if parsed, ok := domain.ParseEventReportStatus(strings.TrimSpace(*body.Status)); !ok {
errs["status"] = "must be one of: PENDING, REVIEWED, DISMISSED"
} else {
status = parsed
}
return admin.UpdateEventReportStatusInput{AdminUserID: httpapi.UserClaims(c).UserID, ReportID: reportID, Status: status, Reason: optionalTrimmedPtr(body.Reason)}, errs
}
func parseUpdateEventStatusInput(c *fiber.Ctx) (admin.UpdateEventStatusInput, map[string]string) {
errs := map[string]string{}
eventID := parsePathUUID(c, "event_id", errs)
body := parseStatusBody(c, errs)
var status domain.EventStatus
if body.Status == nil {
errs["status"] = "is required"
} else if parsed, ok := domain.ParseEventStatus(strings.TrimSpace(*body.Status)); !ok {
errs["status"] = "must be one of: ACTIVE, IN_PROGRESS, CANCELED, COMPLETED"
} else {
status = parsed
}
return admin.UpdateEventStatusInput{AdminUserID: httpapi.UserClaims(c).UserID, EventID: eventID, Status: status, Reason: optionalTrimmedPtr(body.Reason)}, errs
}
func parseCancelEventInput(c *fiber.Ctx) (admin.CancelEventInput, map[string]string) {
errs := map[string]string{}
eventID := parsePathUUID(c, "event_id", errs)
body := parseReasonBody(c, errs)
return admin.CancelEventInput{AdminUserID: httpapi.UserClaims(c).UserID, EventID: eventID, Reason: optionalTrimmedPtr(body.Reason)}, errs
}
func parseDeactivateUserInput(c *fiber.Ctx) (admin.DeactivateUserInput, map[string]string) {
errs := map[string]string{}
userID := parsePathUUID(c, "user_id", errs)
body := parseReasonBody(c, errs)
return admin.DeactivateUserInput{AdminUserID: httpapi.UserClaims(c).UserID, UserID: userID, Reason: optionalTrimmedPtr(body.Reason)}, errs
}
func parseListUsersInput(c *fiber.Ctx) (admin.ListUsersInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListUsersInput{PageInput: page}
input.Query = optionalTrimmed(c.Query("q"))
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
status, ok := domain.ParseUserStatus(raw)
if !ok {
errs["status"] = "must be one of: active"
} else {
input.Status = &status
}
}
if raw := strings.TrimSpace(c.Query("role")); raw != "" {
role, ok := domain.ParseUserRole(raw)
if !ok {
errs["role"] = "must be one of: USER, ADMIN"
} else {
input.Role = &role
}
}
return input, errs
}
func parseListEventsInput(c *fiber.Ctx) (admin.ListEventsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListEventsInput{PageInput: page}
input.Query = optionalTrimmed(c.Query("q"))
input.HostID = parseOptionalUUID(c, "host_id", errs)
input.CategoryID = parseOptionalInt(c, "category_id", errs)
input.StartFrom = parseOptionalTime(c, "start_from", errs)
input.StartTo = parseOptionalTime(c, "start_to", errs)
if raw := strings.TrimSpace(c.Query("privacy_level")); raw != "" {
level, ok := domain.ParseEventPrivacyLevel(raw)
if !ok {
errs["privacy_level"] = "must be one of: PUBLIC, PROTECTED, PRIVATE"
} else {
input.PrivacyLevel = &level
}
}
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
status, ok := domain.ParseEventStatus(raw)
if !ok {
errs["status"] = "must be one of: ACTIVE, IN_PROGRESS, CANCELED, COMPLETED"
} else {
input.Status = &status
}
}
return input, errs
}
func parseListParticipationsInput(c *fiber.Ctx) (admin.ListParticipationsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListParticipationsInput{PageInput: page}
input.Query = optionalTrimmed(c.Query("q"))
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
status, ok := domain.ParseParticipationStatus(raw)
if !ok {
errs["status"] = "must be one of: APPROVED, PENDING, CANCELED, LEAVED"
} else {
input.Status = &status
}
}
return input, errs
}
func parseListTicketsInput(c *fiber.Ctx) (admin.ListTicketsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListTicketsInput{PageInput: page}
input.Query = optionalTrimmed(c.Query("q"))
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.ParticipationID = parseOptionalUUID(c, "participation_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
status, ok := domain.ParseTicketStatus(raw)
if !ok {
errs["status"] = "must be one of: ACTIVE, PENDING, EXPIRED, USED, CANCELED"
} else {
input.Status = &status
}
}
return input, errs
}
func parseListNotificationsInput(c *fiber.Ctx) (admin.ListNotificationsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListNotificationsInput{PageInput: page}
input.Query = optionalTrimmed(c.Query("q"))
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
input.Type = optionalTrimmed(c.Query("type"))
if raw := strings.TrimSpace(c.Query("is_read")); raw != "" {
value, err := strconv.ParseBool(raw)
if err != nil {
errs["is_read"] = "must be true or false"
} else {
input.IsRead = &value
}
}
return input, errs
}
func parseListEventReportsInput(c *fiber.Ctx) (admin.ListEventReportsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListEventReportsInput{PageInput: page}
input.Query = optionalTrimmed(c.Query("q"))
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.ReporterUserID = parseOptionalUUID(c, "reporter_user_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
status, ok := domain.ParseEventReportStatus(raw)
if !ok {
errs["status"] = "must be one of: PENDING, REVIEWED, DISMISSED"
} else {
input.Status = &status
}
}
if raw := strings.TrimSpace(c.Query("report_category")); raw != "" {
category, ok := domain.ParseEventReportCategory(raw)
if !ok {
errs["report_category"] = "must be a valid report category"
} else {
input.ReportCategory = &category
}
}
return input, errs
}
func parseListInvitationsInput(c *fiber.Ctx) (admin.ListInvitationsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListInvitationsInput{PageInput: page, Query: optionalTrimmed(c.Query("q"))}
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.HostID = parseOptionalUUID(c, "host_id", errs)
input.InvitedUserID = parseOptionalUUID(c, "invited_user_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
status, ok := domain.ParseInvitationStatus(raw)
if !ok {
errs["status"] = "must be a valid invitation status"
} else {
input.Status = &status
}
}
return input, errs
}
func parseListJoinRequestsInput(c *fiber.Ctx) (admin.ListJoinRequestsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListJoinRequestsInput{PageInput: page, Query: optionalTrimmed(c.Query("q"))}
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.HostUserID = parseOptionalUUID(c, "host_user_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
status, ok := domain.ParseJoinRequestStatus(raw)
if !ok {
errs["status"] = "must be a valid join request status"
} else {
input.Status = &status
}
}
return input, errs
}
func parseListCommentsInput(c *fiber.Ctx) (admin.ListCommentsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListCommentsInput{PageInput: page, Query: optionalTrimmed(c.Query("q")), Type: optionalTrimmed(c.Query("type"))}
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
return input, errs
}
func parseListEventRatingsInput(c *fiber.Ctx) (admin.ListEventRatingsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListEventRatingsInput{PageInput: page}
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
return input, errs
}
func parseListParticipantRatingsInput(c *fiber.Ctx) (admin.ListParticipantRatingsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListParticipantRatingsInput{PageInput: page}
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.HostID = parseOptionalUUID(c, "host_id", errs)
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
return input, errs
}
func parseListFavoriteEventsInput(c *fiber.Ctx) (admin.ListFavoriteEventsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListFavoriteEventsInput{PageInput: page}
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.EventID = parseOptionalUUID(c, "event_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
return input, errs
}
func parseListFavoriteLocationsInput(c *fiber.Ctx) (admin.ListFavoriteLocationsInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListFavoriteLocationsInput{PageInput: page, Query: optionalTrimmed(c.Query("q"))}
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
return input, errs
}
func parseListUserBadgesInput(c *fiber.Ctx) (admin.ListUserBadgesInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListUserBadgesInput{PageInput: page, Query: optionalTrimmed(c.Query("q"))}
input.UserID = parseOptionalUUID(c, "user_id", errs)
return input, errs
}
func parseListPushDevicesInput(c *fiber.Ctx) (admin.ListPushDevicesInput, map[string]string) {
page, errs := parsePage(c)
input := admin.ListPushDevicesInput{PageInput: page, Platform: optionalTrimmed(c.Query("platform"))}
input.UserID = parseOptionalUUID(c, "user_id", errs)
input.CreatedFrom = parseOptionalTime(c, "created_from", errs)
input.CreatedTo = parseOptionalTime(c, "created_to", errs)
if raw := strings.TrimSpace(c.Query("active")); raw != "" {
value, err := strconv.ParseBool(raw)
if err != nil {
errs["active"] = "must be true or false"
} else {
input.Active = &value
}
}
return input, errs
}
func parseUpdateInvitationStatusInput(c *fiber.Ctx) (admin.UpdateInvitationStatusInput, map[string]string) {
errs := map[string]string{}
invitationID := parsePathUUID(c, "invitation_id", errs)
body := parseStatusBody(c, errs)
var status domain.InvitationStatus
if body.Status == nil {
errs["status"] = "is required"
} else if parsed, ok := domain.ParseInvitationStatus(strings.TrimSpace(*body.Status)); !ok {
errs["status"] = "must be a valid invitation status"
} else {
status = parsed
}
return admin.UpdateInvitationStatusInput{AdminUserID: httpapi.UserClaims(c).UserID, InvitationID: invitationID, Status: status, Reason: optionalTrimmedPtr(body.Reason)}, errs
}
func parseUpdateJoinRequestStatusInput(c *fiber.Ctx) (admin.UpdateJoinRequestStatusInput, map[string]string) {
errs := map[string]string{}
joinRequestID := parsePathUUID(c, "join_request_id", errs)
body := parseStatusBody(c, errs)
var status domain.JoinRequestStatus
if body.Status == nil {
errs["status"] = "is required"
} else if parsed, ok := domain.ParseJoinRequestStatus(strings.TrimSpace(*body.Status)); !ok {
errs["status"] = "must be a valid join request status"
} else {
status = parsed
}
return admin.UpdateJoinRequestStatusInput{AdminUserID: httpapi.UserClaims(c).UserID, JoinRequestID: joinRequestID, Status: status, Reason: optionalTrimmedPtr(body.Reason)}, errs
}
func parseDeleteCommentInput(c *fiber.Ctx) (admin.DeleteCommentInput, map[string]string) {
errs := map[string]string{}
return admin.DeleteCommentInput{AdminUserID: httpapi.UserClaims(c).UserID, CommentID: parsePathUUID(c, "comment_id", errs)}, errs
}
func parseDeleteRatingInput(c *fiber.Ctx, key string) (admin.DeleteRatingInput, map[string]string) {
errs := map[string]string{}
body := parseReasonBody(c, errs)
return admin.DeleteRatingInput{AdminUserID: httpapi.UserClaims(c).UserID, RatingID: parsePathUUID(c, key, errs), Reason: optionalTrimmedPtr(body.Reason)}, errs
}
func parseRevokePushDeviceInput(c *fiber.Ctx) (admin.RevokePushDeviceInput, map[string]string) {
errs := map[string]string{}
body := parseReasonBody(c, errs)
return admin.RevokePushDeviceInput{AdminUserID: httpapi.UserClaims(c).UserID, DeviceID: parsePathUUID(c, "device_id", errs), Reason: optionalTrimmedPtr(body.Reason)}, errs
}
func parsePage(c *fiber.Ctx) (admin.PageInput, map[string]string) {
errs := map[string]string{}
page := admin.PageInput{Limit: admin.DefaultLimit}
if raw := strings.TrimSpace(c.Query("limit")); raw != "" {
limit, err := strconv.Atoi(raw)
if err != nil || limit < 1 || limit > admin.MaxLimit {
errs["limit"] = "must be an integer between 1 and 100"
} else {
page.Limit = limit
}
}
if raw := strings.TrimSpace(c.Query("offset")); raw != "" {
offset, err := strconv.Atoi(raw)
if err != nil || offset < 0 {
errs["offset"] = "must be a non-negative integer"
} else {
page.Offset = offset
}
}
return page, errs
}
func optionalTrimmed(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func optionalTrimmedPtr(value *string) *string {
if value == nil {
return nil
}
return optionalTrimmed(*value)
}
func parseOptionalUUID(c *fiber.Ctx, key string, errs map[string]string) *uuid.UUID {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return nil
}
value, err := uuid.Parse(raw)
if err != nil {
errs[key] = "must be a valid UUID"
return nil
}
return &value
}
func parsePathUUID(c *fiber.Ctx, key string, errs map[string]string) uuid.UUID {
value, err := uuid.Parse(strings.TrimSpace(c.Params(key)))
if err != nil || value == uuid.Nil {
errs[key] = "must be a valid UUID"
return uuid.Nil
}
return value
}
func parseReasonBody(c *fiber.Ctx, errs map[string]string) reasonRequest {
var body reasonRequest
if len(c.Body()) > 0 {
if err := c.BodyParser(&body); err != nil {
errs["body"] = "must be a valid JSON object"
}
}
return body
}
func parseStatusBody(c *fiber.Ctx, errs map[string]string) statusRequest {
var body statusRequest
if err := c.BodyParser(&body); err != nil {
errs["body"] = "must be a valid JSON object"
}
return body
}
func parseOptionalInt(c *fiber.Ctx, key string, errs map[string]string) *int {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return nil
}
value, err := strconv.Atoi(raw)
if err != nil {
errs[key] = "must be an integer"
return nil
}
return &value
}
func parseOptionalTime(c *fiber.Ctx, key string, errs map[string]string) *time.Time {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return nil
}
value, err := time.Parse(time.RFC3339, raw)
if err != nil {
errs[key] = "must be an RFC3339 timestamp"
return nil
}
value = value.UTC()
return &value
}
func logAdminList(c *fiber.Ctx, operation string, resultCount int, page admin.PageMeta, summary string) {
httpapi.LogInfo(
c.UserContext(),
"admin list query completed",
httpapi.OperationAttr(operation),
httpapi.UserIDAttr(httpapi.UserClaims(c).UserID),
slog.Int("result_count", resultCount),
slog.Int("limit", page.Limit),
slog.Int("offset", page.Offset),
slog.Bool("has_next", page.HasNext),
httpapi.QuerySummaryAttr(summary),
)
}
func summarizeUsers(input admin.ListUsersInput) string {
return httpapi.JoinSummary(
httpapi.CountSummary("limit", input.Limit),
httpapi.CountSummary("offset", input.Offset),
httpapi.StringPtrSummary("q", input.Query),
pointerSummary("status", input.Status),
pointerSummary("role", input.Role),
timePointerSummary("created_from", input.CreatedFrom),
timePointerSummary("created_to", input.CreatedTo),
)
}
func summarizeEvents(input admin.ListEventsInput) string {
return httpapi.JoinSummary(
httpapi.CountSummary("limit", input.Limit),
httpapi.CountSummary("offset", input.Offset),
httpapi.StringPtrSummary("q", input.Query),
uuidPointerSummary("host_id", input.HostID),
intPointerSummary("category_id", input.CategoryID),
pointerSummary("privacy_level", input.PrivacyLevel),
pointerSummary("status", input.Status),
timePointerSummary("start_from", input.StartFrom),
timePointerSummary("start_to", input.StartTo),
)
}
func summarizeParticipations(input admin.ListParticipationsInput) string {
return httpapi.JoinSummary(
httpapi.CountSummary("limit", input.Limit),
httpapi.CountSummary("offset", input.Offset),
httpapi.StringPtrSummary("q", input.Query),
pointerSummary("status", input.Status),
uuidPointerSummary("event_id", input.EventID),
uuidPointerSummary("user_id", input.UserID),
timePointerSummary("created_from", input.CreatedFrom),
timePointerSummary("created_to", input.CreatedTo),
)
}
func summarizeTickets(input admin.ListTicketsInput) string {
return httpapi.JoinSummary(
httpapi.CountSummary("limit", input.Limit),
httpapi.CountSummary("offset", input.Offset),
httpapi.StringPtrSummary("q", input.Query),
pointerSummary("status", input.Status),
uuidPointerSummary("event_id", input.EventID),
uuidPointerSummary("user_id", input.UserID),
uuidPointerSummary("participation_id", input.ParticipationID),
timePointerSummary("created_from", input.CreatedFrom),
timePointerSummary("created_to", input.CreatedTo),
)
}
func summarizeNotifications(input admin.ListNotificationsInput) string {
return httpapi.JoinSummary(
httpapi.CountSummary("limit", input.Limit),
httpapi.CountSummary("offset", input.Offset),
httpapi.StringPtrSummary("q", input.Query),
uuidPointerSummary("user_id", input.UserID),
uuidPointerSummary("event_id", input.EventID),
httpapi.StringPtrSummary("type", input.Type),
boolPointerSummary("is_read", input.IsRead),
timePointerSummary("created_from", input.CreatedFrom),
timePointerSummary("created_to", input.CreatedTo),
)
}
func summarizeEventReports(input admin.ListEventReportsInput) string {
return httpapi.JoinSummary(
httpapi.CountSummary("limit", input.Limit),
httpapi.CountSummary("offset", input.Offset),
httpapi.StringPtrSummary("q", input.Query),
pointerSummary("status", input.Status),
pointerSummary("report_category", input.ReportCategory),
uuidPointerSummary("event_id", input.EventID),
uuidPointerSummary("reporter_user_id", input.ReporterUserID),
timePointerSummary("created_from", input.CreatedFrom),
timePointerSummary("created_to", input.CreatedTo),
)
}
func pointerSummary[T ~string](label string, value *T) string {
if value == nil {
return label + "=<nil>"
}
return httpapi.StringSummary(label, string(*value))
}
func uuidPointerSummary(label string, value *uuid.UUID) string {
if value == nil {
return label + "=<nil>"
}
return httpapi.StringSummary(label, value.String())
}
func intPointerSummary(label string, value *int) string {
if value == nil {
return label + "=<nil>"
}
return httpapi.CountSummary(label, *value)
}
func boolPointerSummary(label string, value *bool) string {
if value == nil {
return label + "=<nil>"
}
return httpapi.BoolSummary(label, *value)
}
func timePointerSummary(label string, value *time.Time) string {
if value == nil {
return label + "=<nil>"
}
return httpapi.StringSummary(label, value.Format(time.RFC3339))
}
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"
"github.com/google/uuid"
)
// 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)
}
httpapi.LogInfo(
c.UserContext(),
"user registration completed",
httpapi.OperationAttr("auth.register.complete"),
httpapi.UserIDAttr(session.User.ID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"user logged in",
httpapi.OperationAttr("auth.login"),
httpapi.UserIDAttr(session.User.ID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"session refreshed",
httpapi.OperationAttr("auth.refresh"),
httpapi.UserIDAttr(session.User.ID),
)
return c.Status(fiber.StatusOK).JSON(toSessionResponse(session))
}
// Logout handles POST /auth/logout.
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
var body logoutBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
var deviceInstallationID *uuid.UUID
if body.DeviceInstallationID != nil && strings.TrimSpace(*body.DeviceInstallationID) != "" {
parsed, err := uuid.Parse(strings.TrimSpace(*body.DeviceInstallationID))
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"device_installation_id": "must be a valid UUID"}))
}
deviceInstallationID = &parsed
}
if err := h.service.Logout(c.UserContext(), auth.LogoutInput{
RefreshToken: body.RefreshToken,
DeviceInstallationID: deviceInstallationID,
}); 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 badge_handler
import (
"log/slog"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/badge"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// Handler exposes HTTP routes for the badge use cases.
type Handler struct {
service badge.UseCase
}
// NewHandler constructs a badge handler delegating to the given use case.
func NewHandler(service badge.UseCase) *Handler {
return &Handler{service: service}
}
// RegisterRoutes wires the badge routes onto the given fiber router.
//
// Routes registered:
// - GET /me/badges (authenticated viewer's earned badges)
// - GET /users/:id/badges (public profile owner's earned badges)
// - GET /badges (full catalog with viewer earned status)
func RegisterRoutes(router fiber.Router, handler *Handler, auth fiber.Handler) {
router.Get("/me/badges", auth, handler.ListMyBadges)
router.Get("/users/:id/badges", auth, handler.ListUserBadges)
router.Get("/badges", auth, handler.ListBadgeCatalog)
}
// ListMyBadges handles GET /me/badges.
func (h *Handler) ListMyBadges(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.ListMyBadges(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"my badges fetched",
httpapi.OperationAttr("badges.list_mine"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("result_count", len(result.Items)),
)
return c.JSON(result)
}
// ListUserBadges handles GET /users/:id/badges.
func (h *Handler) ListUserBadges(c *fiber.Ctx) error {
userID, err := uuid.Parse(c.Params("id"))
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"id": "must be a valid UUID"}))
}
result, err := h.service.ListUserBadges(c.UserContext(), userID)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
httpapi.LogInfo(
c.UserContext(),
"user badges fetched",
httpapi.OperationAttr("badges.list_user"),
httpapi.UserIDAttr(claims.UserID),
slog.String("target_user_id", userID.String()),
slog.Int("result_count", len(result.Items)),
)
return c.JSON(result)
}
// ListBadgeCatalog handles GET /badges.
func (h *Handler) ListBadgeCatalog(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.ListBadges(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"badge catalog fetched",
httpapi.OperationAttr("badges.list_catalog"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("result_count", len(result.Items)),
)
return c.JSON(result)
}
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 comment_handler
import (
"log/slog"
"strconv"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
commentapp "github.com/bounswe/bounswe2026group11/backend/internal/application/comment"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// Handler groups HTTP handlers for event comments and reviews.
type Handler struct {
service commentapp.UseCase
}
// NewHandler constructs a comment handler.
func NewHandler(service commentapp.UseCase) *Handler {
return &Handler{service: service}
}
// RegisterRoutes mounts comment routes under /events.
func RegisterRoutes(router fiber.Router, handler *Handler, auth fiber.Handler, optionalAuth fiber.Handler) {
events := router.Group("/events")
events.Get("/:id/comments", optionalAuth, handler.ListEventComments)
events.Get("/:id/comments/:commentId/replies", optionalAuth, handler.ListCommentReplies)
events.Post("/:id/comments", auth, handler.CreateDiscussionComment)
events.Post("/:id/review-comments", auth, handler.UpsertReviewComment)
}
// ListEventComments handles GET /events/:id/comments.
func (h *Handler) ListEventComments(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)
var viewerUserID *uuid.UUID
if claims != nil {
viewerUserID = &claims.UserID
}
result, svcErr := h.service.ListEventComments(c.UserContext(), viewerUserID, eventID, commentapp.ListEventCommentsInput{
DiscussionLimit: parseOptionalInt(c, "discussion_limit"),
DiscussionCursor: parseOptionalString(c, "discussion_cursor"),
ReviewLimit: parseOptionalInt(c, "review_limit"),
ReviewCursor: parseOptionalString(c, "review_cursor"),
})
if svcErr != nil {
return httpapi.WriteError(c, svcErr)
}
httpapi.LogInfo(
c.UserContext(),
"event comments listed",
httpapi.OperationAttr("comment.event.list"),
httpapi.EventIDAttr(eventID),
slog.Int("discussion_count", len(result.DiscussionComments.Items)),
slog.Int("review_count", len(result.ReviewComments.Items)),
slog.Bool("discussion_has_next", result.DiscussionComments.PageInfo.HasNext),
slog.Bool("review_has_next", result.ReviewComments.PageInfo.HasNext),
)
return c.JSON(result)
}
// ListCommentReplies handles GET /events/:id/comments/:commentId/replies.
func (h *Handler) ListCommentReplies(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"}))
}
commentID, err := parseUUIDParam(c, "commentId")
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"commentId": "must be a valid UUID"}))
}
claims := httpapi.UserClaims(c)
var viewerUserID *uuid.UUID
if claims != nil {
viewerUserID = &claims.UserID
}
result, svcErr := h.service.ListCommentReplies(c.UserContext(), viewerUserID, eventID, commentID, commentapp.ListCommentRepliesInput{
Limit: parseOptionalInt(c, "limit"),
Cursor: parseOptionalString(c, "cursor"),
})
if svcErr != nil {
return httpapi.WriteError(c, svcErr)
}
httpapi.LogInfo(
c.UserContext(),
"comment replies listed",
httpapi.OperationAttr("comment.replies.list"),
httpapi.EventIDAttr(eventID),
slog.String("comment_id", commentID.String()),
slog.Int("result_count", len(result.Items)),
slog.Bool("has_next", result.PageInfo.HasNext),
)
return c.JSON(result)
}
// CreateDiscussionComment handles POST /events/:id/comments.
func (h *Handler) CreateDiscussionComment(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 := parseCreateDiscussionCommentBody(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, svcErr := h.service.CreateDiscussionComment(c.UserContext(), claims.UserID, eventID, input)
if svcErr != nil {
return httpapi.WriteError(c, svcErr)
}
httpapi.LogInfo(
c.UserContext(),
"discussion comment created",
httpapi.OperationAttr("comment.discussion.create"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.Bool("is_reply", input.ParentID != nil),
)
return c.Status(fiber.StatusCreated).JSON(result)
}
// UpsertReviewComment handles POST /events/:id/review-comments.
func (h *Handler) UpsertReviewComment(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 := parseUpsertReviewCommentBody(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, svcErr := h.service.UpsertReviewComment(c.UserContext(), claims.UserID, eventID, input)
if svcErr != nil {
return httpapi.WriteError(c, svcErr)
}
httpapi.LogInfo(
c.UserContext(),
"review comment upserted",
httpapi.OperationAttr("comment.review.upsert"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.Int("rating", input.Rating),
slog.Bool("has_image_token", input.ImageConfirmToken != nil && *input.ImageConfirmToken != ""),
)
return c.JSON(result)
}
func parseCreateDiscussionCommentBody(c *fiber.Ctx) (commentapp.CreateDiscussionCommentInput, error) {
var body createDiscussionCommentBody
if err := c.BodyParser(&body); err != nil {
return commentapp.CreateDiscussionCommentInput{}, domain.ValidationError(map[string]string{"body": "must be valid JSON"})
}
var parentID *uuid.UUID
if body.ParentID != nil && *body.ParentID != "" {
parsed, err := uuid.Parse(*body.ParentID)
if err != nil {
return commentapp.CreateDiscussionCommentInput{}, domain.ValidationError(map[string]string{"parent_id": "must be a valid UUID"})
}
parentID = &parsed
}
return commentapp.CreateDiscussionCommentInput{
Message: body.Message,
ParentID: parentID,
}, nil
}
func parseUpsertReviewCommentBody(c *fiber.Ctx) (commentapp.UpsertReviewCommentInput, error) {
var body upsertReviewCommentBody
if err := c.BodyParser(&body); err != nil {
return commentapp.UpsertReviewCommentInput{}, domain.ValidationError(map[string]string{"body": "must be valid JSON"})
}
return commentapp.UpsertReviewCommentInput{
Message: body.Message,
Rating: body.Rating,
ImageConfirmToken: body.ImageConfirmToken,
}, nil
}
func parseUUIDParam(c *fiber.Ctx, name string) (uuid.UUID, error) {
return uuid.Parse(c.Params(name))
}
func parseOptionalString(c *fiber.Ctx, name string) *string {
value := c.Query(name)
if value == "" {
return nil
}
return &value
}
func parseOptionalInt(c *fiber.Ctx, name string) *int {
value := c.Query(name)
if value == "" {
return nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
invalid := 0
return &invalid
}
return &parsed
}
package event_handler
import (
"encoding/json"
"fmt"
"log/slog"
"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/application/invitation"
"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
invitationService invitation.UseCase
}
// NewEventHandler creates an event handler backed by the given event use case.
func NewEventHandler(service event.UseCase, invitationService ...invitation.UseCase) *EventHandler {
handler := &EventHandler{service: service}
if len(invitationService) > 0 {
handler.invitationService = invitationService[0]
}
return handler
}
// 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.Patch("/:id", auth, handler.UpdateEvent)
group.Post("/:id/invitations", auth, handler.CreateInvitations)
group.Delete("/:id/invitations/:invitationId", auth, handler.RevokeInvitation)
group.Post("/:id/join", auth, handler.JoinEvent)
group.Patch("/:id/leave", auth, handler.LeaveEvent)
group.Post("/:id/participation/reconfirm", auth, handler.ReconfirmParticipation)
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.Delete("/:id/join-requests/me", auth, handler.CancelJoinRequest)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event discovery completed",
httpapi.OperationAttr("event.discover"),
httpapi.OptionalUserIDAttr(userID),
httpapi.QuerySummaryAttr(summarizeDiscoverEventsInput(input)),
slog.Int("result_count", len(result.Items)),
slog.Bool("has_next", result.PageInfo.HasNext),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event detail fetched",
httpapi.OperationAttr("event.detail"),
httpapi.OptionalUserIDAttr(userID),
httpapi.EventIDAttr(eventID),
slog.Int("event_version", result.VersionNo),
slog.Bool("needs_reconfirmation", result.ViewerContext.NeedsReconfirmation),
slog.Int("latest_event_version", result.ViewerContext.LatestEventVersion),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event host context fetched",
httpapi.OperationAttr("event.host_context"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event participants listed",
httpapi.OperationAttr("event.participants.list"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.QuerySummaryAttr(summarizeEventCollectionInput(input)),
slog.Int("result_count", len(result.Items)),
slog.Bool("has_next", result.PageInfo.HasNext),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event join requests listed",
httpapi.OperationAttr("event.join_requests.list"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.QuerySummaryAttr(summarizeEventCollectionInput(input)),
slog.Int("result_count", len(result.Items)),
slog.Bool("has_next", result.PageInfo.HasNext),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event invitations listed",
httpapi.OperationAttr("event.invitations.list"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.QuerySummaryAttr(summarizeEventCollectionInput(input)),
slog.Int("result_count", len(result.Items)),
slog.Bool("has_next", result.PageInfo.HasNext),
)
return c.JSON(result)
}
type createInvitationsBody struct {
Usernames []string `json:"usernames"`
Message *string `json:"message"`
}
// CreateInvitations handles POST /events/:id/invitations.
func (h *EventHandler) CreateInvitations(c *fiber.Ctx) error {
if h.invitationService == nil {
return httpapi.WriteError(c, domain.ConflictError(domain.ErrorCodeInvitationNotAllowed, "Invitations are not available."))
}
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
var body createInvitationsBody
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.invitationService.CreateInvitations(c.UserContext(), claims.UserID, eventID, invitation.CreateInvitationsInput{
Usernames: body.Usernames,
Message: body.Message,
})
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"event invitations created",
httpapi.OperationAttr("event.invitations.create"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.QuerySummaryAttr(httpapi.JoinSummary(
httpapi.CountSummary("success_count", result.SuccessCount),
httpapi.CountSummary("invalid_username_count", result.InvalidUsernameCount),
httpapi.CountSummary("failed_count", result.FailedCount),
)),
)
return c.Status(fiber.StatusCreated).JSON(result)
}
// RevokeInvitation handles DELETE /events/:id/invitations/:invitationId.
// Allows the authenticated host to cancel a PENDING invitation.
func (h *EventHandler) RevokeInvitation(c *fiber.Ctx) error {
if h.invitationService == nil {
return httpapi.WriteError(c, &domain.AppError{Code: "internal_error", Message: "invitation service is not configured", Status: domain.StatusInternalError})
}
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
invitationID, err := parseInvitationIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.invitationService.RevokeInvitation(c.UserContext(), claims.UserID, eventID, invitationID); err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"event invitation revoked",
httpapi.OperationAttr("event.invitations.revoke"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
)
return c.SendStatus(fiber.StatusNoContent)
}
// 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)
}
httpapi.LogInfo(
c.UserContext(),
"event created",
httpapi.OperationAttr("event.create"),
httpapi.UserIDAttr(claims.UserID),
slog.String("created_event_id", result.ID),
httpapi.QuerySummaryAttr(summarizeCreateEventInput(input)),
)
return c.Status(fiber.StatusCreated).JSON(result)
}
// UpdateEvent handles PATCH /events/:id.
func (h *EventHandler) UpdateEvent(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
input, errs := toUpdateEventInput(c.Body())
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
claims := httpapi.UserClaims(c)
result, err := h.service.UpdateEvent(c.UserContext(), claims.UserID, eventID, input)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"event updated",
httpapi.OperationAttr("event.update"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.QuerySummaryAttr(summarizeUpdateEventInput(input)),
slog.Int("event_version", result.VersionNo),
slog.Bool("reconfirmation_required", result.ReconfirmationRequired),
slog.Int("reconfirmation_triggered_field_count", len(result.ReconfirmationTriggeredFields)),
slog.Int("participants_marked_pending", result.ParticipantsMarkedPending),
)
return c.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
}
}
input.ChildFriendly = body.ChildFriendly
input.FamilyOriented = body.FamilyOriented
return input, errs
}
// toUpdateEventInput parses a PATCH body while preserving omitted vs explicit
// null fields.
func toUpdateEventInput(body []byte) (event.UpdateEventInput, map[string]string) {
if len(body) == 0 {
return event.UpdateEventInput{}, map[string]string{"body": "must be valid JSON"}
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return event.UpdateEventInput{}, map[string]string{"body": "must be valid JSON"}
}
input := event.UpdateEventInput{}
errs := make(map[string]string)
if value, ok := raw["title"]; ok {
if string(value) == "null" {
errs["title"] = "title must be a string"
} else if parsed, err := parseRawString(value); err != nil {
errs["title"] = "title must be a string"
} else {
input.Title = &parsed
}
}
if value, ok := raw["description"]; ok {
input.Description.Set = true
if string(value) != "null" {
if parsed, err := parseRawString(value); err != nil {
errs["description"] = "description must be a string or null"
} else {
input.Description.Value = &parsed
}
}
}
if value, ok := raw["category_id"]; ok {
input.CategoryID.Set = true
if string(value) != "null" {
if parsed, err := parseRawInt(value); err != nil {
errs["category_id"] = "category_id must be an integer or null"
} else {
input.CategoryID.Value = &parsed
}
}
}
if value, ok := raw["address"]; ok {
input.Address.Set = true
if string(value) != "null" {
if parsed, err := parseRawString(value); err != nil {
errs["address"] = "address must be a string or null"
} else {
input.Address.Value = &parsed
}
}
}
if value, ok := raw["location_type"]; ok {
if string(value) == "null" {
errs["location_type"] = "location_type must be one of: POINT, ROUTE"
} else if parsed, err := parseRawString(value); err != nil {
errs["location_type"] = "location_type must be one of: POINT, ROUTE"
} else if locationType, ok := domain.ParseEventLocationType(parsed); !ok {
errs["location_type"] = "must be one of: POINT, ROUTE"
} else {
input.LocationType = &locationType
}
}
if value, ok := raw["lat"]; ok {
if string(value) == "null" {
errs["lat"] = "lat must be a number"
} else if parsed, err := parseRawFloat(value); err != nil {
errs["lat"] = "lat must be a number"
} else {
input.Lat = &parsed
}
}
if value, ok := raw["lon"]; ok {
if string(value) == "null" {
errs["lon"] = "lon must be a number"
} else if parsed, err := parseRawFloat(value); err != nil {
errs["lon"] = "lon must be a number"
} else {
input.Lon = &parsed
}
}
if value, ok := raw["route_points"]; ok {
if string(value) == "null" {
errs["route_points"] = "route_points must be an array"
} else {
var points []routePointBody
if err := json.Unmarshal(value, &points); err != nil {
errs["route_points"] = "route_points must be an array"
} else {
converted := toRoutePointInputs(points)
input.RoutePoints = &converted
}
}
}
if value, ok := raw["start_time"]; ok {
if string(value) == "null" {
errs["start_time"] = "start_time must be a valid RFC3339 date-time with timezone"
} else if parsed, err := parseRawString(value); err != nil {
errs["start_time"] = "start_time must be a valid RFC3339 date-time with timezone"
} else if startTime, err := time.Parse(time.RFC3339, parsed); err != nil {
errs["start_time"] = "start_time must be a valid RFC3339 date-time with timezone"
} else {
input.StartTime = &startTime
}
}
if value, ok := raw["end_time"]; ok {
input.EndTime.Set = true
if string(value) != "null" {
if parsed, err := parseRawString(value); err != nil {
errs["end_time"] = "end_time must be a valid RFC3339 date-time with timezone or null"
} else if endTime, err := time.Parse(time.RFC3339, parsed); err != nil {
errs["end_time"] = "end_time must be a valid RFC3339 date-time with timezone or null"
} else {
input.EndTime.Value = &endTime
}
}
}
if value, ok := raw["capacity"]; ok {
input.Capacity.Set = true
if string(value) != "null" {
if parsed, err := parseRawInt(value); err != nil {
errs["capacity"] = "capacity must be an integer or null"
} else {
input.Capacity.Value = &parsed
}
}
}
if value, ok := raw["constraints"]; ok {
if string(value) == "null" {
errs["constraints"] = "constraints must be an array"
} else {
var constraints []constraintBody
if err := json.Unmarshal(value, &constraints); err != nil {
errs["constraints"] = "constraints must be an array"
} else {
converted := toConstraintInputs(constraints)
input.Constraints = &converted
}
}
}
return input, errs
}
func parseRawString(value json.RawMessage) (string, error) {
var parsed string
err := json.Unmarshal(value, &parsed)
return parsed, err
}
func parseRawInt(value json.RawMessage) (int, error) {
var parsed int
err := json.Unmarshal(value, &parsed)
return parsed, err
}
func parseRawFloat(value json.RawMessage) (float64, error) {
var parsed float64
err := json.Unmarshal(value, &parsed)
return parsed, err
}
// 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)
}
httpapi.LogInfo(
c.UserContext(),
"event joined",
httpapi.OperationAttr("event.join"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.String("participation_id", result.ParticipationID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event left",
httpapi.OperationAttr("event.leave"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.String("participation_id", result.ParticipationID),
)
return c.JSON(result)
}
// ReconfirmParticipation handles POST /events/:id/participation/reconfirm.
func (h *EventHandler) ReconfirmParticipation(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.ReconfirmParticipation(c.UserContext(), claims.UserID, eventID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"event participation reconfirmed",
httpapi.OperationAttr("event.participation.reconfirm"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.String("participation_id", result.ParticipationID),
slog.Int("last_confirmed_event_version", result.LastConfirmedEventVersion),
slog.Int("latest_event_version", result.LatestEventVersion),
slog.Bool("ticket_created", result.TicketStatus != nil),
)
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,
ImageConfirmToken: body.ImageConfirmToken,
})
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"event join request created",
httpapi.OperationAttr("event.join_request.create"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.QuerySummaryAttr(httpapi.JoinSummary(
httpapi.BoolSummary("has_message", body.Message != nil && strings.TrimSpace(*body.Message) != ""),
httpapi.BoolSummary("has_image_token", body.ImageConfirmToken != nil && strings.TrimSpace(*body.ImageConfirmToken) != ""),
)),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event join request approved",
httpapi.OperationAttr("event.join_request.approve"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.JoinRequestIDAttr(joinRequestID),
slog.String("participation_id", result.ParticipationID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event join request rejected",
httpapi.OperationAttr("event.join_request.reject"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.JoinRequestIDAttr(joinRequestID),
)
return c.JSON(result)
}
// CancelJoinRequest handles DELETE /events/:id/join-requests/me.
// Allows the authenticated user to cancel their own pending join request.
func (h *EventHandler) CancelJoinRequest(c *fiber.Ctx) error {
eventID, err := parseEventIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.service.CancelJoinRequest(c.UserContext(), claims.UserID, eventID); err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"join request canceled",
httpapi.OperationAttr("event.join_request.cancel"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
)
return c.SendStatus(fiber.StatusNoContent)
}
// 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)
}
httpapi.LogInfo(
c.UserContext(),
"event canceled",
httpapi.OperationAttr("event.cancel"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event completed",
httpapi.OperationAttr("event.complete"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event favorited",
httpapi.OperationAttr("event.favorite.add"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event favorite removed",
httpapi.OperationAttr("event.favorite.remove"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
)
return c.SendStatus(fiber.StatusNoContent)
}
func summarizeDiscoverEventsInput(input event.DiscoverEventsInput) string {
parts := []string{
fmt.Sprintf("lat_set=%t", input.Lat != nil),
fmt.Sprintf("lon_set=%t", input.Lon != nil),
fmt.Sprintf("radius_meters=%s", optionalIntSummaryValue(input.RadiusMeters)),
fmt.Sprintf("minimum_age=%s", optionalIntSummaryValue(input.MinimumAge)),
fmt.Sprintf("limit=%s", optionalIntSummaryValue(input.Limit)),
fmt.Sprintf("q=%s", optionalStringSummaryValue(input.Query)),
fmt.Sprintf("privacy_levels=%d", len(input.PrivacyLevels)),
fmt.Sprintf("category_ids=%d", len(input.CategoryIDs)),
fmt.Sprintf("tag_names=%d", len(input.TagNames)),
fmt.Sprintf("start_from=%s", optionalTimeSummaryValue(input.StartFrom)),
fmt.Sprintf("start_to=%s", optionalTimeSummaryValue(input.StartTo)),
fmt.Sprintf("only_favorited=%t", input.OnlyFavorited),
fmt.Sprintf("sort_by=%s", optionalSortBySummaryValue(input.SortBy)),
fmt.Sprintf("cursor=%s", optionalStringSummaryValue(input.Cursor)),
}
if input.OnlyChildFriendly {
parts = append(parts, "child_friendly=true")
}
if input.OnlyFamilyOriented {
parts = append(parts, "family_oriented=true")
}
return strings.Join(parts, " ")
}
func summarizeEventCollectionInput(input event.ListEventCollectionInput) string {
status := "<nil>"
if input.Status != nil {
status = input.Status.String()
}
return httpapi.JoinSummary(
fmt.Sprintf("limit=%s", optionalIntSummaryValue(input.Limit)),
fmt.Sprintf("cursor=%s", optionalStringSummaryValue(input.Cursor)),
fmt.Sprintf("status=%s", status),
)
}
func summarizeCreateEventInput(input event.CreateEventInput) string {
return httpapi.JoinSummary(
fmt.Sprintf("category_id=%s", optionalIntSummaryValue(input.CategoryID)),
fmt.Sprintf("privacy_level=%s", input.PrivacyLevel),
fmt.Sprintf("location_type=%s", input.LocationType),
fmt.Sprintf("route_points=%d", len(input.RoutePoints)),
fmt.Sprintf("tags=%d", len(input.Tags)),
fmt.Sprintf("constraints=%d", len(input.Constraints)),
fmt.Sprintf("capacity_set=%t", input.Capacity != nil),
fmt.Sprintf("minimum_age_set=%t", input.MinimumAge != nil),
fmt.Sprintf("preferred_gender_set=%t", input.PreferredGender != nil),
fmt.Sprintf("end_time_set=%t", input.EndTime != nil),
fmt.Sprintf("image_url_set=%t", input.ImageURL != nil && strings.TrimSpace(*input.ImageURL) != ""),
fmt.Sprintf("start_time=%s", input.StartTime.UTC().Format(time.RFC3339)),
)
}
func summarizeUpdateEventInput(input event.UpdateEventInput) string {
return httpapi.JoinSummary(
fmt.Sprintf("title_set=%t", input.Title != nil),
fmt.Sprintf("description_set=%t", input.Description.Set),
fmt.Sprintf("category_set=%t", input.CategoryID.Set),
fmt.Sprintf("location_type_set=%t", input.LocationType != nil),
fmt.Sprintf("address_set=%t", input.Address.Set),
fmt.Sprintf("point_set=%t", input.Lat != nil || input.Lon != nil),
fmt.Sprintf("route_points_set=%t", input.RoutePoints != nil),
fmt.Sprintf("start_time_set=%t", input.StartTime != nil),
fmt.Sprintf("end_time_set=%t", input.EndTime.Set),
fmt.Sprintf("capacity_set=%t", input.Capacity.Set),
fmt.Sprintf("constraints_set=%t", input.Constraints != nil),
)
}
func optionalIntSummaryValue(value *int) string {
if value == nil {
return "<nil>"
}
return strconv.Itoa(*value)
}
func optionalStringSummaryValue(value *string) string {
if value == nil {
return "<nil>"
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return "<empty>"
}
return trimmed
}
func optionalTimeSummaryValue(value *time.Time) string {
if value == nil {
return "<nil>"
}
return value.UTC().Format(time.RFC3339)
}
func optionalSortBySummaryValue(value *domain.EventDiscoverySort) string {
if value == nil {
return "<nil>"
}
return string(*value)
}
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 parseInvitationIDParam(c *fiber.Ctx) (uuid.UUID, error) {
invitationID, err := uuid.Parse(c.Params("invitationId"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"invitationId": "must be a valid UUID"})
}
return invitationID, 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 minimumAge, ok, msg := parseOptionalIntQuery(c, "minimum_age"); msg != "" {
errs["minimum_age"] = msg
} else if ok {
input.MinimumAge = &minimumAge
}
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
}
// Backward-compat: the discovery endpoint historically accepted a
// singular `category_id=N` parameter. We keep it as an alias for a
// one-element `category_ids`. When both are present, the richer
// plural form wins so clients that adopted the new contract are
// unaffected by stray legacy params.
if len(input.CategoryIDs) == 0 {
if rawSingle := strings.TrimSpace(c.Query("category_id")); rawSingle != "" {
value, err := strconv.Atoi(rawSingle)
if err != nil {
errs["category_id"] = "category_id must be an integer"
} else {
input.CategoryIDs = []int{value}
}
}
}
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 rawChildFriendly := strings.TrimSpace(c.Query("child_friendly")); rawChildFriendly != "" {
parsed, err := strconv.ParseBool(rawChildFriendly)
if err != nil {
errs["child_friendly"] = "child_friendly must be a boolean"
} else {
input.OnlyChildFriendly = parsed
}
}
if rawFamilyOriented := strings.TrimSpace(c.Query("family_oriented")); rawFamilyOriented != "" {
parsed, err := strconv.ParseBool(rawFamilyOriented)
if err != nil {
errs["family_oriented"] = "family_oriented must be a boolean"
} else {
input.OnlyFamilyOriented = 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
}
if rawStatus := strings.TrimSpace(c.Query("status")); rawStatus != "" {
status, ok := domain.ParseParticipationStatus(rawStatus)
if !ok || (status != domain.ParticipationStatusApproved && status != domain.ParticipationStatusPending) {
errs["status"] = "must be one of: APPROVED, PENDING"
} else {
input.Status = &status
}
}
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 event_report_handler
import (
"log/slog"
"strings"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
eventreportapp "github.com/bounswe/bounswe2026group11/backend/internal/application/eventreport"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// Handler groups HTTP handlers for event reports.
type Handler struct {
service eventreportapp.UseCase
}
// NewHandler constructs an event-report handler.
func NewHandler(service eventreportapp.UseCase) *Handler {
return &Handler{service: service}
}
// RegisterRoutes mounts event-report routes under /events.
func RegisterRoutes(router fiber.Router, handler *Handler, auth fiber.Handler) {
events := router.Group("/events", auth)
events.Post("/:id/reports", handler.CreateEventReport)
}
// CreateEventReport handles POST /events/:id/reports.
func (h *Handler) CreateEventReport(c *fiber.Ctx) error {
eventID, err := parseEventID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
input, err := parseCreateEventReportBody(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, svcErr := h.service.CreateEventReport(c.UserContext(), claims.UserID, eventID, input)
if svcErr != nil {
return httpapi.WriteError(c, svcErr)
}
httpapi.LogInfo(
c.UserContext(),
"event report created",
httpapi.OperationAttr("event_report.create"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.String("report_category", result.Category),
slog.Bool("has_image_token", input.ImageConfirmToken != nil && strings.TrimSpace(*input.ImageConfirmToken) != ""),
)
return c.Status(fiber.StatusCreated).JSON(result)
}
func parseCreateEventReportBody(c *fiber.Ctx) (eventreportapp.CreateEventReportInput, error) {
var body createEventReportBody
if err := c.BodyParser(&body); err != nil {
return eventreportapp.CreateEventReportInput{}, domain.ValidationError(map[string]string{"body": "must be valid JSON"})
}
category, ok := domain.ParseEventReportCategory(body.ReportCategory)
if !ok {
return eventreportapp.CreateEventReportInput{}, domain.ValidationError(map[string]string{
"report_category": "must be one of: SAFETY, HARASSMENT, SPAM_OR_SCAM, INAPPROPRIATE_CONTENT, EVENT_NOT_AS_DESCRIBED, ILLEGAL_OR_DANGEROUS, OTHER",
})
}
return eventreportapp.CreateEventReportInput{
Category: category,
Message: body.Message,
ImageConfirmToken: body.ImageConfirmToken,
}, 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 favorite_location_handler
import (
"log/slog"
"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)
}
httpapi.LogInfo(
c.UserContext(),
"favorite locations fetched",
httpapi.OperationAttr("favorite_location.list"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("result_count", len(result.Items)),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"favorite location created",
httpapi.OperationAttr("favorite_location.create"),
httpapi.UserIDAttr(claims.UserID),
slog.String("created_favorite_location_id", result.ID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"favorite location updated",
httpapi.OperationAttr("favorite_location.update"),
httpapi.UserIDAttr(claims.UserID),
httpapi.FavoriteLocationIDAttr(favoriteLocationID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"favorite location deleted",
httpapi.OperationAttr("favorite_location.delete"),
httpapi.UserIDAttr(claims.UserID),
httpapi.FavoriteLocationIDAttr(favoriteLocationID),
)
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/slog"
"sync/atomic"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/bounswe/bounswe2026group11/backend/internal/i18n"
"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"`
}
// translatorRef holds the package-level translator used by error responses.
// Stored via atomic.Pointer so SetTranslator can be called from server
// startup without locking the read path on every request.
var translatorRef atomic.Pointer[i18n.Catalog]
// SetTranslator installs the i18n catalog used to resolve MessageKey/
// DetailKeys on AppError into localized text. Calling with nil disables
// translation (errors fall back to their literal Message/Details).
func SetTranslator(cat *i18n.Catalog) {
translatorRef.Store(cat)
}
// translatorFor returns the active translator or nil.
func translatorFor() *i18n.Catalog {
return translatorRef.Load()
}
// WriteError converts err into a JSON error response. Known AppErrors are
// serialized with their original status; unexpected errors produce a 500.
// When the active translator can resolve MessageKey/DetailKeys for the
// request locale (i18n.LocaleFrom on the user context) those resolved
// strings replace the literal Message/Details.
func WriteError(c *fiber.Ctx, err error) error {
if appErr, ok := errors.AsType[*domain.AppError](err); ok {
body := ErrorBody{
Code: appErr.Code,
Message: appErr.Message,
Details: appErr.Details,
}
if cat := translatorFor(); cat != nil {
loc := i18n.LocaleFrom(c.UserContext())
if appErr.MessageKey != "" {
body.Message = cat.T(loc, appErr.MessageKey)
}
if len(appErr.DetailKeys) > 0 {
resolved := make(map[string]string, len(appErr.DetailKeys))
for field, key := range appErr.DetailKeys {
resolved[field] = cat.T(loc, key)
}
body.Details = resolved
}
}
return c.Status(appErr.Status).JSON(ErrorEnvelope{Error: body})
}
slog.ErrorContext(c.UserContext(), "handler error",
"error", err,
"method", c.Method(),
"path", c.Path(),
)
message := "An unexpected error occurred."
if cat := translatorFor(); cat != nil {
message = cat.T(i18n.LocaleFrom(c.UserContext()), "error.internal")
}
return c.Status(fiber.StatusInternalServerError).JSON(ErrorEnvelope{
Error: ErrorBody{
Code: "internal_server_error",
Message: message,
},
})
}
package image_upload_handler
import (
"log/slog"
"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)
me.Post("/showcase-images/upload-url", handler.CreateProfileShowcaseImageUpload)
me.Post("/showcase-images/confirm", handler.ConfirmProfileShowcaseImageUpload)
events := router.Group("/events", auth)
events.Post("/:id/image/upload-url", handler.CreateEventImageUpload)
events.Post("/:id/image/confirm", handler.ConfirmEventImageUpload)
events.Post("/:id/review-comments/image/upload-url", handler.CreateEventReviewImageUpload)
events.Post("/:id/join-request/image/upload-url", handler.CreateEventJoinRequestImageUpload)
events.Post("/:id/reports/image/upload-url", handler.CreateEventReportImageUpload)
}
// 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)
}
httpapi.LogInfo(
c.UserContext(),
"profile avatar upload URL created",
httpapi.OperationAttr("image_upload.profile_avatar.create"),
httpapi.UserIDAttr(claims.UserID),
slog.String("base_url", result.BaseURL),
slog.Int("upload_count", len(result.Uploads)),
slog.Int("version", result.Version),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"profile avatar upload confirmed",
httpapi.OperationAttr("image_upload.profile_avatar.confirm"),
httpapi.UserIDAttr(claims.UserID),
)
return c.SendStatus(fiber.StatusNoContent)
}
// CreateProfileShowcaseImageUpload handles POST /me/showcase-images/upload-url.
func (h *Handler) CreateProfileShowcaseImageUpload(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.CreateProfileShowcaseImageUpload(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"showcase image upload URL created",
httpapi.OperationAttr("image_upload.profile_showcase.create"),
httpapi.UserIDAttr(claims.UserID),
slog.String("base_url", result.BaseURL),
slog.Int("upload_count", len(result.Uploads)),
)
return c.JSON(result)
}
// ConfirmProfileShowcaseImageUpload handles POST /me/showcase-images/confirm.
func (h *Handler) ConfirmProfileShowcaseImageUpload(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
body, err := parseConfirmBody(c)
if err != nil {
return httpapi.WriteError(c, err)
}
result, err := h.service.ConfirmProfileShowcaseImageUpload(c.UserContext(), claims.UserID, body)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"showcase image upload confirmed",
httpapi.OperationAttr("image_upload.profile_showcase.confirm"),
httpapi.UserIDAttr(claims.UserID),
slog.String("showcase_image_id", result.ID),
)
return c.Status(fiber.StatusCreated).JSON(result)
}
// 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)
}
httpapi.LogInfo(
c.UserContext(),
"event image upload URL created",
httpapi.OperationAttr("image_upload.event_image.create"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.String("base_url", result.BaseURL),
slog.Int("upload_count", len(result.Uploads)),
slog.Int("version", result.Version),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event image upload confirmed",
httpapi.OperationAttr("image_upload.event_image.confirm"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
)
return c.SendStatus(fiber.StatusNoContent)
}
// CreateEventReviewImageUpload handles POST /events/:id/review-comments/image/upload-url.
func (h *Handler) CreateEventReviewImageUpload(c *fiber.Ctx) error {
eventID, err := parseEventID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.CreateEventReviewImageUpload(c.UserContext(), claims.UserID, eventID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"event review image upload URL created",
httpapi.OperationAttr("image_upload.event_review.create"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.String("base_url", result.BaseURL),
slog.Int("upload_count", len(result.Uploads)),
)
return c.JSON(result)
}
// CreateEventJoinRequestImageUpload handles POST /events/:id/join-request/image/upload-url.
func (h *Handler) CreateEventJoinRequestImageUpload(c *fiber.Ctx) error {
eventID, err := parseEventID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.CreateEventJoinRequestImageUpload(c.UserContext(), claims.UserID, eventID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"event join request image upload URL created",
httpapi.OperationAttr("image_upload.event_join_request.create"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.String("base_url", result.BaseURL),
slog.Int("upload_count", len(result.Uploads)),
)
return c.JSON(result)
}
// CreateEventReportImageUpload handles POST /events/:id/reports/image/upload-url.
func (h *Handler) CreateEventReportImageUpload(c *fiber.Ctx) error {
eventID, err := parseEventID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.CreateEventReportImageUpload(c.UserContext(), claims.UserID, eventID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"event report image upload URL created",
httpapi.OperationAttr("image_upload.event_report.create"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.String("base_url", result.BaseURL),
slog.Int("upload_count", len(result.Uploads)),
)
return c.JSON(result)
}
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 (
"context"
"sync/atomic"
"github.com/bounswe/bounswe2026group11/backend/internal/i18n"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// LocalePreferenceLookup resolves the persisted locale preference for the
// authenticated user. Implementations should return ("", false) on miss
// (user not found, no preference, or DB error) so the middleware falls
// back to the default. Errors are intentionally swallowed at this seam:
// locale resolution must never fail a request.
type LocalePreferenceLookup func(ctx context.Context, userID uuid.UUID) (i18n.Locale, bool)
// localePrefLookupRef holds the optional user-preference lookup. It is set
// once at server startup via SetLocalePreferenceLookup and consulted by the
// auth middlewares after they attach claims, so that authenticated requests
// without an explicit Accept-Language header can still be localized to the
// user's stored preference.
var localePrefLookupRef atomic.Pointer[LocalePreferenceLookup]
// SetLocalePreferenceLookup registers (or clears with nil) the function used
// by RequireAuth/RequireAdmin/OptionalAuth to fall back to the user's saved
// locale preference when the request has no Accept-Language header.
func SetLocalePreferenceLookup(lookup LocalePreferenceLookup) {
if lookup == nil {
localePrefLookupRef.Store(nil)
return
}
localePrefLookupRef.Store(&lookup)
}
func localePrefLookup() LocalePreferenceLookup {
if p := localePrefLookupRef.Load(); p != nil {
return *p
}
return nil
}
// ResolveLocale returns a Fiber middleware that resolves the request locale
// from the Accept-Language header. It runs globally so unauthenticated
// routes (e.g. /auth/*) also produce localized error envelopes. The user-
// preference fallback for authenticated requests is applied separately by
// the auth middlewares via SetLocalePreferenceLookup.
func ResolveLocale() fiber.Handler {
return func(c *fiber.Ctx) error {
loc := i18n.DefaultLocale
if header := c.Get(fiber.HeaderAcceptLanguage); header != "" {
if resolved, ok := i18n.ResolveFromAcceptLanguage(header); ok {
loc = resolved
}
}
attachLocale(c, loc)
return c.Next()
}
}
// applyLocalePreference is called by auth middlewares after claims are set.
// It overrides the request locale with the user's saved preference only when
// the request did not carry a parseable Accept-Language header (so an
// explicit per-request choice always wins).
func applyLocalePreference(c *fiber.Ctx, userID uuid.UUID) {
if header := c.Get(fiber.HeaderAcceptLanguage); header != "" {
if _, ok := i18n.ResolveFromAcceptLanguage(header); ok {
return
}
}
lookup := localePrefLookup()
if lookup == nil {
return
}
if loc, ok := lookup(c.UserContext(), userID); ok && loc != "" {
attachLocale(c, loc)
}
}
func attachLocale(c *fiber.Ctx, loc i18n.Locale) {
c.Locals(contextKeyLocale, loc)
c.SetUserContext(i18n.WithLocale(c.UserContext(), loc))
}
// contextKeyLocale is the Fiber-locals key used by middleware that wants
// to read the resolved locale without going through the request context.
const contextKeyLocale = "locale"
// LocaleFromCtx returns the locale resolved by ResolveLocale, or
// i18n.DefaultLocale when the middleware was not mounted on this route.
func LocaleFromCtx(c *fiber.Ctx) i18n.Locale {
if v, ok := c.Locals(contextKeyLocale).(i18n.Locale); ok && v != "" {
return v
}
return i18n.DefaultLocale
}
package httpapi
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/google/uuid"
)
// LogInfo emits a structured info-level application log.
func LogInfo(ctx context.Context, msg string, attrs ...slog.Attr) {
log(ctx, slog.LevelInfo, msg, attrs...)
}
func log(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) {
logger := slog.Default()
if len(attrs) > 0 {
logger.LogAttrs(ctx, level, msg, attrs...)
return
}
logger.Log(ctx, level, msg)
}
// UserIDAttr records the authenticated user ID for log correlation.
func UserIDAttr(userID uuid.UUID) slog.Attr {
return slog.String("user_id", userID.String())
}
// OptionalUserIDAttr records the caller identity, or "anonymous" when the
// request is not authenticated.
func OptionalUserIDAttr(userID uuid.UUID) slog.Attr {
if userID == uuid.Nil {
return slog.String("user_id", "anonymous")
}
return UserIDAttr(userID)
}
func EventIDAttr(eventID uuid.UUID) slog.Attr {
return slog.String("event_id", eventID.String())
}
func JoinRequestIDAttr(joinRequestID uuid.UUID) slog.Attr {
return slog.String("join_request_id", joinRequestID.String())
}
func FavoriteLocationIDAttr(favoriteLocationID uuid.UUID) slog.Attr {
return slog.String("favorite_location_id", favoriteLocationID.String())
}
func ParticipantUserIDAttr(userID uuid.UUID) slog.Attr {
return slog.String("participant_user_id", userID.String())
}
func QuerySummaryAttr(summary string) slog.Attr {
return slog.String("query_summary", summary)
}
func OperationAttr(operation string) slog.Attr {
return slog.String("operation", operation)
}
func BoolSummary(label string, value bool) string {
if value {
return label + "=true"
}
return label + "=false"
}
func CountSummary(label string, value int) string {
return fmt.Sprintf("%s=%d", label, value)
}
func StringSummary(label, value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return label + "=<empty>"
}
return fmt.Sprintf("%s=%s", label, trimmed)
}
func StringPtrSummary(label string, value *string) string {
if value == nil {
return label + "=<nil>"
}
return StringSummary(label, *value)
}
func JoinSummary(parts ...string) string {
filtered := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) != "" {
filtered = append(filtered, part)
}
}
return strings.Join(filtered, " ")
}
package httpapi
import (
"strings"
"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"
// Catalog keys used by auth middleware error responses.
const (
msgKeyMissingToken = "error.missing_token"
msgKeyInvalidToken = "error.invalid_token"
msgKeyAdminAccessRequired = "error.admin_access_required"
)
func missingTokenError() *domain.AppError {
return domain.AuthErrorI18n("missing_token", msgKeyMissingToken)
}
func invalidTokenError() *domain.AppError {
return domain.AuthErrorI18n("invalid_token", msgKeyInvalidToken)
}
func adminAccessRequiredError() *domain.AppError {
return domain.ForbiddenErrorI18n(domain.ErrorCodeAdminAccessRequired, msgKeyAdminAccessRequired)
}
// 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 WriteError(c, missingTokenError())
}
claims, err := verifier.VerifyAccessToken(token)
if err != nil {
return WriteError(c, invalidTokenError())
}
c.Locals(contextKeyUserClaims, claims)
applyLocalePreference(c, claims.UserID)
return c.Next()
}
}
// RequireAdmin returns middleware that first authenticates the request and then
// requires the caller's role claim to be ADMIN. Authentication failures keep the
// standard 401 behavior from RequireAuth; authenticated non-admins receive 403.
func RequireAdmin(verifier domain.TokenVerifier) fiber.Handler {
return func(c *fiber.Ctx) error {
header := c.Get(fiber.HeaderAuthorization)
token, ok := extractBearer(header)
if !ok {
return WriteError(c, missingTokenError())
}
claims, err := verifier.VerifyAccessToken(token)
if err != nil {
return WriteError(c, invalidTokenError())
}
if claims.Role != domain.UserRoleAdmin {
return WriteError(c, adminAccessRequiredError())
}
c.Locals(contextKeyUserClaims, claims)
applyLocalePreference(c, claims.UserID)
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 WriteError(c, invalidTokenError())
}
c.Locals(contextKeyUserClaims, claims)
applyLocalePreference(c, claims.UserID)
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
}
// RequireAdminRole is intended to be mounted after RequireAuth when route
// groups need to compose authentication separately from authorization.
func RequireAdminRole(c *fiber.Ctx) error {
claims := UserClaims(c)
if claims == nil {
return WriteError(c, missingTokenError())
}
if claims.Role != domain.UserRoleAdmin {
return WriteError(c, adminAccessRequiredError())
}
return c.Next()
}
// 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 notification_handler
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
notificationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/notification"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// Handler groups HTTP handlers that delegate to the notification use case.
type Handler struct {
service notificationapp.UseCase
realtime notificationapp.RealtimeBroker
}
func NewHandler(service notificationapp.UseCase, realtime ...notificationapp.RealtimeBroker) *Handler {
handler := &Handler{service: service}
if len(realtime) > 0 {
handler.realtime = realtime[0]
}
return handler
}
func RegisterRoutes(router fiber.Router, handler *Handler, auth fiber.Handler) {
me := router.Group("/me", auth)
me.Put("/push-devices/:installation_id", handler.RegisterPushDevice)
me.Delete("/push-devices/:installation_id", handler.UnregisterPushDevice)
me.Get("/notifications/stream", handler.StreamNotifications)
me.Get("/notifications/unread", handler.ListUnreadNotifications)
me.Get("/notifications/unread-count", handler.GetUnreadNotificationCount)
me.Patch("/notifications/read", handler.MarkAllNotificationsRead)
me.Get("/notifications", handler.ListNotifications)
me.Patch("/notifications/:notification_id/read", handler.MarkNotificationRead)
me.Delete("/notifications/:notification_id", handler.DeleteNotification)
me.Delete("/notifications", handler.DeleteAllNotifications)
}
// RegisterPushDevice handles PUT /me/push-devices/:installation_id.
func (h *Handler) RegisterPushDevice(c *fiber.Ctx) error {
installationID, err := parseInstallationID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
var body registerPushDeviceBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
input, errs := toRegisterDeviceInput(body)
if len(errs) > 0 {
return httpapi.WriteError(c, domain.ValidationError(errs))
}
claims := httpapi.UserClaims(c)
input.UserID = claims.UserID
input.InstallationID = installationID
result, err := h.service.RegisterDevice(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"push device registered",
httpapi.OperationAttr("notification.push_device.register"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("active_device_count", result.ActiveDeviceCount),
)
return c.JSON(registerPushDeviceResponse{
InstallationID: result.InstallationID,
Platform: result.Platform.String(),
ActiveDeviceCount: result.ActiveDeviceCount,
UpdatedAt: result.UpdatedAt,
})
}
// UnregisterPushDevice handles DELETE /me/push-devices/:installation_id.
func (h *Handler) UnregisterPushDevice(c *fiber.Ctx) error {
installationID, err := parseInstallationID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.service.UnregisterDevice(c.UserContext(), claims.UserID, installationID); err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"push device unregistered",
httpapi.OperationAttr("notification.push_device.unregister"),
httpapi.UserIDAttr(claims.UserID),
)
return c.SendStatus(fiber.StatusNoContent)
}
// ListNotifications handles GET /me/notifications.
func (h *Handler) ListNotifications(c *fiber.Ctx) error {
return h.listNotifications(c, false)
}
// ListUnreadNotifications handles GET /me/notifications/unread.
func (h *Handler) ListUnreadNotifications(c *fiber.Ctx) error {
return h.listNotifications(c, true)
}
func (h *Handler) listNotifications(c *fiber.Ctx, onlyUnread bool) error {
input, err := parseListNotificationsInput(c)
if err != nil {
return httpapi.WriteError(c, err)
}
input.UserID = httpapi.UserClaims(c).UserID
input.OnlyUnread = onlyUnread
result, err := h.service.ListNotifications(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(toListNotificationsResponse(result))
}
// GetUnreadNotificationCount handles GET /me/notifications/unread-count.
func (h *Handler) GetUnreadNotificationCount(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.CountUnreadNotifications(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(unreadCountResponse{UnreadCount: result.UnreadCount})
}
// MarkNotificationRead handles PATCH /me/notifications/:notification_id/read.
func (h *Handler) MarkNotificationRead(c *fiber.Ctx) error {
notificationID, err := parseNotificationID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.service.MarkNotificationRead(c.UserContext(), claims.UserID, notificationID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// MarkAllNotificationsRead handles PATCH /me/notifications/read.
func (h *Handler) MarkAllNotificationsRead(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.MarkAllNotificationsRead(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(markAllReadResponse{UpdatedCount: result.UpdatedCount})
}
// DeleteNotification handles DELETE /me/notifications/:notification_id.
func (h *Handler) DeleteNotification(c *fiber.Ctx) error {
notificationID, err := parseNotificationID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
if err := h.service.DeleteNotification(c.UserContext(), claims.UserID, notificationID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// DeleteAllNotifications handles DELETE /me/notifications.
func (h *Handler) DeleteAllNotifications(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
if err := h.service.DeleteAllNotifications(c.UserContext(), claims.UserID); err != nil {
return httpapi.WriteError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
// StreamNotifications handles GET /me/notifications/stream.
func (h *Handler) StreamNotifications(c *fiber.Ctx) error {
if h.realtime == nil {
return httpapi.WriteError(c, fmt.Errorf("notification realtime broker is not configured"))
}
claims := httpapi.UserClaims(c)
sub := h.realtime.Subscribe(claims.UserID)
if sub == nil {
return httpapi.WriteError(c, fmt.Errorf("notification realtime broker is not configured"))
}
c.Set(fiber.HeaderContentType, "text/event-stream")
c.Set(fiber.HeaderCacheControl, "no-cache")
c.Set(fiber.HeaderConnection, "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
defer sub.Cancel()
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
if !writeSSE(w, "heartbeat", "", []byte("{}")) {
return
}
for {
select {
case notification, ok := <-sub.Events:
if !ok {
return
}
payload, err := json.Marshal(toNotificationResponse(notification))
if err != nil {
return
}
if !writeSSE(w, "notification", notification.ID.String(), payload) {
return
}
case <-ticker.C:
if !writeSSE(w, "heartbeat", "", []byte("{}")) {
return
}
}
}
})
return nil
}
func parseInstallationID(c *fiber.Ctx) (uuid.UUID, error) {
installationID, err := uuid.Parse(c.Params("installation_id"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"installation_id": "must be a valid UUID"})
}
return installationID, nil
}
func parseNotificationID(c *fiber.Ctx) (uuid.UUID, error) {
notificationID, err := uuid.Parse(c.Params("notification_id"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"notification_id": "must be a valid UUID"})
}
return notificationID, nil
}
func parseListNotificationsInput(c *fiber.Ctx) (notificationapp.ListNotificationsInput, error) {
input := notificationapp.ListNotificationsInput{}
if rawLimit := strings.TrimSpace(c.Query("limit")); rawLimit != "" {
limit, err := strconv.Atoi(rawLimit)
if err != nil {
return input, domain.ValidationError(map[string]string{"limit": "must be an integer"})
}
input.Limit = &limit
}
if rawCursor := strings.TrimSpace(c.Query("cursor")); rawCursor != "" {
input.Cursor = &rawCursor
}
return input, nil
}
func toRegisterDeviceInput(body registerPushDeviceBody) (notificationapp.RegisterDeviceInput, map[string]string) {
input := notificationapp.RegisterDeviceInput{DeviceInfo: body.DeviceInfo}
errs := map[string]string{}
if body.FCMToken == nil {
errs["fcm_token"] = "is required"
} else {
input.FCMToken = *body.FCMToken
}
if body.Platform == nil {
errs["platform"] = "is required"
} else {
input.Platform = *body.Platform
}
return input, errs
}
func toListNotificationsResponse(result *notificationapp.ListNotificationsResult) listNotificationsResponse {
items := make([]notificationResponse, len(result.Items))
for i, notification := range result.Items {
items[i] = toNotificationResponse(notification)
}
return listNotificationsResponse{
Items: items,
PageInfo: notificationPageInfo{
NextCursor: result.PageInfo.NextCursor,
HasNext: result.PageInfo.HasNext,
},
}
}
func toNotificationResponse(notification domain.Notification) notificationResponse {
var eventID *string
if notification.EventID != nil {
value := notification.EventID.String()
eventID = &value
}
data := notification.Data
if data == nil {
data = map[string]string{}
}
return notificationResponse{
ID: notification.ID.String(),
EventID: eventID,
Title: notification.Title,
Body: notification.Body,
Type: notification.Type,
DeepLink: notification.DeepLink,
ImageURL: notification.ImageURL,
Data: data,
IsRead: notification.IsRead,
ReadAt: notification.ReadAt,
CreatedAt: notification.CreatedAt,
}
}
func writeSSE(w *bufio.Writer, event, id string, data []byte) bool {
if id != "" {
if _, err := fmt.Fprintf(w, "id: %s\n", id); err != nil {
return false
}
}
if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil {
return false
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return false
}
return w.Flush() == nil
}
package profile_handler
import (
"log/slog"
"strconv"
"strings"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/event"
"github.com/bounswe/bounswe2026group11/backend/internal/application/invitation"
"github.com/bounswe/bounswe2026group11/backend/internal/application/profile"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// ProfileHandler groups HTTP handlers that delegate to the profile use-case port.
type ProfileHandler struct {
service profile.UseCase
eventService event.UseCase
invitationService invitation.UseCase
}
// NewProfileHandler creates a profile handler backed by the given use cases.
func NewProfileHandler(service profile.UseCase, eventService event.UseCase, invitationService ...invitation.UseCase) *ProfileHandler {
handler := &ProfileHandler{service: service, eventService: eventService}
if len(invitationService) > 0 {
handler.invitationService = invitationService[0]
}
return handler
}
// 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.Post("/change-password", handler.ChangePassword)
me.Get("/equipment", handler.ListMyEquipment)
me.Post("/equipment", handler.CreateMyEquipment)
me.Patch("/equipment/:equipment_id", handler.UpdateMyEquipment)
me.Delete("/equipment/:equipment_id", handler.DeleteMyEquipment)
me.Delete("/showcase-images/:showcase_image_id", handler.DeleteMyShowcaseImage)
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)
me.Get("/invitations", handler.ListReceivedInvitations)
me.Get("/invitations/:invitationId", handler.GetReceivedInvitation)
me.Post("/invitations/:invitationId/accept", handler.AcceptInvitation)
me.Post("/invitations/:invitationId/decline", handler.DeclineInvitation)
users := router.Group("/users")
users.Get("/:user_id/profile", handler.GetPublicProfile)
}
// 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)
}
httpapi.LogInfo(
c.UserContext(),
"my profile fetched",
httpapi.OperationAttr("profile.get"),
httpapi.UserIDAttr(claims.UserID),
)
return c.JSON(result)
}
// GetPublicProfile handles GET /users/:user_id/profile.
func (h *ProfileHandler) GetPublicProfile(c *fiber.Ctx) error {
userID, err := parseUserIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
result, err := h.service.GetPublicProfile(c.UserContext(), userID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"public profile fetched",
httpapi.OperationAttr("profile.public.get"),
slog.String("target_user_id", userID.String()),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"my hosted events fetched",
httpapi.OperationAttr("profile.events.hosted"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("result_count", len(events)),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"my upcoming events fetched",
httpapi.OperationAttr("profile.events.upcoming"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("result_count", len(events)),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"my completed events fetched",
httpapi.OperationAttr("profile.events.completed"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("result_count", len(events)),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"my canceled events fetched",
httpapi.OperationAttr("profile.events.canceled"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("result_count", len(events)),
)
return c.JSON(fiber.Map{"events": events})
}
type createEquipmentBody struct {
Name string `json:"name"`
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
}
type updateEquipmentBody struct {
Name *string `json:"name"`
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
}
// ListMyEquipment handles GET /me/equipment.
func (h *ProfileHandler) ListMyEquipment(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.ListMyEquipment(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"my equipment fetched",
httpapi.OperationAttr("profile.equipment.list"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("result_count", len(result.Items)),
)
return c.JSON(result)
}
// CreateMyEquipment handles POST /me/equipment.
func (h *ProfileHandler) CreateMyEquipment(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
var body createEquipmentBody
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.CreateMyEquipment(c.UserContext(), profile.CreateEquipmentInput{
UserID: claims.UserID,
Name: body.Name,
Description: body.Description,
ImageURL: body.ImageURL,
})
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"equipment created",
httpapi.OperationAttr("profile.equipment.create"),
httpapi.UserIDAttr(claims.UserID),
slog.String("equipment_id", result.ID),
)
return c.Status(fiber.StatusCreated).JSON(result)
}
// UpdateMyEquipment handles PATCH /me/equipment/:equipment_id.
func (h *ProfileHandler) UpdateMyEquipment(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
equipmentID, err := parseEquipmentIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
var body updateEquipmentBody
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.UpdateMyEquipment(c.UserContext(), profile.UpdateEquipmentInput{
UserID: claims.UserID,
EquipmentID: equipmentID,
Name: body.Name,
Description: body.Description,
ImageURL: body.ImageURL,
})
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"equipment updated",
httpapi.OperationAttr("profile.equipment.update"),
httpapi.UserIDAttr(claims.UserID),
slog.String("equipment_id", equipmentID.String()),
)
return c.JSON(result)
}
// DeleteMyEquipment handles DELETE /me/equipment/:equipment_id.
func (h *ProfileHandler) DeleteMyEquipment(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
equipmentID, err := parseEquipmentIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
if err := h.service.DeleteMyEquipment(c.UserContext(), claims.UserID, equipmentID); err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"equipment deleted",
httpapi.OperationAttr("profile.equipment.delete"),
httpapi.UserIDAttr(claims.UserID),
slog.String("equipment_id", equipmentID.String()),
)
return c.SendStatus(fiber.StatusNoContent)
}
// DeleteMyShowcaseImage handles DELETE /me/showcase-images/:showcase_image_id.
func (h *ProfileHandler) DeleteMyShowcaseImage(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
showcaseImageID, err := parseShowcaseImageIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
if err := h.service.DeleteMyShowcaseImage(c.UserContext(), claims.UserID, showcaseImageID); err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"showcase image deleted",
httpapi.OperationAttr("profile.showcase.delete"),
httpapi.UserIDAttr(claims.UserID),
slog.String("showcase_image_id", showcaseImageID.String()),
)
return c.SendStatus(fiber.StatusNoContent)
}
// 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"`
Locale *string `json:"locale"`
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.ValidationErrorI18n(map[string]string{"body": "validation.body.invalid_json"}))
}
if err := h.service.UpdateMyProfile(c.UserContext(), profile.UpdateProfileInput{
UserID: claims.UserID,
PhoneNumber: body.PhoneNumber,
Gender: body.Gender,
BirthDate: body.BirthDate,
Locale: body.Locale,
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)
}
httpapi.LogInfo(
c.UserContext(),
"my profile updated",
httpapi.OperationAttr("profile.update"),
httpapi.UserIDAttr(claims.UserID),
httpapi.QuerySummaryAttr(summarizeUpdatedProfileFields(body)),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"my favorite events fetched",
httpapi.OperationAttr("profile.favorites.list"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("result_count", len(result.Items)),
)
return c.JSON(result)
}
// ListReceivedInvitations handles GET /me/invitations and returns the
// authenticated user's invitations split into pending and past buckets.
// Past is paginated via past_cursor / past_limit query params; pending is
// always returned in full because it is small and clients render it eagerly.
func (h *ProfileHandler) ListReceivedInvitations(c *fiber.Ctx) error {
if h.invitationService == nil {
return httpapi.WriteError(c, domain.ConflictError(domain.ErrorCodeInvitationNotAllowed, "Invitations are not available."))
}
claims := httpapi.UserClaims(c)
input := invitation.ListReceivedInvitationsInput{UserID: claims.UserID}
if raw := strings.TrimSpace(c.Query("past_cursor")); raw != "" {
input.PastCursor = &raw
}
if raw := strings.TrimSpace(c.Query("past_limit")); raw != "" {
v, err := strconv.Atoi(raw)
if err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{
"past_limit": "must be an integer",
}))
}
input.PastLimit = &v
}
result, err := h.invitationService.ListReceivedInvitations(c.UserContext(), input)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"my invitations fetched",
httpapi.OperationAttr("profile.invitations.list"),
httpapi.UserIDAttr(claims.UserID),
slog.Int("pending_count", len(result.Pending)),
slog.Int("past_count", len(result.Past.Items)),
slog.Bool("past_has_next", result.Past.PageInfo.HasNext),
)
return c.JSON(result)
}
// GetReceivedInvitation handles GET /me/invitations/:invitationId. It
// returns the latest state of an invitation addressed to the authenticated
// user, regardless of status. Clients use this to refresh modal content
// opened from a (possibly stale) notification — e.g. to render a "host
// canceled" banner when the underlying invitation is no longer actionable.
func (h *ProfileHandler) GetReceivedInvitation(c *fiber.Ctx) error {
if h.invitationService == nil {
return httpapi.WriteError(c, domain.ConflictError(domain.ErrorCodeInvitationNotAllowed, "Invitations are not available."))
}
invitationID, err := parseInvitationIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.invitationService.GetReceivedInvitation(c.UserContext(), claims.UserID, invitationID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"my invitation fetched",
httpapi.OperationAttr("profile.invitations.get"),
httpapi.UserIDAttr(claims.UserID),
slog.String("invitation_id", invitationID.String()),
slog.String("status", result.Status),
)
return c.JSON(result)
}
// AcceptInvitation handles POST /me/invitations/:invitationId/accept.
func (h *ProfileHandler) AcceptInvitation(c *fiber.Ctx) error {
if h.invitationService == nil {
return httpapi.WriteError(c, domain.ConflictError(domain.ErrorCodeInvitationNotAllowed, "Invitations are not available."))
}
invitationID, err := parseInvitationIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.invitationService.AcceptInvitation(c.UserContext(), claims.UserID, invitationID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"invitation accepted",
httpapi.OperationAttr("profile.invitations.accept"),
httpapi.UserIDAttr(claims.UserID),
slog.String("invitation_id", invitationID.String()),
slog.String("participation_id", result.ParticipationID),
)
return c.JSON(result)
}
// DeclineInvitation handles POST /me/invitations/:invitationId/decline.
func (h *ProfileHandler) DeclineInvitation(c *fiber.Ctx) error {
if h.invitationService == nil {
return httpapi.WriteError(c, domain.ConflictError(domain.ErrorCodeInvitationNotAllowed, "Invitations are not available."))
}
invitationID, err := parseInvitationIDParam(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.invitationService.DeclineInvitation(c.UserContext(), claims.UserID, invitationID)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"invitation declined",
httpapi.OperationAttr("profile.invitations.decline"),
httpapi.UserIDAttr(claims.UserID),
slog.String("invitation_id", invitationID.String()),
)
return c.JSON(result)
}
// changePasswordBody is the request body for POST /me/change-password.
type changePasswordBody struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
// ChangePassword handles POST /me/change-password.
func (h *ProfileHandler) ChangePassword(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
var body changePasswordBody
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.ChangePassword(c.UserContext(), profile.ChangePasswordInput{
UserID: claims.UserID,
OldPassword: body.OldPassword,
NewPassword: body.NewPassword,
}); err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"password changed",
httpapi.OperationAttr("profile.change_password"),
httpapi.UserIDAttr(claims.UserID),
)
return c.SendStatus(fiber.StatusNoContent)
}
func parseInvitationIDParam(c *fiber.Ctx) (uuid.UUID, error) {
id, err := uuid.Parse(c.Params("invitationId"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"invitation_id": "must be a valid UUID"})
}
return id, nil
}
func parseUserIDParam(c *fiber.Ctx) (uuid.UUID, error) {
id, err := uuid.Parse(c.Params("user_id"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"user_id": "must be a valid UUID"})
}
return id, nil
}
func parseEquipmentIDParam(c *fiber.Ctx) (uuid.UUID, error) {
id, err := uuid.Parse(c.Params("equipment_id"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"equipment_id": "must be a valid UUID"})
}
return id, nil
}
func parseShowcaseImageIDParam(c *fiber.Ctx) (uuid.UUID, error) {
id, err := uuid.Parse(c.Params("showcase_image_id"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"showcase_image_id": "must be a valid UUID"})
}
return id, nil
}
func summarizeUpdatedProfileFields(body updateProfileBody) string {
fields := make([]string, 0, 9)
if body.PhoneNumber != nil {
fields = append(fields, "phone_number")
}
if body.Gender != nil {
fields = append(fields, "gender")
}
if body.BirthDate != nil {
fields = append(fields, "birth_date")
}
if body.Locale != nil {
fields = append(fields, "locale")
}
if body.DefaultLocationAddress != nil {
fields = append(fields, "default_location_address")
}
if body.DefaultLocationLat != nil {
fields = append(fields, "default_location_lat")
}
if body.DefaultLocationLon != nil {
fields = append(fields, "default_location_lon")
}
if body.DisplayName != nil {
fields = append(fields, "display_name")
}
if body.Bio != nil {
fields = append(fields, "bio")
}
if body.AvatarURL != nil {
fields = append(fields, "avatar_url")
}
if len(fields) == 0 {
return "updated_fields=<none>"
}
return "updated_fields=" + strings.Join(fields, ",")
}
package rating_handler
import (
"log/slog"
"strings"
"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)
}
httpapi.LogInfo(
c.UserContext(),
"event rating upserted",
httpapi.OperationAttr("rating.event.upsert"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.Int("rating", input.Rating),
slog.Bool("has_message", input.Message != nil && strings.TrimSpace(*input.Message) != ""),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"event rating deleted",
httpapi.OperationAttr("rating.event.delete"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"participant rating upserted",
httpapi.OperationAttr("rating.participant.upsert"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.ParticipantUserIDAttr(participantUserID),
slog.Int("rating", input.Rating),
slog.Bool("has_message", input.Message != nil && strings.TrimSpace(*input.Message) != ""),
)
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)
}
httpapi.LogInfo(
c.UserContext(),
"participant rating deleted",
httpapi.OperationAttr("rating.participant.delete"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
httpapi.ParticipantUserIDAttr(participantUserID),
)
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 ticket_handler
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/ticket"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
const clientSurfaceHeader = "X-Client-Surface"
// Handler groups HTTP handlers that delegate to the ticket use-case port.
type Handler struct {
service ticket.UseCase
}
// NewHandler creates a ticket handler backed by the given ticket use case.
func NewHandler(service ticket.UseCase) *Handler {
return &Handler{service: service}
}
// RegisterRoutes mounts ticket endpoints.
func RegisterRoutes(router fiber.Router, handler *Handler, auth fiber.Handler) {
me := router.Group("/me", auth)
me.Get("/tickets", handler.ListMyTickets)
me.Get("/tickets/:ticketId", handler.GetMyTicket)
me.Get("/tickets/:ticketId/qr-stream", handler.StreamQRToken)
host := router.Group("/host", auth)
host.Post("/events/:eventId/ticket-scans", handler.ScanTicket)
}
// ListMyTickets handles GET /me/tickets.
func (h *Handler) ListMyTickets(c *fiber.Ctx) error {
claims := httpapi.UserClaims(c)
result, err := h.service.ListMyTickets(c.UserContext(), claims.UserID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// GetMyTicket handles GET /me/tickets/:ticketId.
func (h *Handler) GetMyTicket(c *fiber.Ctx) error {
ticketID, err := parseTicketID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
result, err := h.service.GetMyTicket(c.UserContext(), claims.UserID, ticketID)
if err != nil {
return httpapi.WriteError(c, err)
}
return c.JSON(result)
}
// StreamQRToken handles GET /me/tickets/:ticketId/qr-stream.
func (h *Handler) StreamQRToken(c *fiber.Ctx) error {
if err := requireMobileClient(c); err != nil {
return httpapi.WriteError(c, err)
}
ticketID, err := parseTicketID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
input, err := parseQRTokenInput(c)
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
firstToken, err := h.service.IssueQRToken(c.UserContext(), claims.UserID, ticketID, input)
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"ticket qr stream opened",
httpapi.OperationAttr("ticket.qr_stream"),
httpapi.UserIDAttr(claims.UserID),
slog.String("ticket_id", ticketID.String()),
)
requestCtx := c.UserContext()
done := requestCtx.Done()
c.Set(fiber.HeaderContentType, "text/event-stream")
c.Set(fiber.HeaderCacheControl, "no-cache, no-store")
c.Set(fiber.HeaderConnection, "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
if !writeSSE(w, "qr_token", firstToken) {
return
}
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
refreshCtx, cancel := context.WithTimeout(requestCtx, 5*time.Second)
nextToken, err := h.service.IssueQRToken(refreshCtx, claims.UserID, ticketID, input)
cancel()
select {
case <-done:
return
default:
}
if err != nil {
_ = writeSSE(w, "error", fiber.Map{"message": err.Error()})
return
}
if !writeSSE(w, "qr_token", nextToken) {
return
}
}
}
})
return nil
}
// ScanTicket handles POST /host/events/:eventId/ticket-scans.
func (h *Handler) ScanTicket(c *fiber.Ctx) error {
if err := requireMobileClient(c); err != nil {
return httpapi.WriteError(c, err)
}
eventID, err := parseEventID(c)
if err != nil {
return httpapi.WriteError(c, err)
}
var body scanTicketBody
if err := c.BodyParser(&body); err != nil {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{"body": "must be valid JSON"}))
}
if strings.TrimSpace(body.QRToken) == "" {
return httpapi.WriteError(c, domain.ValidationError(map[string]string{
"qr_token": "qr_token is required", // #nosec G101 -- JSON field name and validation text, not a secret
}))
}
claims := httpapi.UserClaims(c)
result, err := h.service.ScanTicket(c.UserContext(), claims.UserID, eventID, ticket.ScanTicketInput{QRToken: body.QRToken})
if err != nil {
return httpapi.WriteError(c, err)
}
httpapi.LogInfo(
c.UserContext(),
"ticket scan completed",
httpapi.OperationAttr("ticket.scan"),
httpapi.UserIDAttr(claims.UserID),
httpapi.EventIDAttr(eventID),
slog.String("result", result.Result),
)
return c.JSON(result)
}
type scanTicketBody struct {
QRToken string `json:"qr_token"`
}
func requireMobileClient(c *fiber.Ctx) error {
if strings.EqualFold(strings.TrimSpace(c.Get(clientSurfaceHeader)), "MOBILE") {
return nil
}
return domain.ForbiddenError(domain.ErrorCodeTicketClientNotSupported, "This ticket operation is supported only by the mobile client.")
}
func parseTicketID(c *fiber.Ctx) (uuid.UUID, error) {
ticketID, err := uuid.Parse(c.Params("ticketId"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"ticketId": "must be a valid UUID"})
}
return ticketID, nil
}
func parseEventID(c *fiber.Ctx) (uuid.UUID, error) {
eventID, err := uuid.Parse(c.Params("eventId"))
if err != nil {
return uuid.Nil, domain.ValidationError(map[string]string{"eventId": "must be a valid UUID"})
}
return eventID, nil
}
func parseQRTokenInput(c *fiber.Ctx) (ticket.QRTokenInput, error) {
lat, err := parseRequiredFloatQuery(c, "lat")
if err != nil {
return ticket.QRTokenInput{}, err
}
lon, err := parseRequiredFloatQuery(c, "lon")
if err != nil {
return ticket.QRTokenInput{}, err
}
return ticket.QRTokenInput{Lat: lat, Lon: lon}, nil
}
func parseRequiredFloatQuery(c *fiber.Ctx, key string) (float64, error) {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return 0, domain.ValidationError(map[string]string{key: key + " is required"})
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, domain.ValidationError(map[string]string{key: key + " must be a number"})
}
return value, nil
}
func writeSSE(w *bufio.Writer, event string, data any) bool {
payload, err := json.Marshal(data)
if err != nil {
payload = []byte(`{"message":"failed to encode event"}`)
}
if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil {
return false
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", payload); err != nil {
return false
}
return w.Flush() == nil
}
package user_handler
import (
"log/slog"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/profile"
"github.com/gofiber/fiber/v2"
)
type Handler struct {
service profile.UseCase
}
func NewHandler(service profile.UseCase) *Handler {
return &Handler{service: service}
}
func RegisterRoutes(router fiber.Router, handler *Handler, auth fiber.Handler) {
users := router.Group("/users", auth)
users.Get("/search", handler.SearchUsers)
}
func (h *Handler) SearchUsers(c *fiber.Ctx) error {
result, err := h.service.SearchUsers(c.UserContext(), profile.UserSearchInput{Query: c.Query("query")})
if err != nil {
return httpapi.WriteError(c, err)
}
claims := httpapi.UserClaims(c)
httpapi.LogInfo(
c.UserContext(),
"users searched",
httpapi.OperationAttr("users.search"),
httpapi.UserIDAttr(claims.UserID),
httpapi.QuerySummaryAttr("has_query=true"),
slog.Int("result_count", len(result.Items)),
)
return c.JSON(result)
}
package admin
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/bounswe/bounswe2026group11/backend/internal/application/notification"
"github.com/bounswe/bounswe2026group11/backend/internal/application/ticket"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service implements admin backoffice use cases.
type Service struct {
repo Repository
notifications notification.UseCase
tickets ticket.LifecycleUseCase
unitOfWork uow.UnitOfWork
idempotencyKeyFunc func() string
}
var _ UseCase = (*Service)(nil)
type Option func(*Service)
func WithMutationDependencies(notifications notification.UseCase, tickets ticket.LifecycleUseCase, unitOfWork uow.UnitOfWork) Option {
return func(s *Service) {
s.notifications = notifications
s.tickets = tickets
s.unitOfWork = unitOfWork
}
}
// NewService constructs an admin service with the given repository.
func NewService(repo Repository, options ...Option) *Service {
service := &Service{
repo: repo,
idempotencyKeyFunc: uuid.NewString,
}
for _, option := range options {
option(service)
}
return service
}
func (s *Service) SendCustomNotification(ctx context.Context, input SendCustomNotificationInput) (*SendCustomNotificationResult, error) {
if s.notifications == nil {
return nil, domain.ConflictError(domain.ErrorCodeAdminDependencyUnavailable, "Admin notification mutations are not configured.")
}
if err := s.validateCustomNotification(ctx, input); err != nil {
return nil, err
}
idempotencyKey := ""
if input.IdempotencyKey != nil {
idempotencyKey = strings.TrimSpace(*input.IdempotencyKey)
}
if idempotencyKey == "" {
idempotencyKey = "ADMIN:" + s.idempotencyKeyFunc()
}
result, err := s.notifications.SendCustomNotificationToUsers(ctx, notification.SendCustomNotificationInput{
UserIDs: input.UserIDs,
DeliveryMode: input.DeliveryMode,
Title: input.Title,
Body: input.Body,
Type: input.Type,
DeepLink: input.DeepLink,
Data: input.Data,
EventID: input.EventID,
IdempotencyKey: idempotencyKey,
})
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "admin notification mutation completed",
"operation", "admin.notifications.create",
"admin_user_id", input.AdminUserID.String(),
"delivery_mode", input.DeliveryMode.String(),
"target_user_count", result.TargetUserCount,
"created_count", result.CreatedCount,
"idempotent_count", result.IdempotentCount,
"sse_delivery_count", result.SSEDeliveryCount,
"push_active_device_count", result.PushActiveDeviceCount,
"push_sent_count", result.PushSentCount,
"push_failed_count", result.PushFailedCount,
"invalid_token_count", result.InvalidTokenCount,
)
return &SendCustomNotificationResult{
TargetUserCount: result.TargetUserCount,
CreatedCount: result.CreatedCount,
IdempotentCount: result.IdempotentCount,
SSEDeliveryCount: result.SSEDeliveryCount,
PushActiveDeviceCount: result.PushActiveDeviceCount,
PushSentCount: result.PushSentCount,
PushFailedCount: result.PushFailedCount,
InvalidTokenCount: result.InvalidTokenCount,
}, nil
}
func (s *Service) CreateManualParticipation(ctx context.Context, input CreateManualParticipationInput) (*CreateManualParticipationResult, error) {
if s.unitOfWork == nil || s.tickets == nil {
return nil, domain.ConflictError(domain.ErrorCodeAdminDependencyUnavailable, "Admin participation mutations are not configured.")
}
if err := validateManualParticipationInput(input); err != nil {
return nil, err
}
var result *CreateManualParticipationResult
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
eventState, err := s.repo.GetEventState(ctx, input.EventID, true)
if err != nil {
return fmt.Errorf("load admin participation event: %w", err)
}
if eventState == nil {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
if (input.Status == domain.ParticipationStatusApproved || input.Status == domain.ParticipationStatusPending) &&
eventState.Capacity != nil &&
eventState.ApprovedParticipantCount+eventState.PendingParticipantCount >= *eventState.Capacity {
return domain.ConflictError(domain.ErrorCodeCapacityExceeded, "This event has reached its maximum capacity.")
}
if err := s.requireUsersExist(ctx, []uuid.UUID{input.UserID}); err != nil {
return err
}
participation, err := s.repo.CreateManualParticipation(ctx, input.EventID, input.UserID, input.Status)
if err != nil {
return err
}
result = &CreateManualParticipationResult{
ParticipationID: participation.ID,
EventID: participation.EventID,
UserID: participation.UserID,
Status: participation.Status,
}
if participation.Status == domain.ParticipationStatusApproved {
createdTicket, err := s.tickets.CreateTicketForParticipation(ctx, participation, domain.TicketStatusActive)
if err != nil {
return err
}
result.TicketID = &createdTicket.ID
result.TicketStatus = &createdTicket.Status
}
return nil
})
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "admin participation created",
"operation", "admin.participations.create",
"admin_user_id", input.AdminUserID.String(),
"event_id", result.EventID.String(),
"participant_user_id", result.UserID.String(),
"participation_id", result.ParticipationID.String(),
"participation_status", result.Status.String(),
"ticket_created", result.TicketID != nil,
"has_reason", input.Reason != nil && strings.TrimSpace(*input.Reason) != "",
)
return result, nil
}
func (s *Service) CancelParticipation(ctx context.Context, input CancelParticipationInput) (*CancelParticipationResult, error) {
if s.unitOfWork == nil || s.tickets == nil {
return nil, domain.ConflictError(domain.ErrorCodeAdminDependencyUnavailable, "Admin participation mutations are not configured.")
}
if input.ParticipationID == uuid.Nil {
return nil, domain.ValidationError(map[string]string{"participation_id": "is required"})
}
var result *CancelParticipationResult
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
existing, err := s.repo.GetParticipationByID(ctx, input.ParticipationID, true)
if err != nil {
return fmt.Errorf("load admin participation: %w", err)
}
if existing == nil {
return domain.NotFoundError(domain.ErrorCodeParticipationNotFound, "The requested participation does not exist.")
}
participation, alreadyCanceled, err := s.repo.CancelParticipation(ctx, input.ParticipationID)
if err != nil {
return err
}
if !alreadyCanceled {
if err := s.tickets.CancelTicketForParticipation(ctx, input.ParticipationID); err != nil {
return err
}
}
result = &CancelParticipationResult{
ParticipationID: participation.ID,
EventID: participation.EventID,
UserID: participation.UserID,
Status: participation.Status,
AlreadyCanceled: alreadyCanceled,
}
return nil
})
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "admin participation canceled",
"operation", "admin.participations.cancel",
"admin_user_id", input.AdminUserID.String(),
"event_id", result.EventID.String(),
"participant_user_id", result.UserID.String(),
"participation_id", result.ParticipationID.String(),
"already_canceled", result.AlreadyCanceled,
"has_reason", input.Reason != nil && strings.TrimSpace(*input.Reason) != "",
)
return result, nil
}
func (s *Service) validateCustomNotification(ctx context.Context, input SendCustomNotificationInput) error {
details := map[string]string{}
if len(input.UserIDs) == 0 {
details["user_ids"] = "must contain at least one user id"
}
if input.DeliveryMode == "" {
details["delivery_mode"] = "must be one of: IN_APP, PUSH, BOTH"
}
if strings.TrimSpace(input.Title) == "" {
details["title"] = "is required"
}
if strings.TrimSpace(input.Body) == "" {
details["body"] = "is required"
}
if len(details) > 0 {
return domain.ValidationError(details)
}
if err := s.requireUsersExist(ctx, input.UserIDs); err != nil {
return err
}
if input.EventID != nil {
eventState, err := s.repo.GetEventState(ctx, *input.EventID, false)
if err != nil {
return fmt.Errorf("load admin notification event: %w", err)
}
if eventState == nil {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
}
return nil
}
func validateManualParticipationInput(input CreateManualParticipationInput) error {
details := map[string]string{}
if input.EventID == uuid.Nil {
details["event_id"] = "is required"
}
if input.UserID == uuid.Nil {
details["user_id"] = "is required"
}
switch input.Status {
case domain.ParticipationStatusApproved, domain.ParticipationStatusPending:
default:
details["status"] = "must be one of: APPROVED, PENDING"
}
if len(details) > 0 {
return domain.ValidationError(details)
}
return nil
}
func (s *Service) requireUsersExist(ctx context.Context, userIDs []uuid.UUID) error {
unique := make([]uuid.UUID, 0, len(userIDs))
seen := map[uuid.UUID]struct{}{}
for _, userID := range userIDs {
if userID == uuid.Nil {
return domain.ValidationError(map[string]string{"user_ids": "must contain valid UUIDs"})
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
unique = append(unique, userID)
}
count, err := s.repo.CountExistingUsers(ctx, unique)
if err != nil {
return fmt.Errorf("count admin target users: %w", err)
}
if count != len(unique) {
return domain.NotFoundError(domain.ErrorCodeUserNotFound, "One or more requested users do not exist.")
}
return nil
}
func (s *Service) CreateCategory(ctx context.Context, input CreateCategoryInput) (*AdminCategoryItem, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, domain.ValidationError(map[string]string{"name": "is required"})
}
if len(name) > 50 {
return nil, domain.ValidationError(map[string]string{"name": "must be at most 50 characters"})
}
item, err := s.repo.CreateCategory(ctx, name)
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "admin category created",
"operation", "admin.categories.create",
"admin_user_id", input.AdminUserID.String(),
"category_id", item.ID,
)
return item, nil
}
func (s *Service) DeleteCategory(ctx context.Context, input DeleteCategoryInput) error {
if input.CategoryID <= 0 {
return domain.ValidationError(map[string]string{"category_id": "must be a positive integer"})
}
if err := s.repo.DeleteCategory(ctx, input.CategoryID); err != nil {
return err
}
slog.InfoContext(ctx, "admin category deleted",
"operation", "admin.categories.delete",
"admin_user_id", input.AdminUserID.String(),
"category_id", input.CategoryID,
)
return nil
}
func (s *Service) UpdateEventReportStatus(ctx context.Context, input UpdateEventReportStatusInput) (*AdminEventReportItem, error) {
if input.ReportID == uuid.Nil {
return nil, domain.ValidationError(map[string]string{"report_id": "is required"})
}
item, err := s.repo.UpdateEventReportStatus(ctx, input.ReportID, input.Status)
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "admin event report status updated",
"operation", "admin.event_reports.status",
"admin_user_id", input.AdminUserID.String(),
"report_id", input.ReportID.String(),
"status", input.Status.String(),
"has_reason", input.Reason != nil && strings.TrimSpace(*input.Reason) != "",
)
return item, nil
}
func (s *Service) UpdateEventStatus(ctx context.Context, input UpdateEventStatusInput) (*AdminEventItem, error) {
if input.EventID == uuid.Nil {
return nil, domain.ValidationError(map[string]string{"event_id": "is required"})
}
if input.Status == domain.EventStatusCanceled {
if _, err := s.CancelEvent(ctx, CancelEventInput{AdminUserID: input.AdminUserID, EventID: input.EventID, Reason: input.Reason}); err != nil {
return nil, err
}
return s.repo.UpdateEventStatus(ctx, input.EventID, domain.EventStatusCanceled)
}
item, err := s.repo.UpdateEventStatus(ctx, input.EventID, input.Status)
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "admin event status updated",
"operation", "admin.events.status",
"admin_user_id", input.AdminUserID.String(),
"event_id", input.EventID.String(),
"status", input.Status.String(),
"has_reason", input.Reason != nil && strings.TrimSpace(*input.Reason) != "",
)
return item, nil
}
func (s *Service) CancelEvent(ctx context.Context, input CancelEventInput) (*CancelEventResult, error) {
if s.unitOfWork == nil || s.tickets == nil {
return nil, domain.ConflictError(domain.ErrorCodeAdminDependencyUnavailable, "Admin event mutations are not configured.")
}
if input.EventID == uuid.Nil {
return nil, domain.ValidationError(map[string]string{"event_id": "is required"})
}
result := &CancelEventResult{EventID: input.EventID, Status: domain.EventStatusCanceled}
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
alreadyCanceled, err := s.repo.CancelEvent(ctx, input.EventID)
if err != nil {
return err
}
result.AlreadyCanceled = alreadyCanceled
if alreadyCanceled {
return nil
}
if err := s.repo.CancelEventParticipations(ctx, input.EventID); err != nil {
return err
}
if err := s.tickets.CancelTicketsForEvent(ctx, input.EventID); err != nil {
return err
}
if err := s.repo.CancelPendingInvitationsForEvent(ctx, input.EventID); err != nil {
return err
}
return s.repo.CancelPendingJoinRequestsForEvent(ctx, input.EventID)
})
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "admin event canceled",
"operation", "admin.events.cancel",
"admin_user_id", input.AdminUserID.String(),
"event_id", input.EventID.String(),
"already_canceled", result.AlreadyCanceled,
"has_reason", input.Reason != nil && strings.TrimSpace(*input.Reason) != "",
)
return result, nil
}
func (s *Service) DeactivateUser(ctx context.Context, input DeactivateUserInput) (*DeactivateUserResult, error) {
if s.unitOfWork == nil || s.tickets == nil {
return nil, domain.ConflictError(domain.ErrorCodeAdminDependencyUnavailable, "Admin user mutations are not configured.")
}
if input.UserID == uuid.Nil {
return nil, domain.ValidationError(map[string]string{"user_id": "is required"})
}
result := &DeactivateUserResult{UserID: input.UserID, Status: domain.UserStatusDeactivated}
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
status, err := s.repo.GetUserStatus(ctx, input.UserID, true)
if err != nil {
return err
}
if status == nil {
return domain.NotFoundError(domain.ErrorCodeUserNotFound, "The requested user does not exist.")
}
if *status == domain.UserStatusDeactivated {
result.AlreadyDeactivated = true
return nil
}
if err := s.repo.DeactivateUser(ctx, input.UserID); err != nil {
return err
}
if err := s.repo.RevokeRefreshTokensForUser(ctx, input.UserID); err != nil {
return err
}
if err := s.repo.RevokePushDevicesForUser(ctx, input.UserID); err != nil {
return err
}
eventIDs, err := s.repo.ListHostedCancelableEventIDs(ctx, input.UserID)
if err != nil {
return err
}
result.CanceledEventCount = len(eventIDs)
for _, eventID := range eventIDs {
alreadyCanceled, err := s.repo.CancelEvent(ctx, eventID)
if err != nil {
return err
}
if alreadyCanceled {
continue
}
if err := s.repo.CancelEventParticipations(ctx, eventID); err != nil {
return err
}
if err := s.tickets.CancelTicketsForEvent(ctx, eventID); err != nil {
return err
}
if err := s.repo.CancelPendingInvitationsForEvent(ctx, eventID); err != nil {
return err
}
if err := s.repo.CancelPendingJoinRequestsForEvent(ctx, eventID); err != nil {
return err
}
}
if err := s.repo.CancelUserParticipations(ctx, input.UserID); err != nil {
return err
}
if err := s.repo.CancelUserTickets(ctx, input.UserID); err != nil {
return err
}
if err := s.repo.CancelPendingInvitationsForUser(ctx, input.UserID); err != nil {
return err
}
return s.repo.CancelPendingJoinRequestsForUser(ctx, input.UserID)
})
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "admin user deactivated",
"operation", "admin.users.deactivate",
"admin_user_id", input.AdminUserID.String(),
"target_user_id", input.UserID.String(),
"already_deactivated", result.AlreadyDeactivated,
"canceled_event_count", result.CanceledEventCount,
"has_reason", input.Reason != nil && strings.TrimSpace(*input.Reason) != "",
)
return result, nil
}
func (s *Service) UpdateInvitationStatus(ctx context.Context, input UpdateInvitationStatusInput) (*AdminInvitationItem, error) {
if input.InvitationID == uuid.Nil {
return nil, domain.ValidationError(map[string]string{"invitation_id": "is required"})
}
if input.Status != domain.InvitationStatusCanceled {
return nil, domain.ValidationError(map[string]string{"status": "must be CANCELED"})
}
return s.repo.UpdateInvitationStatus(ctx, input.InvitationID, input.Status)
}
func (s *Service) UpdateJoinRequestStatus(ctx context.Context, input UpdateJoinRequestStatusInput) (*AdminJoinRequestItem, error) {
if input.JoinRequestID == uuid.Nil {
return nil, domain.ValidationError(map[string]string{"join_request_id": "is required"})
}
if input.Status != domain.JoinRequestStatusCanceled && input.Status != domain.JoinRequestStatusRejected {
return nil, domain.ValidationError(map[string]string{"status": "must be one of: CANCELED, REJECTED"})
}
return s.repo.UpdateJoinRequestStatus(ctx, input.JoinRequestID, input.Status)
}
func (s *Service) DeleteComment(ctx context.Context, input DeleteCommentInput) error {
if input.CommentID == uuid.Nil {
return domain.ValidationError(map[string]string{"comment_id": "is required"})
}
return s.repo.DeleteComment(ctx, input.CommentID)
}
func (s *Service) DeleteEventRating(ctx context.Context, input DeleteRatingInput) error {
if input.RatingID == uuid.Nil {
return domain.ValidationError(map[string]string{"rating_id": "is required"})
}
return s.repo.DeleteEventRating(ctx, input.RatingID)
}
func (s *Service) DeleteParticipantRating(ctx context.Context, input DeleteRatingInput) error {
if input.RatingID == uuid.Nil {
return domain.ValidationError(map[string]string{"rating_id": "is required"})
}
return s.repo.DeleteParticipantRating(ctx, input.RatingID)
}
func (s *Service) RevokePushDevice(ctx context.Context, input RevokePushDeviceInput) error {
if input.DeviceID == uuid.Nil {
return domain.ValidationError(map[string]string{"device_id": "is required"})
}
return s.repo.RevokePushDevice(ctx, input.DeviceID)
}
package admin
import (
"context"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
const (
DefaultLimit = 50
MaxLimit = 100
)
// PageInput carries offset pagination controls.
type PageInput struct {
Limit int
Offset int
}
// PageMeta describes offset pagination state for table clients.
type PageMeta struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalCount int `json:"total_count"`
HasNext bool `json:"has_next"`
}
type CreatedRange struct {
CreatedFrom *time.Time
CreatedTo *time.Time
}
type StartRange struct {
StartFrom *time.Time
StartTo *time.Time
}
type ListUsersInput struct {
PageInput
CreatedRange
Query *string
Status *domain.UserStatus
Role *domain.UserRole
}
type AdminUserItem struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PhoneNumber *string `json:"phone_number"`
EmailVerified bool `json:"email_verified"`
LastLogin *time.Time `json:"last_login"`
Status string `json:"status"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListUsersResult struct {
Items []AdminUserItem `json:"items"`
PageMeta
}
type ListEventsInput struct {
PageInput
StartRange
Query *string
HostID *uuid.UUID
CategoryID *int
PrivacyLevel *domain.EventPrivacyLevel
Status *domain.EventStatus
}
type AdminEventItem struct {
ID uuid.UUID `json:"id"`
HostID uuid.UUID `json:"host_id"`
HostUsername string `json:"host_username"`
Title string `json:"title"`
CategoryID *int `json:"category_id"`
CategoryName *string `json:"category_name"`
StartTime time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
PrivacyLevel string `json:"privacy_level"`
Status string `json:"status"`
Capacity *int `json:"capacity"`
ApprovedParticipantCount int `json:"approved_participant_count"`
PendingParticipantCount int `json:"pending_participant_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListEventsResult struct {
Items []AdminEventItem `json:"items"`
PageMeta
}
type ListParticipationsInput struct {
PageInput
CreatedRange
Query *string
Status *domain.ParticipationStatus
EventID *uuid.UUID
UserID *uuid.UUID
}
type AdminParticipationItem struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
EventTitle string `json:"event_title"`
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
Status string `json:"status"`
ReconfirmedAt *time.Time `json:"reconfirmed_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListParticipationsResult struct {
Items []AdminParticipationItem `json:"items"`
PageMeta
}
type ListTicketsInput struct {
PageInput
CreatedRange
Query *string
Status *domain.TicketStatus
EventID *uuid.UUID
UserID *uuid.UUID
ParticipationID *uuid.UUID
}
type AdminTicketItem struct {
ID uuid.UUID `json:"id"`
ParticipationID uuid.UUID `json:"participation_id"`
EventID uuid.UUID `json:"event_id"`
EventTitle string `json:"event_title"`
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at"`
CanceledAt *time.Time `json:"canceled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListTicketsResult struct {
Items []AdminTicketItem `json:"items"`
PageMeta
}
type ListNotificationsInput struct {
PageInput
CreatedRange
Query *string
UserID *uuid.UUID
EventID *uuid.UUID
Type *string
IsRead *bool
}
type AdminNotificationItem struct {
ID uuid.UUID `json:"id"`
ReceiverUserID uuid.UUID `json:"receiver_user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
EventID *uuid.UUID `json:"event_id"`
EventTitle *string `json:"event_title"`
Title string `json:"title"`
Type *string `json:"type"`
Body string `json:"body"`
DeepLink *string `json:"deep_link"`
Data map[string]string `json:"data"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
DeletedAt *time.Time `json:"deleted_at"`
SSESentCount int `json:"sse_sent_count"`
PushSentCount int `json:"push_sent_count"`
PushFailedCount int `json:"push_failed_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListNotificationsResult struct {
Items []AdminNotificationItem `json:"items"`
PageMeta
}
type ListEventReportsInput struct {
PageInput
CreatedRange
Query *string
Status *domain.EventReportStatus
ReportCategory *domain.EventReportCategory
EventID *uuid.UUID
ReporterUserID *uuid.UUID
}
type AdminEventReportItem struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
EventTitle *string `json:"event_title"`
ReporterUserID uuid.UUID `json:"reporter_user_id"`
ReporterUsername *string `json:"reporter_username"`
ReporterEmail *string `json:"reporter_email"`
ReportCategory string `json:"report_category"`
Message string `json:"message"`
ImageURL *string `json:"image_url"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListEventReportsResult struct {
Items []AdminEventReportItem `json:"items"`
PageMeta
}
type AdminCategoryItem struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCategoriesResult struct {
Items []AdminCategoryItem `json:"items"`
}
type CreateCategoryInput struct {
AdminUserID uuid.UUID
Name string
}
type DeleteCategoryInput struct {
AdminUserID uuid.UUID
CategoryID int
}
type UpdateEventReportStatusInput struct {
AdminUserID uuid.UUID
ReportID uuid.UUID
Status domain.EventReportStatus
Reason *string
}
type UpdateEventStatusInput struct {
AdminUserID uuid.UUID
EventID uuid.UUID
Status domain.EventStatus
Reason *string
}
type CancelEventInput struct {
AdminUserID uuid.UUID
EventID uuid.UUID
Reason *string
}
type CancelEventResult struct {
EventID uuid.UUID `json:"event_id"`
Status domain.EventStatus `json:"status"`
AlreadyCanceled bool `json:"already_canceled"`
}
type DeactivateUserInput struct {
AdminUserID uuid.UUID
UserID uuid.UUID
Reason *string
}
type DeactivateUserResult struct {
UserID uuid.UUID `json:"user_id"`
Status domain.UserStatus `json:"status"`
AlreadyDeactivated bool `json:"already_deactivated"`
CanceledEventCount int `json:"canceled_event_count"`
}
type ListInvitationsInput struct {
PageInput
CreatedRange
Query *string
Status *domain.InvitationStatus
EventID *uuid.UUID
HostID *uuid.UUID
InvitedUserID *uuid.UUID
}
type AdminInvitationItem struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
EventTitle string `json:"event_title"`
HostID uuid.UUID `json:"host_id"`
HostUsername string `json:"host_username"`
InvitedUserID uuid.UUID `json:"invited_user_id"`
InvitedUsername string `json:"invited_username"`
InvitedEmail string `json:"invited_email"`
Status string `json:"status"`
Message *string `json:"message"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListInvitationsResult struct {
Items []AdminInvitationItem `json:"items"`
PageMeta
}
type UpdateInvitationStatusInput struct {
AdminUserID uuid.UUID
InvitationID uuid.UUID
Status domain.InvitationStatus
Reason *string
}
type ListJoinRequestsInput struct {
PageInput
CreatedRange
Query *string
Status *domain.JoinRequestStatus
EventID *uuid.UUID
UserID *uuid.UUID
HostUserID *uuid.UUID
}
type AdminJoinRequestItem struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
EventTitle string `json:"event_title"`
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
HostUserID uuid.UUID `json:"host_user_id"`
HostUsername string `json:"host_username"`
Status string `json:"status"`
Message *string `json:"message"`
ImageURL *string `json:"image_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListJoinRequestsResult struct {
Items []AdminJoinRequestItem `json:"items"`
PageMeta
}
type UpdateJoinRequestStatusInput struct {
AdminUserID uuid.UUID
JoinRequestID uuid.UUID
Status domain.JoinRequestStatus
Reason *string
}
type ListCommentsInput struct {
PageInput
CreatedRange
Query *string
EventID *uuid.UUID
UserID *uuid.UUID
Type *string
}
type AdminCommentItem struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
EventTitle string `json:"event_title"`
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
Type string `json:"type"`
ParentID *uuid.UUID `json:"parent_id"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCommentsResult struct {
Items []AdminCommentItem `json:"items"`
PageMeta
}
type DeleteCommentInput struct {
AdminUserID uuid.UUID
CommentID uuid.UUID
Reason *string
}
type ListEventRatingsInput struct {
PageInput
CreatedRange
EventID *uuid.UUID
UserID *uuid.UUID
}
type AdminEventRatingItem struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
EventTitle string `json:"event_title"`
ParticipantUserID uuid.UUID `json:"participant_user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
Score float64 `json:"score"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListEventRatingsResult struct {
Items []AdminEventRatingItem `json:"items"`
PageMeta
}
type ListParticipantRatingsInput struct {
PageInput
CreatedRange
EventID *uuid.UUID
HostID *uuid.UUID
UserID *uuid.UUID
}
type AdminParticipantRatingItem struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
EventTitle string `json:"event_title"`
HostUserID uuid.UUID `json:"host_user_id"`
HostUsername string `json:"host_username"`
ParticipantUserID uuid.UUID `json:"participant_user_id"`
ParticipantUsername string `json:"participant_username"`
Score float64 `json:"score"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListParticipantRatingsResult struct {
Items []AdminParticipantRatingItem `json:"items"`
PageMeta
}
type DeleteRatingInput struct {
AdminUserID uuid.UUID
RatingID uuid.UUID
Reason *string
}
type ListFavoriteEventsInput struct {
PageInput
CreatedRange
UserID *uuid.UUID
EventID *uuid.UUID
}
type AdminFavoriteEventItem struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
EventID uuid.UUID `json:"event_id"`
EventTitle string `json:"event_title"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListFavoriteEventsResult struct {
Items []AdminFavoriteEventItem `json:"items"`
PageMeta
}
type ListFavoriteLocationsInput struct {
PageInput
CreatedRange
UserID *uuid.UUID
Query *string
}
type AdminFavoriteLocationItem struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
Name *string `json:"name"`
Address *string `json:"address"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListFavoriteLocationsResult struct {
Items []AdminFavoriteLocationItem `json:"items"`
PageMeta
}
type ListUserBadgesInput struct {
PageInput
UserID *uuid.UUID
Query *string
}
type AdminUserBadgeItem struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
BadgeID int `json:"badge_id"`
BadgeSlug string `json:"badge_slug"`
BadgeName string `json:"badge_name"`
BadgeCategory string `json:"badge_category"`
EarnedAt time.Time `json:"earned_at"`
}
type ListUserBadgesResult struct {
Items []AdminUserBadgeItem `json:"items"`
PageMeta
}
type ListPushDevicesInput struct {
PageInput
CreatedRange
UserID *uuid.UUID
Platform *string
Active *bool
}
type AdminPushDeviceItem struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
InstallationID uuid.UUID `json:"installation_id"`
Platform string `json:"platform"`
LastSeenAt time.Time `json:"last_seen_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListPushDevicesResult struct {
Items []AdminPushDeviceItem `json:"items"`
PageMeta
}
type RevokePushDeviceInput struct {
AdminUserID uuid.UUID
DeviceID uuid.UUID
Reason *string
}
type SendCustomNotificationInput struct {
AdminUserID uuid.UUID
UserIDs []uuid.UUID
DeliveryMode domain.NotificationDeliveryMode
Title string
Body string
Type *string
DeepLink *string
EventID *uuid.UUID
Data map[string]string
IdempotencyKey *string
}
type SendCustomNotificationResult struct {
TargetUserCount int `json:"target_user_count"`
CreatedCount int `json:"created_count"`
IdempotentCount int `json:"idempotent_count"`
SSEDeliveryCount int `json:"sse_delivery_count"`
PushActiveDeviceCount int `json:"push_active_device_count"`
PushSentCount int `json:"push_sent_count"`
PushFailedCount int `json:"push_failed_count"`
InvalidTokenCount int `json:"invalid_token_count"`
}
type CreateManualParticipationInput struct {
AdminUserID uuid.UUID
EventID uuid.UUID
UserID uuid.UUID
Status domain.ParticipationStatus
Reason *string
}
type CreateManualParticipationResult struct {
ParticipationID uuid.UUID `json:"participation_id"`
EventID uuid.UUID `json:"event_id"`
UserID uuid.UUID `json:"user_id"`
Status domain.ParticipationStatus `json:"status"`
TicketID *uuid.UUID `json:"ticket_id,omitempty"`
TicketStatus *domain.TicketStatus `json:"ticket_status,omitempty"`
}
type CancelParticipationInput struct {
AdminUserID uuid.UUID
ParticipationID uuid.UUID
Reason *string
}
type CancelParticipationResult struct {
ParticipationID uuid.UUID `json:"participation_id"`
EventID uuid.UUID `json:"event_id"`
UserID uuid.UUID `json:"user_id"`
Status domain.ParticipationStatus `json:"status"`
AlreadyCanceled bool `json:"already_canceled"`
}
type AdminEventState struct {
ID uuid.UUID
PrivacyLevel domain.EventPrivacyLevel
Capacity *int
ApprovedParticipantCount int
PendingParticipantCount int
}
func (s *Service) ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersResult, error) {
return s.repo.ListUsers(ctx, input)
}
func (s *Service) ListEvents(ctx context.Context, input ListEventsInput) (*ListEventsResult, error) {
return s.repo.ListEvents(ctx, input)
}
func (s *Service) ListParticipations(ctx context.Context, input ListParticipationsInput) (*ListParticipationsResult, error) {
return s.repo.ListParticipations(ctx, input)
}
func (s *Service) ListTickets(ctx context.Context, input ListTicketsInput) (*ListTicketsResult, error) {
return s.repo.ListTickets(ctx, input)
}
func (s *Service) ListNotifications(ctx context.Context, input ListNotificationsInput) (*ListNotificationsResult, error) {
return s.repo.ListNotifications(ctx, input)
}
func (s *Service) ListEventReports(ctx context.Context, input ListEventReportsInput) (*ListEventReportsResult, error) {
return s.repo.ListEventReports(ctx, input)
}
func (s *Service) ListCategories(ctx context.Context) (*ListCategoriesResult, error) {
return s.repo.ListCategories(ctx)
}
func (s *Service) ListInvitations(ctx context.Context, input ListInvitationsInput) (*ListInvitationsResult, error) {
return s.repo.ListInvitations(ctx, input)
}
func (s *Service) ListJoinRequests(ctx context.Context, input ListJoinRequestsInput) (*ListJoinRequestsResult, error) {
return s.repo.ListJoinRequests(ctx, input)
}
func (s *Service) ListComments(ctx context.Context, input ListCommentsInput) (*ListCommentsResult, error) {
return s.repo.ListComments(ctx, input)
}
func (s *Service) ListEventRatings(ctx context.Context, input ListEventRatingsInput) (*ListEventRatingsResult, error) {
return s.repo.ListEventRatings(ctx, input)
}
func (s *Service) ListParticipantRatings(ctx context.Context, input ListParticipantRatingsInput) (*ListParticipantRatingsResult, error) {
return s.repo.ListParticipantRatings(ctx, input)
}
func (s *Service) ListFavoriteEvents(ctx context.Context, input ListFavoriteEventsInput) (*ListFavoriteEventsResult, error) {
return s.repo.ListFavoriteEvents(ctx, input)
}
func (s *Service) ListFavoriteLocations(ctx context.Context, input ListFavoriteLocationsInput) (*ListFavoriteLocationsResult, error) {
return s.repo.ListFavoriteLocations(ctx, input)
}
func (s *Service) ListUserBadges(ctx context.Context, input ListUserBadgesInput) (*ListUserBadgesResult, error) {
return s.repo.ListUserBadges(ctx, input)
}
func (s *Service) ListPushDevices(ctx context.Context, input ListPushDevicesInput) (*ListPushDevicesResult, error) {
return s.repo.ListPushDevices(ctx, input)
}
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"
"log/slog"
"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
pushDevices PushDeviceRevoker
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,
}
}
// SetPushDeviceRevoker attaches the optional push-device cleanup dependency.
func (s *Service) SetPushDeviceRevoker(revoker PushDeviceRevoker) {
s.pushDevices = revoker
}
// 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.")
}
if user.Status != domain.UserStatusActive {
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)
}
if user.Status != domain.UserStatusActive {
if err := s.repo.RevokeRefreshTokenFamily(ctx, current.FamilyID, now); err != nil {
return fmt.Errorf("revoke inactive user refresh token family: %w", err)
}
return domain.AuthError(domain.ErrorCodeInvalidRefresh, "The refresh token is invalid or expired.")
}
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, input LogoutInput) error {
refreshToken := strings.TrimSpace(input.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)
}
if input.DeviceInstallationID != nil && s.pushDevices != nil {
if err := s.pushDevices.UnregisterDevice(ctx, current.UserID, *input.DeviceInstallationID); err != nil {
return fmt.Errorf("revoke logout push device: %w", err)
}
}
slog.InfoContext(ctx, "session logged out",
"operation", "auth.logout",
"user_id", current.UserID.String(),
"push_device_unregistered", input.DeviceInstallationID != nil && s.pushDevices != nil,
)
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 badge
import (
"context"
"log/slog"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
const (
// minTopRatedRatingCount is the minimum number of host ratings required
// before the TOP_RATED badge becomes eligible. Mirrors the acceptance
// criteria of issue #435.
minTopRatedRatingCount = 5
// topRatedThreshold is the minimum host score required for TOP_RATED.
topRatedThreshold = 4.5
)
// Service owns badge-specific application behavior.
type Service struct {
repo Repository
}
var _ UseCase = (*Service)(nil)
// NewService constructs a badge service backed by its repository port.
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
// ListMyBadges returns the badges earned by the authenticated user.
func (s *Service) ListMyBadges(ctx context.Context, userID uuid.UUID) (*ListUserBadgesResult, error) {
return s.listUserBadges(ctx, userID)
}
// ListUserBadges returns the badges earned by the given public profile owner.
func (s *Service) ListUserBadges(ctx context.Context, userID uuid.UUID) (*ListUserBadgesResult, error) {
return s.listUserBadges(ctx, userID)
}
func (s *Service) listUserBadges(ctx context.Context, userID uuid.UUID) (*ListUserBadgesResult, error) {
earned, err := s.repo.ListUserBadges(ctx, userID)
if err != nil {
return nil, err
}
items := make([]EarnedBadgeItem, len(earned))
for i, ub := range earned {
items[i] = EarnedBadgeItem{
BadgeItem: toBadgeItem(ub.Definition),
EarnedAt: ub.EarnedAt.Format(time.RFC3339),
}
}
return &ListUserBadgesResult{Items: items}, nil
}
// ListBadges returns the full catalog with the viewer's earned status overlay.
func (s *Service) ListBadges(ctx context.Context, userID uuid.UUID) (*ListBadgesResult, error) {
all, err := s.repo.ListAllBadges(ctx)
if err != nil {
return nil, err
}
earned, err := s.repo.ListUserBadges(ctx, userID)
if err != nil {
return nil, err
}
earnedAt := make(map[domain.BadgeSlug]time.Time, len(earned))
for _, ub := range earned {
earnedAt[ub.Slug] = ub.EarnedAt
}
items := make([]CatalogBadgeItem, len(all))
for i, b := range all {
entry := CatalogBadgeItem{BadgeItem: toBadgeItem(b)}
if t, ok := earnedAt[b.Slug]; ok {
entry.Earned = true
formatted := t.Format(time.RFC3339)
entry.EarnedAt = &formatted
}
items[i] = entry
}
return &ListBadgesResult{Items: items}, nil
}
func toBadgeItem(b domain.Badge) BadgeItem {
return BadgeItem{
Slug: b.Slug.String(),
Name: b.Name,
Description: b.Description,
IconURL: b.IconURL,
Category: b.Category.String(),
}
}
// EvaluateParticipationBadges awards FIRST_STEPS / REGULAR / VETERAN / EXPLORER
// based on the participant-side metrics. AwardBadge is idempotent so this
// method is safe to call from any participation transition.
func (s *Service) EvaluateParticipationBadges(ctx context.Context, userID uuid.UUID) error {
stats, err := s.repo.ParticipationStats(ctx, userID)
if err != nil {
return err
}
if stats.CompletedEventCount >= 1 {
if _, err := s.repo.AwardBadge(ctx, userID, domain.BadgeSlugFirstSteps); err != nil {
return err
}
}
if stats.CompletedEventCount >= 5 {
if _, err := s.repo.AwardBadge(ctx, userID, domain.BadgeSlugRegular); err != nil {
return err
}
}
if stats.CompletedEventCount >= 20 {
if _, err := s.repo.AwardBadge(ctx, userID, domain.BadgeSlugVeteran); err != nil {
return err
}
}
if stats.DistinctCategoriesCount >= 3 {
if _, err := s.repo.AwardBadge(ctx, userID, domain.BadgeSlugExplorer); err != nil {
return err
}
}
return nil
}
// EvaluateHostBadges awards HOST_DEBUT / SUPER_HOST / TOP_RATED for the host.
// Should be called after a host completes a new event or receives a new rating.
func (s *Service) EvaluateHostBadges(ctx context.Context, hostID uuid.UUID) error {
stats, err := s.repo.HostStats(ctx, hostID)
if err != nil {
return err
}
if stats.CompletedHostedEventCount >= 1 {
if _, err := s.repo.AwardBadge(ctx, hostID, domain.BadgeSlugHostDebut); err != nil {
return err
}
}
if stats.CompletedHostedEventCount >= 10 {
if _, err := s.repo.AwardBadge(ctx, hostID, domain.BadgeSlugSuperHost); err != nil {
return err
}
}
if stats.HostRatingCount >= minTopRatedRatingCount && stats.HostScore != nil && *stats.HostScore >= topRatedThreshold {
if _, err := s.repo.AwardBadge(ctx, hostID, domain.BadgeSlugTopRated); err != nil {
return err
}
}
return nil
}
// EvaluateFavoriteLocationBadges awards FAVORITE_FINDER once the user has saved
// at least 3 favorite locations.
func (s *Service) EvaluateFavoriteLocationBadges(ctx context.Context, userID uuid.UUID) error {
count, err := s.repo.FavoriteLocationCount(ctx, userID)
if err != nil {
return err
}
if count >= 3 {
if _, err := s.repo.AwardBadge(ctx, userID, domain.BadgeSlugFavoriteFinder); err != nil {
return err
}
}
return nil
}
// BackfillExistingBadges replays idempotent badge evaluation for users who may
// already qualify based on historical data created before badge-trigger hooks
// existed. Per-user evaluation is best-effort so one broken row never blocks
// the rest of the population.
func (s *Service) BackfillExistingBadges(ctx context.Context) error {
participantUserIDs, err := s.repo.ListParticipationBadgeCandidateUserIDs(ctx)
if err != nil {
return err
}
for _, userID := range participantUserIDs {
if err := s.EvaluateParticipationBadges(ctx, userID); err != nil {
slog.WarnContext(ctx, "badge backfill participation evaluation failed",
slog.String("operation", "badge.backfill.participation"),
slog.String("user_id", userID.String()),
slog.String("error", err.Error()),
)
}
}
hostUserIDs, err := s.repo.ListHostBadgeCandidateUserIDs(ctx)
if err != nil {
return err
}
for _, hostID := range hostUserIDs {
if err := s.EvaluateHostBadges(ctx, hostID); err != nil {
slog.WarnContext(ctx, "badge backfill host evaluation failed",
slog.String("operation", "badge.backfill.host"),
slog.String("host_id", hostID.String()),
slog.String("error", err.Error()),
)
}
}
favoriteUserIDs, err := s.repo.ListFavoriteLocationBadgeCandidateUserIDs(ctx)
if err != nil {
return err
}
for _, userID := range favoriteUserIDs {
if err := s.EvaluateFavoriteLocationBadges(ctx, userID); err != nil {
slog.WarnContext(ctx, "badge backfill favorite-location evaluation failed",
slog.String("operation", "badge.backfill.favorite_location"),
slog.String("user_id", userID.String()),
slog.String("error", err.Error()),
)
}
}
return nil
}
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 comment
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/google/uuid"
)
const (
commentCollectionDiscussion = "DISCUSSION"
commentCollectionReview = "REVIEW"
commentCollectionReplies = "REPLIES"
)
func encodeCommentCursor(cursor CommentCursor) (string, error) {
raw, err := json.Marshal(cursor)
if err != nil {
return "", fmt.Errorf("marshal comment cursor: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func decodeCommentCursor(token string) (*CommentCursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return nil, fmt.Errorf("decode comment cursor: %w", err)
}
var cursor CommentCursor
if err := json.Unmarshal(raw, &cursor); err != nil {
return nil, fmt.Errorf("unmarshal comment cursor: %w", err)
}
if cursor.Collection == "" {
return nil, fmt.Errorf("cursor is missing collection")
}
if cursor.CreatedAt.IsZero() {
return nil, fmt.Errorf("cursor is missing created_at")
}
if cursor.CommentID == uuid.Nil {
return nil, fmt.Errorf("cursor is missing comment_id")
}
return &cursor, nil
}
package comment
import (
"strings"
"unicode/utf8"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
const (
defaultCommentLimit = 25
maxCommentLimit = 50
)
func normalizeMessage(message string) string {
return strings.TrimSpace(message)
}
func validateMessage(message string) map[string]string {
errs := make(map[string]string)
length := utf8.RuneCountInString(message)
if length < domain.CommentMessageMinLength || length > domain.CommentMessageMaxLength {
errs["message"] = "message must be between 1 and 1000 characters"
}
return errs
}
func normalizeLimit(value *int) (int, map[string]string) {
if value == nil {
return defaultCommentLimit, nil
}
if *value < 1 || *value > maxCommentLimit {
return 0, map[string]string{"limit": "limit must be between 1 and 50"}
}
return *value, nil
}
func toCommentResult(record CommentRecord) CommentResult {
var parentID *string
if record.ParentID != nil {
value := record.ParentID.String()
parentID = &value
}
return CommentResult{
ID: record.ID.String(),
EventID: record.EventID.String(),
User: CommentAuthorResult{
ID: record.User.ID.String(),
Username: record.User.Username,
DisplayName: record.User.DisplayName,
AvatarURL: record.User.AvatarURL,
},
Type: string(record.Type),
Message: record.Message,
ParentID: parentID,
Rating: record.Rating,
ImageURL: record.ImageURL,
LikesCount: record.LikesCount,
ReplyCount: record.ReplyCount,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
}
}
func toCommentResults(records []CommentRecord) []CommentResult {
items := make([]CommentResult, len(records))
for i, record := range records {
items[i] = toCommentResult(record)
}
return items
}
func buildPageInfo(records []CommentRecord, hasNext bool, collection string) (CommentPageInfo, error) {
if !hasNext || len(records) == 0 {
return CommentPageInfo{HasNext: hasNext}, nil
}
last := records[len(records)-1]
cursor, err := encodeCommentCursor(CommentCursor{
Collection: collection,
CreatedAt: last.CreatedAt,
CommentID: last.ID,
})
if err != nil {
return CommentPageInfo{}, err
}
return CommentPageInfo{
NextCursor: &cursor,
HasNext: true,
}, nil
}
package comment
import (
"context"
"errors"
"log/slog"
"github.com/bounswe/bounswe2026group11/backend/internal/application/imageupload"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service owns event comment and review application behavior.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
imageConfirmer ReviewImageConfirmer
scoreUpdater ReviewScoreUpdater
}
var _ UseCase = (*Service)(nil)
// NewService constructs a comment service backed by its repository.
func NewService(repo Repository, unitOfWork uow.UnitOfWork) *Service {
return &Service{
repo: repo,
unitOfWork: unitOfWork,
}
}
// SetReviewImageConfirmer wires in the image-upload service.
func (s *Service) SetReviewImageConfirmer(confirmer ReviewImageConfirmer) {
s.imageConfirmer = confirmer
}
// SetReviewScoreUpdater wires in the rating score updater.
func (s *Service) SetReviewScoreUpdater(updater ReviewScoreUpdater) {
s.scoreUpdater = updater
}
// ListEventComments returns top-level discussion and review comments.
func (s *Service) ListEventComments(ctx context.Context, viewerUserID *uuid.UUID, eventID uuid.UUID, input ListEventCommentsInput) (*ListEventCommentsResult, error) {
commentContext, err := s.loadReadableContext(ctx, eventID, viewerUserID)
if err != nil {
return nil, err
}
if commentContext.PrivacyLevel == domain.PrivacyPrivate {
return nil, domain.ConflictError(domain.ErrorCodeCommentsNotAllowed, "Comments are available only for PUBLIC and PROTECTED events.")
}
discussion, err := s.listTopLevelCollection(ctx, eventID, domain.CommentTypeDiscussion, input.DiscussionLimit, input.DiscussionCursor, commentCollectionDiscussion)
if err != nil {
return nil, err
}
reviews, err := s.listTopLevelCollection(ctx, eventID, domain.CommentTypeReview, input.ReviewLimit, input.ReviewCursor, commentCollectionReview)
if err != nil {
return nil, err
}
return &ListEventCommentsResult{
DiscussionComments: *discussion,
ReviewComments: *reviews,
}, nil
}
// ListCommentReplies returns child discussion comments for a parent discussion.
func (s *Service) ListCommentReplies(ctx context.Context, viewerUserID *uuid.UUID, eventID, commentID uuid.UUID, input ListCommentRepliesInput) (*ListCommentRepliesResult, error) {
commentContext, err := s.loadReadableContext(ctx, eventID, viewerUserID)
if err != nil {
return nil, err
}
if commentContext.PrivacyLevel == domain.PrivacyPrivate {
return nil, domain.ConflictError(domain.ErrorCodeCommentsNotAllowed, "Comments are available only for PUBLIC and PROTECTED events.")
}
parent, err := s.repo.GetDiscussionParentContext(ctx, eventID, commentID)
if err != nil {
return nil, s.mapCommentLookupError(err)
}
if parent.Type != domain.CommentTypeDiscussion || parent.ParentID != nil {
return nil, domain.NotFoundError(domain.ErrorCodeCommentNotFound, "The requested discussion comment does not exist.")
}
limit, validationErrs := normalizeLimit(input.Limit)
if len(validationErrs) > 0 {
return nil, domain.ValidationError(validationErrs)
}
params := ListCommentsParams{
Limit: limit,
RepositoryFetchLimit: limit + 1,
CommentType: domain.CommentTypeDiscussion,
}
if input.Cursor != nil && *input.Cursor != "" {
cursor, err := decodeCommentCursor(*input.Cursor)
if err != nil || cursor.Collection != commentCollectionReplies {
return nil, domain.ValidationError(map[string]string{"cursor": "cursor is invalid"})
}
params.DecodedCursor = cursor
}
records, err := s.repo.ListReplies(ctx, eventID, commentID, params)
if err != nil {
return nil, err
}
hasNext := len(records) > limit
if hasNext {
records = records[:limit]
}
pageInfo, err := buildPageInfo(records, hasNext, commentCollectionReplies)
if err != nil {
return nil, err
}
return &ListCommentRepliesResult{
Items: toCommentResults(records),
PageInfo: pageInfo,
}, nil
}
// CreateDiscussionComment creates a top-level discussion comment or reply.
func (s *Service) CreateDiscussionComment(ctx context.Context, userID, eventID uuid.UUID, input CreateDiscussionCommentInput) (*CommentResult, error) {
input.Message = normalizeMessage(input.Message)
if errs := validateMessage(input.Message); len(errs) > 0 {
return nil, domain.ValidationError(errs)
}
var result *CommentRecord
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
commentContext, err := s.loadWritableContext(ctx, eventID, userID)
if err != nil {
return err
}
if err := s.validateDiscussionWrite(commentContext); err != nil {
return err
}
if input.ParentID != nil {
parent, err := s.repo.GetDiscussionParentContext(ctx, eventID, *input.ParentID)
if err != nil {
return s.mapCommentLookupError(err)
}
if parent.Type != domain.CommentTypeDiscussion || parent.ParentID != nil {
return domain.NotFoundError(domain.ErrorCodeCommentNotFound, "The requested discussion comment does not exist.")
}
}
created, err := s.repo.CreateDiscussionComment(ctx, CreateDiscussionCommentParams{
EventID: eventID,
UserID: userID,
Message: input.Message,
ParentID: input.ParentID,
})
if err != nil {
return err
}
result = created
return nil
})
if err != nil {
return nil, err
}
mapped := toCommentResult(*result)
return &mapped, nil
}
// UpsertReviewComment creates or updates the caller's completed-event review.
func (s *Service) UpsertReviewComment(ctx context.Context, userID, eventID uuid.UUID, input UpsertReviewCommentInput) (*CommentResult, error) {
input.Message = normalizeMessage(input.Message)
errs := validateMessage(input.Message)
if input.Rating < domain.RatingMin || input.Rating > domain.RatingMax {
errs["rating"] = "rating must be between 1 and 5"
}
if len(errs) > 0 {
return nil, domain.ValidationError(errs)
}
var (
result *CommentRecord
hostID uuid.UUID
)
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
commentContext, err := s.loadWritableContext(ctx, eventID, userID)
if err != nil {
return err
}
if err := s.validateReviewWrite(commentContext); err != nil {
return err
}
var imageURL *string
if input.ImageConfirmToken != nil && *input.ImageConfirmToken != "" {
if s.imageConfirmer == nil {
return domain.ForbiddenError(domain.ErrorCodeReviewImageNotAllowed, "Review image uploads are not available.")
}
confirmed, err := s.imageConfirmer.ConfirmEventReviewImageUpload(ctx, userID, eventID, imageupload.ConfirmUploadInput{
ConfirmToken: *input.ImageConfirmToken,
})
if err != nil {
return err
}
imageURL = &confirmed.BaseURL
}
created, err := s.repo.UpsertReviewComment(ctx, UpsertReviewCommentParams{
EventID: eventID,
UserID: userID,
Message: input.Message,
Rating: input.Rating,
ImageURL: imageURL,
})
if err != nil {
return err
}
result = created
hostID = commentContext.HostUserID
return s.refreshHostScore(ctx, hostID)
})
if err != nil {
return nil, err
}
mapped := toCommentResult(*result)
return &mapped, nil
}
// DeleteReviewComment hard deletes the caller's completed-event review.
func (s *Service) DeleteReviewComment(ctx context.Context, userID, eventID uuid.UUID) error {
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
commentContext, err := s.loadWritableContext(ctx, eventID, userID)
if err != nil {
return err
}
if err := s.validateReviewWrite(commentContext); err != nil {
return err
}
deleted, err := s.repo.DeleteReviewComment(ctx, eventID, userID)
if err != nil {
return err
}
if !deleted {
return domain.NotFoundError(domain.ErrorCodeEventRatingNotFound, "The requested event rating does not exist.")
}
return s.refreshHostScore(ctx, commentContext.HostUserID)
})
}
func (s *Service) listTopLevelCollection(ctx context.Context, eventID uuid.UUID, commentType domain.CommentType, limitInput *int, cursorInput *string, collection string) (*CommentCollectionResult, error) {
limit, validationErrs := normalizeLimit(limitInput)
if len(validationErrs) > 0 {
return nil, domain.ValidationError(validationErrs)
}
params := ListCommentsParams{
Limit: limit,
RepositoryFetchLimit: limit + 1,
CommentType: commentType,
}
if cursorInput != nil && *cursorInput != "" {
cursor, err := decodeCommentCursor(*cursorInput)
if err != nil || cursor.Collection != collection {
return nil, domain.ValidationError(map[string]string{"cursor": "cursor is invalid"})
}
params.DecodedCursor = cursor
}
records, err := s.repo.ListTopLevelComments(ctx, eventID, params)
if err != nil {
return nil, err
}
hasNext := len(records) > limit
if hasNext {
records = records[:limit]
}
pageInfo, err := buildPageInfo(records, hasNext, collection)
if err != nil {
return nil, err
}
return &CommentCollectionResult{
Items: toCommentResults(records),
PageInfo: pageInfo,
}, nil
}
func (s *Service) loadReadableContext(ctx context.Context, eventID uuid.UUID, viewerUserID *uuid.UUID) (*EventCommentContext, error) {
commentContext, err := s.repo.GetEventCommentContext(ctx, eventID, viewerUserID)
if err != nil {
return nil, s.mapEventLookupError(err)
}
if !commentContext.IsVisible {
return nil, domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return commentContext, nil
}
func (s *Service) loadWritableContext(ctx context.Context, eventID, userID uuid.UUID) (*EventCommentContext, error) {
return s.loadReadableContext(ctx, eventID, &userID)
}
func (s *Service) validateDiscussionWrite(commentContext *EventCommentContext) error {
if commentContext.PrivacyLevel == domain.PrivacyPrivate {
return domain.ConflictError(domain.ErrorCodeCommentsNotAllowed, "Comments are available only for PUBLIC and PROTECTED events.")
}
switch commentContext.Status {
case domain.EventStatusCanceled:
return domain.ConflictError(domain.ErrorCodeCommentWriteNotAllowed, "Comments are closed for canceled events.")
case domain.EventStatusCompleted:
return domain.ConflictError(domain.ErrorCodeCommentWriteNotAllowed, "Only review comments are allowed after the event is completed.")
case domain.EventStatusInProgress:
if !commentContext.IsHost && !commentContext.IsQualifyingParticipant {
return domain.ForbiddenError(domain.ErrorCodeCommentWriteNotAllowed, "Only participants can comment while the event is in progress.")
}
}
return nil
}
func (s *Service) validateReviewWrite(commentContext *EventCommentContext) error {
if commentContext.PrivacyLevel == domain.PrivacyPrivate {
return domain.ConflictError(domain.ErrorCodeCommentsNotAllowed, "Reviews are available only for PUBLIC and PROTECTED events.")
}
if commentContext.IsHost {
return domain.ForbiddenError(domain.ErrorCodeHostCannotRateSelf, "The event host cannot rate their own event.")
}
if commentContext.Status == domain.EventStatusCanceled {
return domain.ConflictError(domain.ErrorCodeReviewNotAllowed, "Reviews are not allowed for canceled events.")
}
if commentContext.Status != domain.EventStatusCompleted {
return domain.ConflictError(domain.ErrorCodeReviewNotAllowed, "Review comments are allowed only after the event is completed.")
}
if !commentContext.IsQualifyingParticipant {
return domain.ForbiddenError(domain.ErrorCodeReviewNotAllowed, "Only participants can review this event.")
}
return nil
}
func (s *Service) refreshHostScore(ctx context.Context, hostID uuid.UUID) error {
if s.scoreUpdater == nil {
return nil
}
if err := s.scoreUpdater.RefreshHostedEventScore(ctx, hostID); err != nil {
slog.WarnContext(ctx, "review score refresh failed",
slog.String("operation", "comment.review.refresh_host_score"),
slog.String("host_id", hostID.String()),
slog.String("error", err.Error()),
)
return err
}
return nil
}
func (s *Service) mapEventLookupError(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) mapCommentLookupError(err error) error {
if errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeCommentNotFound, "The requested discussion comment does not exist.")
}
return err
}
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 "reflect"
func buildEventDetailDiff(from, to *EventHistorySnapshotRecord) *EventDetailDiff {
if from == nil || to == nil || from.VersionNo >= to.VersionNo {
return nil
}
comparisons := []struct {
field string
old any
new any
}{
{"title", from.Snapshot.Title, to.Snapshot.Title},
{"description", from.Snapshot.Description, to.Snapshot.Description},
{"image_url", from.Snapshot.ImageURL, to.Snapshot.ImageURL},
{"privacy_level", from.Snapshot.PrivacyLevel, to.Snapshot.PrivacyLevel},
{"status", from.Snapshot.Status, to.Snapshot.Status},
{"start_time", from.Snapshot.StartTime, to.Snapshot.StartTime},
{"end_time", from.Snapshot.EndTime, to.Snapshot.EndTime},
{"capacity", from.Snapshot.Capacity, to.Snapshot.Capacity},
{"minimum_age", from.Snapshot.MinimumAge, to.Snapshot.MinimumAge},
{"preferred_gender", from.Snapshot.PreferredGender, to.Snapshot.PreferredGender},
{"child_friendly", from.Snapshot.ChildFriendly, to.Snapshot.ChildFriendly},
{"family_oriented", from.Snapshot.FamilyOriented, to.Snapshot.FamilyOriented},
{"category", from.Snapshot.Category, to.Snapshot.Category},
{"location", from.Snapshot.Location, to.Snapshot.Location},
{"tags", from.Snapshot.Tags, to.Snapshot.Tags},
{"constraints", from.Snapshot.Constraints, to.Snapshot.Constraints},
}
changes := make([]EventDetailDiffChange, 0)
changedFields := make([]string, 0)
for _, comparison := range comparisons {
if reflect.DeepEqual(comparison.old, comparison.new) {
continue
}
changedFields = append(changedFields, comparison.field)
changes = append(changes, EventDetailDiffChange{
Field: comparison.field,
OldValue: comparison.old,
NewValue: comparison.new,
})
}
if len(changes) == 0 {
return nil
}
return &EventDetailDiff{
FromVersionNo: from.VersionNo,
ToVersionNo: to.VersionNo,
ChangedFields: changedFields,
Changes: changes,
}
}
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"`
MinimumAge *int `json:"minimum_age,omitempty"`
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"`
OnlyChildFriendly bool `json:"only_child_friendly"`
OnlyFamilyOriented bool `json:"only_family_oriented"`
}
// 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,
MinimumAge: params.MinimumAge,
Query: params.Query,
PrivacyLevels: toPrivacyLevelStrings(params.PrivacyLevels),
CategoryIDs: params.CategoryIDs,
TagNames: params.TagNames,
OnlyFavorited: params.OnlyFavorited,
OnlyChildFriendly: params.OnlyChildFriendly,
OnlyFamilyOriented: params.OnlyFamilyOriented,
}
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,
OnlyChildFriendly: input.OnlyChildFriendly,
OnlyFamilyOriented: input.OnlyFamilyOriented,
}
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"
}
if input.MinimumAge != nil {
params.MinimumAge = input.MinimumAge
if *input.MinimumAge < 0 || *input.MinimumAge > 120 {
errs["minimum_age"] = "minimum_age must be between 0 and 120"
}
}
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,
}
}
func toUpdateEventResult(e *domain.Event, versionNo int, triggeredFields []string, markedPending int) *UpdateEventResult {
return &UpdateEventResult{
ID: e.ID.String(),
Title: e.Title,
PrivacyLevel: string(e.PrivacyLevel),
Status: string(e.Status),
StartTime: e.StartTime,
EndTime: e.EndTime,
VersionNo: versionNo,
ReconfirmationRequired: len(triggeredFields) > 0,
ReconfirmationTriggeredFields: append([]string{}, triggeredFields...),
ParticipantsMarkedPending: markedPending,
UpdatedAt: e.UpdatedAt,
}
}
// 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,
ChildFriendly: input.ChildFriendly,
FamilyOriented: input.FamilyOriented,
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 {
item := 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),
}
if record.LocationLat != nil && record.LocationLon != nil {
lat, lon := *record.LocationLat, *record.LocationLon
if record.PrivacyLevel == domain.PrivacyProtected {
p := domain.ApproximateGeoPoint(domain.GeoPoint{Lat: lat, Lon: lon})
lat, lon = p.Lat, p.Lon
item.IsLocationApproximate = true
}
item.LocationLat = &lat
item.LocationLon = &lon
}
item.ChildFriendly = record.ChildFriendly
item.FamilyOriented = record.FamilyOriented
return item
}
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(),
VersionNo: record.VersionNo,
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, record.PrivacyLevel, record.ViewerContext.IsHost, record.ViewerContext.ParticipationStatus),
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: record.ViewerContext.ParticipationStatus,
JoinRequestStatus: record.ViewerContext.JoinRequestStatus,
InvitationStatus: record.ViewerContext.InvitationStatus,
LastConfirmedEventVersion: record.ViewerContext.LastConfirmedEventVersion,
LatestEventVersion: record.ViewerContext.LatestEventVersion,
},
}
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
}
result.ChildFriendly = record.ChildFriendly
result.FamilyOriented = record.FamilyOriented
if record.HostContext != nil {
result.HostContext = &EventDetailHostContext{
ApprovedParticipants: toEventDetailApprovedParticipants(record.HostContext.ApprovedParticipants),
PendingJoinRequests: toEventDetailPendingJoinRequests(record.HostContext.PendingJoinRequests),
Invitations: toEventDetailInvitations(record.HostContext.Invitations),
}
}
return result
}
func toEventDetailLocation(
record EventDetailLocationRecord,
privacyLevel domain.EventPrivacyLevel,
isHost bool,
participationStatus *domain.ParticipationStatus,
) EventDetailLocation {
isAcceptedParticipant := participationStatus != nil &&
(*participationStatus == domain.ParticipationStatusApproved || *participationStatus == domain.ParticipationStatusPending)
shouldApproximate := privacyLevel == domain.PrivacyProtected &&
!isHost &&
!isAcceptedParticipant
location := EventDetailLocation{
Type: string(record.Type),
Address: record.Address,
IsLocationApproximate: shouldApproximate,
}
if record.Point != nil {
p := *record.Point
if shouldApproximate {
p = domain.ApproximateGeoPoint(p)
}
location.Point = &EventDetailPoint{Lat: p.Lat, Lon: p.Lon}
}
if len(record.RoutePoints) > 0 {
location.RoutePoints = make([]EventDetailPoint, len(record.RoutePoints))
for i, pt := range record.RoutePoints {
if shouldApproximate {
pt = domain.ApproximateGeoPoint(pt)
}
location.RoutePoints[i] = EventDetailPoint{Lat: pt.Lat, Lon: pt.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,
ImageURL: record.ImageURL,
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"
"fmt"
"log/slog"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/application/imageupload"
"github.com/bounswe/bounswe2026group11/backend/internal/application/join_request"
notificationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/notification"
"github.com/bounswe/bounswe2026group11/backend/internal/application/participation"
"github.com/bounswe/bounswe2026group11/backend/internal/application/ticket"
"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
joinRequestImages JoinRequestImageConfirmer
ticketService ticket.LifecycleUseCase
badgeEvaluator BadgeEvaluator
notifications notificationapp.UseCase
unitOfWork uow.UnitOfWork
now func() time.Time
}
// BadgeEvaluator is the local port for triggering hosting badge evaluation
// after event-completion lifecycle changes. It stays intentionally narrow so
// the event service does not depend on the full badge use case.
type BadgeEvaluator interface {
EvaluateHostBadges(ctx context.Context, hostID uuid.UUID) error
}
// SetNotificationService wires in the notification use case so the event
// service can fan out notifications for cancellations and other lifecycle events.
func (s *Service) SetNotificationService(notifications notificationapp.UseCase) {
s.notifications = notifications
}
// SetBadgeEvaluator wires in the badge use case so the event service can
// re-evaluate host badges after events complete.
func (s *Service) SetBadgeEvaluator(evaluator BadgeEvaluator) {
s.badgeEvaluator = evaluator
}
// SetJoinRequestImageConfirmer wires in the image upload service for optional
// join-request image confirmation.
func (s *Service) SetJoinRequestImageConfirmer(confirmer JoinRequestImageConfirmer) {
s.joinRequestImages = confirmer
}
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,
ticketLifecycle ...ticket.LifecycleUseCase,
) *Service {
service := &Service{
eventRepo: eventRepo,
participationService: participationService,
joinRequestService: joinRequestService,
unitOfWork: unitOfWork,
now: time.Now,
}
if len(ticketLifecycle) > 0 {
service.ticketService = ticketLifecycle[0]
}
return service
}
// 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, s.now().UTC())
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)
if err != nil {
slog.ErrorContext(ctx, "event create failed before version snapshot",
"operation", "event.create.version_snapshot",
"host_user_id", hostID.String(),
"error", err,
)
return err
}
if err := s.eventRepo.CreateEventHistorySnapshot(ctx, created.ID, created.VersionNo, nil, hostID); err != nil {
slog.ErrorContext(ctx, "event initial version snapshot failed",
"operation", "event.create.version_snapshot",
"event_id", created.ID.String(),
"host_user_id", hostID.String(),
"event_version", created.VersionNo,
"error", err,
)
return err
}
slog.InfoContext(ctx, "event initial version snapshot created",
"operation", "event.create.version_snapshot",
"event_id", created.ID.String(),
"host_user_id", hostID.String(),
"event_version", created.VersionNo,
)
return nil
})
if err != nil {
return nil, err
}
return toCreateEventResult(created), nil
}
// UpdateEvent edits an ACTIVE event before it starts. Material changes move
// approved participants into PENDING so they can reconfirm against the new
// event details.
func (s *Service) UpdateEvent(ctx context.Context, hostID, eventID uuid.UUID, input UpdateEventInput) (*UpdateEventResult, error) {
var (
updated *domain.Event
versionNo int
versionChangedFields []string
triggeredFields []string
markedPendingUserIDs []uuid.UUID
)
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
snapshot, err := s.eventRepo.GetEventEditSnapshot(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 snapshot.Event.HostID != hostID {
return domain.ForbiddenError(domain.ErrorCodeEventHostManagementNotAllowed, "Only the event host can edit this event.")
}
if snapshot.Event.Status != domain.EventStatusActive || !s.now().UTC().Before(snapshot.Event.StartTime) {
return domain.ConflictError(domain.ErrorCodeEventNotEditable, "Only ACTIVE events that have not started can be edited.")
}
params, changedFields, triggers, validationErrs := buildUpdateEventParams(snapshot, input, s.now().UTC())
if len(validationErrs) > 0 {
return domain.ValidationError(validationErrs)
}
if params.Capacity != nil && !intPtrEqual(params.Capacity, snapshot.Event.Capacity) &&
*params.Capacity < snapshot.Event.ApprovedParticipantCount+snapshot.Event.PendingParticipantCount {
return domain.ConflictError(domain.ErrorCodeCapacityBelowParticipantCount, "Capacity cannot be lower than the approved plus pending participant count.")
}
triggeredFields = triggers
versionNo = snapshot.VersionNo
updated = &snapshot.Event
if len(changedFields) == 0 {
return nil
}
versionChangedFields = changedFields
updated, err = s.eventRepo.UpdateEvent(ctx, params)
if err != nil {
if errors.Is(err, ErrEventNotEditable) {
return domain.ConflictError(domain.ErrorCodeEventNotEditable, "Only ACTIVE events that have not started can be edited.")
}
return err
}
versionNo = snapshot.VersionNo + 1
if err := s.eventRepo.CreateEventHistorySnapshot(ctx, eventID, versionNo, versionChangedFields, hostID); err != nil {
slog.ErrorContext(ctx, "event version snapshot failed",
"operation", "event.update.version_snapshot",
"event_id", eventID.String(),
"host_user_id", hostID.String(),
"from_event_version", snapshot.VersionNo,
"to_event_version", versionNo,
"changed_field_count", len(versionChangedFields),
"reconfirmation_required", len(triggeredFields) > 0,
"error", err,
)
return err
}
slog.InfoContext(ctx, "event version snapshot created",
"operation", "event.update.version_snapshot",
"event_id", eventID.String(),
"host_user_id", hostID.String(),
"from_event_version", snapshot.VersionNo,
"to_event_version", versionNo,
"changed_field_count", len(versionChangedFields),
"reconfirmation_required", len(triggeredFields) > 0,
)
if len(triggeredFields) == 0 {
return nil
}
markedPendingUserIDs, err = s.participationService.MarkApprovedParticipationsPending(ctx, eventID, hostID)
if err != nil {
return err
}
if len(markedPendingUserIDs) > 0 && s.ticketService != nil {
if err := s.ticketService.MarkTicketsPendingForEvent(ctx, eventID); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
s.notifyEventReconfirmationRequired(ctx, updated, versionNo, triggeredFields, markedPendingUserIDs)
return toUpdateEventResult(updated, versionNo, triggeredFields, len(markedPendingUserIDs)), 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
// Eligibility filtering is derived from the viewer (anonymous vs auth'd
// + their stored age/gender), not from query input, so it deliberately
// does NOT enter FilterFingerprint. Two viewers with different profiles
// see different result sets but each one paginates consistently.
params.AnonymousViewer = userID == uuid.Nil
params.Now = s.now().UTC()
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
}
result := toEventDetailResult(record, s.now().UTC())
if record.ViewerContext.ParticipationStatus != nil &&
*record.ViewerContext.ParticipationStatus == domain.ParticipationStatusPending &&
record.ViewerContext.LastConfirmedEventVersion != nil &&
*record.ViewerContext.LastConfirmedEventVersion < record.ViewerContext.LatestEventVersion {
from, err := s.eventRepo.GetEventHistorySnapshot(ctx, eventID, *record.ViewerContext.LastConfirmedEventVersion)
if err != nil {
slog.ErrorContext(ctx, "event reconfirmation diff base snapshot failed",
"operation", "event.detail.reconfirmation_diff",
"event_id", eventID.String(),
"user_id", userID.String(),
"from_event_version", *record.ViewerContext.LastConfirmedEventVersion,
"to_event_version", record.ViewerContext.LatestEventVersion,
"error", err,
)
return nil, err
}
to, err := s.eventRepo.GetLatestEventHistorySnapshot(ctx, eventID)
if err != nil {
slog.ErrorContext(ctx, "event reconfirmation diff latest snapshot failed",
"operation", "event.detail.reconfirmation_diff",
"event_id", eventID.String(),
"user_id", userID.String(),
"from_event_version", *record.ViewerContext.LastConfirmedEventVersion,
"to_event_version", record.ViewerContext.LatestEventVersion,
"error", err,
)
return nil, err
}
result.ViewerContext.NeedsReconfirmation = true
result.ViewerContext.EventDiff = buildEventDetailDiff(from, to)
changeCount := 0
if result.ViewerContext.EventDiff != nil {
changeCount = len(result.ViewerContext.EventDiff.Changes)
}
slog.InfoContext(ctx, "event reconfirmation diff generated",
"operation", "event.detail.reconfirmation_diff",
"event_id", eventID.String(),
"user_id", userID.String(),
"from_event_version", from.VersionNo,
"to_event_version", to.VersionNo,
"diff_change_count", changeCount,
)
}
return result, 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
}
if params.Status == "" {
params.Status = domain.ParticipationStatusApproved
}
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
// - 400 profile_incomplete – event has an age/gender restriction but user profile is missing the required field
// - 409 age_requirement_not_met – user is below the event's minimum age
// - 409 gender_requirement_not_met – user gender does not match the event's preferred gender
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.")
}
if err := s.ensureRequesterEligible(ctx, userID, event); err != nil {
return nil, err
}
var (
p *domain.Participation
ticketCreated bool
)
err = s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
p, err = s.participationService.CreateApprovedParticipation(ctx, eventID, userID)
if err != nil {
return err
}
if s.ticketService != nil {
if _, err := s.ticketService.CreateTicketForParticipation(ctx, p, domain.TicketStatusActive); err != nil {
return err
}
ticketCreated = true
}
return nil
})
if err != nil {
return nil, err
}
acceptedVersion := 0
if p.LastConfirmedEventVersion != nil {
acceptedVersion = *p.LastConfirmedEventVersion
}
slog.InfoContext(ctx, "event participation created",
"operation", "event.participation.create",
"event_id", eventID.String(),
"user_id", userID.String(),
"participation_id", p.ID.String(),
"participation_status", p.Status.String(),
"accepted_event_version", acceptedVersion,
"ticket_created", ticketCreated,
)
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.")
}
var p *domain.Participation
err = s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
p, err = s.participationService.LeaveParticipation(ctx, eventID, userID)
if err != nil {
return err
}
if s.ticketService != nil {
return s.ticketService.CancelTicketForParticipation(ctx, p.ID)
}
return nil
})
if err != nil {
return nil, err
}
return &LeaveEventResult{
ParticipationID: p.ID.String(),
EventID: p.EventID.String(),
Status: p.Status,
UpdatedAt: p.UpdatedAt,
}, nil
}
// ReconfirmParticipation moves the caller from PENDING back to APPROVED for
// the latest event version.
func (s *Service) ReconfirmParticipation(ctx context.Context, userID, eventID uuid.UUID) (*ReconfirmParticipationResult, error) {
var (
participation *domain.Participation
ticketStatus *domain.TicketStatus
reconfirmedAt time.Time
latestVersion int
)
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
snapshot, err := s.eventRepo.GetEventEditSnapshot(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 snapshot.Event.HostID == userID {
return domain.ForbiddenError(domain.ErrorCodeHostCannotJoin, "The event host cannot reconfirm as a participant.")
}
if snapshot.Event.Status == domain.EventStatusCanceled || snapshot.Event.Status == domain.EventStatusCompleted {
return domain.ConflictError(domain.ErrorCodeEventNotJoinable, "This event is no longer accepting participant reconfirmations.")
}
latestVersion = snapshot.VersionNo
participation, err = s.participationService.ReconfirmParticipation(ctx, eventID, userID, snapshot.VersionNo)
if err != nil {
slog.ErrorContext(ctx, "event participation reconfirm failed",
"operation", "event.participation.reconfirm",
"event_id", eventID.String(),
"user_id", userID.String(),
"event_version", snapshot.VersionNo,
"error", err,
)
return err
}
reconfirmedAt = participation.UpdatedAt
if participation.ReconfirmedAt != nil {
reconfirmedAt = *participation.ReconfirmedAt
}
if s.ticketService != nil {
t, err := s.ticketService.CreateTicketForParticipation(ctx, participation, domain.TicketStatusActive)
if err != nil {
return err
}
ticketStatus = &t.Status
}
return nil
})
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "event participation reconfirmed",
"operation", "event.participation.reconfirm",
"event_id", eventID.String(),
"user_id", userID.String(),
"participation_id", participation.ID.String(),
"event_version", latestVersion,
"ticket_created", ticketStatus != nil,
)
return &ReconfirmParticipationResult{
ParticipationID: participation.ID.String(),
EventID: participation.EventID.String(),
Status: participation.Status,
ReconfirmedAt: reconfirmedAt,
UpdatedAt: participation.UpdatedAt,
LastConfirmedEventVersion: latestVersion,
LatestEventVersion: latestVersion,
TicketStatus: ticketStatus,
}, 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
// - 400 profile_incomplete – event has an age/gender restriction but user profile is missing the required field
// - 409 age_requirement_not_met – user is below the event's minimum age
// - 409 gender_requirement_not_met – user gender does not match the event's preferred gender
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.")
}
if err := s.ensureRequesterEligible(ctx, userID, event); err != nil {
return nil, err
}
imageURL, err := s.confirmJoinRequestImage(ctx, userID, eventID, input.ImageConfirmToken)
if err != nil {
return nil, err
}
jr, err := s.joinRequestService.CreatePendingJoinRequest(ctx, eventID, userID, event.HostID, join_request.CreatePendingJoinRequestInput{
Message: input.Message,
ImageURL: imageURL,
})
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "event join request created",
"operation", "event.join_request.create",
"event_id", eventID.String(),
"user_id", userID.String(),
"host_user_id", event.HostID.String(),
"join_request_id", jr.ID.String(),
"join_request_status", string(domain.JoinRequestStatusPending),
)
return &RequestJoinResult{
JoinRequestID: jr.ID.String(),
EventID: jr.EventID.String(),
Status: string(domain.JoinRequestStatusPending),
ImageURL: jr.ImageURL,
CreatedAt: jr.CreatedAt,
}, nil
}
func (s *Service) confirmJoinRequestImage(ctx context.Context, userID, eventID uuid.UUID, confirmToken *string) (*string, error) {
if confirmToken == nil || strings.TrimSpace(*confirmToken) == "" {
return nil, nil
}
if s.joinRequestImages == nil {
return nil, domain.ForbiddenError(domain.ErrorCodeImageUploadNotAllowed, "Join request image uploads are not available.")
}
confirmed, err := s.joinRequestImages.ConfirmEventJoinRequestImageUpload(ctx, userID, eventID, imageupload.ConfirmUploadInput{
ConfirmToken: *confirmToken,
})
if err != nil {
return nil, err
}
return &confirmed.BaseURL, 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
}
acceptedVersion := 0
if result.Participation.LastConfirmedEventVersion != nil {
acceptedVersion = *result.Participation.LastConfirmedEventVersion
}
slog.InfoContext(ctx, "event join request approved",
"operation", "event.join_request.approve",
"event_id", eventID.String(),
"host_user_id", hostUserID.String(),
"join_request_id", joinRequestID.String(),
"participation_id", result.Participation.ID.String(),
"participant_user_id", result.Participation.UserID.String(),
"participation_status", result.Participation.Status.String(),
"accepted_event_version", acceptedVersion,
)
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
}
// CancelJoinRequest allows the authenticated user to cancel their own PENDING join request.
func (s *Service) CancelJoinRequest(ctx context.Context, userID, eventID uuid.UUID) error {
_, err := s.joinRequestService.CancelJoinRequest(ctx, eventID, userID)
return err
}
// 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 {
var (
cancelledUserIDs []uuid.UUID
event *domain.Event
)
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err 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
}
cancelledUserIDs, err = s.participationService.CancelEventParticipations(ctx, eventID)
if err != nil {
return err
}
if s.ticketService != nil {
if err := s.ticketService.CancelTicketsForEvent(ctx, eventID); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
s.notifyEventCanceled(ctx, event, userID, cancelledUserIDs)
return nil
}
func (s *Service) notifyEventCanceled(ctx context.Context, event *domain.Event, hostUserID uuid.UUID, cancelledUserIDs []uuid.UUID) {
if s.notifications == nil || len(cancelledUserIDs) == 0 {
return
}
// Exclude the host — they initiated the cancellation.
recipients := make([]uuid.UUID, 0, len(cancelledUserIDs))
for _, id := range cancelledUserIDs {
if id != hostUserID {
recipients = append(recipients, id)
}
}
if len(recipients) == 0 {
return
}
notificationType := "EVENT_CANCELED"
deepLink := fmt.Sprintf("/events/%s", event.ID.String())
body := fmt.Sprintf("The event \"%s\" has been cancelled by the host.", event.Title)
data := map[string]string{
"event_id": event.ID.String(),
"event_title": event.Title,
"event_start_time": event.StartTime.UTC().Format(time.RFC3339),
}
_, err := s.notifications.SendNotificationToUsers(ctx, notificationapp.SendNotificationInput{
UserIDs: recipients,
Title: "Event cancelled",
TitleKey: "notification.event.cancelled.title",
Body: body,
BodyKey: "notification.event.cancelled.body",
BodyArgs: []any{event.Title},
Type: ¬ificationType,
DeepLink: &deepLink,
EventID: &event.ID,
ImageURL: event.ImageURL,
Data: data,
IdempotencyKey: fmt.Sprintf("EVENT_CANCELED:%s", event.ID.String()),
})
if err != nil {
slog.ErrorContext(ctx, "event cancellation notification failed",
"operation", "event.cancel.notification",
"event_id", event.ID.String(),
"recipient_count", len(recipients),
"error", err,
)
}
}
func (s *Service) notifyEventReconfirmationRequired(ctx context.Context, event *domain.Event, versionNo int, changedFields []string, userIDs []uuid.UUID) {
if s.notifications == nil || event == nil || len(changedFields) == 0 || len(userIDs) == 0 {
return
}
notificationType := "EVENT_RECONFIRMATION_REQUIRED"
deepLink := fmt.Sprintf("/events/%s", event.ID.String())
body := fmt.Sprintf("The event \"%s\" has changed. Please reconfirm your attendance.", event.Title)
data := map[string]string{
"event_id": event.ID.String(),
"event_title": event.Title,
"event_start_time": event.StartTime.UTC().Format(time.RFC3339),
"event_version": fmt.Sprintf("%d", versionNo),
"changed_fields": strings.Join(changedFields, ","),
}
_, err := s.notifications.SendNotificationToUsers(ctx, notificationapp.SendNotificationInput{
UserIDs: userIDs,
Title: "Event details changed",
Body: body,
Type: ¬ificationType,
DeepLink: &deepLink,
EventID: &event.ID,
ImageURL: event.ImageURL,
Data: data,
IdempotencyKey: fmt.Sprintf("EVENT_RECONFIRMATION_REQUIRED:%s:v%d", event.ID.String(), versionNo),
})
if err != nil {
slog.ErrorContext(ctx, "event reconfirmation notification failed",
"operation", "event.update.reconfirmation_notification",
"event_id", event.ID.String(),
"recipient_count", len(userIDs),
"error", err,
)
}
}
// 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.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
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
}
if s.ticketService != nil {
if err := s.ticketService.ExpireTicketsForEvent(ctx, eventID); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
s.evaluateParticipationBadgesForEvent(ctx, eventID)
s.evaluateHostBadges(ctx, event.HostID)
return nil
}
// TransitionExpiredEvents runs the periodic event-status transition (used by
// the event expiry job) and evaluates participation badges for every approved
// participant of every event that just transitioned to COMPLETED.
func (s *Service) TransitionExpiredEvents(ctx context.Context) error {
return s.TransitionEventStatuses(ctx)
}
// evaluateParticipationBadgesForEvent runs participant-side badge evaluation
// for every approved participant of the given event as a best-effort hook so
// transient failures never fail the parent operation.
func (s *Service) evaluateParticipationBadgesForEvent(ctx context.Context, eventID uuid.UUID) {
if s.participationService == nil {
return
}
if err := s.participationService.EvaluateBadgesForEventParticipants(ctx, eventID); err != nil {
slog.WarnContext(ctx, "event participation badge evaluation failed",
slog.String("operation", "event.evaluate_participation_badges"),
slog.String("event_id", eventID.String()),
slog.String("error", err.Error()),
)
}
}
// evaluateHostBadges runs host-side badge evaluation after completed-event
// lifecycle changes as a best-effort hook so transient failures never fail the
// parent operation.
func (s *Service) evaluateHostBadges(ctx context.Context, hostID uuid.UUID) {
if s.badgeEvaluator == nil {
return
}
if err := s.badgeEvaluator.EvaluateHostBadges(ctx, hostID); err != nil {
slog.WarnContext(ctx, "event host badge evaluation failed",
slog.String("operation", "event.evaluate_host_badges"),
slog.String("host_id", hostID.String()),
slog.String("error", err.Error()),
)
}
}
// TransitionEventStatuses advances lifecycle states and applies dependent
// participation/ticket transitions in one transaction.
func (s *Service) TransitionEventStatuses(ctx context.Context) error {
var records []EventStatusTransitionRecord
if err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var txErr error
records, txErr = s.eventRepo.TransitionEventStatuses(ctx)
if txErr != nil {
return txErr
}
for _, record := range records {
switch record.Status {
case domain.EventStatusInProgress:
if err := s.participationService.ApprovePendingParticipationsForEvent(ctx, record.EventID); err != nil {
return err
}
if s.ticketService != nil {
if err := s.ticketService.ActivatePendingTicketsForEvent(ctx, record.EventID); err != nil {
return err
}
}
case domain.EventStatusCompleted:
if s.ticketService != nil {
if err := s.ticketService.ExpireTicketsForEvent(ctx, record.EventID); err != nil {
return err
}
}
}
}
return nil
}); err != nil {
return err
}
for _, record := range records {
if record.Status == domain.EventStatusCompleted {
s.evaluateParticipationBadgesForEvent(ctx, record.EventID)
s.evaluateHostBadges(ctx, record.HostID)
}
}
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
}
if input.Status != nil {
params.Status = *input.Status
}
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
}
// ensureRequesterEligible loads the requester's profile fields and runs the
// shared domain eligibility check. It returns nil when the user is eligible
// to join or request to join the given event.
func (s *Service) ensureRequesterEligible(ctx context.Context, userID uuid.UUID, ev *domain.Event) error {
if ev.MinimumAge == nil && ev.PreferredGender == nil {
return nil
}
user, err := s.eventRepo.GetRequesterForJoin(ctx, userID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.BadRequestError(domain.ErrorCodeProfileIncomplete, "Your account was not found.")
}
return err
}
if appErr := domain.CheckParticipationEligibility(user, ev, s.now().UTC()); appErr != nil {
return appErr
}
return nil
}
package event
import (
"sort"
"strconv"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
func buildUpdateEventParams(snapshot *EventEditSnapshot, input UpdateEventInput, now time.Time) (UpdateEventParams, []string, []string, map[string]string) {
errs := make(map[string]string)
current := snapshot.Event
params := UpdateEventParams{
EventID: current.ID,
Title: current.Title,
Description: current.Description,
CategoryID: current.CategoryID,
StartTime: current.StartTime,
EndTime: current.EndTime,
Capacity: current.Capacity,
LocationType: snapshot.Location.Type,
Address: snapshot.Location.Address,
Constraints: toConstraintParams(snapshot.Constraints),
}
if snapshot.Location.Point != nil {
params.Point = &domain.GeoPoint{Lat: snapshot.Location.Point.Lat, Lon: snapshot.Location.Point.Lon}
}
params.RoutePoints = append([]domain.GeoPoint{}, snapshot.Location.RoutePoints...)
changed := map[string]bool{}
triggered := map[string]bool{}
if input.Title != nil {
title := strings.TrimSpace(*input.Title)
if title == "" {
errs["title"] = "title must not be empty"
} else if title != current.Title {
params.Title = title
changed["title"] = true
triggered["title"] = true
}
}
if input.Description.Set {
description := trimOptionalString(input.Description.Value)
if !stringPtrEqual(description, current.Description) {
params.Description = description
changed["description"] = true
triggered["description"] = true
}
}
if input.CategoryID.Set {
if input.CategoryID.Value != nil && *input.CategoryID.Value <= 0 {
errs["category_id"] = "category_id must be a positive integer"
} else if !intPtrEqual(input.CategoryID.Value, current.CategoryID) {
params.CategoryID = copyIntPtr(input.CategoryID.Value)
changed["category_id"] = true
triggered["category_id"] = true
}
}
if input.StartTime != nil {
if input.StartTime.IsZero() {
errs["start_time"] = "start_time is required"
} else if !input.StartTime.After(now) {
errs["start_time"] = "start_time must be in the future"
} else if !input.StartTime.Equal(current.StartTime) {
params.StartTime = *input.StartTime
changed["start_time"] = true
triggered["start_time"] = true
}
}
if input.EndTime.Set {
if !timePtrEqual(input.EndTime.Value, current.EndTime) {
params.EndTime = copyTimePtr(input.EndTime.Value)
changed["end_time"] = true
triggered["end_time"] = true
}
}
if input.Capacity.Set {
if input.Capacity.Value != nil && *input.Capacity.Value <= 0 {
errs["capacity"] = "capacity must be a positive integer"
} else if !intPtrEqual(input.Capacity.Value, current.Capacity) {
params.Capacity = copyIntPtr(input.Capacity.Value)
changed["capacity"] = true
}
}
if params.EndTime != nil && !params.EndTime.After(params.StartTime) {
errs["end_time"] = "end_time must be after start_time"
}
mergeLocationUpdate(snapshot, input, ¶ms, changed, triggered, errs)
if input.Constraints != nil {
constraints := normalizeConstraintInputs(*input.Constraints, errs)
if !constraintParamsEqual(constraints, params.Constraints) {
if hasAddedConstraint(params.Constraints, constraints) {
triggered["constraints"] = true
}
params.Constraints = constraints
changed["constraints"] = true
params.ConstraintsChanged = true
}
}
if len(changed) > 0 {
params.Changed = true
}
return params, sortedKeys(changed), sortedKeys(triggered), errs
}
func mergeLocationUpdate(
snapshot *EventEditSnapshot,
input UpdateEventInput,
params *UpdateEventParams,
changed map[string]bool,
triggered map[string]bool,
errs map[string]string,
) {
locationTouched := input.Address.Set || input.LocationType != nil || input.Lat != nil || input.Lon != nil || input.RoutePoints != nil
if !locationTouched {
return
}
if input.Address.Set {
params.Address = trimOptionalString(input.Address.Value)
}
if input.LocationType != nil {
params.LocationType = *input.LocationType
}
switch params.LocationType {
case domain.LocationPoint:
if input.RoutePoints != nil {
errs["route_points"] = "route_points must be omitted when location_type is POINT"
}
point := params.Point
if input.Lat != nil || input.Lon != nil || snapshot.Location.Type != 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 input.Lat != nil && input.Lon != nil {
point = &domain.GeoPoint{Lat: *input.Lat, Lon: *input.Lon}
}
}
params.Point = point
params.RoutePoints = nil
if params.Point == nil {
errs["lat"] = "lat is required when location_type is POINT"
errs["lon"] = "lon is required 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 input.RoutePoints != nil {
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"
}
}
if len(errs) == 0 {
params.RoutePoints = toDomainRoutePoints(*input.RoutePoints)
}
} else if snapshot.Location.Type != domain.LocationRoute {
params.RoutePoints = nil
}
params.Point = nil
if len(params.RoutePoints) < domain.MinRoutePoints {
errs["route_points"] = "route_points must contain at least 2 points when location_type is ROUTE"
}
default:
errs["location_type"] = "must be one of: POINT, ROUTE"
}
if !locationEqual(snapshot.Location, *params) {
changed["location"] = true
triggered["location"] = true
params.LocationChanged = true
}
}
func normalizeConstraintInputs(inputs []ConstraintInput, errs map[string]string) []EventConstraintParams {
if len(inputs) > domain.MaxEventConstraints {
errs["constraints"] = "at most 5 constraints are allowed"
return nil
}
constraints := make([]EventConstraintParams, len(inputs))
for i, c := range inputs {
constraintType := strings.TrimSpace(c.Type)
info := strings.TrimSpace(c.Info)
if constraintType == "" {
errs["constraints["+strconv.Itoa(i)+"].type"] = "type is required"
}
if info == "" {
errs["constraints["+strconv.Itoa(i)+"].info"] = "info is required"
}
constraints[i] = EventConstraintParams{Type: constraintType, Info: info}
}
return constraints
}
func toConstraintParams(records []EventDetailConstraintRecord) []EventConstraintParams {
params := make([]EventConstraintParams, len(records))
for i, record := range records {
params[i] = EventConstraintParams(record)
}
return params
}
func locationEqual(current EventDetailLocationRecord, params UpdateEventParams) bool {
if current.Type != params.LocationType || !stringPtrEqual(current.Address, params.Address) {
return false
}
switch params.LocationType {
case domain.LocationPoint:
if current.Point == nil || params.Point == nil {
return current.Point == nil && params.Point == nil
}
return current.Point.Lat == params.Point.Lat && current.Point.Lon == params.Point.Lon
case domain.LocationRoute:
if len(current.RoutePoints) != len(params.RoutePoints) {
return false
}
for i := range current.RoutePoints {
if current.RoutePoints[i] != params.RoutePoints[i] {
return false
}
}
return true
default:
return true
}
}
func constraintParamsEqual(a, b []EventConstraintParams) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func hasAddedConstraint(oldConstraints, newConstraints []EventConstraintParams) bool {
oldSet := make(map[string]struct{}, len(oldConstraints))
for _, c := range oldConstraints {
oldSet[constraintKey(c)] = struct{}{}
}
for _, c := range newConstraints {
if _, ok := oldSet[constraintKey(c)]; !ok {
return true
}
}
return false
}
func constraintKey(c EventConstraintParams) string {
return c.Type + "\x00" + c.Info
}
func sortedKeys(values map[string]bool) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func trimOptionalString(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
return &trimmed
}
func stringPtrEqual(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func intPtrEqual(a, b *int) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func timePtrEqual(a, b *time.Time) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return a.Equal(*b)
}
func copyIntPtr(value *int) *int {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyTimePtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
copied := *value
return &copied
}
package event
import (
"strconv"
"strings"
"time"
"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, now time.Time) 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"
} else if !input.StartTime.After(now) {
errs["start_time"] = "start_time must be in the future"
}
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 eventreport
import (
"strings"
"unicode/utf8"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
func normalizeMessage(message string) string {
return strings.TrimSpace(message)
}
func validateMessage(message string) map[string]string {
errs := make(map[string]string)
length := utf8.RuneCountInString(message)
if length < domain.EventReportMessageMinLength || length > domain.EventReportMessageMaxLength {
errs["message"] = "message must be between 1 and 1000 characters"
}
return errs
}
func toEventReportResult(record *EventReportRecord) *EventReportResult {
return &EventReportResult{
ID: record.ID.String(),
EventID: record.EventID.String(),
ReporterUserID: record.ReporterUserID.String(),
Category: string(record.Category),
Message: record.Message,
ImageURL: record.ImageURL,
Status: string(record.Status),
CreatedAt: record.CreatedAt,
}
}
package eventreport
import (
"context"
"errors"
"github.com/bounswe/bounswe2026group11/backend/internal/application/imageupload"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// Service owns event-report application behavior.
type Service struct {
repo Repository
imageConfirmer ReportImageConfirmer
}
var _ UseCase = (*Service)(nil)
// NewService constructs an event-report service backed by its repository.
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
// SetReportImageConfirmer wires in the image-upload service.
func (s *Service) SetReportImageConfirmer(confirmer ReportImageConfirmer) {
s.imageConfirmer = confirmer
}
// CreateEventReport creates a moderation report for an existing event.
func (s *Service) CreateEventReport(ctx context.Context, userID, eventID uuid.UUID, input CreateEventReportInput) (*EventReportResult, error) {
input.Message = normalizeMessage(input.Message)
if errs := validateMessage(input.Message); len(errs) > 0 {
return nil, domain.ValidationError(errs)
}
reportContext, err := s.repo.GetEventReportContext(ctx, eventID)
if err != nil {
return nil, s.mapEventLookupError(err)
}
var imageURL *string
if input.ImageConfirmToken != nil && *input.ImageConfirmToken != "" {
if reportContext.Status != domain.EventStatusInProgress && reportContext.Status != domain.EventStatusCompleted {
return nil, domain.ConflictError(domain.ErrorCodeEventReportImageNotAllowed, "Report images are allowed only while an event is in progress or completed.")
}
if s.imageConfirmer == nil {
return nil, domain.ForbiddenError(domain.ErrorCodeEventReportImageNotAllowed, "Report image uploads are not available.")
}
confirmed, err := s.imageConfirmer.ConfirmEventReportImageUpload(ctx, userID, eventID, imageupload.ConfirmUploadInput{
ConfirmToken: *input.ImageConfirmToken,
})
if err != nil {
return nil, err
}
imageURL = &confirmed.BaseURL
}
created, err := s.repo.CreateEventReport(ctx, CreateEventReportParams{
EventID: eventID,
ReporterUserID: userID,
Category: input.Category,
Message: input.Message,
ImageURL: imageURL,
})
if err != nil {
return nil, err
}
return toEventReportResult(created), nil
}
func (s *Service) mapEventLookupError(err error) error {
if errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeEventNotFound, "The requested event does not exist.")
}
return err
}
package favorite_location
import (
"context"
"errors"
"log/slog"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// BadgeEvaluator is the local port for triggering favorite-location badge
// evaluation after a save. It is intentionally minimal so this service does
// not depend on the full badge use case.
type BadgeEvaluator interface {
EvaluateFavoriteLocationBadges(ctx context.Context, userID uuid.UUID) error
}
// Service owns favorite-location application behavior.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
badgeEvaluator BadgeEvaluator
}
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,
}
}
// SetBadgeEvaluator wires in the badge use case so the service can re-evaluate
// favorite-location badges after a new location is saved.
func (s *Service) SetBadgeEvaluator(evaluator BadgeEvaluator) {
s.badgeEvaluator = evaluator
}
// 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
}
s.evaluateFavoriteLocationBadges(ctx, input.UserID)
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.",
)
}
// evaluateFavoriteLocationBadges runs badge evaluation as a best-effort hook
// so transient failures never fail the parent operation.
func (s *Service) evaluateFavoriteLocationBadges(ctx context.Context, userID uuid.UUID) {
if s.badgeEvaluator == nil {
return
}
if err := s.badgeEvaluator.EvaluateFavoriteLocationBadges(ctx, userID); err != nil {
slog.WarnContext(ctx, "favorite location badge evaluation failed",
slog.String("operation", "favorite_location.evaluate_badges"),
slog.String("user_id", userID.String()),
slog.String("error", err.Error()),
)
}
}
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
}
// CreateProfileShowcaseImageUpload prepares a direct-upload flow for one
// showcase image owned by the authenticated user.
func (s *Service) CreateProfileShowcaseImageUpload(ctx context.Context, userID uuid.UUID) (*CreateUploadResult, error) {
uploadID := uuid.NewString()
return s.createUpload(ctx, uploadDescriptor{
Resource: ResourceProfileShowcase,
OwnerUserID: userID,
NextVersion: 1,
OriginalKey: fmt.Sprintf("profiles/%s/showcase/%s", userID, uploadID),
UploadID: uploadID,
CurrentEvent: nil,
})
}
// ConfirmProfileShowcaseImageUpload verifies the uploaded objects and persists
// a new showcase image row for the authenticated user.
func (s *Service) ConfirmProfileShowcaseImageUpload(ctx context.Context, userID uuid.UUID, input ConfirmUploadInput) (*ConfirmShowcaseImageResult, error) {
payload, err := s.verifyConfirmToken(input, ResourceProfileShowcase)
if err != nil {
return nil, err
}
if payload.OwnerUserID != userID || payload.EventID != nil {
return nil, invalidConfirmTokenError()
}
if err := s.ensureUploadedObjectsExist(ctx, payload); err != nil {
return nil, err
}
imageRecord, err := s.profileRepo.CreateShowcaseImage(ctx, userID, payload.BaseURL)
if err != nil {
return nil, err
}
return &ConfirmShowcaseImageResult{
ID: imageRecord.ID.String(),
ImageURL: imageRecord.ImageURL,
}, 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
}
// CreateEventReviewImageUpload prepares a direct-upload flow for one review image.
func (s *Service) CreateEventReviewImageUpload(ctx context.Context, userID, eventID uuid.UUID) (*CreateUploadResult, error) {
state, err := s.eventRepo.GetEventReviewImageState(ctx, eventID, userID)
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 err := validateReviewImageState(state, userID); err != nil {
return nil, err
}
uploadID := uuid.NewString()
return s.createUpload(ctx, uploadDescriptor{
Resource: ResourceEventReviewImage,
OwnerUserID: userID,
NextVersion: 1,
OriginalKey: fmt.Sprintf("events/%s/reviews/%s/%s", eventID, userID, uploadID),
UploadID: uploadID,
CurrentEvent: &eventID,
})
}
// ConfirmEventReviewImageUpload verifies a review image upload and returns the
// public image URL. Persistence happens when the review comment is upserted.
func (s *Service) ConfirmEventReviewImageUpload(ctx context.Context, userID, eventID uuid.UUID, input ConfirmUploadInput) (*ConfirmReviewImageResult, error) {
payload, err := s.verifyConfirmToken(input, ResourceEventReviewImage)
if err != nil {
return nil, err
}
if payload.OwnerUserID != userID || payload.EventID == nil || *payload.EventID != eventID {
return nil, invalidConfirmTokenError()
}
if err := s.ensureUploadedObjectsExist(ctx, payload); err != nil {
return nil, err
}
return &ConfirmReviewImageResult{BaseURL: payload.BaseURL}, nil
}
// CreateEventJoinRequestImageUpload prepares a direct-upload flow for one join-request image.
func (s *Service) CreateEventJoinRequestImageUpload(ctx context.Context, userID, eventID uuid.UUID) (*CreateUploadResult, error) {
state, err := s.eventRepo.GetEventJoinRequestImageState(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 err := validateJoinRequestImageState(state, userID); err != nil {
return nil, err
}
uploadID := uuid.NewString()
return s.createUpload(ctx, uploadDescriptor{
Resource: ResourceJoinRequestImage,
OwnerUserID: userID,
NextVersion: 1,
OriginalKey: fmt.Sprintf("events/%s/join-requests/%s/%s", eventID, userID, uploadID),
UploadID: uploadID,
CurrentEvent: &eventID,
})
}
// CreateEventReportImageUpload prepares a direct-upload flow for one event report image.
func (s *Service) CreateEventReportImageUpload(ctx context.Context, userID, eventID uuid.UUID) (*CreateUploadResult, error) {
state, err := s.eventRepo.GetEventReportImageState(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 err := validateReportImageState(state); err != nil {
return nil, err
}
uploadID := uuid.NewString()
return s.createUpload(ctx, uploadDescriptor{
Resource: ResourceEventReportImage,
OwnerUserID: userID,
NextVersion: 1,
OriginalKey: fmt.Sprintf("events/%s/reports/%s/%s", eventID, userID, uploadID),
UploadID: uploadID,
CurrentEvent: &eventID,
})
}
// ConfirmEventJoinRequestImageUpload verifies a join-request image upload and
// returns the public image URL. Persistence happens when the join request is created.
func (s *Service) ConfirmEventJoinRequestImageUpload(ctx context.Context, userID, eventID uuid.UUID, input ConfirmUploadInput) (*ConfirmJoinRequestImageResult, error) {
payload, err := s.verifyConfirmToken(input, ResourceJoinRequestImage)
if err != nil {
return nil, err
}
if payload.OwnerUserID != userID || payload.EventID == nil || *payload.EventID != eventID {
return nil, invalidConfirmTokenError()
}
if err := s.ensureUploadedObjectsExist(ctx, payload); err != nil {
return nil, err
}
return &ConfirmJoinRequestImageResult{BaseURL: payload.BaseURL}, nil
}
// ConfirmEventReportImageUpload verifies a report image upload and returns the
// public image URL. Persistence happens when the event report is created.
func (s *Service) ConfirmEventReportImageUpload(ctx context.Context, userID, eventID uuid.UUID, input ConfirmUploadInput) (*ConfirmReportImageResult, error) {
payload, err := s.verifyConfirmToken(input, ResourceEventReportImage)
if err != nil {
return nil, err
}
if payload.OwnerUserID != userID || payload.EventID == nil || *payload.EventID != eventID {
return nil, invalidConfirmTokenError()
}
if err := s.ensureUploadedObjectsExist(ctx, payload); err != nil {
return nil, err
}
return &ConfirmReportImageResult{BaseURL: payload.BaseURL}, nil
}
type uploadDescriptor struct {
Resource string
OwnerUserID uuid.UUID
NextVersion int
OriginalKey string
UploadID string
CurrentEvent *uuid.UUID
}
func validateReviewImageState(state *EventReviewImageState, userID uuid.UUID) error {
if state.PrivacyLevel == string(domain.PrivacyPrivate) {
return domain.ConflictError(domain.ErrorCodeCommentsNotAllowed, "Reviews are available only for PUBLIC and PROTECTED events.")
}
if state.HostID == userID {
return domain.ForbiddenError(domain.ErrorCodeHostCannotRateSelf, "The event host cannot rate their own event.")
}
if state.Status == string(domain.EventStatusCanceled) {
return domain.ConflictError(domain.ErrorCodeReviewImageNotAllowed, "Review images are not allowed for canceled events.")
}
if state.Status != string(domain.EventStatusCompleted) {
return domain.ConflictError(domain.ErrorCodeReviewImageNotAllowed, "Review images are allowed only after the event is completed.")
}
if !state.IsQualifyingParticipant {
return domain.ForbiddenError(domain.ErrorCodeReviewImageNotAllowed, "Only participants can upload review images.")
}
return nil
}
func validateJoinRequestImageState(state *EventJoinRequestImageState, userID uuid.UUID) error {
if state.HostID == userID {
return domain.ForbiddenError(domain.ErrorCodeHostCannotJoin, "The event host cannot request to join their own event.")
}
if state.Status == string(domain.EventStatusCanceled) || state.Status == string(domain.EventStatusCompleted) {
return domain.ConflictError(domain.ErrorCodeEventNotJoinable, "This event is no longer accepting participants.")
}
if state.PrivacyLevel != string(domain.PrivacyProtected) {
return domain.ConflictError(domain.ErrorCodeEventJoinNotAllowed, "Only PROTECTED events accept join requests.")
}
return nil
}
func validateReportImageState(state *EventReportImageState) error {
if state.Status != string(domain.EventStatusInProgress) && state.Status != string(domain.EventStatusCompleted) {
return domain.ConflictError(domain.ErrorCodeEventReportImageNotAllowed, "Report images are allowed only while an event is in progress or completed.")
}
return nil
}
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 invitation
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/google/uuid"
)
func encodePastInvitationCursor(cursor PastInvitationCursor) (string, error) {
raw, err := json.Marshal(cursor)
if err != nil {
return "", fmt.Errorf("marshal past invitation cursor: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
// decodePastInvitationCursor parses an opaque token. It enforces both
// fields are populated so callers cannot smuggle a partial cursor that
// would skip pages or scan from the start of the index.
func decodePastInvitationCursor(token string) (*PastInvitationCursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return nil, fmt.Errorf("decode past invitation cursor: %w", err)
}
var cursor PastInvitationCursor
if err := json.Unmarshal(raw, &cursor); err != nil {
return nil, fmt.Errorf("unmarshal past invitation cursor: %w", err)
}
if cursor.UpdatedAt.IsZero() {
return nil, fmt.Errorf("cursor is missing updated_at")
}
if cursor.InvitationID == uuid.Nil {
return nil, fmt.Errorf("cursor is missing invitation_id")
}
return &cursor, nil
}
// buildNextPastInvitationCursor returns nil when there is no next page,
// avoiding unnecessary cursors in the response.
func buildNextPastInvitationCursor(items []ReceivedInvitation, hasNext bool) (*string, error) {
if !hasNext || len(items) == 0 {
return nil, nil
}
last := items[len(items)-1]
id, err := uuid.Parse(last.InvitationID)
if err != nil {
return nil, fmt.Errorf("parse last invitation id: %w", err)
}
encoded, err := encodePastInvitationCursor(PastInvitationCursor{
UpdatedAt: last.UpdatedAt,
InvitationID: id,
})
if err != nil {
return nil, err
}
return &encoded, nil
}
package invitation
func toCreateInvitationsResult(record *CreateInvitationsRecord) *CreateInvitationsResult {
if record == nil {
return &CreateInvitationsResult{}
}
successful := make([]CreatedInvitation, len(record.SuccessfulInvitations))
for i, item := range record.SuccessfulInvitations {
successful[i] = CreatedInvitation{
InvitationID: item.Invitation.ID.String(),
EventID: item.Invitation.EventID.String(),
InvitedUserID: item.Invitation.InvitedUserID.String(),
Username: item.Username,
Status: item.Invitation.Status.String(),
CreatedAt: item.Invitation.CreatedAt,
}
}
failed := make([]InvitationFailure, len(record.Failed))
for i, item := range record.Failed {
failed[i] = InvitationFailure(item)
}
return &CreateInvitationsResult{
SuccessCount: len(successful),
InvalidUsernameCount: len(record.InvalidUsernames),
FailedCount: len(failed),
SuccessfulInvitations: successful,
InvalidUsernames: record.InvalidUsernames,
Failed: failed,
}
}
func toReceivedInvitation(record ReceivedInvitationRecord) ReceivedInvitation {
return ReceivedInvitation{
InvitationID: record.InvitationID.String(),
Status: record.Status.String(),
Message: record.Message,
ExpiresAt: record.ExpiresAt,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
Event: ReceivedInvitationEvent{
ID: record.Event.ID.String(),
Title: record.Event.Title,
ImageURL: record.Event.ImageURL,
StartTime: record.Event.StartTime,
EndTime: record.Event.EndTime,
Status: string(record.Event.Status),
PrivacyLevel: string(record.Event.PrivacyLevel),
ApprovedParticipantCount: record.Event.ApprovedParticipantCount,
},
Host: ReceivedInvitationUser{
ID: record.Host.ID.String(),
Username: record.Host.Username,
DisplayName: record.Host.DisplayName,
AvatarURL: record.Host.AvatarURL,
},
}
}
package invitation
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
notificationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/notification"
"github.com/bounswe/bounswe2026group11/backend/internal/application/ticket"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
const maxBatchInviteUsernames = 100
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
tickets ticket.LifecycleUseCase
notifications notificationapp.UseCase
now func() time.Time
}
var _ UseCase = (*Service)(nil)
func NewService(repo Repository, unitOfWork uow.UnitOfWork, ticketLifecycle ...ticket.LifecycleUseCase) *Service {
service := &Service{
repo: repo,
unitOfWork: unitOfWork,
now: time.Now,
}
if len(ticketLifecycle) > 0 {
service.tickets = ticketLifecycle[0]
}
return service
}
func (s *Service) SetNotificationService(notifications notificationapp.UseCase) {
s.notifications = notifications
}
func (s *Service) CreateInvitations(
ctx context.Context,
hostID, eventID uuid.UUID,
input CreateInvitationsInput,
) (*CreateInvitationsResult, error) {
usernames, errs := normalizeInviteUsernames(input.Usernames)
if len(errs) > 0 {
return nil, domain.ValidationError(errs)
}
var record *CreateInvitationsRecord
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
record, err = s.repo.CreateInvitations(ctx, CreateInvitationsParams{
EventID: eventID,
HostID: hostID,
Usernames: usernames,
Message: normalizeOptionalMessage(input.Message),
Now: s.now().UTC(),
})
return err
})
if err != nil {
return nil, err
}
s.notifyCreatedInvitations(ctx, record)
return toCreateInvitationsResult(record), nil
}
func (s *Service) ListReceivedInvitations(ctx context.Context, input ListReceivedInvitationsInput) (*ReceivedInvitationsResult, error) {
limit, cursor, appErr := normalizeListReceivedInvitationsInput(input)
if appErr != nil {
return nil, appErr
}
pendingRecords, err := s.repo.ListReceivedPendingInvitations(ctx, input.UserID)
if err != nil {
return nil, err
}
pending := make([]ReceivedInvitation, len(pendingRecords))
for i, record := range pendingRecords {
pending[i] = toReceivedInvitation(record)
}
pastRecords, err := s.repo.ListReceivedPastInvitations(ctx, input.UserID, ListPastInvitationsParams{
Cursor: cursor,
FetchLimit: limit + 1,
})
if err != nil {
return nil, err
}
hasNext := len(pastRecords) > limit
if hasNext {
pastRecords = pastRecords[:limit]
}
pastItems := make([]ReceivedInvitation, len(pastRecords))
for i, record := range pastRecords {
pastItems[i] = toReceivedInvitation(record)
}
nextCursor, err := buildNextPastInvitationCursor(pastItems, hasNext)
if err != nil {
return nil, err
}
return &ReceivedInvitationsResult{
Pending: pending,
Past: ReceivedInvitationsPastResult{
Items: pastItems,
PageInfo: InvitationPageInfo{
NextCursor: nextCursor,
HasNext: hasNext,
},
},
}, nil
}
// GetReceivedInvitation fetches the latest state of one invitation owned
// by the caller. It deliberately returns no event-status or invitation-
// status filter so the modal flow can render warnings for CANCELED,
// EXPIRED, or already-actioned invitations. domain.ErrNotFound from the
// repo (missing row, wrong recipient, non-PRIVATE event) maps to a 404
// with the standard invitation_not_found code; the caller's identity is
// never leaked via a 403.
func (s *Service) GetReceivedInvitation(ctx context.Context, userID, invitationID uuid.UUID) (*ReceivedInvitation, error) {
record, err := s.repo.GetReceivedInvitation(ctx, userID, invitationID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeInvitationNotFound, "The requested invitation does not exist.")
}
return nil, fmt.Errorf("get received invitation: %w", err)
}
dto := toReceivedInvitation(*record)
return &dto, nil
}
// normalizeListReceivedInvitationsInput resolves the past-bucket page size
// and decodes the cursor token. A bad cursor produces a 400 with code
// `validation_error` so clients can react predictably; a missing cursor or
// limit falls back to safe defaults.
func normalizeListReceivedInvitationsInput(input ListReceivedInvitationsInput) (int, *PastInvitationCursor, *domain.AppError) {
limit := DefaultPastInvitationLimit
if input.PastLimit != nil {
v := *input.PastLimit
if v < 1 || v > MaxPastInvitationLimit {
return 0, nil, domain.ValidationError(map[string]string{
"past_limit": fmt.Sprintf("must be between 1 and %d", MaxPastInvitationLimit),
})
}
limit = v
}
var cursor *PastInvitationCursor
if input.PastCursor != nil {
token := strings.TrimSpace(*input.PastCursor)
if token != "" {
decoded, err := decodePastInvitationCursor(token)
if err != nil {
return 0, nil, domain.ValidationError(map[string]string{
"past_cursor": "cursor is invalid",
})
}
cursor = decoded
}
}
return limit, cursor, nil
}
func (s *Service) AcceptInvitation(ctx context.Context, userID, invitationID uuid.UUID) (*AcceptInvitationResult, error) {
var record *AcceptInvitationRecord
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
record, err = s.repo.AcceptInvitation(ctx, userID, invitationID)
if err != nil {
slog.ErrorContext(ctx, "invitation accept failed",
"operation", "invitation.accept",
"invitation_id", invitationID.String(),
"user_id", userID.String(),
"error", err,
)
return err
}
if s.tickets != nil {
_, err = s.tickets.CreateTicketForParticipation(ctx, record.Participation, domain.TicketStatusActive)
if err != nil {
slog.ErrorContext(ctx, "ticket creation after invitation accept failed",
"operation", "invitation.accept.ticket_create",
"invitation_id", invitationID.String(),
"event_id", record.Invitation.EventID.String(),
"user_id", userID.String(),
"participation_id", record.Participation.ID.String(),
"error", err,
)
return err
}
}
return nil
})
if err != nil {
return nil, err
}
s.notifyInvitationResponse(ctx, record.Invitation.ID, domain.InvitationStatusAccepted)
acceptedVersion := 0
if record.Participation.LastConfirmedEventVersion != nil {
acceptedVersion = *record.Participation.LastConfirmedEventVersion
}
slog.InfoContext(ctx, "invitation accepted",
"operation", "invitation.accept",
"invitation_id", record.Invitation.ID.String(),
"event_id", record.Invitation.EventID.String(),
"user_id", userID.String(),
"participation_id", record.Participation.ID.String(),
"participation_status", record.Participation.Status.String(),
"accepted_event_version", acceptedVersion,
"ticket_created", s.tickets != nil,
)
return &AcceptInvitationResult{
InvitationID: record.Invitation.ID.String(),
EventID: record.Invitation.EventID.String(),
InvitationStatus: record.Invitation.Status.String(),
ParticipationID: record.Participation.ID.String(),
ParticipationStatus: record.Participation.Status.String(),
UpdatedAt: record.Invitation.UpdatedAt,
}, nil
}
func (s *Service) RevokeInvitation(
ctx context.Context,
hostID, eventID, invitationID uuid.UUID,
) error {
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
_, err := s.repo.RevokeInvitation(ctx, RevokeInvitationParams{
EventID: eventID,
HostID: hostID,
InvitationID: invitationID,
})
return err
})
}
func (s *Service) DeclineInvitation(ctx context.Context, userID, invitationID uuid.UUID) (*DeclineInvitationResult, error) {
var invitation *domain.Invitation
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
invitation, err = s.repo.DeclineInvitation(ctx, userID, invitationID)
return err
})
if err != nil {
return nil, err
}
s.notifyInvitationResponse(ctx, invitation.ID, domain.InvitationStatusDeclined)
return &DeclineInvitationResult{
InvitationID: invitation.ID.String(),
EventID: invitation.EventID.String(),
Status: invitation.Status.String(),
UpdatedAt: invitation.UpdatedAt,
CooldownEndsAt: invitation.UpdatedAt.Add(domain.InvitationDeclineCooldown),
}, nil
}
func normalizeInviteUsernames(raw []string) ([]string, map[string]string) {
errs := make(map[string]string)
if len(raw) == 0 {
errs["usernames"] = "must contain at least 1 username"
return nil, errs
}
if len(raw) > maxBatchInviteUsernames {
errs["usernames"] = "must contain at most 100 usernames"
return nil, errs
}
usernames := make([]string, len(raw))
for i, username := range raw {
usernames[i] = strings.TrimSpace(username)
}
return usernames, errs
}
func normalizeOptionalMessage(message *string) *string {
if message == nil {
return nil
}
trimmed := strings.TrimSpace(*message)
if trimmed == "" {
return nil
}
return &trimmed
}
func (s *Service) notifyCreatedInvitations(ctx context.Context, record *CreateInvitationsRecord) {
if s.notifications == nil || record == nil {
return
}
for _, item := range record.SuccessfulInvitations {
if item.Invitation == nil {
continue
}
notificationCtx, err := s.repo.GetInvitationNotificationContext(ctx, item.Invitation.ID)
if err != nil {
slog.ErrorContext(ctx, "invitation notification context load failed",
"operation", "invitation.notification.context",
"invitation_id", item.Invitation.ID.String(),
"error", err,
)
continue
}
s.sendInvitationReceivedNotification(ctx, notificationCtx)
}
}
func (s *Service) notifyInvitationResponse(ctx context.Context, invitationID uuid.UUID, status domain.InvitationStatus) {
if s.notifications == nil {
return
}
notificationCtx, err := s.repo.GetInvitationNotificationContext(ctx, invitationID)
if err != nil {
slog.ErrorContext(ctx, "invitation response notification context load failed",
"operation", "invitation.response.notification.context",
"invitation_id", invitationID.String(),
"status", status.String(),
"error", err,
)
return
}
notificationType := "PRIVATE_EVENT_INVITATION_ACCEPTED"
title := "Invitation accepted"
titleKey := "notification.invitation.accepted.title"
bodyKey := "notification.invitation.accepted.body"
invitedLabel := displayLabel(notificationCtx.InvitedDisplayName, notificationCtx.InvitedUsername)
body := fmt.Sprintf("%s accepted your invitation to %s.", invitedLabel, notificationCtx.EventTitle)
if status == domain.InvitationStatusDeclined {
notificationType = "PRIVATE_EVENT_INVITATION_DECLINED"
title = "Invitation declined"
titleKey = "notification.invitation.declined.title"
bodyKey = "notification.invitation.declined.body"
body = fmt.Sprintf("%s declined your invitation to %s.", invitedLabel, notificationCtx.EventTitle)
}
deepLink := fmt.Sprintf("/events/%s", notificationCtx.EventID.String())
_, err = s.notifications.SendNotificationToUsers(ctx, notificationapp.SendNotificationInput{
UserIDs: []uuid.UUID{notificationCtx.HostUserID},
Title: title,
TitleKey: titleKey,
Type: ¬ificationType,
Body: body,
BodyKey: bodyKey,
BodyArgs: []any{invitedLabel, notificationCtx.EventTitle},
DeepLink: &deepLink,
EventID: ¬ificationCtx.EventID,
ImageURL: notificationCtx.EventImageURL,
Data: invitationNotificationData(notificationCtx, map[string]string{
"invitation_id": notificationCtx.InvitationID.String(),
"actor_user_id": notificationCtx.InvitedUserID.String(),
"actor_username": notificationCtx.InvitedUsername,
"status": status.String(),
}, notificationCtx.InvitedDisplayName),
IdempotencyKey: fmt.Sprintf("INVITATION_%s:%s", status.String(), notificationCtx.InvitationID.String()),
})
if err != nil {
slog.ErrorContext(ctx, "invitation response notification send failed",
"operation", "invitation.response.notification.send",
"invitation_id", notificationCtx.InvitationID.String(),
"event_id", notificationCtx.EventID.String(),
"receiver_user_id", notificationCtx.HostUserID.String(),
"status", status.String(),
"error", err,
)
}
}
func (s *Service) sendInvitationReceivedNotification(ctx context.Context, notificationCtx *InvitationNotificationContext) {
if notificationCtx == nil {
return
}
notificationType := "PRIVATE_EVENT_INVITATION_RECEIVED"
deepLink := fmt.Sprintf("/events/%s", notificationCtx.EventID.String())
hostLabel := displayLabel(notificationCtx.HostDisplayName, notificationCtx.HostUsername)
_, err := s.notifications.SendNotificationToUsers(ctx, notificationapp.SendNotificationInput{
UserIDs: []uuid.UUID{notificationCtx.InvitedUserID},
Title: "Private event invitation",
TitleKey: "notification.invitation.received.title",
Type: ¬ificationType,
Body: fmt.Sprintf("%s invited you to %s.", hostLabel, notificationCtx.EventTitle),
BodyKey: "notification.invitation.received.body",
BodyArgs: []any{hostLabel, notificationCtx.EventTitle},
DeepLink: &deepLink,
EventID: ¬ificationCtx.EventID,
ImageURL: notificationCtx.EventImageURL,
Data: invitationNotificationData(notificationCtx, map[string]string{
"invitation_id": notificationCtx.InvitationID.String(),
"actor_user_id": notificationCtx.HostUserID.String(),
"actor_username": notificationCtx.HostUsername,
"status": domain.InvitationStatusPending.String(),
}, notificationCtx.HostDisplayName),
IdempotencyKey: fmt.Sprintf("INVITATION_RECEIVED:%s", notificationCtx.InvitationID.String()),
})
if err != nil {
slog.ErrorContext(ctx, "invitation received notification send failed",
"operation", "invitation.received.notification.send",
"invitation_id", notificationCtx.InvitationID.String(),
"event_id", notificationCtx.EventID.String(),
"receiver_user_id", notificationCtx.InvitedUserID.String(),
"error", err,
)
}
}
func invitationNotificationData(notificationCtx *InvitationNotificationContext, extra map[string]string, actorDisplayName *string) map[string]string {
data := map[string]string{
"event_id": notificationCtx.EventID.String(),
"event_title": notificationCtx.EventTitle,
"event_start_time": notificationCtx.EventStartTime.UTC().Format(time.RFC3339),
}
for key, value := range extra {
data[key] = value
}
if actorDisplayName != nil && strings.TrimSpace(*actorDisplayName) != "" {
data["actor_display_name"] = strings.TrimSpace(*actorDisplayName)
}
return data
}
func displayLabel(displayName *string, username string) string {
if displayName != nil && strings.TrimSpace(*displayName) != "" {
return strings.TrimSpace(*displayName)
}
return username
}
package join_request
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
notificationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/notification"
"github.com/bounswe/bounswe2026group11/backend/internal/application/ticket"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
var tracer = otel.Tracer("github.com/bounswe/bounswe2026group11/backend/internal/application/join_request")
// Service owns join-request-specific application behavior.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
tickets ticket.LifecycleUseCase
notifications notificationapp.UseCase
}
var _ UseCase = (*Service)(nil)
// NewService constructs a join request service backed by its own repository.
func NewService(repo Repository, unitOfWork uow.UnitOfWork, ticketLifecycle ...ticket.LifecycleUseCase) *Service {
service := &Service{
repo: repo,
unitOfWork: unitOfWork,
}
if len(ticketLifecycle) > 0 {
service.tickets = ticketLifecycle[0]
}
return service
}
func (s *Service) SetNotificationService(notifications notificationapp.UseCase) {
s.notifications = notifications
}
// 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,
ImageURL: input.ImageURL,
})
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) {
ctx, span := tracer.Start(ctx, "join_request.approve")
defer span.End()
span.SetAttributes(
attribute.String("operation", "join_request.approve"),
attribute.String("event_id", eventID.String()),
attribute.String("join_request_id", joinRequestID.String()),
attribute.String("host_user_id", hostUserID.String()),
)
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,
})
if err != nil {
slog.ErrorContext(ctx, "join request approval failed",
"operation", "join_request.approve",
"event_id", eventID.String(),
"join_request_id", joinRequestID.String(),
"host_user_id", hostUserID.String(),
"error", err,
)
return err
}
if result == nil || result.Participation == nil {
err := errors.New("join request approval returned no participation")
slog.ErrorContext(ctx, "join request approval failed",
"operation", "join_request.approve",
"event_id", eventID.String(),
"join_request_id", joinRequestID.String(),
"host_user_id", hostUserID.String(),
"error", err,
)
return err
}
if s.tickets != nil {
slog.InfoContext(ctx, "join request approved; creating ticket",
"operation", "join_request.approve.ticket_create",
"event_id", eventID.String(),
"join_request_id", joinRequestID.String(),
"host_user_id", hostUserID.String(),
"participation_id", result.Participation.ID.String(),
"participant_user_id", result.Participation.UserID.String(),
"ticket_status", domain.TicketStatusActive.String(),
)
_, err = s.tickets.CreateTicketForParticipation(ctx, result.Participation, domain.TicketStatusActive)
if err != nil {
slog.ErrorContext(ctx, "ticket creation after join request approval failed",
"operation", "join_request.approve.ticket_create",
"event_id", eventID.String(),
"join_request_id", joinRequestID.String(),
"host_user_id", hostUserID.String(),
"participation_id", result.Participation.ID.String(),
"participant_user_id", result.Participation.UserID.String(),
"ticket_status", domain.TicketStatusActive.String(),
"error", err,
)
}
}
return err
})
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if result != nil && result.Participation != nil {
span.SetAttributes(
attribute.String("participation_id", result.Participation.ID.String()),
attribute.String("participant_user_id", result.Participation.UserID.String()),
)
acceptedVersion := 0
if result.Participation.LastConfirmedEventVersion != nil {
acceptedVersion = *result.Participation.LastConfirmedEventVersion
}
slog.InfoContext(ctx, "join request approved",
"operation", "join_request.approve",
"event_id", eventID.String(),
"join_request_id", joinRequestID.String(),
"host_user_id", hostUserID.String(),
"participation_id", result.Participation.ID.String(),
"participant_user_id", result.Participation.UserID.String(),
"participation_status", result.Participation.Status.String(),
"accepted_event_version", acceptedVersion,
"ticket_service_enabled", s.tickets != nil,
)
}
s.notifyModeratedJoinRequest(ctx, joinRequestID, domain.JoinRequestStatusApproved, nil)
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
}
s.notifyModeratedJoinRequest(ctx, joinRequestID, domain.JoinRequestStatusRejected, &result.CooldownEndsAt)
return result, nil
}
// CancelJoinRequest transitions the caller's own PENDING join request to CANCELED.
func (s *Service) CancelJoinRequest(
ctx context.Context,
eventID, userID uuid.UUID,
) (*domain.JoinRequest, error) {
var result *domain.JoinRequest
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
result, err = s.repo.CancelJoinRequestByUser(ctx, CancelJoinRequestByUserParams{
EventID: eventID,
UserID: userID,
})
return err
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *Service) notifyModeratedJoinRequest(ctx context.Context, joinRequestID uuid.UUID, status domain.JoinRequestStatus, cooldownEndsAt *time.Time) {
if s.notifications == nil {
return
}
notificationCtx, err := s.repo.GetNotificationContext(ctx, joinRequestID)
if err != nil {
slog.ErrorContext(ctx, "join request notification context load failed",
"operation", "join_request.notification.context",
"join_request_id", joinRequestID.String(),
"status", string(status),
"error", err,
)
return
}
notificationType := "PROTECTED_EVENT_JOIN_REQUEST_APPROVED"
title := "Join request approved"
titleKey := "notification.join_request.approved.title"
bodyKey := "notification.join_request.approved.body"
hostLabel := displayLabel(notificationCtx.HostDisplayName, notificationCtx.HostUsername)
body := fmt.Sprintf("%s approved your request to join %s.", hostLabel, notificationCtx.EventTitle)
idempotencyKey := fmt.Sprintf("JOIN_REQUEST_APPROVED:%s", notificationCtx.JoinRequestID.String())
if status == domain.JoinRequestStatusRejected {
notificationType = "PROTECTED_EVENT_JOIN_REQUEST_REJECTED"
title = "Join request rejected"
titleKey = "notification.join_request.rejected.title"
bodyKey = "notification.join_request.rejected.body"
body = fmt.Sprintf("%s rejected your request to join %s.", hostLabel, notificationCtx.EventTitle)
idempotencyKey = fmt.Sprintf("JOIN_REQUEST_REJECTED:%s", notificationCtx.JoinRequestID.String())
}
deepLink := fmt.Sprintf("/events/%s", notificationCtx.EventID.String())
data := joinRequestNotificationData(notificationCtx, status)
if cooldownEndsAt != nil {
data["cooldown_ends_at"] = cooldownEndsAt.UTC().Format(time.RFC3339)
}
_, err = s.notifications.SendNotificationToUsers(ctx, notificationapp.SendNotificationInput{
UserIDs: []uuid.UUID{notificationCtx.RequesterUserID},
Title: title,
TitleKey: titleKey,
Type: ¬ificationType,
Body: body,
BodyKey: bodyKey,
BodyArgs: []any{hostLabel, notificationCtx.EventTitle},
DeepLink: &deepLink,
EventID: ¬ificationCtx.EventID,
ImageURL: notificationCtx.EventImageURL,
Data: data,
IdempotencyKey: idempotencyKey,
})
if err != nil {
slog.ErrorContext(ctx, "join request notification send failed",
"operation", "join_request.notification.send",
"join_request_id", notificationCtx.JoinRequestID.String(),
"event_id", notificationCtx.EventID.String(),
"receiver_user_id", notificationCtx.RequesterUserID.String(),
"status", string(status),
"error", err,
)
}
}
func joinRequestNotificationData(notificationCtx *NotificationContext, status domain.JoinRequestStatus) map[string]string {
data := map[string]string{
"event_id": notificationCtx.EventID.String(),
"event_title": notificationCtx.EventTitle,
"event_start_time": notificationCtx.EventStartTime.UTC().Format(time.RFC3339),
"join_request_id": notificationCtx.JoinRequestID.String(),
"actor_user_id": notificationCtx.HostUserID.String(),
"actor_username": notificationCtx.HostUsername,
"status": string(status),
}
if notificationCtx.HostDisplayName != nil && strings.TrimSpace(*notificationCtx.HostDisplayName) != "" {
data["actor_display_name"] = strings.TrimSpace(*notificationCtx.HostDisplayName)
}
return data
}
func displayLabel(displayName *string, username string) string {
if displayName != nil && strings.TrimSpace(*displayName) != "" {
return strings.TrimSpace(*displayName)
}
return username
}
package notification
import (
"context"
"sync"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
const realtimeSubscriptionBuffer = 16
type brokerSubscription struct {
id uuid.UUID
userID uuid.UUID
events chan domain.Notification
}
// Broker stores active in-process SSE subscriptions by user id.
type Broker struct {
mu sync.Mutex
subscriptions map[uuid.UUID]map[uuid.UUID]*brokerSubscription
}
var _ RealtimeBroker = (*Broker)(nil)
func NewBroker() *Broker {
return &Broker{subscriptions: map[uuid.UUID]map[uuid.UUID]*brokerSubscription{}}
}
func (b *Broker) Subscribe(userID uuid.UUID) *Subscription {
if b == nil {
return nil
}
sub := &brokerSubscription{
id: uuid.New(),
userID: userID,
events: make(chan domain.Notification, realtimeSubscriptionBuffer),
}
b.mu.Lock()
if b.subscriptions == nil {
b.subscriptions = map[uuid.UUID]map[uuid.UUID]*brokerSubscription{}
}
if b.subscriptions[userID] == nil {
b.subscriptions[userID] = map[uuid.UUID]*brokerSubscription{}
}
b.subscriptions[userID][sub.id] = sub
b.mu.Unlock()
return &Subscription{
ID: sub.id,
UserID: userID,
Events: sub.events,
Cancel: func() {
b.unsubscribe(userID, sub.id)
},
}
}
func (b *Broker) Publish(_ context.Context, userID uuid.UUID, notification domain.Notification) int {
if b == nil {
return 0
}
var stale []*brokerSubscription
delivered := 0
b.mu.Lock()
for id, sub := range b.subscriptions[userID] {
select {
case sub.events <- notification:
delivered++
default:
delete(b.subscriptions[userID], id)
stale = append(stale, sub)
}
}
if len(b.subscriptions[userID]) == 0 {
delete(b.subscriptions, userID)
}
b.mu.Unlock()
for _, sub := range stale {
close(sub.events)
}
return delivered
}
func (b *Broker) unsubscribe(userID, subscriptionID uuid.UUID) {
b.mu.Lock()
sub, ok := b.subscriptions[userID][subscriptionID]
if ok {
delete(b.subscriptions[userID], subscriptionID)
if len(b.subscriptions[userID]) == 0 {
delete(b.subscriptions, userID)
}
}
b.mu.Unlock()
if ok {
close(sub.events)
}
}
package notification
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
func encodeNotificationCursor(cursor NotificationCursor) (string, error) {
raw, err := json.Marshal(cursor)
if err != nil {
return "", fmt.Errorf("marshal notification cursor: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func decodeNotificationCursor(token string) (*NotificationCursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return nil, fmt.Errorf("decode notification cursor: %w", err)
}
var cursor NotificationCursor
if err := json.Unmarshal(raw, &cursor); err != nil {
return nil, fmt.Errorf("unmarshal notification cursor: %w", err)
}
if cursor.CreatedAt.IsZero() {
return nil, fmt.Errorf("cursor is missing created_at")
}
if cursor.NotificationID == uuid.Nil {
return nil, fmt.Errorf("cursor is missing notification_id")
}
return &cursor, nil
}
func buildNextNotificationCursor(items []domain.Notification, hasNext bool) (*string, error) {
if !hasNext || len(items) == 0 {
return nil, nil
}
last := items[len(items)-1]
encoded, err := encodeNotificationCursor(NotificationCursor{
CreatedAt: last.CreatedAt,
NotificationID: last.ID,
})
if err != nil {
return nil, err
}
return &encoded, nil
}
package notification
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/bounswe/bounswe2026group11/backend/internal/i18n"
"github.com/google/uuid"
)
// Service owns push-device registration and push notification delivery.
type Service struct {
repo Repository
sender PushSender
realtime RealtimeBroker
unitOfWork uow.UnitOfWork
translator *i18n.Catalog
now func() time.Time
}
var _ UseCase = (*Service)(nil)
func NewService(repo Repository, sender PushSender, unitOfWork uow.UnitOfWork, realtime ...RealtimeBroker) *Service {
service := &Service{
repo: repo,
sender: sender,
unitOfWork: unitOfWork,
now: time.Now,
}
if len(realtime) > 0 {
service.realtime = realtime[0]
}
return service
}
// SetTranslator installs the i18n catalog used to translate keyed
// notification text per recipient. Calling with nil disables translation;
// in that case keyed-notification call sites still produce a usable row
// from the literal Title/Body fallback fields.
func (s *Service) SetTranslator(catalog *i18n.Catalog) {
s.translator = catalog
}
func (s *Service) RegisterDevice(ctx context.Context, input RegisterDeviceInput) (*RegisterDeviceResult, error) {
platform, token, appErr := validateRegisterDeviceInput(input)
if appErr != nil {
return nil, appErr
}
now := s.now().UTC()
var (
device *domain.PushDevice
activeCount int
)
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
if err := s.repo.LockUser(ctx, input.UserID); err != nil {
return fmt.Errorf("lock push-device user: %w", err)
}
var err error
device, err = s.repo.UpsertDevice(ctx, RegisterDeviceParams{
UserID: input.UserID,
InstallationID: input.InstallationID,
Platform: platform,
FCMToken: token,
DeviceInfo: input.DeviceInfo,
LastSeenAt: now,
})
if err != nil {
return fmt.Errorf("upsert push device: %w", err)
}
if _, err := s.repo.RevokeOldestActiveDevices(ctx, input.UserID, MaxActiveDevicesPerUser, now); err != nil {
return fmt.Errorf("enforce push-device limit: %w", err)
}
activeCount, err = s.repo.CountActiveDevices(ctx, input.UserID)
if err != nil {
return fmt.Errorf("count active push devices: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return &RegisterDeviceResult{
InstallationID: device.InstallationID.String(),
Platform: device.Platform,
ActiveDeviceCount: activeCount,
UpdatedAt: device.UpdatedAt,
}, nil
}
func (s *Service) UnregisterDevice(ctx context.Context, userID, installationID uuid.UUID) error {
now := s.now().UTC()
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
if _, err := s.repo.RevokeDevice(ctx, userID, installationID, now); err != nil {
return fmt.Errorf("revoke push device: %w", err)
}
return nil
})
}
func (s *Service) ListNotifications(ctx context.Context, input ListNotificationsInput) (*ListNotificationsResult, error) {
params, err := normalizeListNotificationsInput(input, s.now().UTC())
if err != nil {
return nil, err
}
records, err := s.repo.ListNotifications(ctx, params)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
hasNext := len(records) > params.Limit
if hasNext {
records = records[:params.Limit]
}
nextCursor, err := buildNextNotificationCursor(records, hasNext)
if err != nil {
return nil, err
}
return &ListNotificationsResult{
Items: records,
PageInfo: NotificationPageInfo{
NextCursor: nextCursor,
HasNext: hasNext,
},
}, nil
}
func (s *Service) CountUnreadNotifications(ctx context.Context, userID uuid.UUID) (*UnreadCountResult, error) {
count, err := s.repo.CountUnreadNotifications(ctx, userID, s.visibleAfter())
if err != nil {
return nil, fmt.Errorf("count unread notifications: %w", err)
}
return &UnreadCountResult{UnreadCount: count}, nil
}
func (s *Service) MarkNotificationRead(ctx context.Context, userID, notificationID uuid.UUID) error {
readAt := s.now().UTC()
updated, err := s.repo.MarkNotificationRead(ctx, userID, notificationID, readAt, s.visibleAfter())
if err != nil {
return fmt.Errorf("mark notification read: %w", err)
}
if !updated {
return domain.NotFoundError(domain.ErrorCodeNotificationNotFound, "The requested notification does not exist.")
}
return nil
}
func (s *Service) MarkAllNotificationsRead(ctx context.Context, userID uuid.UUID) (*MarkAllReadResult, error) {
readAt := s.now().UTC()
updated, err := s.repo.MarkAllNotificationsRead(ctx, userID, readAt, s.visibleAfter())
if err != nil {
return nil, fmt.Errorf("mark all notifications read: %w", err)
}
return &MarkAllReadResult{UpdatedCount: updated}, nil
}
func (s *Service) DeleteNotification(ctx context.Context, userID, notificationID uuid.UUID) error {
now := s.now().UTC()
if err := s.repo.SoftDeleteNotification(ctx, userID, notificationID, now, s.visibleAfter()); err != nil {
return fmt.Errorf("delete notification: %w", err)
}
return nil
}
func (s *Service) DeleteAllNotifications(ctx context.Context, userID uuid.UUID) error {
now := s.now().UTC()
if err := s.repo.SoftDeleteAllNotifications(ctx, userID, now, s.visibleAfter()); err != nil {
return fmt.Errorf("delete all notifications: %w", err)
}
return nil
}
func (s *Service) DeleteExpiredNotifications(ctx context.Context) (int, error) {
deleted, err := s.repo.DeleteExpiredNotifications(ctx, s.visibleAfter())
if err != nil {
return 0, fmt.Errorf("delete expired notifications: %w", err)
}
return deleted, nil
}
func (s *Service) SendNotificationToUsers(ctx context.Context, input SendNotificationInput) (*SendNotificationResult, error) {
if appErr := validateSendNotificationInput(input); appErr != nil {
return nil, appErr
}
now := s.now().UTC()
result := &SendNotificationResult{
TargetUserCount: len(uniqueUserIDs(input.UserIDs)),
}
for userID := range uniqueUserIDs(input.UserIDs) {
title, body := s.resolveNotificationText(ctx, userID, input)
createResult, err := s.repo.CreateNotificationIfAbsent(ctx, CreateNotificationParams{
UserID: userID,
EventID: input.EventID,
Title: title,
Type: input.Type,
Body: body,
DeepLink: input.DeepLink,
ImageURL: input.ImageURL,
Data: input.Data,
IdempotencyKey: strings.TrimSpace(input.IdempotencyKey),
CreatedAt: now,
})
if err != nil {
return nil, fmt.Errorf("create notification: %w", err)
}
if !createResult.Created {
result.IdempotentCount++
continue
}
result.CreatedCount++
delivered := 0
if s.realtime != nil {
delivered = s.realtime.Publish(ctx, userID, createResult.Notification)
}
if delivered > 0 {
result.SSEDeliveryCount += delivered
for i := 0; i < delivered; i++ {
sentAt := now
if err := s.repo.CreateDeliveryAttempt(ctx, CreateDeliveryAttemptParams{
NotificationID: createResult.Notification.ID,
UserID: userID,
Method: domain.NotificationDeliveryMethodSSE,
Status: domain.NotificationDeliveryStatusSent,
SentAt: &sentAt,
}); err != nil {
return nil, fmt.Errorf("store sse delivery attempt: %w", err)
}
}
continue
}
pushResult, err := s.sendPushForNotification(ctx, createResult.Notification)
if err != nil {
return nil, err
}
result.PushActiveDeviceCount += pushResult.ActiveDeviceCount
result.PushSentCount += pushResult.SentCount
result.PushFailedCount += pushResult.FailedCount
result.InvalidTokenCount += pushResult.InvalidTokenCount
}
slog.InfoContext(ctx, "notifications sent",
"operation", "notification.send",
"target_user_count", result.TargetUserCount,
"created_count", result.CreatedCount,
"idempotent_count", result.IdempotentCount,
"sse_delivery_count", result.SSEDeliveryCount,
"push_active_device_count", result.PushActiveDeviceCount,
"push_sent_count", result.PushSentCount,
"push_failed_count", result.PushFailedCount,
"invalid_token_count", result.InvalidTokenCount,
)
return result, nil
}
// SendCustomNotificationToUsers sends an admin-triggered notification through
// the requested channels while sharing inbox persistence, realtime attempts,
// push attempts, invalid-token handling, and delivery metrics.
func (s *Service) SendCustomNotificationToUsers(ctx context.Context, input SendCustomNotificationInput) (*SendNotificationResult, error) {
if appErr := validateSendCustomNotificationInput(input); appErr != nil {
return nil, appErr
}
now := s.now().UTC()
result := &SendNotificationResult{
TargetUserCount: len(uniqueUserIDs(input.UserIDs)),
}
for userID := range uniqueUserIDs(input.UserIDs) {
createResult, err := s.repo.CreateNotificationIfAbsent(ctx, CreateNotificationParams{
UserID: userID,
EventID: input.EventID,
Title: strings.TrimSpace(input.Title),
Type: input.Type,
Body: strings.TrimSpace(input.Body),
DeepLink: input.DeepLink,
ImageURL: input.ImageURL,
Data: input.Data,
IdempotencyKey: strings.TrimSpace(input.IdempotencyKey),
CreatedAt: now,
})
if err != nil {
return nil, fmt.Errorf("create custom notification: %w", err)
}
if !createResult.Created {
result.IdempotentCount++
continue
}
result.CreatedCount++
if input.DeliveryMode == domain.NotificationDeliveryModeInApp || input.DeliveryMode == domain.NotificationDeliveryModeBoth {
delivered := 0
if s.realtime != nil {
delivered = s.realtime.Publish(ctx, userID, createResult.Notification)
}
if delivered > 0 {
result.SSEDeliveryCount += delivered
for i := 0; i < delivered; i++ {
sentAt := now
if err := s.repo.CreateDeliveryAttempt(ctx, CreateDeliveryAttemptParams{
NotificationID: createResult.Notification.ID,
UserID: userID,
Method: domain.NotificationDeliveryMethodSSE,
Status: domain.NotificationDeliveryStatusSent,
SentAt: &sentAt,
}); err != nil {
return nil, fmt.Errorf("store custom sse delivery attempt: %w", err)
}
}
}
}
if input.DeliveryMode == domain.NotificationDeliveryModePush || input.DeliveryMode == domain.NotificationDeliveryModeBoth {
pushResult, err := s.sendPushForNotification(ctx, createResult.Notification)
if err != nil {
return nil, err
}
result.PushActiveDeviceCount += pushResult.ActiveDeviceCount
result.PushSentCount += pushResult.SentCount
result.PushFailedCount += pushResult.FailedCount
result.InvalidTokenCount += pushResult.InvalidTokenCount
}
}
slog.InfoContext(ctx, "admin custom notifications sent",
"operation", "admin.notifications.send",
"delivery_mode", input.DeliveryMode.String(),
"target_user_count", result.TargetUserCount,
"created_count", result.CreatedCount,
"idempotent_count", result.IdempotentCount,
"sse_delivery_count", result.SSEDeliveryCount,
"push_active_device_count", result.PushActiveDeviceCount,
"push_sent_count", result.PushSentCount,
"push_failed_count", result.PushFailedCount,
"invalid_token_count", result.InvalidTokenCount,
)
return result, nil
}
func (s *Service) SendPushToUsers(ctx context.Context, input SendPushInput) (*SendPushResult, error) {
if appErr := validateSendPushInput(input); appErr != nil {
return nil, appErr
}
result, err := s.SendNotificationToUsers(ctx, SendNotificationInput{
UserIDs: input.UserIDs,
Title: input.Title,
Body: input.Body,
Type: input.Type,
DeepLink: input.DeepLink,
Data: input.Data,
EventID: input.EventID,
IdempotencyKey: "PUSH:" + uuid.NewString(),
})
if err != nil {
return nil, err
}
return &SendPushResult{
TargetUserCount: result.TargetUserCount,
ActiveDeviceCount: result.PushActiveDeviceCount,
SentCount: result.PushSentCount,
FailedCount: result.PushFailedCount,
InvalidTokenCount: result.InvalidTokenCount,
}, nil
}
func (s *Service) sendPushForNotification(ctx context.Context, notification domain.Notification) (*SendPushResult, error) {
devices, err := s.repo.ListActiveDevicesForUsers(ctx, []uuid.UUID{notification.ReceiverUserID})
if err != nil {
return nil, fmt.Errorf("list active push devices: %w", err)
}
result := &SendPushResult{
TargetUserCount: 1,
ActiveDeviceCount: len(devices),
}
now := s.now().UTC()
for _, device := range devices {
sendResult, sendErr := s.sendPushWithRetries(ctx, notification, device, now)
if sendResult != nil && sendResult.InvalidToken {
result.InvalidTokenCount++
if err := s.repo.RevokeDeviceByID(ctx, device.ID, now); err != nil {
return nil, fmt.Errorf("revoke invalid push device: %w", err)
}
}
if sendErr != nil {
result.FailedCount++
continue
}
result.SentCount++
}
return result, nil
}
func (s *Service) sendPushWithRetries(
ctx context.Context,
notification domain.Notification,
device domain.PushDevice,
now time.Time,
) (*PushSendResult, error) {
var (
lastResult *PushSendResult
lastErr error
)
for attempt := 0; attempt <= MaxPushDeliveryRetries; attempt++ {
sendResult, sendErr := s.sender.Send(ctx, PushSendMessage{
Token: device.FCMToken,
Title: notification.Title,
Body: notification.Body,
ImageURL: notification.ImageURL,
DeepLink: notification.DeepLink,
Data: notification.Data,
})
status := domain.NotificationDeliveryStatusSent
sentAt := &now
var errorSummary *string
if sendErr != nil {
slog.ErrorContext(ctx, "push notification delivery failed",
"operation", "notification.push.device_send",
"user_id", device.UserID.String(),
"device_id", device.ID.String(),
"attempt", attempt+1,
"error", sendErr,
)
status = domain.NotificationDeliveryStatusFailed
sentAt = nil
errorSummary = shortErrorSummary(sendErr)
}
if err := s.repo.CreateDeliveryAttempt(ctx, CreateDeliveryAttemptParams{
NotificationID: notification.ID,
UserID: device.UserID,
Method: domain.NotificationDeliveryMethodFCM,
Status: status,
PushDeviceID: &device.ID,
ErrorSummary: errorSummary,
SentAt: sentAt,
}); err != nil {
return nil, fmt.Errorf("store push delivery attempt: %w", err)
}
lastResult = sendResult
lastErr = sendErr
if sendErr == nil || (sendResult != nil && sendResult.InvalidToken) {
return sendResult, sendErr
}
}
return lastResult, lastErr
}
// resolveNotificationText translates keyed title/body to the recipient's
// stored locale, falling back to the literal English Title/Body when the
// translator or the recipient locale is unavailable.
func (s *Service) resolveNotificationText(ctx context.Context, userID uuid.UUID, input SendNotificationInput) (string, string) {
title := strings.TrimSpace(input.Title)
body := strings.TrimSpace(input.Body)
if s.translator == nil || (input.TitleKey == "" && input.BodyKey == "") {
return title, body
}
loc := i18n.DefaultLocale
if raw, err := s.repo.GetLocale(ctx, userID); err == nil {
if parsed, ok := i18n.Parse(raw); ok {
loc = parsed
}
}
if input.TitleKey != "" {
title = strings.TrimSpace(s.translator.T(loc, input.TitleKey, input.TitleArgs...))
}
if input.BodyKey != "" {
body = strings.TrimSpace(s.translator.T(loc, input.BodyKey, input.BodyArgs...))
}
return title, body
}
func uniqueUserIDs(userIDs []uuid.UUID) map[uuid.UUID]struct{} {
seen := make(map[uuid.UUID]struct{}, len(userIDs))
for _, userID := range userIDs {
seen[userID] = struct{}{}
}
return seen
}
func (s *Service) visibleAfter() time.Time {
return s.now().UTC().AddDate(0, 0, -NotificationRetentionDays)
}
func shortErrorSummary(err error) *string {
if err == nil {
return nil
}
value := err.Error()
if len(value) > 512 {
value = value[:512]
}
return &value
}
package notification
import (
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
)
func validateRegisterDeviceInput(input RegisterDeviceInput) (domain.PushDevicePlatform, string, *domain.AppError) {
platform, ok := domain.ParsePushDevicePlatform(input.Platform)
token := strings.TrimSpace(input.FCMToken)
details := map[string]string{}
if !ok {
details["platform"] = "must be IOS or ANDROID"
}
if token == "" {
details["fcm_token"] = "is required"
}
if len(token) > 4096 {
details["fcm_token"] = "must be at most 4096 characters"
}
if len(details) > 0 {
return "", "", domain.ValidationError(details)
}
return platform, token, nil
}
func validateSendPushInput(input SendPushInput) *domain.AppError {
details := map[string]string{}
if len(input.UserIDs) == 0 {
details["user_ids"] = "must contain at least one user id"
}
if strings.TrimSpace(input.Title) == "" {
details["title"] = "is required"
}
if strings.TrimSpace(input.Body) == "" {
details["body"] = "is required"
}
if len(details) > 0 {
return domain.ValidationError(details)
}
return nil
}
func validateSendNotificationInput(input SendNotificationInput) *domain.AppError {
details := map[string]string{}
if len(input.UserIDs) == 0 {
details["user_ids"] = "must contain at least one user id"
}
// Either a literal Title or a TitleKey is required; same for body.
// Callers that go through the i18n path supply the keys, while admin
// custom notifications and legacy paths supply the literal text.
if strings.TrimSpace(input.Title) == "" && strings.TrimSpace(input.TitleKey) == "" {
details["title"] = "is required"
}
if strings.TrimSpace(input.Body) == "" && strings.TrimSpace(input.BodyKey) == "" {
details["body"] = "is required"
}
if strings.TrimSpace(input.IdempotencyKey) == "" {
details["idempotency_key"] = "is required"
}
if len(details) > 0 {
return domain.ValidationError(details)
}
return nil
}
func validateSendCustomNotificationInput(input SendCustomNotificationInput) *domain.AppError {
details := map[string]string{}
if len(input.UserIDs) == 0 {
details["user_ids"] = "must contain at least one user id"
}
if input.DeliveryMode == "" {
details["delivery_mode"] = "must be one of: IN_APP, PUSH, BOTH"
}
if input.DeliveryMode != "" {
switch input.DeliveryMode {
case domain.NotificationDeliveryModeInApp, domain.NotificationDeliveryModePush, domain.NotificationDeliveryModeBoth:
default:
details["delivery_mode"] = "must be one of: IN_APP, PUSH, BOTH"
}
}
if strings.TrimSpace(input.Title) == "" {
details["title"] = "is required"
}
if strings.TrimSpace(input.Body) == "" {
details["body"] = "is required"
}
if strings.TrimSpace(input.IdempotencyKey) == "" {
details["idempotency_key"] = "is required"
}
if len(details) > 0 {
return domain.ValidationError(details)
}
return nil
}
func normalizeListNotificationsInput(input ListNotificationsInput, now time.Time) (ListNotificationsParams, error) {
params := ListNotificationsParams{
UserID: input.UserID,
OnlyUnread: input.OnlyUnread,
Limit: DefaultNotificationLimit,
VisibleAfter: now.UTC().AddDate(0, 0, -NotificationRetentionDays),
RepositoryFetchLimit: DefaultNotificationLimit + 1,
}
if input.Limit != nil {
if *input.Limit < 1 || *input.Limit > MaxNotificationLimit {
return ListNotificationsParams{}, domain.ValidationError(map[string]string{
"limit": "limit must be between 1 and 50",
})
}
params.Limit = *input.Limit
params.RepositoryFetchLimit = params.Limit + 1
}
if input.Cursor != nil {
cursorToken := strings.TrimSpace(*input.Cursor)
if cursorToken != "" {
cursor, err := decodeNotificationCursor(cursorToken)
if err != nil {
return ListNotificationsParams{}, domain.ValidationError(map[string]string{
"cursor": "cursor is invalid",
})
}
params.DecodedCursor = cursor
}
}
return params, nil
}
package participation
import (
"context"
"log/slog"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// BadgeEvaluator is the local port for triggering participation-related badge
// evaluation after a participation status change. It is intentionally minimal
// so the participation service does not depend on the full badge use case.
type BadgeEvaluator interface {
EvaluateParticipationBadges(ctx context.Context, userID uuid.UUID) error
}
// Service owns participation-specific application behavior.
type Service struct {
repo Repository
badgeEvaluator BadgeEvaluator
}
var _ UseCase = (*Service)(nil)
// NewService constructs a participation service backed by its own repository.
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
// SetBadgeEvaluator wires in the badge use case so the participation service
// can re-evaluate participation badges after status changes.
func (s *Service) SetBadgeEvaluator(evaluator BadgeEvaluator) {
s.badgeEvaluator = evaluator
}
// 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) {
participation, err := s.repo.CreateParticipation(ctx, eventID, userID)
if err != nil {
return nil, err
}
s.evaluateParticipationBadges(ctx, userID)
return participation, nil
}
// 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
// and returns the user IDs of every participation that was transitioned.
func (s *Service) CancelEventParticipations(ctx context.Context, eventID uuid.UUID) ([]uuid.UUID, error) {
return s.repo.CancelEventParticipations(ctx, eventID)
}
// EvaluateBadgesForEventParticipants runs participant-side badge evaluation for
// every approved participant of the given event. Best-effort per user so a
// transient failure for one participant never blocks the others.
func (s *Service) EvaluateBadgesForEventParticipants(ctx context.Context, eventID uuid.UUID) error {
if s.badgeEvaluator == nil {
return nil
}
userIDs, err := s.repo.ListApprovedParticipantUserIDs(ctx, eventID)
if err != nil {
return err
}
for _, userID := range userIDs {
if err := s.badgeEvaluator.EvaluateParticipationBadges(ctx, userID); err != nil {
slog.WarnContext(ctx, "participation badge evaluation failed",
slog.String("operation", "participation.evaluate_badges_for_event"),
slog.String("event_id", eventID.String()),
slog.String("user_id", userID.String()),
slog.String("error", err.Error()),
)
}
}
return nil
}
// MarkApprovedParticipationsPending moves currently approved non-host
// participations into the reconfirmation state and returns transitioned users.
func (s *Service) MarkApprovedParticipationsPending(ctx context.Context, eventID, hostUserID uuid.UUID) ([]uuid.UUID, error) {
return s.repo.MarkApprovedParticipationsPending(ctx, eventID, hostUserID)
}
// ReconfirmParticipation marks one pending participation approved for the
// current event version.
func (s *Service) ReconfirmParticipation(ctx context.Context, eventID, userID uuid.UUID, eventVersion int) (*domain.Participation, error) {
participation, err := s.repo.ReconfirmParticipation(ctx, eventID, userID, eventVersion)
if err != nil {
return nil, err
}
s.evaluateParticipationBadges(ctx, userID)
return participation, nil
}
// ApprovePendingParticipationsForEvent auto-approves pending reconfirmations
// when an event starts.
func (s *Service) ApprovePendingParticipationsForEvent(ctx context.Context, eventID uuid.UUID) error {
return s.repo.ApprovePendingParticipationsForEvent(ctx, eventID)
}
// evaluateParticipationBadges runs badge evaluation as a best-effort hook so
// transient badge-evaluation failures never fail the parent operation.
func (s *Service) evaluateParticipationBadges(ctx context.Context, userID uuid.UUID) {
if s.badgeEvaluator == nil {
return
}
if err := s.badgeEvaluator.EvaluateParticipationBadges(ctx, userID); err != nil {
slog.WarnContext(ctx, "participation badge evaluation failed",
slog.String("operation", "participation.evaluate_badges"),
slog.String("user_id", userID.String()),
slog.String("error", err.Error()),
)
}
}
package profile
import (
"context"
"errors"
"strings"
"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
passwordHasher PasswordHasher
}
var _ UseCase = (*Service)(nil)
// NewService constructs a profile service backed by its own repository.
func NewService(repo Repository, unitOfWork uow.UnitOfWork, passwordHasher ...PasswordHasher) *Service {
s := &Service{
repo: repo,
unitOfWork: unitOfWork,
}
if len(passwordHasher) > 0 {
s.passwordHasher = passwordHasher[0]
}
return s
}
// 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,
Locale: p.Locale,
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
}
// GetPublicProfile returns the public-safe projection of another user's
// profile, enriched with equipment and showcase images.
func (s *Service) GetPublicProfile(ctx context.Context, userID uuid.UUID) (*PublicProfileResult, error) {
profileRecord, err := s.repo.GetPublicProfile(ctx, userID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.NotFoundError(domain.ErrorCodeUserNotFound, "The requested user does not exist.")
}
return nil, err
}
equipment, err := s.repo.ListEquipment(ctx, userID)
if err != nil {
return nil, err
}
showcaseImages, err := s.repo.ListShowcaseImages(ctx, userID)
if err != nil {
return nil, err
}
return &PublicProfileResult{
UserID: profileRecord.UserID.String(),
Username: profileRecord.Username,
DisplayName: profileRecord.DisplayName,
AvatarURL: profileRecord.AvatarURL,
Bio: profileRecord.Bio,
FinalScore: profileRecord.FinalScore,
HostRatingCount: profileRecord.HostRatingCount,
ParticipantRatingCount: profileRecord.ParticipantRatingCount,
Equipment: toEquipmentItems(equipment),
ShowcaseImages: toShowcaseImageItems(showcaseImages),
}, 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
}
// ListMyEquipment returns the authenticated user's equipment entries.
func (s *Service) ListMyEquipment(ctx context.Context, userID uuid.UUID) (*ListEquipmentResult, error) {
items, err := s.repo.ListEquipment(ctx, userID)
if err != nil {
return nil, err
}
return &ListEquipmentResult{Items: toEquipmentItems(items)}, nil
}
// CreateMyEquipment validates and persists one equipment item for the
// authenticated user.
func (s *Service) CreateMyEquipment(ctx context.Context, input CreateEquipmentInput) (*EquipmentItem, error) {
validated, appErr := validateCreateEquipmentInput(input)
if appErr != nil {
return nil, appErr
}
var created *domain.ProfileEquipment
if err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
created, err = s.repo.CreateEquipment(ctx, CreateEquipmentParams{
UserID: validated.UserID,
Name: validated.Name,
Description: validated.Description,
ImageURL: validated.ImageURL,
})
return err
}); err != nil {
return nil, err
}
return toEquipmentItem(*created), nil
}
// UpdateMyEquipment updates one of the authenticated user's equipment entries.
func (s *Service) UpdateMyEquipment(ctx context.Context, input UpdateEquipmentInput) (*EquipmentItem, error) {
validated, appErr := validateUpdateEquipmentInput(input)
if appErr != nil {
return nil, appErr
}
var updated *domain.ProfileEquipment
if err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
existing, err := s.repo.GetEquipmentByID(ctx, validated.EquipmentID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeProfileEquipmentNotFound, "The requested equipment item does not exist.")
}
return err
}
if existing.UserID != validated.UserID {
return domain.ForbiddenError(domain.ErrorCodeProfileMutationNotAllowed, "You can only modify your own profile resources.")
}
updated, err = s.repo.UpdateEquipment(ctx, UpdateEquipmentParams{
EquipmentID: validated.EquipmentID,
Name: validated.Name,
Description: validated.Description,
ImageURL: validated.ImageURL,
})
if err != nil && errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeProfileEquipmentNotFound, "The requested equipment item does not exist.")
}
return err
}); err != nil {
return nil, err
}
return toEquipmentItem(*updated), nil
}
// DeleteMyEquipment deletes one of the authenticated user's equipment entries.
func (s *Service) DeleteMyEquipment(ctx context.Context, userID, equipmentID uuid.UUID) error {
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
existing, err := s.repo.GetEquipmentByID(ctx, equipmentID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeProfileEquipmentNotFound, "The requested equipment item does not exist.")
}
return err
}
if existing.UserID != userID {
return domain.ForbiddenError(domain.ErrorCodeProfileMutationNotAllowed, "You can only modify your own profile resources.")
}
return s.repo.DeleteEquipment(ctx, equipmentID)
})
}
// DeleteMyShowcaseImage deletes one of the authenticated user's showcase
// images.
func (s *Service) DeleteMyShowcaseImage(ctx context.Context, userID, showcaseImageID uuid.UUID) error {
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
existing, err := s.repo.GetShowcaseImageByID(ctx, showcaseImageID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return domain.NotFoundError(domain.ErrorCodeProfileShowcaseImageNotFound, "The requested showcase image does not exist.")
}
return err
}
if existing.UserID != userID {
return domain.ForbiddenError(domain.ErrorCodeProfileMutationNotAllowed, "You can only modify your own profile resources.")
}
return s.repo.DeleteShowcaseImage(ctx, showcaseImageID)
})
}
// SearchUsers returns lightweight user summaries for username picker UIs.
func (s *Service) SearchUsers(ctx context.Context, input UserSearchInput) (*UserSearchResult, error) {
query := strings.TrimSpace(input.Query)
if len(query) < 1 || len(query) > 32 {
return nil, domain.ValidationError(map[string]string{"query": "must be between 1 and 32 characters"})
}
records, err := s.repo.SearchUsers(ctx, query, 10)
if err != nil {
return nil, err
}
items := make([]UserSearchItem, len(records))
for i, record := range records {
items[i] = UserSearchItem{
ID: record.ID.String(),
Username: record.Username,
DisplayName: record.DisplayName,
AvatarURL: record.AvatarURL,
}
}
return &UserSearchResult{Items: items}, 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
}
func toEquipmentItems(items []domain.ProfileEquipment) []EquipmentItem {
result := make([]EquipmentItem, len(items))
for i, item := range items {
result[i] = EquipmentItem{
ID: item.ID.String(),
Name: item.Name,
Description: item.Description,
ImageURL: item.ImageURL,
}
}
return result
}
func toEquipmentItem(item domain.ProfileEquipment) *EquipmentItem {
return &EquipmentItem{
ID: item.ID.String(),
Name: item.Name,
Description: item.Description,
ImageURL: item.ImageURL,
}
}
func toShowcaseImageItems(items []domain.ProfileShowcaseImage) []ShowcaseImageItem {
result := make([]ShowcaseImageItem, len(items))
for i, item := range items {
result[i] = ShowcaseImageItem{
ID: item.ID.String(),
ImageURL: item.ImageURL,
}
}
return result
}
// ChangePassword verifies the current password and replaces it with the new one.
func (s *Service) ChangePassword(ctx context.Context, input ChangePasswordInput) error {
if err := validateChangePasswordInput(input); err != nil {
return err
}
return s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
currentHash, err := s.repo.GetPasswordHash(ctx, input.UserID)
if err != nil {
return err
}
if err := s.passwordHasher.Compare(currentHash, input.OldPassword); err != nil {
return domain.AuthError(domain.ErrorCodePasswordMismatch, "Current password is incorrect.")
}
newHash, err := s.passwordHasher.Hash(input.NewPassword)
if err != nil {
return err
}
return s.repo.UpdatePasswordHash(ctx, input.UserID, newHash)
})
}
// 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,
Locale: validated.Locale,
DefaultLocationAddress: validated.DefaultLocationAddress,
DefaultLocationLat: validated.DefaultLocationLat,
DefaultLocationLon: validated.DefaultLocationLon,
DisplayName: validated.DisplayName,
Bio: validated.Bio,
AvatarURL: validated.AvatarURL,
})
})
}
package profile
import (
"strconv"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/bounswe/bounswe2026group11/backend/internal/i18n"
)
func validateChangePasswordInput(input ChangePasswordInput) *domain.AppError {
details := make(map[string]string)
if len(input.OldPassword) == 0 {
details["old_password"] = "must not be empty"
}
if len(input.NewPassword) < 8 || len(input.NewPassword) > 128 {
details["new_password"] = "must be between 8 and 128 characters"
}
if input.OldPassword == input.NewPassword && len(details) == 0 {
details["new_password"] = "must differ from current password"
}
if len(details) > 0 {
return domain.ValidationError(details)
}
return nil
}
func validateUpdateProfileInput(input UpdateProfileInput) (*UpdateProfileInput, *domain.AppError) {
detailKeys := 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 {
detailKeys["phone_number"] = "validation.phone_number.too_long"
} 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:
detailKeys["gender"] = "validation.gender.invalid"
}
}
}
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 {
detailKeys["birth_date"] = "validation.birth_date.format"
} else {
out.BirthDate = &trimmed
}
}
}
if input.DefaultLocationAddress != nil {
trimmed := strings.TrimSpace(*input.DefaultLocationAddress)
if trimmed == "" {
out.DefaultLocationAddress = nil
} else if len(trimmed) > 512 {
detailKeys["default_location_address"] = "validation.default_location_address.too_long"
} else {
out.DefaultLocationAddress = &trimmed
}
}
if input.DefaultLocationLat != nil {
lat := *input.DefaultLocationLat
if lat < -90 || lat > 90 {
detailKeys["default_location_lat"] = "validation.default_location_lat.range"
} else {
out.DefaultLocationLat = &lat
}
}
if input.DefaultLocationLon != nil {
lon := *input.DefaultLocationLon
if lon < -180 || lon > 180 {
detailKeys["default_location_lon"] = "validation.default_location_lon.range"
} else {
out.DefaultLocationLon = &lon
}
}
if (input.DefaultLocationLat != nil) != (input.DefaultLocationLon != nil) {
detailKeys["default_location"] = "validation.default_location.lat_lon_pair_required"
}
if input.DisplayName != nil {
trimmed := strings.TrimSpace(*input.DisplayName)
if trimmed == "" {
out.DisplayName = nil
} else if len(trimmed) > 64 {
detailKeys["display_name"] = "validation.display_name.too_long"
} else {
out.DisplayName = &trimmed
}
}
if input.Bio != nil {
trimmed := strings.TrimSpace(*input.Bio)
if trimmed == "" {
out.Bio = nil
} else if len(trimmed) > 512 {
detailKeys["bio"] = "validation.bio.too_long"
} else {
out.Bio = &trimmed
}
}
if input.AvatarURL != nil {
trimmed := strings.TrimSpace(*input.AvatarURL)
if trimmed == "" {
out.AvatarURL = nil
} else if len(trimmed) > 512 {
detailKeys["avatar_url"] = "validation.avatar_url.too_long"
} else {
out.AvatarURL = &trimmed
}
}
if input.Locale != nil {
trimmed := strings.TrimSpace(*input.Locale)
if trimmed == "" {
out.Locale = nil
} else if loc, ok := i18n.Parse(trimmed); ok {
normalized := string(loc)
out.Locale = &normalized
} else {
detailKeys["locale"] = "validation.locale.unsupported"
}
}
if len(detailKeys) > 0 {
return nil, domain.ValidationErrorI18n(detailKeys)
}
return &out, nil
}
func validateCreateEquipmentInput(input CreateEquipmentInput) (*CreateEquipmentInput, *domain.AppError) {
details := make(map[string]string)
name := strings.TrimSpace(input.Name)
if name == "" {
details["name"] = "must not be empty"
} else if len(name) > 64 {
details["name"] = "must be at most 64 characters"
}
description := normalizeOptionalEquipmentText(input.Description, 512, "description", details)
imageURL := normalizeOptionalEquipmentText(input.ImageURL, 512, "image_url", details)
if len(details) > 0 {
return nil, domain.ValidationError(details)
}
return &CreateEquipmentInput{
UserID: input.UserID,
Name: name,
Description: description,
ImageURL: imageURL,
}, nil
}
func validateUpdateEquipmentInput(input UpdateEquipmentInput) (*UpdateEquipmentInput, *domain.AppError) {
details := make(map[string]string)
var (
name *string
description *string
imageURL *string
)
if input.Name != nil {
trimmed := strings.TrimSpace(*input.Name)
if trimmed == "" {
details["name"] = "must not be empty"
} else if len(trimmed) > 64 {
details["name"] = "must be at most 64 characters"
} else {
name = &trimmed
}
}
description = normalizeOptionalEquipmentText(input.Description, 512, "description", details)
imageURL = normalizeOptionalEquipmentText(input.ImageURL, 512, "image_url", details)
if input.Name == nil && input.Description == nil && input.ImageURL == nil {
details["body"] = "must include at least one updatable field"
}
if len(details) > 0 {
return nil, domain.ValidationError(details)
}
return &UpdateEquipmentInput{
UserID: input.UserID,
EquipmentID: input.EquipmentID,
Name: name,
Description: description,
ImageURL: imageURL,
}, nil
}
func normalizeOptionalEquipmentText(value *string, maxLen int, field string, details map[string]string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
if len(trimmed) > maxLen {
details[field] = "must be at most " + strconv.Itoa(maxLen) + " characters"
return nil
}
return &trimmed
}
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"
"log/slog"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// BadgeEvaluator is the local port for triggering rating-driven badge
// evaluation after rating writes. It is intentionally minimal so the rating
// service does not depend on the full badge use case.
type BadgeEvaluator interface {
EvaluateHostBadges(ctx context.Context, hostID uuid.UUID) error
EvaluateParticipationBadges(ctx context.Context, userID uuid.UUID) error
}
// Service owns rating-specific application behavior.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
settings Settings
now func() time.Time
badgeEvaluator BadgeEvaluator
}
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,
}
}
// SetBadgeEvaluator wires in the badge use case so the rating service can
// re-evaluate host and participant badges after rating changes.
func (s *Service) SetBadgeEvaluator(evaluator BadgeEvaluator) {
s.badgeEvaluator = evaluator
}
// 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
hostID uuid.UUID
)
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
}
hostID = ratingContext.HostUserID
return s.refreshUserScore(ctx, s.repo, ratingContext.HostUserID)
})
if err != nil {
return nil, err
}
s.evaluateHostBadges(ctx, hostID)
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
}
s.evaluateParticipationBadges(ctx, participantUserID)
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.PrivacyLevel == domain.PrivacyPrivate {
return domain.ConflictError(domain.ErrorCodeCommentsNotAllowed, "Reviews are available only for PUBLIC and PROTECTED events.")
}
if ratingContext.Status == domain.EventStatusCanceled {
return domain.ConflictError(domain.ErrorCodeReviewNotAllowed, "Reviews are not allowed for canceled events.")
}
if ratingContext.Status != domain.EventStatusCompleted {
return domain.ConflictError(domain.ErrorCodeReviewNotAllowed, "Review comments are allowed only after the event is completed.")
}
if !ratingContext.IsQualifyingParticipant {
return domain.ForbiddenError(domain.ErrorCodeReviewNotAllowed, "Only participants can review this event.")
}
return nil
}
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),
})
}
// RefreshHostedEventScore recalculates the cached score for a host after review
// comment mutations outside the rating service.
func (s *Service) RefreshHostedEventScore(ctx context.Context, hostID uuid.UUID) error {
if err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
return s.refreshUserScore(ctx, s.repo, hostID)
}); err != nil {
return err
}
s.evaluateHostBadges(ctx, hostID)
return nil
}
// evaluateHostBadges runs host-side badge evaluation as a best-effort hook so
// transient failures never fail the parent rating operation.
func (s *Service) evaluateHostBadges(ctx context.Context, hostID uuid.UUID) {
if s.badgeEvaluator == nil {
return
}
if err := s.badgeEvaluator.EvaluateHostBadges(ctx, hostID); err != nil {
slog.WarnContext(ctx, "host badge evaluation failed",
slog.String("operation", "rating.evaluate_host_badges"),
slog.String("host_id", hostID.String()),
slog.String("error", err.Error()),
)
}
}
// evaluateParticipationBadges runs participant-side badge evaluation as a
// best-effort hook so transient failures never fail the parent rating
// operation.
func (s *Service) evaluateParticipationBadges(ctx context.Context, userID uuid.UUID) {
if s.badgeEvaluator == nil {
return
}
if err := s.badgeEvaluator.EvaluateParticipationBadges(ctx, userID); err != nil {
slog.WarnContext(ctx, "participation badge evaluation failed",
slog.String("operation", "rating.evaluate_participation_badges"),
slog.String("user_id", userID.String()),
slog.String("error", err.Error()),
)
}
}
package ticket
import (
"context"
"errors"
"log/slog"
"strings"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
const (
defaultQRTokenTTL = 10 * time.Second
defaultProximityMeters = 200.0
)
var tracer = otel.Tracer("github.com/bounswe/bounswe2026group11/backend/internal/application/ticket")
// Service owns ticket-specific application behavior.
type Service struct {
repo Repository
unitOfWork uow.UnitOfWork
tokens QRTokenManager
settings Settings
now func() time.Time
}
var _ UseCase = (*Service)(nil)
// NewService constructs a ticket service backed by its repository and token manager.
func NewService(repo Repository, unitOfWork uow.UnitOfWork, tokens QRTokenManager, settings Settings) *Service {
if settings.QRTokenTTL <= 0 {
settings.QRTokenTTL = defaultQRTokenTTL
}
if settings.ProximityMeters <= 0 {
settings.ProximityMeters = defaultProximityMeters
}
return &Service{
repo: repo,
unitOfWork: unitOfWork,
tokens: tokens,
settings: settings,
now: time.Now,
}
}
// CreateTicketForParticipation creates the event ticket linked to an approved participation.
func (s *Service) CreateTicketForParticipation(ctx context.Context, participation *domain.Participation, status domain.TicketStatus) (*domain.Ticket, error) {
ctx, span := tracer.Start(ctx, "ticket.create_for_participation")
defer span.End()
span.SetAttributes(
attribute.String("operation", "ticket.create_for_participation"),
attribute.String("ticket_status", status.String()),
)
if participation == nil {
err := errors.New("create ticket: participation is nil")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
slog.ErrorContext(ctx, "ticket creation failed",
"operation", "ticket.create_for_participation",
"ticket_status", status.String(),
"error", err,
)
return nil, err
}
span.SetAttributes(
attribute.String("participation_id", participation.ID.String()),
attribute.String("event_id", participation.EventID.String()),
attribute.String("participant_user_id", participation.UserID.String()),
)
var result *domain.Ticket
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
var err error
result, err = s.repo.CreateTicketForParticipation(ctx, participation.ID, status)
return err
})
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
slog.ErrorContext(ctx, "ticket creation failed",
"operation", "ticket.create_for_participation",
"participation_id", participation.ID.String(),
"event_id", participation.EventID.String(),
"participant_user_id", participation.UserID.String(),
"ticket_status", status.String(),
"error", err,
)
return nil, err
}
span.SetAttributes(attribute.String("ticket_id", result.ID.String()))
slog.InfoContext(ctx, "ticket created",
"operation", "ticket.create_for_participation",
"ticket_id", result.ID.String(),
"participation_id", participation.ID.String(),
"event_id", participation.EventID.String(),
"participant_user_id", participation.UserID.String(),
"ticket_status", result.Status.String(),
)
return result, nil
}
// CancelTicketForParticipation cancels a non-terminal ticket linked to the participation, if present.
func (s *Service) CancelTicketForParticipation(ctx context.Context, participationID uuid.UUID) error {
ctx, span := tracer.Start(ctx, "ticket.cancel_for_participation")
defer span.End()
span.SetAttributes(
attribute.String("operation", "ticket.cancel_for_participation"),
attribute.String("participation_id", participationID.String()),
)
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
return s.repo.CancelTicketForParticipation(ctx, participationID)
})
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
slog.ErrorContext(ctx, "ticket cancellation failed",
"operation", "ticket.cancel_for_participation",
"participation_id", participationID.String(),
"error", err,
)
return err
}
slog.InfoContext(ctx, "ticket canceled for participation",
"operation", "ticket.cancel_for_participation",
"participation_id", participationID.String(),
)
return nil
}
// CancelTicketsForEvent cancels all non-terminal tickets linked to an event.
func (s *Service) CancelTicketsForEvent(ctx context.Context, eventID uuid.UUID) error {
ctx, span := tracer.Start(ctx, "ticket.cancel_for_event")
defer span.End()
span.SetAttributes(
attribute.String("operation", "ticket.cancel_for_event"),
attribute.String("event_id", eventID.String()),
)
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
return s.repo.CancelTicketsForEvent(ctx, eventID)
})
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
slog.ErrorContext(ctx, "event ticket cancellation failed",
"operation", "ticket.cancel_for_event",
"event_id", eventID.String(),
"error", err,
)
return err
}
slog.InfoContext(ctx, "event tickets canceled",
"operation", "ticket.cancel_for_event",
"event_id", eventID.String(),
)
return nil
}
// ExpireTicketsForEvent expires all unused non-terminal tickets linked to an event.
func (s *Service) ExpireTicketsForEvent(ctx context.Context, eventID uuid.UUID) error {
ctx, span := tracer.Start(ctx, "ticket.expire_for_event")
defer span.End()
span.SetAttributes(
attribute.String("operation", "ticket.expire_for_event"),
attribute.String("event_id", eventID.String()),
)
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
return s.repo.ExpireTicketsForEvent(ctx, eventID)
})
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
slog.ErrorContext(ctx, "event ticket expiration failed",
"operation", "ticket.expire_for_event",
"event_id", eventID.String(),
"error", err,
)
return err
}
slog.InfoContext(ctx, "event tickets expired",
"operation", "ticket.expire_for_event",
"event_id", eventID.String(),
)
return nil
}
// MarkTicketsPendingForEvent blocks QR access for tickets whose participants
// must reconfirm event changes.
func (s *Service) MarkTicketsPendingForEvent(ctx context.Context, eventID uuid.UUID) error {
return s.repo.MarkTicketsPendingForEvent(ctx, eventID)
}
// ActivatePendingTicketsForEvent restores QR access after reconfirmation or
// automatic event-start approval.
func (s *Service) ActivatePendingTicketsForEvent(ctx context.Context, eventID uuid.UUID) error {
return s.repo.ActivatePendingTicketsForEvent(ctx, eventID)
}
// ListMyTickets returns tickets owned by the authenticated user.
func (s *Service) ListMyTickets(ctx context.Context, userID uuid.UUID) (*ListTicketsResult, error) {
records, err := s.repo.ListTicketsByUser(ctx, userID)
if err != nil {
return nil, err
}
items := make([]TicketListItem, len(records))
for i, record := range records {
items[i] = toTicketListItem(record)
}
return &ListTicketsResult{Items: items}, nil
}
// GetMyTicket returns a single ticket detail owned by the authenticated user.
func (s *Service) GetMyTicket(ctx context.Context, userID, ticketID uuid.UUID) (*TicketDetailResult, error) {
record, err := s.repo.GetTicketDetail(ctx, userID, ticketID)
if err != nil {
return nil, err
}
if record == nil {
return nil, domain.NotFoundError(domain.ErrorCodeTicketNotFound, "The requested ticket does not exist.")
}
return toTicketDetailResult(*record, s.settings.ProximityMeters, s.now().UTC()), nil
}
// IssueQRToken validates current access and issues a short-lived signed token.
func (s *Service) IssueQRToken(ctx context.Context, userID, ticketID uuid.UUID, input QRTokenInput) (*QRTokenResult, error) {
ctx, span := tracer.Start(ctx, "ticket.issue_qr_token")
defer span.End()
span.SetAttributes(
attribute.String("operation", "ticket.issue_qr_token"),
attribute.String("user_id", userID.String()),
attribute.String("ticket_id", ticketID.String()),
)
var result *QRTokenResult
err := s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
record, err := s.repo.GetTicketAccessForUser(ctx, userID, ticketID, input.Lat, input.Lon, true)
if err != nil {
slog.ErrorContext(ctx, "ticket qr token access lookup failed",
"operation", "ticket.issue_qr_token",
"user_id", userID.String(),
"ticket_id", ticketID.String(),
"error", err,
)
return err
}
if record == nil {
err := domain.NotFoundError(domain.ErrorCodeTicketNotFound, "The requested ticket does not exist.")
slog.InfoContext(ctx, "ticket qr token rejected",
"operation", "ticket.issue_qr_token",
"user_id", userID.String(),
"ticket_id", ticketID.String(),
"reason", domain.ErrorCodeTicketNotFound,
)
return err
}
span.SetAttributes(
attribute.String("event_id", record.EventID.String()),
attribute.String("participation_id", record.Participation.ID.String()),
)
if err := validateQRAccess(*record, s.settings.ProximityMeters, s.now().UTC()); err != nil {
slog.InfoContext(ctx, "ticket qr token rejected",
"operation", "ticket.issue_qr_token",
"user_id", userID.String(),
"ticket_id", ticketID.String(),
"event_id", record.EventID.String(),
"participation_id", record.Participation.ID.String(),
"ticket_status", record.Ticket.Status.String(),
"participation_status", record.Participation.Status.String(),
"event_status", string(record.EventStatus),
"error", err,
)
return err
}
issuedAt := s.now().UTC()
expiresAt := issuedAt.Add(s.settings.QRTokenTTL)
version := record.Ticket.QRTokenVersion + 1
token, err := s.tokens.Issue(QRTokenClaims{
TicketID: record.Ticket.ID,
ParticipationID: record.Participation.ID,
EventID: record.EventID,
UserID: userID,
Version: version,
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
})
if err != nil {
slog.ErrorContext(ctx, "ticket qr token signing failed",
"operation", "ticket.issue_qr_token",
"user_id", userID.String(),
"ticket_id", ticketID.String(),
"event_id", record.EventID.String(),
"participation_id", record.Participation.ID.String(),
"version", version,
"error", err,
)
return err
}
if err := s.repo.StoreIssuedToken(ctx, record.Ticket.ID, version, s.tokens.Hash(token)); err != nil {
slog.ErrorContext(ctx, "ticket qr token persistence failed",
"operation", "ticket.issue_qr_token",
"user_id", userID.String(),
"ticket_id", ticketID.String(),
"event_id", record.EventID.String(),
"participation_id", record.Participation.ID.String(),
"version", version,
"error", err,
)
return err
}
result = &QRTokenResult{
Token: token,
ExpiresAt: expiresAt,
Version: version,
}
return nil
})
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
span.SetAttributes(attribute.Int("ticket_qr_version", result.Version))
slog.InfoContext(ctx, "ticket qr token issued",
"operation", "ticket.issue_qr_token",
"user_id", userID.String(),
"ticket_id", ticketID.String(),
"version", result.Version,
)
return result, nil
}
// ScanTicket verifies a scanned QR token and marks its ticket USED when all checks pass.
func (s *Service) ScanTicket(ctx context.Context, hostUserID, eventID uuid.UUID, input ScanTicketInput) (*ScanTicketResult, error) {
ctx, span := tracer.Start(ctx, "ticket.scan")
defer span.End()
span.SetAttributes(
attribute.String("operation", "ticket.scan"),
attribute.String("host_user_id", hostUserID.String()),
attribute.String("event_id", eventID.String()),
)
token := strings.TrimSpace(input.QRToken)
if token == "" {
logTicketScanRejected(ctx, hostUserID, eventID, uuid.Nil, RejectReasonInvalidToken)
return rejected(RejectReasonInvalidToken), nil
}
claims, err := s.tokens.Verify(token)
if err != nil {
logTicketScanRejected(ctx, hostUserID, eventID, uuid.Nil, RejectReasonInvalidToken)
return rejected(RejectReasonInvalidToken), nil
}
span.SetAttributes(
attribute.String("ticket_id", claims.TicketID.String()),
attribute.String("participation_id", claims.ParticipationID.String()),
attribute.String("participant_user_id", claims.UserID.String()),
attribute.Int("ticket_qr_version", claims.Version),
)
if claims.EventID != eventID {
logTicketScanRejected(ctx, hostUserID, eventID, claims.TicketID, RejectReasonEventMismatch)
return rejected(RejectReasonEventMismatch), nil
}
var result *ScanTicketResult
err = s.unitOfWork.RunInTx(ctx, func(ctx context.Context) error {
record, err := s.repo.GetTicketForScan(ctx, eventID, claims.TicketID, true)
if err != nil {
slog.ErrorContext(ctx, "ticket scan lookup failed",
"operation", "ticket.scan",
"host_user_id", hostUserID.String(),
"event_id", eventID.String(),
"ticket_id", claims.TicketID.String(),
"error", err,
)
return err
}
if record == nil {
result = rejected(RejectReasonTicketNotFound)
logTicketScanRejected(ctx, hostUserID, eventID, claims.TicketID, RejectReasonTicketNotFound)
return nil
}
if record.HostID != hostUserID {
err := domain.ForbiddenError(domain.ErrorCodeEventHostManagementNotAllowed, "Only the event host can scan tickets for this event.")
slog.InfoContext(ctx, "ticket scan rejected",
"operation", "ticket.scan",
"host_user_id", hostUserID.String(),
"event_id", eventID.String(),
"ticket_id", record.Ticket.ID.String(),
"participation_id", record.Participation.ID.String(),
"reason", domain.ErrorCodeEventHostManagementNotAllowed,
)
return err
}
if reason := scanRejectReason(*record, *claims, s.tokens.Hash(token)); reason != "" {
result = rejected(reason)
logTicketScanRejected(ctx, hostUserID, eventID, record.Ticket.ID, reason,
"participation_id", record.Participation.ID.String(),
"participant_user_id", record.UserID.String(),
"ticket_status", record.Ticket.Status.String(),
"participation_status", record.Participation.Status.String(),
"event_status", string(record.EventStatus),
)
return nil
}
used, err := s.repo.MarkTicketUsed(ctx, record.Ticket.ID)
if err != nil {
slog.ErrorContext(ctx, "ticket usage persistence failed",
"operation", "ticket.scan",
"host_user_id", hostUserID.String(),
"event_id", eventID.String(),
"ticket_id", record.Ticket.ID.String(),
"participation_id", record.Participation.ID.String(),
"error", err,
)
return err
}
result = accepted(*record, used.Status)
return nil
})
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if result != nil {
span.SetAttributes(attribute.String("ticket_scan_result", result.Result))
if result.Reason != nil {
span.SetAttributes(attribute.String("ticket_scan_reject_reason", *result.Reason))
}
}
if result != nil && result.Result == ScanResultAccepted {
slog.InfoContext(ctx, "ticket scan accepted",
"operation", "ticket.scan",
"host_user_id", hostUserID.String(),
"event_id", eventID.String(),
"ticket_id", claims.TicketID.String(),
"participation_id", claims.ParticipationID.String(),
"participant_user_id", claims.UserID.String(),
)
}
return result, nil
}
func logTicketScanRejected(ctx context.Context, hostUserID, eventID, ticketID uuid.UUID, reason string, attrs ...any) {
fields := []any{
"operation", "ticket.scan",
"host_user_id", hostUserID.String(),
"event_id", eventID.String(),
"reason", reason,
}
if ticketID != uuid.Nil {
fields = append(fields, "ticket_id", ticketID.String())
}
fields = append(fields, attrs...)
slog.InfoContext(ctx, "ticket scan rejected", fields...)
}
func validateQRAccess(record TicketAccessRecord, proximityMeters float64, now time.Time) error {
if record.Ticket.Status != domain.TicketStatusActive {
return domain.ConflictError(domain.ErrorCodeTicketNotActive, "Only ACTIVE tickets can issue QR tokens.")
}
if record.Participation.Status != domain.ParticipationStatusApproved {
return domain.ConflictError(domain.ErrorCodeTicketNotActive, "The linked participation is not approved.")
}
if record.EventStatus != domain.EventStatusActive && record.EventStatus != domain.EventStatusInProgress {
return domain.ConflictError(domain.ErrorCodeTicketNotActive, "The event is not accepting ticket access.")
}
if !record.Ticket.ExpiresAt.After(now) {
return domain.ConflictError(domain.ErrorCodeTicketNotActive, "The ticket has expired.")
}
if record.DistanceMeters > proximityMeters {
return domain.ForbiddenError(domain.ErrorCodeTicketProximityRequired, "You must be near the event location to show this ticket QR.")
}
return nil
}
func scanRejectReason(record TicketScanRecord, claims QRTokenClaims, tokenHash string) string {
if record.Participation.ID != claims.ParticipationID {
return RejectReasonParticipationMismatch
}
if record.UserID != claims.UserID {
return RejectReasonInvalidToken
}
if record.Ticket.Status == domain.TicketStatusUsed {
return RejectReasonTicketAlreadyUsed
}
if record.Ticket.Status != domain.TicketStatusActive {
return RejectReasonTicketNotActive
}
if record.Participation.Status != domain.ParticipationStatusApproved {
return RejectReasonParticipationInvalid
}
if record.EventStatus != domain.EventStatusActive && record.EventStatus != domain.EventStatusInProgress {
return RejectReasonEventInvalid
}
if claims.Version != record.Ticket.QRTokenVersion {
return RejectReasonTokenOldVersion
}
if record.Ticket.LastIssuedQRTokenHash == nil || *record.Ticket.LastIssuedQRTokenHash != tokenHash {
return RejectReasonTokenNotLatest
}
return ""
}
func rejected(reason string) *ScanTicketResult {
return &ScanTicketResult{
Result: ScanResultRejected,
Reason: &reason,
}
}
func accepted(record TicketScanRecord, status domain.TicketStatus) *ScanTicketResult {
ticketID := record.Ticket.ID.String()
participationID := record.Participation.ID.String()
userID := record.UserID.String()
return &ScanTicketResult{
Result: ScanResultAccepted,
TicketID: &ticketID,
ParticipationID: &participationID,
UserID: &userID,
TicketStatus: &status,
}
}
func toTicketListItem(record TicketRecord) TicketListItem {
return TicketListItem{
TicketID: record.Ticket.ID.String(),
Status: record.Ticket.Status,
ExpiresAt: record.Ticket.ExpiresAt,
Event: toTicketEventSummary(record),
Participation: toTicketParticipationInfo(record.Participation),
}
}
func toTicketDetailResult(record TicketRecord, proximityMeters float64, now time.Time) *TicketDetailResult {
return &TicketDetailResult{
Ticket: TicketInfo{
ID: record.Ticket.ID.String(),
Status: record.Ticket.Status,
ExpiresAt: record.Ticket.ExpiresAt,
UsedAt: record.Ticket.UsedAt,
CreatedAt: record.Ticket.CreatedAt,
UpdatedAt: record.Ticket.UpdatedAt,
},
Participation: toTicketParticipationInfo(record.Participation),
Event: toTicketEventSummary(record),
Location: TicketLocationSummary{
Type: record.LocationType,
Address: record.Address,
AnchorLat: record.Anchor.Lat,
AnchorLon: record.Anchor.Lon,
},
QRAccess: buildQRAccessInfo(record, proximityMeters, now),
}
}
func toTicketParticipationInfo(participation domain.Participation) TicketParticipationInfo {
return TicketParticipationInfo{
ID: participation.ID.String(),
Status: participation.Status,
}
}
func toTicketEventSummary(record TicketRecord) TicketEventSummary {
return TicketEventSummary{
ID: record.EventID.String(),
Title: record.EventTitle,
Status: record.EventStatus,
PrivacyLevel: record.PrivacyLevel,
StartTime: record.StartTime,
EndTime: record.EndTime,
LocationType: record.LocationType,
Address: record.Address,
}
}
func buildQRAccessInfo(record TicketRecord, proximityMeters float64, now time.Time) QRAccessInfo {
reason := qrAccessReason(record, now)
return QRAccessInfo{
RequiresLocationPermission: true,
RequiresProximity: true,
ProximityMeters: proximityMeters,
EligibleNow: reason == nil,
Reason: reason,
}
}
func qrAccessReason(record TicketRecord, now time.Time) *string {
var reason string
switch {
case record.Ticket.Status == domain.TicketStatusPending:
reason = "PARTICIPATION_PENDING_REAPPROVAL"
case record.Ticket.Status != domain.TicketStatusActive:
reason = "TICKET_" + record.Ticket.Status.String()
case record.Participation.Status != domain.ParticipationStatusApproved:
reason = "PARTICIPATION_" + record.Participation.Status.String()
case record.EventStatus != domain.EventStatusActive && record.EventStatus != domain.EventStatusInProgress:
reason = "EVENT_" + string(record.EventStatus)
case !record.Ticket.ExpiresAt.After(now):
reason = "TICKET_EXPIRED"
default:
return nil
}
return &reason
}
package bootstrap
import (
"context"
"fmt"
"log/slog"
"time"
emailadapter "github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/email"
pushadapter "github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/firebasepush"
"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/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/application/admin"
"github.com/bounswe/bounswe2026group11/backend/internal/application/auth"
"github.com/bounswe/bounswe2026group11/backend/internal/application/badge"
"github.com/bounswe/bounswe2026group11/backend/internal/application/category"
"github.com/bounswe/bounswe2026group11/backend/internal/application/comment"
emailapp "github.com/bounswe/bounswe2026group11/backend/internal/application/email"
"github.com/bounswe/bounswe2026group11/backend/internal/application/event"
"github.com/bounswe/bounswe2026group11/backend/internal/application/eventreport"
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/invitation"
"github.com/bounswe/bounswe2026group11/backend/internal/application/join_request"
"github.com/bounswe/bounswe2026group11/backend/internal/application/notification"
"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/ticket"
"github.com/bounswe/bounswe2026group11/backend/internal/application/uow"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/bounswe/bounswe2026group11/backend/internal/i18n"
i18nlocales "github.com/bounswe/bounswe2026group11/backend/internal/i18n/locales"
"github.com/bounswe/bounswe2026group11/backend/internal/infrastructure/config"
"github.com/bounswe/bounswe2026group11/backend/internal/infrastructure/database"
"github.com/google/uuid"
"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
Translator *i18n.Catalog
authRepo *postgres.AuthRepository
adminRepo *postgres.AdminRepository
eventRepo *postgres.EventRepository
participationRepo *postgres.ParticipationRepository
invitationRepo *postgres.InvitationRepository
joinRequestRepo *postgres.JoinRequestRepository
ticketRepo *postgres.TicketRepository
ratingRepo *postgres.RatingRepository
commentRepo *postgres.CommentRepository
eventReportRepo *postgres.EventReportRepository
notificationRepo *postgres.NotificationRepository
categoryRepo *postgres.CategoryRepository
profileRepo *postgres.ProfileRepository
favoriteLocationRepo *postgres.FavoriteLocationRepository
badgeRepo *postgres.BadgeRepository
AuthService auth.UseCase
AdminService admin.UseCase
EventService event.UseCase
ParticipationService participation.UseCase
InvitationService invitation.UseCase
JoinRequestService join_request.UseCase
TicketService ticket.UseCase
RatingService rating.UseCase
CommentService comment.UseCase
EventReportService eventreport.UseCase
NotificationService notification.UseCase
NotificationBroker *notification.Broker
CategoryService category.UseCase
ProfileService profile.UseCase
FavoriteLocationService favoritelocation.UseCase
ImageUploadService imageupload.UseCase
BadgeService badge.UseCase
// Extend with additional services as business-flows 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)
translator, err := i18n.LoadFromFS(i18nlocales.FS, i18nlocales.Dir)
if err != nil {
db.Close()
return nil, fmt.Errorf("load i18n catalog: %w", err)
}
container := &Container{
Config: cfg,
DB: db,
UnitOfWork: postgres.NewUnitOfWork(db),
MailProvider: mailProvider,
TokenIssuer: buildTokenIssuer(cfg),
TokenVerifier: buildTokenVerifier(cfg),
Translator: translator,
}
container.authRepo = postgres.NewAuthRepository(container.DB)
container.adminRepo = postgres.NewAdminRepository(container.DB)
container.eventRepo = postgres.NewEventRepository(container.DB)
container.participationRepo = postgres.NewParticipationRepository(container.DB)
container.invitationRepo = postgres.NewInvitationRepository(container.DB)
container.joinRequestRepo = postgres.NewJoinRequestRepository(container.DB)
container.ticketRepo = postgres.NewTicketRepository(container.DB)
container.ratingRepo = postgres.NewRatingRepository(container.DB)
container.commentRepo = postgres.NewCommentRepository(container.DB)
container.eventReportRepo = postgres.NewEventReportRepository(container.DB)
container.notificationRepo = postgres.NewNotificationRepository(container.DB)
container.NotificationBroker = notification.NewBroker()
container.categoryRepo = postgres.NewCategoryRepository(container.DB)
container.profileRepo = postgres.NewProfileRepository(container.DB)
container.favoriteLocationRepo = postgres.NewFavoriteLocationRepository(container.DB)
container.badgeRepo = postgres.NewBadgeRepository(container.DB)
notificationService, err := newNotificationService(ctx, container)
if err != nil {
db.Close()
return nil, err
}
container.NotificationService = notificationService
container.BadgeService = newBadgeService(container)
container.ParticipationService = newParticipationService(container)
container.TicketService = newTicketService(container)
container.InvitationService = newInvitationService(container)
container.JoinRequestService = newJoinRequestService(container)
container.RatingService = newRatingService(container)
container.CommentService = newCommentService(container)
container.EventReportService = newEventReportService(container)
container.AuthService = newAuthService(container)
container.AdminService = newAdminService(container)
container.EventService = newEventService(container)
container.CategoryService = newCategoryService(container)
container.ProfileService = newProfileService(container)
container.FavoriteLocationService = newFavoriteLocationService(container)
container.ImageUploadService = newImageUploadService(container, spacesStorage)
if eventService, ok := container.EventService.(*event.Service); ok {
eventService.SetJoinRequestImageConfirmer(container.ImageUploadService)
}
if commentService, ok := container.CommentService.(*comment.Service); ok {
commentService.SetReviewImageConfirmer(container.ImageUploadService)
}
if eventReportService, ok := container.EventReportService.(*eventreport.Service); ok {
eventReportService.SetReportImageConfirmer(container.ImageUploadService)
}
return container, nil
}
// StartEventExpiryJob immediately transitions event statuses
// (ACTIVE → IN_PROGRESS → COMPLETED) and evaluates participation badges for
// participants of any newly-completed events, then repeats every interval
// until ctx is cancelled.
func (c *Container) StartEventExpiryJob(ctx context.Context, interval time.Duration) {
expire := func() {
if err := c.EventService.TransitionEventStatuses(ctx); err != nil {
slog.ErrorContext(ctx, "event status transition job failed", "error", err)
}
}
expire()
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
expire()
case <-ctx.Done():
return
}
}
}()
}
// StartNotificationRetentionJob deletes inbox notifications older than the
// configured retention window, then repeats until ctx is cancelled.
func (c *Container) StartNotificationRetentionJob(ctx context.Context, interval time.Duration) {
purge := func() {
deleted, err := c.NotificationService.DeleteExpiredNotifications(ctx)
if err != nil {
slog.ErrorContext(ctx, "notification retention job failed", "error", err)
return
}
if deleted > 0 {
slog.InfoContext(ctx, "expired notifications deleted",
"operation", "notification.retention.delete_expired",
"deleted_count", deleted,
)
}
}
purge()
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
purge()
case <-ctx.Done():
return
}
}
}()
}
// RunBadgeBackfill recomputes badge state for users who may already qualify
// from historical data created before badge-trigger hooks existed.
func (c *Container) RunBadgeBackfill(ctx context.Context) error {
if c == nil || c.BadgeService == nil {
return nil
}
return c.BadgeService.BackfillExistingBadges(ctx)
}
// Close releases all long-lived resources (e.g. database connections).
func (c *Container) Close() {
if c == nil || c.DB == nil {
return
}
c.DB.Close()
}
// LocalePreferenceLookup returns the function used by the HTTP layer to
// resolve a user's saved locale preference as a fallback for requests
// without an Accept-Language header. Lookup failures are intentionally
// swallowed (logged would be too noisy at this seam): the caller falls
// back to i18n.DefaultLocale.
func (c *Container) LocalePreferenceLookup() httpapi.LocalePreferenceLookup {
return func(ctx context.Context, userID uuid.UUID) (i18n.Locale, bool) {
raw, err := c.profileRepo.GetLocale(ctx, userID)
if err != nil {
return "", false
}
loc, ok := i18n.Parse(raw)
if !ok {
return "", false
}
return loc, true
}
}
// 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,
})
}
func buildPushSender(ctx context.Context, cfg *config.Config) (notification.PushSender, error) {
switch cfg.PushProvider {
case "mock":
return pushadapter.MockSender{}, nil
case "firebase":
return pushadapter.NewSender(ctx, cfg.FirebaseCredentialsFile, cfg.FirebaseServiceAccountJSONBase64)
default:
return nil, fmt.Errorf("unsupported push provider %q", cfg.PushProvider)
}
}
// newAdminService wires the admin backoffice use case with its read repository.
func newAdminService(c *Container) admin.UseCase {
return admin.NewService(c.adminRepo, admin.WithMutationDependencies(c.NotificationService, c.TicketService, c.UnitOfWork))
}
// newEventService wires the event use case with its driven adapters.
func newEventService(c *Container) event.UseCase {
service := event.NewService(c.eventRepo, c.ParticipationService, c.JoinRequestService, c.UnitOfWork, c.TicketService)
service.SetNotificationService(c.NotificationService)
service.SetBadgeEvaluator(c.BadgeService)
return service
}
// newParticipationService wires the participation use-case service with its
// driven adapter.
func newParticipationService(c *Container) participation.UseCase {
service := participation.NewService(c.participationRepo)
service.SetBadgeEvaluator(c.BadgeService)
return service
}
func newInvitationService(c *Container) invitation.UseCase {
service := invitation.NewService(c.invitationRepo, c.UnitOfWork, c.TicketService)
service.SetNotificationService(c.NotificationService)
return service
}
// newJoinRequestService wires the join request use-case service with its
// driven adapter.
func newJoinRequestService(c *Container) join_request.UseCase {
service := join_request.NewService(c.joinRequestRepo, c.UnitOfWork, c.TicketService)
service.SetNotificationService(c.NotificationService)
return service
}
// newTicketService wires the ticket use-case service with its driven adapters.
func newTicketService(c *Container) ticket.UseCase {
return ticket.NewService(
c.ticketRepo,
c.UnitOfWork,
jwtadapter.TicketTokenManager{Secret: []byte(c.Config.JWTSecret)},
ticket.Settings{
QRTokenTTL: 10 * time.Second,
ProximityMeters: 200,
},
)
}
// 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, hasher.BcryptHasher{})
}
// newFavoriteLocationService wires the favorite-location use-case service with its driven adapter.
func newFavoriteLocationService(c *Container) favoritelocation.UseCase {
service := favoritelocation.NewService(c.favoriteLocationRepo, c.UnitOfWork)
service.SetBadgeEvaluator(c.BadgeService)
return service
}
// newBadgeService wires the badge use-case service with its driven adapter.
func newBadgeService(c *Container) badge.UseCase {
return badge.NewService(c.badgeRepo)
}
func newNotificationService(ctx context.Context, c *Container) (notification.UseCase, error) {
sender, err := buildPushSender(ctx, c.Config)
if err != nil {
return nil, fmt.Errorf("build push sender: %w", err)
}
service := notification.NewService(c.notificationRepo, sender, c.UnitOfWork, c.NotificationBroker)
service.SetTranslator(c.Translator)
return service, nil
}
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 {
service := rating.NewService(c.ratingRepo, c.UnitOfWork, rating.Settings{
GlobalPrior: c.Config.RatingGlobalPrior,
BayesianM: c.Config.RatingBayesianM,
})
service.SetBadgeEvaluator(c.BadgeService)
return service
}
// newCommentService wires the comment use-case service with its driven adapter.
func newCommentService(c *Container) comment.UseCase {
service := comment.NewService(c.commentRepo, c.UnitOfWork)
service.SetReviewScoreUpdater(c.RatingService)
return service
}
// newEventReportService wires the event-report use-case service with its driven adapter.
func newEventReportService(c *Container) eventreport.UseCase {
return eventreport.NewService(c.eventReportRepo)
}
// newAuthService wires the auth use-case service with its driven adapters.
func newAuthService(c *Container) auth.UseCase {
service := 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,
},
)
service.SetPushDeviceRevoker(c.NotificationService)
return service
}
package domain
import (
"time"
"github.com/google/uuid"
)
// BadgeCategory groups badges by the kind of activity they reward.
type BadgeCategory string
const (
BadgeCategoryHosting BadgeCategory = "HOSTING"
BadgeCategoryParticipation BadgeCategory = "PARTICIPATION"
BadgeCategorySocial BadgeCategory = "SOCIAL"
)
func (c BadgeCategory) String() string { return string(c) }
// BadgeSlug is the stable, machine-friendly identifier of a badge definition.
// Slugs are seeded in migration 000026 and must stay UPPERCASE.
type BadgeSlug string
const (
BadgeSlugFirstSteps BadgeSlug = "FIRST_STEPS"
BadgeSlugRegular BadgeSlug = "REGULAR"
BadgeSlugVeteran BadgeSlug = "VETERAN"
BadgeSlugExplorer BadgeSlug = "EXPLORER"
BadgeSlugHostDebut BadgeSlug = "HOST_DEBUT"
BadgeSlugSuperHost BadgeSlug = "SUPER_HOST"
BadgeSlugTopRated BadgeSlug = "TOP_RATED"
BadgeSlugFavoriteFinder BadgeSlug = "FAVORITE_FINDER"
)
func (s BadgeSlug) String() string { return string(s) }
// Badge is the catalog definition of a badge that users can earn.
type Badge struct {
ID int16
Slug BadgeSlug
Name string
Description string
IconURL *string
Category BadgeCategory
SortOrder int16
}
// UserBadge represents one badge earned by a user.
type UserBadge struct {
UserID uuid.UUID
BadgeID int16
Slug BadgeSlug
EarnedAt time.Time
Definition Badge
}
package domain
import (
"time"
"github.com/google/uuid"
)
const (
CommentMessageMinLength = 1
CommentMessageMaxLength = 1000
)
const (
ErrorCodeCommentsNotAllowed = "comments_not_allowed"
ErrorCodeCommentNotFound = "comment_not_found"
ErrorCodeCommentWriteNotAllowed = "comment_write_not_allowed"
ErrorCodeReviewNotAllowed = "review_not_allowed"
ErrorCodeReviewImageNotAllowed = "review_image_not_allowed"
)
// CommentType defines whether an event comment belongs to discussion or review
// content.
type CommentType string
const (
CommentTypeDiscussion CommentType = "DISCUSSION"
CommentTypeReview CommentType = "REVIEW"
)
var commentTypes = map[string]CommentType{
string(CommentTypeDiscussion): CommentTypeDiscussion,
string(CommentTypeReview): CommentTypeReview,
}
// ParseCommentType converts a wire string to a CommentType.
func ParseCommentType(value string) (CommentType, bool) {
commentType, ok := commentTypes[value]
return commentType, ok
}
// EventComment stores a discussion comment or completed-event review.
type EventComment struct {
ID uuid.UUID
UserID uuid.UUID
EventID uuid.UUID
Type CommentType
Message string
ParentID *uuid.UUID
Rating *int
ImageURL *string
LikesCount int
ReplyCount int
CreatedAt time.Time
UpdatedAt time.Time
}
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"
ErrorCodeUserNotFound = "user_not_found"
ErrorCodeEventNotFound = "event_not_found"
ErrorCodeAlreadyParticipating = "already_participating"
ErrorCodeParticipationNotFound = "participation_not_found"
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"
ErrorCodeInvitationNotFound = "invitation_not_found"
ErrorCodeInvitationNotAllowed = "invitation_not_allowed"
ErrorCodeInvitationStateInvalid = "invitation_state_invalid"
ErrorCodeInvitationCooldownActive = "invitation_cooldown_active"
ErrorCodeEventCancelNotAllowed = "event_cancel_not_allowed"
ErrorCodeEventNotCancelable = "event_not_cancelable"
ErrorCodeEventCompleteNotAllowed = "event_complete_not_allowed"
ErrorCodeEventNotCompletable = "event_not_completable"
ErrorCodeEventNotEditable = "event_not_editable"
ErrorCodeEventCanceled = "event_canceled"
ErrorCodeEventNotJoinable = "event_not_joinable"
ErrorCodeEventLeaveNotAllowed = "event_leave_not_allowed"
ErrorCodeEventNotLeaveable = "event_not_leaveable"
ErrorCodeCapacityBelowParticipantCount = "capacity_below_participant_count"
ErrorCodeParticipationReconfirmNotAllowed = "participation_reconfirm_not_allowed"
ErrorCodeFavoriteLocationNotFound = "favorite_location_not_found"
ErrorCodeFavoriteLocationLimitExceeded = "favorite_location_limit_exceeded"
ErrorCodeAgeRequirementNotMet = "age_requirement_not_met"
ErrorCodeGenderRequirementNotMet = "gender_requirement_not_met"
ErrorCodeProfileIncomplete = "profile_incomplete"
ErrorCodeProfileMutationNotAllowed = "profile_mutation_not_allowed"
ErrorCodeProfileEquipmentNotFound = "profile_equipment_not_found"
ErrorCodeProfileShowcaseImageNotFound = "profile_showcase_image_not_found"
ErrorCodeImageUploadTokenInvalid = "image_upload_token_invalid"
ErrorCodeImageUploadNotAllowed = "image_upload_not_allowed"
ErrorCodeImageUploadIncomplete = "image_upload_incomplete"
ErrorCodeImageUploadVersionConflict = "image_upload_version_conflict"
ErrorCodeTicketNotFound = "ticket_not_found"
ErrorCodeTicketNotActive = "ticket_not_active"
ErrorCodeTicketNotOwned = "ticket_not_owned"
ErrorCodeTicketScanRejected = "ticket_scan_rejected"
ErrorCodeTicketProximityRequired = "ticket_proximity_required"
ErrorCodeTicketClientNotSupported = "ticket_client_not_supported"
ErrorCodeNotificationNotFound = "notification_not_found"
ErrorCodeAdminAccessRequired = "admin_access_required"
ErrorCodeAdminDependencyUnavailable = "admin_dependency_unavailable"
ErrorCodePasswordMismatch = "password_mismatch"
)
// 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.
//
// MessageKey/DetailKeys, when set, point to entries in the i18n catalog and
// are resolved against the request locale by the HTTP layer. They take
// precedence over Message/Details. Existing constructors that set only
// Message/Details remain backward compatible.
type AppError struct {
Code string
Message string
Status int
Details map[string]string
MessageKey string
DetailKeys 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,
}
}
// ValidationErrorI18n creates a 400 Bad Request whose per-field details are
// catalog keys. The HTTP layer resolves them against the request locale.
// The top-level message uses the standard validation key.
func ValidationErrorI18n(detailKeys map[string]string) *AppError {
return &AppError{
Code: ErrorCodeValidation,
MessageKey: "error.validation",
Message: "The request body contains invalid fields. See error.details for field-specific messages.",
Status: StatusBadRequest,
DetailKeys: detailKeys,
}
}
// 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,
}
}
// BadRequestErrorI18n is the i18n-aware variant of BadRequestError.
func BadRequestErrorI18n(code, messageKey string) *AppError {
return &AppError{
Code: code,
MessageKey: messageKey,
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,
}
}
// AuthErrorI18n is the i18n-aware variant of AuthError.
func AuthErrorI18n(code, messageKey string) *AppError {
return &AppError{
Code: code,
MessageKey: messageKey,
Status: StatusUnauthorized,
}
}
// ForbiddenErrorI18n is the i18n-aware variant of ForbiddenError.
func ForbiddenErrorI18n(code, messageKey string) *AppError {
return &AppError{
Code: code,
MessageKey: messageKey,
Status: StatusForbidden,
}
}
// 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 (
"crypto/rand"
"encoding/binary"
"math"
"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
}
// 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"
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,
}
var eventStatuses = map[string]EventStatus{
string(EventStatusActive): EventStatusActive,
string(EventStatusInProgress): EventStatusInProgress,
string(EventStatusCanceled): EventStatusCanceled,
string(EventStatusCompleted): EventStatusCompleted,
}
// 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
}
// ParseEventStatus converts a wire string to an EventStatus.
func ParseEventStatus(value string) (EventStatus, bool) {
status, ok := eventStatuses[value]
return status, ok
}
func (s EventStatus) String() string {
return string(s)
}
// GeoPoint is a single WGS84 coordinate used for event locations.
type GeoPoint struct {
Lat float64
Lon float64
}
// ApproximateGeoPoint returns a random point within a 250 m radius of p so
// that protected event locations cannot be pinpointed while still placing the
// marker in the right neighbourhood. Using a random offset (rather than a
// fixed grid snap) prevents multiple nearby events from collapsing to the same
// map pin.
func ApproximateGeoPoint(p GeoPoint) GeoPoint {
const (
radiusMeters = 250.0
metersPerDegreeLat = 111320.0
)
// Uniform distribution inside a circle: scale by sqrt to avoid
// concentrating points near the centre.
angle := randomFloat64() * 2 * math.Pi
distance := math.Sqrt(randomFloat64()) * radiusMeters
deltaLat := distance * math.Cos(angle) / metersPerDegreeLat
deltaLon := distance * math.Sin(angle) / (metersPerDegreeLat * math.Cos(p.Lat*math.Pi/180))
return GeoPoint{
Lat: p.Lat + deltaLat,
Lon: p.Lon + deltaLon,
}
}
func randomFloat64() float64 {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return 0.5
}
return float64(binary.BigEndian.Uint64(buf[:])) / float64(^uint64(0))
}
// Event is the core event entity.
type Event struct {
ID uuid.UUID
HostID uuid.UUID
VersionNo int
Title string
Description *string
ImageURL *string
CategoryID *int
StartTime time.Time
EndTime *time.Time
PrivacyLevel EventPrivacyLevel
Status EventStatus
Capacity *int
ApprovedParticipantCount int
PendingParticipantCount int
MinimumAge *int
PreferredGender *EventParticipantGender
ChildFriendly bool
FamilyOriented bool
LocationType *EventLocationType
CreatedAt time.Time
UpdatedAt time.Time
}
package domain
import (
"time"
"github.com/google/uuid"
)
const (
EventReportMessageMinLength = 1
EventReportMessageMaxLength = 1000
)
const (
ErrorCodeEventReportNotAllowed = "event_report_not_allowed"
ErrorCodeEventReportImageNotAllowed = "event_report_image_not_allowed"
)
// EventReportCategory defines the reason selected by a user when reporting an event.
type EventReportCategory string
const (
EventReportCategorySafety EventReportCategory = "SAFETY"
EventReportCategoryHarassment EventReportCategory = "HARASSMENT"
EventReportCategorySpamOrScam EventReportCategory = "SPAM_OR_SCAM"
EventReportCategoryInappropriateContent EventReportCategory = "INAPPROPRIATE_CONTENT"
EventReportCategoryEventNotAsDescribed EventReportCategory = "EVENT_NOT_AS_DESCRIBED"
EventReportCategoryIllegalOrDangerous EventReportCategory = "ILLEGAL_OR_DANGEROUS"
EventReportCategoryOther EventReportCategory = "OTHER"
)
// EventReportStatus defines the moderation state for a submitted report.
type EventReportStatus string
const (
EventReportStatusPending EventReportStatus = "PENDING"
EventReportStatusReviewed EventReportStatus = "REVIEWED"
EventReportStatusDismissed EventReportStatus = "DISMISSED"
)
var eventReportCategories = map[string]EventReportCategory{
string(EventReportCategorySafety): EventReportCategorySafety,
string(EventReportCategoryHarassment): EventReportCategoryHarassment,
string(EventReportCategorySpamOrScam): EventReportCategorySpamOrScam,
string(EventReportCategoryInappropriateContent): EventReportCategoryInappropriateContent,
string(EventReportCategoryEventNotAsDescribed): EventReportCategoryEventNotAsDescribed,
string(EventReportCategoryIllegalOrDangerous): EventReportCategoryIllegalOrDangerous,
string(EventReportCategoryOther): EventReportCategoryOther,
}
var eventReportStatuses = map[string]EventReportStatus{
string(EventReportStatusPending): EventReportStatusPending,
string(EventReportStatusReviewed): EventReportStatusReviewed,
string(EventReportStatusDismissed): EventReportStatusDismissed,
}
// ParseEventReportCategory converts a wire string to an EventReportCategory.
func ParseEventReportCategory(value string) (EventReportCategory, bool) {
category, ok := eventReportCategories[value]
return category, ok
}
// ParseEventReportStatus converts a wire string to an EventReportStatus.
func ParseEventReportStatus(value string) (EventReportStatus, bool) {
status, ok := eventReportStatuses[value]
return status, ok
}
func (s EventReportStatus) String() string {
return string(s)
}
// EventReport stores a user-submitted report for an event.
type EventReport struct {
ID uuid.UUID
EventID uuid.UUID
ReporterUserID uuid.UUID
Category EventReportCategory
Message string
ImageURL *string
Status EventReportStatus
CreatedAt time.Time
UpdatedAt time.Time
}
package domain
import (
"time"
"github.com/google/uuid"
)
// InvitationStatus defines the lifecycle state of an invitation row.
type InvitationStatus string
const (
InvitationStatusPending InvitationStatus = "PENDING"
InvitationStatusAccepted InvitationStatus = "ACCEPTED"
InvitationStatusDeclined InvitationStatus = "DECLINED"
InvitationStatusExpired InvitationStatus = "EXPIRED"
InvitationStatusCanceled InvitationStatus = "CANCELED"
InvitationDeclineCooldown = 14 * 24 * time.Hour
)
var invitationStatuses = map[string]InvitationStatus{
string(InvitationStatusPending): InvitationStatusPending,
string(InvitationStatusAccepted): InvitationStatusAccepted,
string(InvitationStatusDeclined): InvitationStatusDeclined,
string(InvitationStatusExpired): InvitationStatusExpired,
string(InvitationStatusCanceled): InvitationStatusCanceled,
}
// ParseInvitationStatus converts a wire or persistence string into an invitation status.
func ParseInvitationStatus(value string) (InvitationStatus, bool) {
status, ok := invitationStatuses[value]
return status, ok
}
// String returns the serialized wire value of the invitation status.
func (s InvitationStatus) String() string {
return string(s)
}
// Invitation records a host-created event invitation.
type Invitation struct {
ID uuid.UUID
EventID uuid.UUID
HostID uuid.UUID
InvitedUserID uuid.UUID
Status InvitationStatus
Message *string
ExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
package domain
import "time"
// CheckParticipationEligibility returns a non-nil *AppError when the user does
// not satisfy the event's participation restrictions. It is called by both
// the direct-join and join-request flows so that every caller enforces the
// same rules regardless of delivery layer.
//
// Errors:
// - 400 profile_incomplete – restriction exists but user has not set the relevant field
// - 409 age_requirement_not_met – user is below the event's minimum age
// - 409 gender_requirement_not_met – user gender does not match the preferred gender
func CheckParticipationEligibility(user *User, event *Event, now time.Time) *AppError {
if event.MinimumAge != nil {
if user.BirthDate == nil {
return BadRequestError(
ErrorCodeProfileIncomplete,
"Your birth date must be set on your profile to join age-restricted events.",
)
}
if !HasMinimumAge(*user.BirthDate, *event.MinimumAge, now) {
return ConflictError(
ErrorCodeAgeRequirementNotMet,
"You do not meet the minimum age required by this event.",
)
}
}
if event.PreferredGender != nil {
if user.Gender == nil {
return BadRequestError(
ErrorCodeProfileIncomplete,
"Your gender must be set on your profile to join this event.",
)
}
if *user.Gender != string(*event.PreferredGender) {
return ConflictError(
ErrorCodeGenderRequirementNotMet,
"This event is restricted to participants of a specific gender.",
)
}
}
return nil
}
// HasMinimumAge reports whether a person born on birthDate has reached at
// least minAge years by now. It accounts for whether the user's birthday has
// passed this year. Comparison is done by month/day (rather than day-of-year)
// so the result is correct across leap years.
func HasMinimumAge(birthDate time.Time, minAge int, now time.Time) bool {
age := now.Year() - birthDate.Year()
nowMonth, nowDay := now.Month(), now.Day()
bMonth, bDay := birthDate.Month(), birthDate.Day()
if nowMonth < bMonth || (nowMonth == bMonth && nowDay < bDay) {
age--
}
return age >= minAge
}
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"
JoinRequestStatusCanceled JoinRequestStatus = "CANCELED"
JoinRequestCooldown = 72 * time.Hour
)
var joinRequestStatuses = map[string]JoinRequestStatus{
string(JoinRequestStatusPending): JoinRequestStatusPending,
string(JoinRequestStatusApproved): JoinRequestStatusApproved,
string(JoinRequestStatusRejected): JoinRequestStatusRejected,
string(JoinRequestStatusCanceled): JoinRequestStatusCanceled,
}
// ParseJoinRequestStatus converts a wire string to a JoinRequestStatus.
func ParseJoinRequestStatus(value string) (JoinRequestStatus, bool) {
status, ok := joinRequestStatuses[value]
return status, ok
}
func (s JoinRequestStatus) String() string {
return string(s)
}
// 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
ImageURL *string
CreatedAt time.Time
UpdatedAt time.Time
}
package domain
import (
"strings"
"time"
"github.com/google/uuid"
)
// PushDevicePlatform identifies the mobile platform that owns an FCM token.
type PushDevicePlatform string
const (
PushDevicePlatformIOS PushDevicePlatform = "IOS"
PushDevicePlatformAndroid PushDevicePlatform = "ANDROID"
)
func ParsePushDevicePlatform(value string) (PushDevicePlatform, bool) {
switch PushDevicePlatform(strings.ToUpper(strings.TrimSpace(value))) {
case PushDevicePlatformIOS:
return PushDevicePlatformIOS, true
case PushDevicePlatformAndroid:
return PushDevicePlatformAndroid, true
default:
return "", false
}
}
func (p PushDevicePlatform) String() string {
return string(p)
}
// NotificationDeliveryMethod identifies how a notification delivery was attempted.
type NotificationDeliveryMethod string
const (
NotificationDeliveryMethodFCM NotificationDeliveryMethod = "FCM"
NotificationDeliveryMethodSSE NotificationDeliveryMethod = "SSE"
)
func ParseNotificationDeliveryMethod(value string) (NotificationDeliveryMethod, bool) {
switch NotificationDeliveryMethod(strings.ToUpper(strings.TrimSpace(value))) {
case NotificationDeliveryMethodFCM:
return NotificationDeliveryMethodFCM, true
case NotificationDeliveryMethodSSE:
return NotificationDeliveryMethodSSE, true
default:
return "", false
}
}
func (m NotificationDeliveryMethod) String() string {
return string(m)
}
// NotificationDeliveryStatus identifies the result of one delivery attempt.
type NotificationDeliveryStatus string
// NotificationDeliveryMode identifies which admin-triggered delivery channels
// should be attempted for a custom notification.
type NotificationDeliveryMode string
const (
NotificationDeliveryStatusSent NotificationDeliveryStatus = "SENT"
NotificationDeliveryStatusFailed NotificationDeliveryStatus = "FAILED"
NotificationDeliveryStatusSkipped NotificationDeliveryStatus = "SKIPPED"
)
const (
NotificationDeliveryModeInApp NotificationDeliveryMode = "IN_APP"
NotificationDeliveryModePush NotificationDeliveryMode = "PUSH"
NotificationDeliveryModeBoth NotificationDeliveryMode = "BOTH"
)
func ParseNotificationDeliveryStatus(value string) (NotificationDeliveryStatus, bool) {
switch NotificationDeliveryStatus(strings.ToUpper(strings.TrimSpace(value))) {
case NotificationDeliveryStatusSent:
return NotificationDeliveryStatusSent, true
case NotificationDeliveryStatusFailed:
return NotificationDeliveryStatusFailed, true
case NotificationDeliveryStatusSkipped:
return NotificationDeliveryStatusSkipped, true
default:
return "", false
}
}
func (s NotificationDeliveryStatus) String() string {
return string(s)
}
func ParseNotificationDeliveryMode(value string) (NotificationDeliveryMode, bool) {
switch NotificationDeliveryMode(strings.ToUpper(strings.TrimSpace(value))) {
case NotificationDeliveryModeInApp:
return NotificationDeliveryModeInApp, true
case NotificationDeliveryModePush:
return NotificationDeliveryModePush, true
case NotificationDeliveryModeBoth:
return NotificationDeliveryModeBoth, true
default:
return "", false
}
}
func (m NotificationDeliveryMode) String() string {
return string(m)
}
// Notification is the user-facing in-app notification inbox item.
type Notification struct {
ID uuid.UUID
EventID *uuid.UUID
ReceiverUserID uuid.UUID
Title string
Type *string
Body string
DeepLink *string
ImageURL *string
Data map[string]string
IsRead bool
ReadAt *time.Time
DeletedAt *time.Time
IdempotencyKey string
CreatedAt time.Time
UpdatedAt time.Time
}
// PushDevice stores the current push-token assignment for one app installation.
type PushDevice struct {
ID uuid.UUID
UserID uuid.UUID
InstallationID uuid.UUID
Platform PushDevicePlatform
FCMToken string
DeviceInfo *string
LastSeenAt time.Time
RevokedAt *time.Time
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
ReconfirmedAt *time.Time
LastConfirmedEventVersion *int
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"
)
// TicketStatus defines the lifecycle state of a protected-event entry ticket.
type TicketStatus string
const (
// TicketStatusActive means the participant can request short-lived QR tokens.
TicketStatusActive TicketStatus = "ACTIVE"
// TicketStatusPending is reserved for future event re-approval flows.
TicketStatusPending TicketStatus = "PENDING"
// TicketStatusExpired marks an unused ticket whose event access window is over.
TicketStatusExpired TicketStatus = "EXPIRED"
// TicketStatusUsed marks a ticket accepted by a host scan.
TicketStatusUsed TicketStatus = "USED"
// TicketStatusCanceled marks a ticket canceled by leave/cancel flows.
TicketStatusCanceled TicketStatus = "CANCELED"
)
var ticketStatuses = map[string]TicketStatus{
string(TicketStatusActive): TicketStatusActive,
string(TicketStatusPending): TicketStatusPending,
string(TicketStatusExpired): TicketStatusExpired,
string(TicketStatusUsed): TicketStatusUsed,
string(TicketStatusCanceled): TicketStatusCanceled,
}
// ParseTicketStatus converts a wire or persistence string into a domain status.
func ParseTicketStatus(value string) (TicketStatus, bool) {
status, ok := ticketStatuses[value]
return status, ok
}
// String returns the serialized wire value of the ticket status.
func (s TicketStatus) String() string {
return string(s)
}
// Ticket is the protected-event access entity linked to a participation.
type Ticket struct {
ID uuid.UUID
ParticipationID uuid.UUID
Status TicketStatus
QRTokenVersion int
LastIssuedQRTokenHash *string
ExpiresAt time.Time
UsedAt *time.Time
CanceledAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
package domain
import (
"strings"
"time"
"github.com/google/uuid"
)
// UserStatus is the lifecycle state of an application account.
type UserStatus string
// UserRole is the authorization role attached to an application account.
type UserRole string
const (
// UserStatusActive is the default status assigned to newly registered users.
UserStatusActive = "active"
// UserStatusDeactivated prevents an account from continuing authenticated use.
UserStatusDeactivated UserStatus = "deactivated"
// UserRoleUser is the default non-admin role for regular accounts.
UserRoleUser UserRole = "USER"
// UserRoleAdmin allows access to web-only admin backoffice endpoints.
UserRoleAdmin UserRole = "ADMIN"
)
var userRoles = map[string]UserRole{
string(UserRoleUser): UserRoleUser,
string(UserRoleAdmin): UserRoleAdmin,
}
var userStatuses = map[string]UserStatus{
UserStatusActive: UserStatus(UserStatusActive),
UserStatusDeactivated.String(): UserStatusDeactivated,
}
// ParseUserRole converts a wire string into a UserRole.
func ParseUserRole(value string) (UserRole, bool) {
role, ok := userRoles[strings.ToUpper(strings.TrimSpace(value))]
return role, ok
}
// ParseUserStatus converts a wire string into a UserStatus.
func ParseUserStatus(value string) (UserStatus, bool) {
status, ok := userStatuses[strings.TrimSpace(value)]
return status, ok
}
// String returns the serialized wire value of the role.
func (r UserRole) String() string {
return string(r)
}
// String returns the serialized persistence value of the status.
func (s UserStatus) String() string {
return string(s)
}
// 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 UserStatus
Role UserRole
Locale 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"`
Role string `json:"role"`
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: string(u.Status),
Role: string(u.Role),
Gender: u.Gender,
}
if u.BirthDate != nil {
formatted := u.BirthDate.Format("2006-01-02")
s.BirthDate = &formatted
}
return s
}
package i18n
import (
"golang.org/x/text/language"
)
var supportedTags = func() []language.Tag {
tags := make([]language.Tag, 0, len(Supported))
for _, loc := range Supported {
tags = append(tags, language.Make(string(loc)))
}
return tags
}()
var languageMatcher = language.NewMatcher(supportedTags)
// ResolveFromAcceptLanguage parses an Accept-Language header value, picks
// the best supported match using q-weights, and returns its base language
// as a Locale. When the header is empty or contains no supported language
// the second return value is false and the caller should fall back.
func ResolveFromAcceptLanguage(header string) (Locale, bool) {
if header == "" {
return "", false
}
tags, _, err := language.ParseAcceptLanguage(header)
if err != nil || len(tags) == 0 {
return "", false
}
_, idx, conf := languageMatcher.Match(tags...)
if conf == language.No {
return "", false
}
if idx < 0 || idx >= len(Supported) {
return "", false
}
return Supported[idx], true
}
package i18n
import (
"encoding/json"
"fmt"
"io/fs"
"path"
"strings"
)
// Catalog holds translations for every supported Locale. It is read-only
// after construction and safe for concurrent use.
type Catalog struct {
entries map[Locale]map[string]string
}
// LoadFromFS reads every "<locale>.json" file from dir and returns a
// Catalog. The locale code in the filename must match a Supported locale.
// Files for locales not in Supported are ignored. Missing files for
// supported locales cause an error so deployments fail fast.
func LoadFromFS(fsys fs.FS, dir string) (*Catalog, error) {
entries, err := fs.ReadDir(fsys, dir)
if err != nil {
return nil, fmt.Errorf("i18n: read locales dir %q: %w", dir, err)
}
cat := &Catalog{entries: make(map[Locale]map[string]string, len(Supported))}
seen := make(map[Locale]bool, len(Supported))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".json") {
continue
}
base := strings.TrimSuffix(name, ".json")
loc, ok := Parse(base)
if !ok {
continue
}
data, err := fs.ReadFile(fsys, path.Join(dir, name))
if err != nil {
return nil, fmt.Errorf("i18n: read %s: %w", name, err)
}
var kv map[string]string
if err := json.Unmarshal(data, &kv); err != nil {
return nil, fmt.Errorf("i18n: parse %s: %w", name, err)
}
cat.entries[loc] = kv
seen[loc] = true
}
for _, loc := range Supported {
if !seen[loc] {
return nil, fmt.Errorf("i18n: missing catalog file for locale %q", loc)
}
}
return cat, nil
}
// T resolves key for the requested locale. When the key is missing in loc
// it falls back to DefaultLocale; when missing in both it returns the key
// itself so callers see the unresolved identifier in responses (which makes
// the gap obvious in QA).
//
// args, when present, are passed to fmt.Sprintf using the resolved value as
// the format string.
func (c *Catalog) T(loc Locale, key string, args ...any) string {
if c == nil {
return formatOrKey(key, key, args)
}
if value, ok := c.entries[loc][key]; ok {
return formatOrKey(value, key, args)
}
if loc != DefaultLocale {
if value, ok := c.entries[DefaultLocale][key]; ok {
return formatOrKey(value, key, args)
}
}
return key
}
// Has reports whether the catalog contains key for loc (no fallback).
// Useful for tests that assert parity across locales.
func (c *Catalog) Has(loc Locale, key string) bool {
if c == nil {
return false
}
_, ok := c.entries[loc][key]
return ok
}
// Keys returns the set of keys defined for loc. The returned slice is a
// copy and may be mutated by the caller.
func (c *Catalog) Keys(loc Locale) []string {
if c == nil {
return nil
}
src := c.entries[loc]
keys := make([]string, 0, len(src))
for k := range src {
keys = append(keys, k)
}
return keys
}
func formatOrKey(value, key string, args []any) string {
if len(args) == 0 {
return value
}
if value == "" {
return key
}
return fmt.Sprintf(value, args...)
}
package i18n
import "context"
type ctxKey struct{}
// WithLocale returns a derived context carrying the resolved request locale.
func WithLocale(ctx context.Context, loc Locale) context.Context {
return context.WithValue(ctx, ctxKey{}, loc)
}
// LocaleFrom reads the locale stored on ctx by WithLocale. It returns
// DefaultLocale when no locale has been attached, so callers never need
// to special-case the unset path.
func LocaleFrom(ctx context.Context) Locale {
if loc, ok := ctx.Value(ctxKey{}).(Locale); ok && loc != "" {
return loc
}
return DefaultLocale
}
// Package i18n provides locale resolution and message translation for
// backend-owned, user-facing strings. Catalog files live under locales/ and
// are embedded at build time.
package i18n
import "strings"
// Locale identifies a supported translation catalog. Use the package
// constants; do not construct values from arbitrary strings without going
// through Parse.
type Locale string
const (
LocaleEN Locale = "en"
LocaleTR Locale = "tr"
// DefaultLocale is the locale used when no preference can be resolved
// and as the fallback when a key is missing in the requested locale.
DefaultLocale = LocaleEN
)
// Supported is the canonical, ordered list of locales the backend ships
// translations for. The order seeds the language matcher, so put the
// default first.
var Supported = []Locale{LocaleEN, LocaleTR}
// Parse normalizes an input string (e.g. "TR", "tr-TR", "en_US") into a
// supported Locale. Region/script subtags are stripped; matching is
// case-insensitive on the base language tag.
func Parse(s string) (Locale, bool) {
if s == "" {
return "", false
}
base := s
for i, r := range s {
if r == '-' || r == '_' {
base = s[:i]
break
}
}
base = strings.ToLower(base)
for _, loc := range Supported {
if string(loc) == base {
return loc, true
}
}
return "", false
}
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
CORSAllowedOrigins []string
MaxRequestBodyBytes 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
PushProvider string
FirebaseCredentialsFile string
FirebaseServiceAccountJSONBase64 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("cors_allowed_origins", "CORS_ALLOWED_ORIGINS")
bind("max_request_body_bytes", "MAX_REQUEST_BODY_BYTES")
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")
bind("push_provider", "PUSH_PROVIDER")
bind("firebase_credentials_file", "FIREBASE_CREDENTIALS_FILE")
bind("firebase_service_account_json_base64", "FIREBASE_SERVICE_ACCOUNT_JSON_BASE64")
cfg := &Config{
AppPort: v.GetInt("app_port"),
CORSAllowedOrigins: normalizeStringList(v.GetStringSlice("cors_allowed_origins")),
MaxRequestBodyBytes: v.GetInt("max_request_body_bytes"),
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")),
PushProvider: strings.TrimSpace(v.GetString("push_provider")),
FirebaseCredentialsFile: strings.TrimSpace(v.GetString("firebase_credentials_file")),
FirebaseServiceAccountJSONBase64: strings.TrimSpace(v.GetString("firebase_service_account_json_base64")),
}
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
}
}
func normalizeStringList(values []string) []string {
seen := make(map[string]struct{}, len(values))
normalized := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
item := strings.TrimSpace(part)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
normalized = append(normalized, item)
}
}
return normalized
}
// 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.MaxRequestBodyBytes < 0 {
return fmt.Errorf("MAX_REQUEST_BODY_BYTES cannot be negative")
}
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")
}
if c.PushProvider == "" {
return fmt.Errorf("PUSH_PROVIDER cannot be empty")
}
if c.PushProvider != "mock" && c.PushProvider != "firebase" {
return fmt.Errorf("PUSH_PROVIDER must be \"mock\" or \"firebase\", got %q", c.PushProvider)
}
if c.PushProvider == "firebase" {
hasFile := c.FirebaseCredentialsFile != ""
hasJSON := c.FirebaseServiceAccountJSONBase64 != ""
if !hasFile && !hasJSON {
return missing("FIREBASE_CREDENTIALS_FILE or FIREBASE_SERVICE_ACCOUNT_JSON_BASE64")
}
if hasFile && hasJSON {
return fmt.Errorf("set either FIREBASE_CREDENTIALS_FILE or FIREBASE_SERVICE_ACCOUNT_JSON_BASE64, not both")
}
}
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 observability
import (
"context"
"errors"
"log/slog"
"os"
"strings"
"time"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
otellogglobal "go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
sdklog "go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
)
const (
defaultServiceName = "sem-backend"
defaultServiceNamespace = "social-event-mapper"
logScopeName = "github.com/bounswe/bounswe2026group11/backend"
shutdownTimeout = 10 * time.Second
)
// Runtime owns the configured OpenTelemetry providers so they can be flushed
// and shut down during process exit.
type Runtime struct {
traceProvider *sdktrace.TracerProvider
meterProvider *metric.MeterProvider
loggerProvider *sdklog.LoggerProvider
}
// LoggerProvider exposes the configured OpenTelemetry logger provider so the
// process logger can bridge slog records into the OTLP logs pipeline.
func (r *Runtime) LoggerProvider() *sdklog.LoggerProvider {
if r == nil {
return nil
}
return r.loggerProvider
}
// ConfigureLogger installs the process-wide logger. Without a LoggerProvider it
// writes JSON logs to stdout. When a LoggerProvider is available, it sends logs
// only to the OpenTelemetry pipeline so container stdout stays quiet.
func ConfigureLogger(provider *sdklog.LoggerProvider) {
stdoutHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
if provider == nil {
slog.SetDefault(slog.New(stdoutHandler))
return
}
otelHandler := otelslog.NewHandler(
logScopeName,
otelslog.WithLoggerProvider(provider),
otelslog.WithSource(true),
)
slog.SetDefault(slog.New(newLevelAttrHandler(otelHandler)))
}
// Setup initializes OpenTelemetry traces, metrics, and logs when an OTLP
// endpoint is configured. Without an endpoint, the backend keeps only stdout
// logging enabled.
func Setup(ctx context.Context, appEnv string) (*Runtime, error) {
if strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) == "" {
return nil, nil
}
res, err := newResource(ctx, appEnv)
if err != nil {
return nil, err
}
traceExporter, err := otlptracehttp.New(ctx)
if err != nil {
return nil, err
}
traceProvider := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExporter),
sdktrace.WithResource(res),
)
metricExporter, err := otlpmetrichttp.New(ctx)
if err != nil {
return nil, err
}
meterProvider := metric.NewMeterProvider(
metric.WithReader(metric.NewPeriodicReader(metricExporter)),
metric.WithResource(res),
)
logExporter, err := otlploghttp.New(ctx)
if err != nil {
return nil, err
}
loggerProvider := sdklog.NewLoggerProvider(
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter)),
sdklog.WithResource(res),
)
otel.SetTracerProvider(traceProvider)
otel.SetMeterProvider(meterProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
otellogglobal.SetLoggerProvider(loggerProvider)
return &Runtime{
traceProvider: traceProvider,
meterProvider: meterProvider,
loggerProvider: loggerProvider,
}, nil
}
// Shutdown flushes telemetry providers in reverse startup order.
func (r *Runtime) Shutdown(ctx context.Context) error {
if r == nil {
return nil
}
shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout)
defer cancel()
var err error
if r.loggerProvider != nil {
err = errors.Join(err, r.loggerProvider.Shutdown(shutdownCtx))
}
if r.meterProvider != nil {
err = errors.Join(err, r.meterProvider.Shutdown(shutdownCtx))
}
if r.traceProvider != nil {
err = errors.Join(err, r.traceProvider.Shutdown(shutdownCtx))
}
return err
}
func newResource(ctx context.Context, appEnv string) (*resource.Resource, error) {
serviceName := strings.TrimSpace(os.Getenv("OTEL_SERVICE_NAME"))
if serviceName == "" {
serviceName = defaultServiceName
}
opts := []resource.Option{
resource.WithFromEnv(),
resource.WithTelemetrySDK(),
resource.WithProcess(),
resource.WithHost(),
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceNamespace(defaultServiceNamespace),
),
}
if strings.TrimSpace(appEnv) != "" {
opts = append(opts, resource.WithAttributes(semconv.DeploymentEnvironmentName(strings.TrimSpace(appEnv))))
}
res, err := resource.New(ctx, opts...)
if err != nil {
return nil, err
}
return res, nil
}
type levelAttrHandler struct {
next slog.Handler
}
func newLevelAttrHandler(next slog.Handler) slog.Handler {
if next == nil {
return nil
}
return &levelAttrHandler{next: next}
}
func (h *levelAttrHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level)
}
func (h *levelAttrHandler) Handle(ctx context.Context, record slog.Record) error {
cloned := record.Clone()
if !recordHasAttr(record, slog.LevelKey) {
cloned.AddAttrs(slog.String(slog.LevelKey, record.Level.String()))
}
return h.next.Handle(ctx, cloned)
}
func (h *levelAttrHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &levelAttrHandler{next: h.next.WithAttrs(attrs)}
}
func (h *levelAttrHandler) WithGroup(name string) slog.Handler {
return &levelAttrHandler{next: h.next.WithGroup(name)}
}
func recordHasAttr(record slog.Record, key string) bool {
found := false
record.Attrs(func(attr slog.Attr) bool {
if attr.Key == key {
found = true
return false
}
return true
})
return found
}
package server
import (
"strings"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/admin_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/auth_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/badge_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/category_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/comment_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/event_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/event_report_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/notification_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/adapter/out/httpapi/ticket_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/out/httpapi/user_handler"
"github.com/bounswe/bounswe2026group11/backend/internal/bootstrap"
"github.com/bounswe/bounswe2026group11/backend/internal/infrastructure/config"
"github.com/gofiber/contrib/otelfiber/v2"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/recover"
)
const (
defaultMaxRequestBodyBytes = 4 * 1024 * 1024
apiContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
)
var defaultCORSAllowedOrigins = []string{
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:8081",
"http://127.0.0.1:8081",
"exp://localhost:8081",
"https://socialeventmapper.com",
"https://www.socialeventmapper.com",
"https://*.socialeventmapper.com",
}
// NewHTTP builds a Fiber application with all registered route groups and middleware.
func NewHTTP(container *bootstrap.Container) *fiber.App {
cfg := container.Config
app := fiber.New(fiber.Config{
BodyLimit: maxRequestBodyBytes(cfg),
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",
},
})
installGlobalSecurityMiddleware(app, cfg)
// otelfiber emits request traces and HTTP metrics; application logs stay
// focused on high-signal business actions inside handlers.
app.Use(otelfiber.Middleware())
// Install i18n translator and resolve the request locale early so every
// route — including unauthenticated ones — can produce localized error
// envelopes. The user-preference fallback for authenticated requests is
// applied by the auth middlewares once they attach claims.
httpapi.SetTranslator(container.Translator)
httpapi.SetLocalePreferenceLookup(container.LocalePreferenceLookup())
app.Use(httpapi.ResolveLocale())
registerHealthRoute(app)
// Auth routes
authHandler := auth_handler.NewAuthHandler(container.AuthService)
auth_handler.RegisterAuthRoutes(app, authHandler)
// Event routes
auth := httpapi.RequireAuth(container.TokenVerifier)
adminAuth := httpapi.RequireAdmin(container.TokenVerifier)
optionalAuth := httpapi.OptionalAuth(container.TokenVerifier)
// Admin backoffice routes (authenticated ADMIN role only)
adminHandler := admin_handler.NewHandler(container.AdminService)
admin_handler.RegisterRoutes(app, adminHandler, adminAuth)
eventHandler := event_handler.NewEventHandler(container.EventService, container.InvitationService)
event_handler.RegisterEventRoutes(app, eventHandler, auth, optionalAuth)
// Comment routes
commentHandler := comment_handler.NewHandler(container.CommentService)
comment_handler.RegisterRoutes(app, commentHandler, auth, optionalAuth)
// Event report routes
eventReportHandler := event_report_handler.NewHandler(container.EventReportService)
event_report_handler.RegisterRoutes(app, eventReportHandler, auth)
// Rating routes
ratingHandler := rating_handler.NewRatingHandler(container.RatingService)
rating_handler.RegisterRatingRoutes(app, ratingHandler, auth)
// Ticket routes
ticketHandler := ticket_handler.NewHandler(container.TicketService)
ticket_handler.RegisterRoutes(app, ticketHandler, 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, container.InvitationService)
profile_handler.RegisterProfileRoutes(app, profileHandler, auth)
userHandler := user_handler.NewHandler(container.ProfileService)
user_handler.RegisterRoutes(app, userHandler, auth)
// Favorite location routes (authenticated)
favoriteLocationHandler := favorite_location_handler.NewHandler(container.FavoriteLocationService)
favorite_location_handler.RegisterRoutes(app, favoriteLocationHandler, auth)
// Push notification device routes (authenticated)
notificationHandler := notification_handler.NewHandler(container.NotificationService, container.NotificationBroker)
notification_handler.RegisterRoutes(app, notificationHandler, auth)
// Direct image upload routes (authenticated)
imageUploadHandler := image_upload_handler.NewHandler(container.ImageUploadService)
image_upload_handler.RegisterRoutes(app, imageUploadHandler, auth)
// Badge routes (authenticated)
badgeHandler := badge_handler.NewHandler(container.BadgeService)
badge_handler.RegisterRoutes(app, badgeHandler, auth)
return app
}
func installGlobalSecurityMiddleware(app *fiber.App, cfg *config.Config) {
app.Use(recover.New())
app.Use(cors.New(cors.Config{
AllowOrigins: strings.Join(corsAllowedOrigins(cfg), ","),
AllowMethods: strings.Join([]string{
fiber.MethodGet,
fiber.MethodPost,
fiber.MethodPut,
fiber.MethodPatch,
fiber.MethodDelete,
fiber.MethodOptions,
}, ","),
AllowHeaders: strings.Join([]string{
fiber.HeaderAuthorization,
fiber.HeaderContentType,
fiber.HeaderAccept,
fiber.HeaderAcceptLanguage,
"X-Requested-With",
}, ","),
}))
app.Use(func(c *fiber.Ctx) error {
c.Set(fiber.HeaderXContentTypeOptions, "nosniff")
c.Set(fiber.HeaderXFrameOptions, "DENY")
c.Set(fiber.HeaderReferrerPolicy, "no-referrer")
c.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
c.Set(fiber.HeaderContentSecurityPolicy, apiContentSecurityPolicy)
return c.Next()
})
}
func maxRequestBodyBytes(cfg *config.Config) int {
if cfg != nil && cfg.MaxRequestBodyBytes > 0 {
return cfg.MaxRequestBodyBytes
}
return defaultMaxRequestBodyBytes
}
func corsAllowedOrigins(cfg *config.Config) []string {
if cfg != nil && len(cfg.CORSAllowedOrigins) > 0 {
return cfg.CORSAllowedOrigins
}
return defaultCORSAllowedOrigins
}
// 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)
})
}
//go:build integration
package common
import (
"context"
"testing"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/hasher"
authapp "github.com/bounswe/bounswe2026group11/backend/internal/application/auth"
eventapp "github.com/bounswe/bounswe2026group11/backend/internal/application/event"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/google/uuid"
)
// UserOption customizes a user fixture before it is persisted.
type UserOption func(*userFixture)
type userFixture struct {
username string
email string
password string
phoneNumber *string
gender *string
birthDate *time.Time
emailVerifiedAt time.Time
status domain.UserStatus
}
// WithUserUsername overrides the default username for the fixture.
func WithUserUsername(username string) UserOption {
return func(f *userFixture) {
f.username = username
}
}
// WithUserEmail overrides the default email for the fixture.
func WithUserEmail(email string) UserOption {
return func(f *userFixture) {
f.email = email
}
}
// WithUserVerifiedAt overrides the default verification timestamp.
func WithUserVerifiedAt(verifiedAt time.Time) UserOption {
return func(f *userFixture) {
f.emailVerifiedAt = verifiedAt
}
}
// WithUserGender overrides the default (nil) gender for the fixture.
func WithUserGender(gender string) UserOption {
return func(f *userFixture) {
f.gender = &gender
}
}
// WithUserBirthDate overrides the default (nil) birth date for the fixture.
func WithUserBirthDate(birthDate time.Time) UserOption {
return func(f *userFixture) {
f.birthDate = &birthDate
}
}
// GivenUser persists a user fixture through the provided auth repository.
func GivenUser(t *testing.T, repo authapp.Repository, opts ...UserOption) *domain.User {
t.Helper()
now := time.Now().UTC()
fixture := &userFixture{
username: "user_" + uuid.NewString()[:8],
email: uuid.NewString()[:8] + "@example.com",
password: "super-secret-password",
emailVerifiedAt: now,
status: domain.UserStatusActive,
}
for _, opt := range opts {
opt(fixture)
}
passwordHash, err := hasher.BcryptHasher{Cost: 4}.Hash(fixture.password)
if err != nil {
t.Fatalf("Hash() error = %v", err)
}
user, err := repo.CreateUser(context.Background(), authapp.CreateUserParams{
Username: fixture.username,
Email: fixture.email,
PhoneNumber: fixture.phoneNumber,
Gender: fixture.gender,
BirthDate: fixture.birthDate,
PasswordHash: passwordHash,
EmailVerifiedAt: fixture.emailVerifiedAt,
Status: fixture.status,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
return user
}
// OTPChallengeOption customizes an OTP challenge fixture before persistence.
type OTPChallengeOption func(*otpChallengeFixture)
type otpChallengeFixture struct {
destination string
code string
channel string
purpose string
expiresAt time.Time
updatedAt time.Time
}
// WithChallengeDestination overrides the default challenge destination.
func WithChallengeDestination(destination string) OTPChallengeOption {
return func(f *otpChallengeFixture) {
f.destination = destination
}
}
// WithChallengeCode overrides the default plaintext OTP code.
func WithChallengeCode(code string) OTPChallengeOption {
return func(f *otpChallengeFixture) {
f.code = code
}
}
// GivenOTPChallenge persists an active OTP challenge fixture.
func GivenOTPChallenge(t *testing.T, repo authapp.Repository, now time.Time, opts ...OTPChallengeOption) *domain.OTPChallenge {
t.Helper()
fixture := &otpChallengeFixture{
destination: "user@example.com",
code: "123456",
channel: domain.OTPChannelEmail,
purpose: domain.OTPPurposeRegistration,
expiresAt: now.Add(10 * time.Minute),
updatedAt: now,
}
for _, opt := range opts {
opt(fixture)
}
codeHash, err := hasher.BcryptHasher{Cost: 4}.Hash(fixture.code)
if err != nil {
t.Fatalf("Hash() error = %v", err)
}
challenge, err := repo.UpsertOTPChallenge(context.Background(), authapp.UpsertOTPChallengeParams{
Channel: fixture.channel,
Destination: fixture.destination,
Purpose: fixture.purpose,
CodeHash: codeHash,
ExpiresAt: fixture.expiresAt,
UpdatedAt: fixture.updatedAt,
})
if err != nil {
t.Fatalf("UpsertOTPChallenge() error = %v", err)
}
return challenge
}
// GivenEventCategory inserts a category row and returns its ID.
func GivenEventCategory(t *testing.T) int {
t.Helper()
name := "category_" + uuid.NewString()[:8]
var id int
err := RequirePool(t).QueryRow(
context.Background(),
`INSERT INTO event_category (name) VALUES ($1) RETURNING id`,
name,
).Scan(&id)
if err != nil {
t.Fatalf("insert event_category error = %v", err)
}
return id
}
// EventRef holds the parsed UUID of a test event fixture.
type EventRef struct {
ID uuid.UUID
}
// GivenPublicEvent creates a PUBLIC point event owned by hostID and returns a reference to it.
func GivenPublicEvent(t *testing.T, svc eventapp.UseCase, hostID uuid.UUID) *EventRef {
t.Helper()
return givenEvent(t, svc, hostID, domain.PrivacyPublic)
}
// GivenProtectedEvent creates a PROTECTED point event owned by hostID and returns a reference to it.
func GivenProtectedEvent(t *testing.T, svc eventapp.UseCase, hostID uuid.UUID) *EventRef {
t.Helper()
return givenEvent(t, svc, hostID, domain.PrivacyProtected)
}
// GivenPrivateEvent creates a PRIVATE point event owned by hostID and returns a reference to it.
func GivenPrivateEvent(t *testing.T, svc eventapp.UseCase, hostID uuid.UUID) *EventRef {
t.Helper()
return givenEvent(t, svc, hostID, domain.PrivacyPrivate)
}
func givenEvent(t *testing.T, svc eventapp.UseCase, hostID uuid.UUID, privacyLevel domain.EventPrivacyLevel) *EventRef {
t.Helper()
categoryID := GivenEventCategory(t)
startTime := time.Now().UTC().Add(24 * time.Hour)
result, err := svc.CreateEvent(context.Background(), hostID, eventapp.CreateEventInput{
Title: "fixture_event_" + uuid.NewString()[:8],
Description: StringPtr("A test event fixture"),
CategoryID: &categoryID,
LocationType: domain.LocationPoint,
Lat: Float64Ptr(41.0),
Lon: Float64Ptr(29.0),
StartTime: startTime,
PrivacyLevel: privacyLevel,
})
if err != nil {
t.Fatalf("GivenEvent() CreateEvent error = %v", err)
}
id, err := uuid.Parse(result.ID)
if err != nil {
t.Fatalf("GivenEvent() uuid.Parse error = %v", err)
}
return &EventRef{ID: id}
}
// GivenExpiredEvent inserts an ACTIVE event whose end_time is in the past directly into the DB.
func GivenExpiredEvent(t *testing.T, hostID uuid.UUID) uuid.UUID {
t.Helper()
pool := RequirePool(t)
categoryID := GivenEventCategory(t)
past := time.Now().UTC().Add(-2 * time.Hour)
var id uuid.UUID
err := pool.QueryRow(context.Background(), `
INSERT INTO event (host_id, title, privacy_level, status, location_type, start_time, end_time, category_id)
VALUES ($1, $2, 'PUBLIC', 'ACTIVE', 'POINT', $3, $4, $5)
RETURNING id`,
hostID,
"expired_event_"+uuid.NewString()[:8],
past.Add(-1*time.Hour),
past,
categoryID,
).Scan(&id)
if err != nil {
t.Fatalf("GivenExpiredEvent() insert error = %v", err)
}
return id
}
// GivenStartedEvent inserts an ACTIVE event whose start_time is in the past
// but end_time is still in the future.
func GivenStartedEvent(t *testing.T, hostID uuid.UUID) uuid.UUID {
t.Helper()
pool := RequirePool(t)
categoryID := GivenEventCategory(t)
now := time.Now().UTC()
var id uuid.UUID
err := pool.QueryRow(context.Background(), `
INSERT INTO event (host_id, title, privacy_level, status, location_type, start_time, end_time, category_id)
VALUES ($1, $2, 'PUBLIC', 'ACTIVE', 'POINT', $3, $4, $5)
RETURNING id`,
hostID,
"started_event_"+uuid.NewString()[:8],
now.Add(-1*time.Hour),
now.Add(2*time.Hour),
categoryID,
).Scan(&id)
if err != nil {
t.Fatalf("GivenStartedEvent() insert error = %v", err)
}
return id
}
// GivenStaleEvent inserts an ACTIVE event whose start_time is >30 days ago
// and updated_at is >7 days ago, making it eligible for stale auto-completion.
func GivenStaleEvent(t *testing.T, hostID uuid.UUID) uuid.UUID {
t.Helper()
pool := RequirePool(t)
categoryID := GivenEventCategory(t)
now := time.Now().UTC()
var id uuid.UUID
err := pool.QueryRow(context.Background(), `
INSERT INTO event (host_id, title, privacy_level, status, location_type, start_time, updated_at, category_id)
VALUES ($1, $2, 'PUBLIC', 'ACTIVE', 'POINT', $3, $4, $5)
RETURNING id`,
hostID,
"stale_event_"+uuid.NewString()[:8],
now.Add(-31*24*time.Hour),
now.Add(-8*24*time.Hour),
categoryID,
).Scan(&id)
if err != nil {
t.Fatalf("GivenStaleEvent() insert error = %v", err)
}
return id
}
// GivenVeryOldEvent inserts an ACTIVE event whose start_time is >60 days ago,
// making it eligible for unconditional auto-completion.
func GivenVeryOldEvent(t *testing.T, hostID uuid.UUID) uuid.UUID {
t.Helper()
pool := RequirePool(t)
categoryID := GivenEventCategory(t)
now := time.Now().UTC()
var id uuid.UUID
err := pool.QueryRow(context.Background(), `
INSERT INTO event (host_id, title, privacy_level, status, location_type, start_time, category_id)
VALUES ($1, $2, 'PUBLIC', 'ACTIVE', 'POINT', $3, $4)
RETURNING id`,
hostID,
"old_event_"+uuid.NewString()[:8],
now.Add(-61*24*time.Hour),
categoryID,
).Scan(&id)
if err != nil {
t.Fatalf("GivenVeryOldEvent() insert error = %v", err)
}
return id
}
// GivenRecentlyUpdatedOldEvent inserts an ACTIVE event whose start_time is >30 days ago
// but updated_at is within the last 7 days — it should NOT be auto-completed by the stale rule.
func GivenRecentlyUpdatedOldEvent(t *testing.T, hostID uuid.UUID) uuid.UUID {
t.Helper()
pool := RequirePool(t)
categoryID := GivenEventCategory(t)
now := time.Now().UTC()
var id uuid.UUID
err := pool.QueryRow(context.Background(), `
INSERT INTO event (host_id, title, privacy_level, status, location_type, start_time, updated_at, category_id)
VALUES ($1, $2, 'PUBLIC', 'ACTIVE', 'POINT', $3, $4, $5)
RETURNING id`,
hostID,
"recent_updated_event_"+uuid.NewString()[:8],
now.Add(-31*24*time.Hour),
now.Add(-1*24*time.Hour),
categoryID,
).Scan(&id)
if err != nil {
t.Fatalf("GivenRecentlyUpdatedOldEvent() insert error = %v", err)
}
return id
}
func StringPtr(value string) *string {
return &value
}
func IntPtr(value int) *int {
return &value
}
func Float64Ptr(value float64) *float64 {
return &value
}
//go:build integration
package common
import (
"context"
"testing"
"time"
pushadapter "github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/firebasepush"
"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"
postgresrepo "github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/postgres"
"github.com/bounswe/bounswe2026group11/backend/internal/adapter/in/security"
authapp "github.com/bounswe/bounswe2026group11/backend/internal/application/auth"
commentapp "github.com/bounswe/bounswe2026group11/backend/internal/application/comment"
eventapp "github.com/bounswe/bounswe2026group11/backend/internal/application/event"
favoritelocationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/favorite_location"
invitationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/invitation"
joinrequestapp "github.com/bounswe/bounswe2026group11/backend/internal/application/join_request"
notificationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/notification"
participationapp "github.com/bounswe/bounswe2026group11/backend/internal/application/participation"
profileapp "github.com/bounswe/bounswe2026group11/backend/internal/application/profile"
ratingapp "github.com/bounswe/bounswe2026group11/backend/internal/application/rating"
ticketapp "github.com/bounswe/bounswe2026group11/backend/internal/application/ticket"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/jackc/pgx/v5"
)
// AuthHarness bundles the shared wiring used by auth integration tests.
type AuthHarness struct {
Service authapp.UseCase
Repo *postgresrepo.AuthRepository
Tx pgx.Tx
Mailer *CapturingMailer
RefreshTokens security.RefreshTokenManager
Now time.Time
}
// NewAuthHarness creates an auth service wired against a rollback-only test transaction.
func NewAuthHarness(t *testing.T) *AuthHarness {
t.Helper()
pool, tx := BeginTx(t)
repo := postgresrepo.NewAuthRepositoryWithTx(pool, tx)
unitOfWork := postgresrepo.NewUnitOfWorkWithTx(pool, tx)
mailer := &CapturingMailer{}
refreshTokens := security.RefreshTokenManager{ByteLength: 32}
now := time.Now().UTC()
bcryptHasher := hasher.BcryptHasher{Cost: 4}
service := authapp.NewService(
repo,
unitOfWork,
bcryptHasher,
bcryptHasher,
jwtadapter.Issuer{
Secret: []byte("integration-test-secret"),
TTL: 15 * time.Minute,
},
refreshTokens,
otp.CodeGenerator{},
mailer,
NoLimitRateLimiter{},
NoLimitRateLimiter{},
NoLimitRateLimiter{},
authapp.Config{
OTPTTL: 10 * time.Minute,
OTPMaxAttempts: 5,
OTPResendCooldown: time.Minute,
RefreshTokenTTL: 14 * 24 * time.Hour,
MaxSessionTTL: 60 * 24 * time.Hour,
},
)
return &AuthHarness{
Service: service,
Repo: repo,
Tx: tx,
Mailer: mailer,
RefreshTokens: refreshTokens,
Now: now,
}
}
// EventHarness bundles the shared wiring used by event integration tests.
type EventHarness struct {
Service eventapp.UseCase
InvitationService invitationapp.UseCase
NotificationService notificationapp.UseCase
EventRepo *postgresrepo.EventRepository
TicketService ticketapp.UseCase
RatingService ratingapp.UseCase
CommentService commentapp.UseCase
ProfileService profileapp.UseCase
AuthRepo authapp.Repository
}
// FavoriteLocationHarness bundles the shared wiring used by favorite-location integration tests.
type FavoriteLocationHarness struct {
Service favoritelocationapp.UseCase
Repo *postgresrepo.FavoriteLocationRepository
AuthRepo authapp.Repository
}
// NotificationHarness bundles notification wiring against a rollback-only transaction.
type NotificationHarness struct {
Service notificationapp.UseCase
Repo *postgresrepo.NotificationRepository
AuthRepo authapp.Repository
Tx pgx.Tx
}
// NewEventHarness creates an event service that shares the package-level pool.
func NewEventHarness(t *testing.T) *EventHarness {
t.Helper()
pool := RequirePool(t)
eventRepo := postgresrepo.NewEventRepository(pool)
participationRepo := postgresrepo.NewParticipationRepository(pool)
invitationRepo := postgresrepo.NewInvitationRepository(pool)
joinRequestRepo := postgresrepo.NewJoinRequestRepository(pool)
ticketRepo := postgresrepo.NewTicketRepository(pool)
ratingRepo := postgresrepo.NewRatingRepository(pool)
commentRepo := postgresrepo.NewCommentRepository(pool)
profileRepo := postgresrepo.NewProfileRepository(pool)
notificationRepo := postgresrepo.NewNotificationRepository(pool)
unitOfWork := postgresrepo.NewUnitOfWork(pool)
participationService := participationapp.NewService(participationRepo)
notificationService := notificationapp.NewService(notificationRepo, pushadapter.MockSender{}, unitOfWork)
ticketService := ticketapp.NewService(
ticketRepo,
unitOfWork,
jwtadapter.TicketTokenManager{Secret: []byte("integration-test-secret")},
ticketapp.Settings{
QRTokenTTL: 10 * time.Second,
ProximityMeters: 200,
},
)
joinRequestService := joinrequestapp.NewService(joinRequestRepo, unitOfWork, ticketService)
joinRequestService.SetNotificationService(notificationService)
invitationService := invitationapp.NewService(invitationRepo, unitOfWork, ticketService)
invitationService.SetNotificationService(notificationService)
ratingService := ratingapp.NewService(ratingRepo, unitOfWork, ratingapp.Settings{
GlobalPrior: 4.0,
BayesianM: 5,
})
commentService := commentapp.NewService(commentRepo, unitOfWork)
commentService.SetReviewScoreUpdater(ratingService)
return &EventHarness{
Service: eventapp.NewService(eventRepo, participationService, joinRequestService, unitOfWork, ticketService),
InvitationService: invitationService,
NotificationService: notificationService,
EventRepo: eventRepo,
TicketService: ticketService,
RatingService: ratingService,
CommentService: commentService,
ProfileService: profileapp.NewService(profileRepo, unitOfWork),
AuthRepo: postgresrepo.NewAuthRepository(pool),
}
}
// NewFavoriteLocationHarness creates a favorite-location service backed by the shared integration database.
func NewFavoriteLocationHarness(t *testing.T) *FavoriteLocationHarness {
t.Helper()
pool := RequirePool(t)
repo := postgresrepo.NewFavoriteLocationRepository(pool)
unitOfWork := postgresrepo.NewUnitOfWork(pool)
return &FavoriteLocationHarness{
Service: favoritelocationapp.NewService(repo, unitOfWork),
Repo: repo,
AuthRepo: postgresrepo.NewAuthRepository(pool),
}
}
func NewNotificationHarness(t *testing.T) *NotificationHarness {
t.Helper()
pool, tx := BeginTx(t)
repo := postgresrepo.NewNotificationRepositoryWithTx(pool, tx)
unitOfWork := postgresrepo.NewUnitOfWorkWithTx(pool, tx)
return &NotificationHarness{
Service: notificationapp.NewService(repo, pushadapter.MockSender{}, unitOfWork),
Repo: repo,
AuthRepo: postgresrepo.NewAuthRepositoryWithTx(pool, tx),
Tx: tx,
}
}
// CapturingMailer stores the last OTP email sent by the auth service.
type CapturingMailer struct {
LastEmail string
LastCode string
LastExpiry time.Duration
LastPurpose string
}
func (m *CapturingMailer) SendRegistrationOTP(_ context.Context, input authapp.OTPMailInput) error {
m.LastEmail = input.Email
m.LastCode = input.Code
m.LastExpiry = input.ExpiresIn
m.LastPurpose = domain.OTPPurposeRegistration
return nil
}
func (m *CapturingMailer) SendPasswordResetOTP(_ context.Context, input authapp.OTPMailInput) error {
m.LastEmail = input.Email
m.LastCode = input.Code
m.LastExpiry = input.ExpiresIn
m.LastPurpose = domain.OTPPurposePasswordReset
return nil
}
// NoLimitRateLimiter disables rate limiting for deterministic tests.
type NoLimitRateLimiter struct{}
func (NoLimitRateLimiter) Allow(string, time.Time) (bool, time.Duration) {
return true, 0
}
//go:build integration
package common
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
"time"
"github.com/bounswe/bounswe2026group11/backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
tc "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
var (
integrationPool *pgxpool.Pool
integrationContainer *tcpostgres.PostgresContainer
integrationSkipReason string
initScriptPath string
)
// Run bootstraps the shared integration database once per test package.
func Run(m *testing.M) int {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := ensureDockerAvailable(); err != nil {
integrationSkipReason = err.Error()
return m.Run()
}
if err := startSharedPostgres(ctx); err != nil {
fmt.Fprintf(os.Stderr, "failed to start shared integration database: %v\n", err)
return 1
}
defer cleanup()
return m.Run()
}
// RequirePool returns the shared integration pool or skips when prerequisites
// are not available.
func RequirePool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
if integrationSkipReason != "" {
t.Skipf("skipping integration test: %s", integrationSkipReason)
}
if integrationPool == nil {
t.Fatal("integration database pool is not initialized")
}
return integrationPool
}
// BeginTx opens a per-test transaction and rolls it back during cleanup.
func BeginTx(t *testing.T) (*pgxpool.Pool, pgx.Tx) {
t.Helper()
pool := RequirePool(t)
tx, err := pool.BeginTx(context.Background(), pgx.TxOptions{})
if err != nil {
t.Fatalf("BeginTx() error = %v", err)
}
t.Cleanup(func() {
_ = tx.Rollback(context.Background())
})
return pool, tx
}
// RequireAppErrorCode asserts that err is a domain.AppError with the expected code.
func RequireAppErrorCode(t *testing.T, err error, code string) {
t.Helper()
var appErr *domain.AppError
if !errors.As(err, &appErr) {
t.Fatalf("expected *domain.AppError, got %T: %v", err, err)
}
if appErr.Code != code {
t.Fatalf("expected error code %q, got %q", code, appErr.Code)
}
}
func cleanup() {
if integrationPool != nil {
integrationPool.Close()
}
if integrationContainer != nil {
_ = tc.TerminateContainer(integrationContainer)
}
if initScriptPath != "" {
_ = os.RemoveAll(filepath.Dir(initScriptPath))
}
}
func startSharedPostgres(ctx context.Context) error {
initScriptPath = writeInitScript()
scripts := []string{initScriptPath}
scripts = append(scripts, migrationPaths()...)
var err error
integrationContainer, err = tcpostgres.Run(
ctx,
"postgis/postgis:16-3.4",
tcpostgres.WithDatabase("sem_integration"),
tcpostgres.WithUsername("postgres"),
tcpostgres.WithPassword("postgres"),
tcpostgres.WithOrderedInitScripts(scripts...),
tc.WithAdditionalWaitStrategyAndDeadline(
2*time.Minute,
wait.ForAll(
wait.ForListeningPort("5432/tcp"),
wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
),
),
)
if err != nil {
return fmt.Errorf("postgres.Run(): %w", err)
}
dsn, err := integrationContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
return fmt.Errorf("ConnectionString(): %w", err)
}
integrationPool, err = pgxpool.New(ctx, dsn)
if err != nil {
return fmt.Errorf("pgxpool.New(): %w", err)
}
if err := integrationPool.Ping(ctx); err != nil {
return fmt.Errorf("pool.Ping(): %w", err)
}
if err := waitForSchema(ctx, integrationPool); err != nil {
return err
}
return nil
}
func waitForSchema(ctx context.Context, pool *pgxpool.Pool) error {
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
var ready bool
err := pool.QueryRow(ctx, `SELECT to_regclass('public.app_user') IS NOT NULL`).Scan(&ready)
if err == nil && ready {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("shared integration schema was not ready before deadline")
}
func ensureDockerAvailable() error {
if _, err := exec.LookPath("docker"); err != nil {
return err
}
cmd := exec.Command("docker", "info", "--format", "{{.ServerVersion}}")
output, err := cmd.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
return err
}
return fmt.Errorf("%w: %s", err, message)
}
return nil
}
func writeInitScript() string {
dir, err := os.MkdirTemp("", "sem-integration-init-*")
if err != nil {
panic(fmt.Errorf("MkdirTemp(): %w", err))
}
path := filepath.Join(dir, "000000_enable_pgcrypto.sql")
if err := os.WriteFile(path, []byte("CREATE EXTENSION IF NOT EXISTS pgcrypto;\n"), 0o600); err != nil {
panic(fmt.Errorf("WriteFile(): %w", err))
}
return path
}
func migrationPaths() []string {
_, filename, _, ok := runtime.Caller(0)
if !ok {
panic("runtime.Caller() failed")
}
pattern := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", "*.up.sql")
paths, err := filepath.Glob(pattern)
if err != nil {
panic(fmt.Errorf("filepath.Glob(): %w", err))
}
if len(paths) == 0 {
panic("no integration migration scripts found")
}
sort.Strings(paths)
return paths
}