Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged. New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer). Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
const (
|
||||
// emailCodeTTL bounds how long an issued confirm-code stays valid.
|
||||
emailCodeTTL = 15 * time.Minute
|
||||
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
|
||||
emailCodeMaxAttempts = 5
|
||||
)
|
||||
|
||||
// Errors returned by the email confirm-code flow.
|
||||
var (
|
||||
// ErrInvalidEmail is returned for an unparseable email address.
|
||||
ErrInvalidEmail = errors.New("account: invalid email address")
|
||||
// ErrEmailTaken is returned when the email is already confirmed by another
|
||||
// account; binding it would be a merge, which Stage 10 owns.
|
||||
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
||||
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
||||
// requesting account.
|
||||
ErrAlreadyConfirmed = errors.New("account: email already confirmed for this account")
|
||||
// ErrNoPendingCode is returned when no live confirm-code exists to verify.
|
||||
ErrNoPendingCode = errors.New("account: no pending confirmation code")
|
||||
// ErrCodeExpired is returned when the confirm-code has passed its TTL.
|
||||
ErrCodeExpired = errors.New("account: confirmation code expired")
|
||||
// ErrTooManyAttempts is returned when the code is locked after too many tries.
|
||||
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
|
||||
// ErrCodeMismatch is returned when the submitted code does not match.
|
||||
ErrCodeMismatch = errors.New("account: confirmation code does not match")
|
||||
)
|
||||
|
||||
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
|
||||
// Mailer and verifies it, binding a confirmed email identity to the requesting
|
||||
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
|
||||
// matching the session model. Binding an email already confirmed by a different
|
||||
// account is refused (ErrEmailTaken) — merging two accounts is Stage 10 — and
|
||||
// using an email as a login is Stage 6, which reuses this mechanism.
|
||||
type EmailService struct {
|
||||
store *Store
|
||||
mailer Mailer
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewEmailService constructs an EmailService over store, sending via mailer.
|
||||
func NewEmailService(store *Store, mailer Mailer) *EmailService {
|
||||
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
|
||||
// replacing any prior pending code for the same account and address. It returns
|
||||
// ErrInvalidEmail, ErrEmailTaken or ErrAlreadyConfirmed without sending.
|
||||
func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
if owner == accountID {
|
||||
return ErrAlreadyConfirmed
|
||||
}
|
||||
return ErrEmailTaken
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return err
|
||||
}
|
||||
subject := "Your Scrabble confirmation code"
|
||||
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
||||
return s.mailer.Send(ctx, addr, subject, body)
|
||||
}
|
||||
|
||||
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
||||
// confirmed email identity and returns the account. It returns ErrNoPendingCode,
|
||||
// ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch (counting the attempt), or
|
||||
// ErrEmailTaken if the address was confirmed elsewhere in the meantime.
|
||||
func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, email, code string) (Account, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if s.now().After(conf.expiresAt) {
|
||||
return Account{}, ErrCodeExpired
|
||||
}
|
||||
if conf.attempts >= emailCodeMaxAttempts {
|
||||
return Account{}, ErrTooManyAttempts
|
||||
}
|
||||
if hashCode(code) != conf.codeHash {
|
||||
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return Account{}, ErrCodeMismatch
|
||||
}
|
||||
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return s.store.GetByID(ctx, accountID)
|
||||
}
|
||||
|
||||
// emailConfirmation is a pending confirm-code row in domain form.
|
||||
type emailConfirmation struct {
|
||||
id uuid.UUID
|
||||
codeHash string
|
||||
expiresAt time.Time
|
||||
attempts int
|
||||
}
|
||||
|
||||
// confirmedEmailAccount returns the account that holds a confirmed email identity
|
||||
// for email and true, or (zero, false) when none does.
|
||||
func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.UUID, bool, error) {
|
||||
stmt := postgres.SELECT(table.Identities.AccountID).
|
||||
FROM(table.Identities).
|
||||
WHERE(
|
||||
table.Identities.Kind.EQ(postgres.String(KindEmail)).
|
||||
AND(table.Identities.ExternalID.EQ(postgres.String(email))).
|
||||
AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
|
||||
).LIMIT(1)
|
||||
var row model.Identities
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return uuid.UUID{}, false, nil
|
||||
}
|
||||
return uuid.UUID{}, false, fmt.Errorf("account: confirmed email owner %s: %w", email, err)
|
||||
}
|
||||
return row.AccountID, true, nil
|
||||
}
|
||||
|
||||
// replacePendingConfirmation clears any pending code for (accountID, email) and
|
||||
// inserts a fresh one, inside one transaction.
|
||||
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return fmt.Errorf("account: new confirmation id: %w", err)
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
del := table.EmailConfirmations.DELETE().WHERE(
|
||||
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
|
||||
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
|
||||
)
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("clear pending confirmations: %w", err)
|
||||
}
|
||||
ins := table.EmailConfirmations.INSERT(
|
||||
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
|
||||
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
|
||||
).VALUES(id, accountID, email, codeHash, expiresAt)
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert confirmation: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// latestPendingConfirmation loads the newest unconsumed confirm-code for
|
||||
// (accountID, email), or ErrNoPendingCode.
|
||||
func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UUID, email string) (emailConfirmation, error) {
|
||||
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
|
||||
FROM(table.EmailConfirmations).
|
||||
WHERE(
|
||||
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
|
||||
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
|
||||
).ORDER_BY(table.EmailConfirmations.CreatedAt.DESC()).LIMIT(1)
|
||||
var row model.EmailConfirmations
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return emailConfirmation{}, ErrNoPendingCode
|
||||
}
|
||||
return emailConfirmation{}, fmt.Errorf("account: load confirmation: %w", err)
|
||||
}
|
||||
return emailConfirmation{
|
||||
id: row.ConfirmationID,
|
||||
codeHash: row.CodeHash,
|
||||
expiresAt: row.ExpiresAt,
|
||||
attempts: int(row.Attempts),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
|
||||
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
|
||||
stmt := table.EmailConfirmations.
|
||||
UPDATE(table.EmailConfirmations.Attempts).
|
||||
SET(table.EmailConfirmations.Attempts.ADD(postgres.Int(1))).
|
||||
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: bump confirmation attempts: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirmEmailIdentity consumes the code and inserts a confirmed email identity,
|
||||
// inside one transaction. A unique-constraint violation means the address was
|
||||
// confirmed by another account first, surfaced as ErrEmailTaken.
|
||||
func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
|
||||
identityID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return fmt.Errorf("account: new identity id: %w", err)
|
||||
}
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
upd := table.EmailConfirmations.
|
||||
UPDATE(table.EmailConfirmations.ConsumedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("consume confirmation: %w", err)
|
||||
}
|
||||
ins := table.Identities.INSERT(
|
||||
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
|
||||
table.Identities.ExternalID, table.Identities.Confirmed,
|
||||
).VALUES(identityID, accountID, KindEmail, email, true)
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrEmailTaken
|
||||
}
|
||||
return fmt.Errorf("account: confirm email identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeEmail parses and lower-cases an email address, or returns ErrInvalidEmail.
|
||||
func normalizeEmail(email string) (string, error) {
|
||||
addr, err := mail.ParseAddress(strings.TrimSpace(email))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidEmail, email)
|
||||
}
|
||||
return strings.ToLower(addr.Address), nil
|
||||
}
|
||||
|
||||
// generateCode returns a random 6-digit code and its SHA-256 hex hash.
|
||||
func generateCode() (code, hash string, err error) {
|
||||
n, err := crand.Int(crand.Reader, big.NewInt(1_000_000))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("account: generate code: %w", err)
|
||||
}
|
||||
code = fmt.Sprintf("%06d", n.Int64())
|
||||
return code, hashCode(code), nil
|
||||
}
|
||||
|
||||
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
|
||||
func hashCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"lowercases", "User@Example.COM", "user@example.com", false},
|
||||
{"trims", " a@b.io ", "a@b.io", false},
|
||||
{"strips display name", "Jane Doe <jane@x.org>", "jane@x.org", false},
|
||||
{"empty", "", "", true},
|
||||
{"no at sign", "notanemail", "", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := normalizeEmail(tc.in)
|
||||
if tc.wantErr {
|
||||
if !errors.Is(err, ErrInvalidEmail) {
|
||||
t.Fatalf("err = %v, want ErrInvalidEmail", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCodeFormat(t *testing.T) {
|
||||
sixDigits := regexp.MustCompile(`^\d{6}$`)
|
||||
for range 50 {
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if !sixDigits.MatchString(code) {
|
||||
t.Fatalf("code %q is not exactly six digits", code)
|
||||
}
|
||||
if hash != hashCode(code) {
|
||||
t.Errorf("returned hash does not match hashCode(%q)", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCodeStable(t *testing.T) {
|
||||
if hashCode("123456") != hashCode("123456") {
|
||||
t.Fatal("hashCode is not deterministic")
|
||||
}
|
||||
if hashCode("123456") == hashCode("654321") {
|
||||
t.Fatal("distinct codes must not share a hash")
|
||||
}
|
||||
if got := len(hashCode("000000")); got != 64 {
|
||||
t.Errorf("hex SHA-256 length = %d, want 64", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Mailer delivers a transactional email. It is the seam behind which the email
|
||||
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
|
||||
// fixture (see docs/TESTING.md: no real network in tests). The context is offered
|
||||
// for cancellation; the standard-library SMTP implementation sends synchronously
|
||||
// and ignores it.
|
||||
type Mailer interface {
|
||||
Send(ctx context.Context, to, subject, body string) error
|
||||
}
|
||||
|
||||
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
|
||||
// instead, so a deployment without a relay still runs (the code lands in the log).
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
// SMTPMailer sends mail through an SMTP relay using the standard library. When a
|
||||
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated.
|
||||
type SMTPMailer struct {
|
||||
cfg SMTPConfig
|
||||
}
|
||||
|
||||
// NewSMTPMailer constructs an SMTPMailer for cfg.
|
||||
func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
|
||||
return SMTPMailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Send delivers a plain-text UTF-8 message to to via the configured relay.
|
||||
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
|
||||
var auth smtp.Auth
|
||||
if m.cfg.Username != "" {
|
||||
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
}
|
||||
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil {
|
||||
return fmt.Errorf("account: send mail to %s: %w", to, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// message renders a minimal RFC 5322 plain-text email.
|
||||
func message(from, to, subject, body string) []byte {
|
||||
return []byte("From: " + from + "\r\n" +
|
||||
"To: " + to + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"\r\n" + body + "\r\n")
|
||||
}
|
||||
|
||||
// LogMailer logs the message instead of sending it. It is the default when no
|
||||
// SMTP relay is configured and is intended for development only: it logs the body,
|
||||
// which carries the confirm-code, so it must not be used in production.
|
||||
type LogMailer struct {
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewLogMailer constructs a LogMailer that logs through log.
|
||||
func NewLogMailer(log *zap.Logger) LogMailer {
|
||||
return LogMailer{log: log}
|
||||
}
|
||||
|
||||
// Send logs the message at info level and reports success.
|
||||
func (m LogMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
if m.log != nil {
|
||||
m.log.Info("email not sent (log mailer)",
|
||||
zap.String("to", to), zap.String("subject", subject), zap.String("body", body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// maxDisplayName caps a display name's length in runes.
|
||||
const maxDisplayName = 64
|
||||
|
||||
// ErrInvalidProfile is returned when a profile update carries an unacceptable
|
||||
// field (an unknown language, an invalid timezone, or an over-long display name).
|
||||
var ErrInvalidProfile = errors.New("account: invalid profile")
|
||||
|
||||
// ProfileUpdate is the full set of player-editable profile fields. UpdateProfile
|
||||
// overwrites every field, so callers send the complete desired profile. AwayStart
|
||||
// and AwayEnd carry only the hour and minute of the daily away window, in the
|
||||
// account's TimeZone.
|
||||
type ProfileUpdate struct {
|
||||
DisplayName string
|
||||
PreferredLanguage string // "en" or "ru"
|
||||
TimeZone string // an IANA location name
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
}
|
||||
|
||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||
// returns the stored row. It reports ErrInvalidProfile for a bad language,
|
||||
// timezone or display name and ErrNotFound when no account matches id.
|
||||
func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate) (Account, error) {
|
||||
lang := strings.TrimSpace(p.PreferredLanguage)
|
||||
if lang != "en" && lang != "ru" {
|
||||
return Account{}, fmt.Errorf("%w: preferred_language %q", ErrInvalidProfile, p.PreferredLanguage)
|
||||
}
|
||||
tz := strings.TrimSpace(p.TimeZone)
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
return Account{}, fmt.Errorf("%w: time_zone %q: %v", ErrInvalidProfile, p.TimeZone, err)
|
||||
}
|
||||
name := strings.TrimSpace(p.DisplayName)
|
||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||
return Account{}, fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName)
|
||||
}
|
||||
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
|
||||
table.Accounts.AwayStart, table.Accounts.AwayEnd,
|
||||
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
postgres.String(name), postgres.String(lang), postgres.String(tz),
|
||||
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
|
||||
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
return Account{}, fmt.Errorf("account: update profile %s: %w", id, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
||||
// database access, so a nil-backed Store is enough to exercise the guards.
|
||||
func TestUpdateProfileValidation(t *testing.T) {
|
||||
s := &Store{}
|
||||
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"}
|
||||
tests := []struct {
|
||||
name string
|
||||
mut func(p *ProfileUpdate)
|
||||
}{
|
||||
{"unknown language", func(p *ProfileUpdate) { p.PreferredLanguage = "fr" }},
|
||||
{"invalid timezone", func(p *ProfileUpdate) { p.TimeZone = "Mars/Olympus" }},
|
||||
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := base
|
||||
tc.mut(&p)
|
||||
if _, err := s.UpdateProfile(context.Background(), uuid.New(), p); !errors.Is(err, ErrInvalidProfile) {
|
||||
t.Fatalf("err = %v, want ErrInvalidProfile", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
@@ -25,6 +26,9 @@ type Config struct {
|
||||
Telemetry telemetry.Config
|
||||
// Game configures the game subsystem (dictionaries, sweeper, live-game cache).
|
||||
Game game.Config
|
||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||
// selects the development log mailer (the code is logged, not sent).
|
||||
SMTP account.SMTPConfig
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
@@ -67,12 +71,21 @@ func Load() (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
smtp := account.SMTPConfig{
|
||||
Host: os.Getenv("BACKEND_SMTP_HOST"),
|
||||
Port: envOr("BACKEND_SMTP_PORT", "587"),
|
||||
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
|
||||
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
||||
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
||||
}
|
||||
|
||||
c := Config{
|
||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
||||
Postgres: pg,
|
||||
Telemetry: tel,
|
||||
Game: gm,
|
||||
SMTP: smtp,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
|
||||
@@ -97,6 +97,9 @@ var (
|
||||
// ErrUnknownVersion is returned when no dictionary is registered for a
|
||||
// (variant, version) pair.
|
||||
ErrUnknownVersion = errors.New("engine: unknown dictionary version")
|
||||
// ErrUnknownDropoutTiles is returned by ParseDropoutTiles for a label that is
|
||||
// neither "remove" nor "return".
|
||||
ErrUnknownDropoutTiles = errors.New("engine: unknown drop-out tile disposition")
|
||||
// ErrIllegalPlay wraps a solver validation failure: off-board geometry, a
|
||||
// word absent from the dictionary, or a play that does not connect.
|
||||
ErrIllegalPlay = errors.New("engine: illegal play")
|
||||
|
||||
+102
-17
@@ -42,6 +42,43 @@ func (r EndReason) String() string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// DropoutTiles is the per-game disposition of a dropped-out player's rack when
|
||||
// they resign or time out of a game with three or more seats: the tiles are
|
||||
// either removed from play or returned to the bag. It is agreed at game creation
|
||||
// (docs/ARCHITECTURE.md §6) and is irrelevant to a two-player game, which ends on
|
||||
// the first drop-out. In both dispositions the leaver's rack is never revealed to
|
||||
// the remaining players.
|
||||
type DropoutTiles uint8
|
||||
|
||||
const (
|
||||
// DropoutRemove removes the dropped player's tiles from play; this is the
|
||||
// default, so the zero value matches it.
|
||||
DropoutRemove DropoutTiles = iota
|
||||
// DropoutReturn returns the dropped player's tiles to the bag, where the
|
||||
// remaining players may draw them.
|
||||
DropoutReturn
|
||||
)
|
||||
|
||||
// String renders the disposition as the stable label the game domain persists.
|
||||
func (d DropoutTiles) String() string {
|
||||
if d == DropoutReturn {
|
||||
return "return"
|
||||
}
|
||||
return "remove"
|
||||
}
|
||||
|
||||
// ParseDropoutTiles maps a persisted label back to a DropoutTiles, reporting
|
||||
// ErrUnknownDropoutTiles for an unrecognised value.
|
||||
func ParseDropoutTiles(s string) (DropoutTiles, error) {
|
||||
switch s {
|
||||
case "remove":
|
||||
return DropoutRemove, nil
|
||||
case "return":
|
||||
return DropoutReturn, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%w: %q", ErrUnknownDropoutTiles, s)
|
||||
}
|
||||
|
||||
// Options configures a new game.
|
||||
type Options struct {
|
||||
// Variant selects the rules and dictionary.
|
||||
@@ -52,6 +89,9 @@ type Options struct {
|
||||
Players int
|
||||
// Seed seeds the tile bag, making the game reproducible.
|
||||
Seed int64
|
||||
// DropoutTiles is the disposition of a dropped-out player's tiles in a game
|
||||
// with three or more seats; the zero value removes them from play.
|
||||
DropoutTiles DropoutTiles
|
||||
}
|
||||
|
||||
// Game is the in-memory state of a single match and the pure rules engine over
|
||||
@@ -72,7 +112,8 @@ type Game struct {
|
||||
scorelessRun int
|
||||
over bool
|
||||
reason EndReason
|
||||
resignedSeat int // seat that resigned, or -1; excludes the resigner from winning
|
||||
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
||||
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
||||
log []MoveRecord
|
||||
}
|
||||
|
||||
@@ -107,7 +148,8 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
||||
bag: NewBag(rs, opts.Seed),
|
||||
hands: make([][]byte, opts.Players),
|
||||
scores: make([]int, opts.Players),
|
||||
resignedSeat: -1,
|
||||
resigned: make([]bool, opts.Players),
|
||||
dropoutTiles: opts.DropoutTiles,
|
||||
}
|
||||
for i := range g.hands {
|
||||
g.hands[i] = g.bag.Draw(rs.RackSize)
|
||||
@@ -195,22 +237,30 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Resign ends the game on the current player's turn (EndReason EndResign). The
|
||||
// resigner always forfeits the win and keeps their accumulated score (it is
|
||||
// neither zeroed nor docked a rack adjustment); the win goes to the highest
|
||||
// score among the remaining seats — in a two-player match, unconditionally to
|
||||
// the other player. A missed-turn timeout reuses Resign in the game domain, so
|
||||
// it inherits this win/loss. Richer multi-player drop-out handling belongs to
|
||||
// the game domain in a later stage.
|
||||
// Resign drops the current player out of the game. The resigner always forfeits
|
||||
// the win and keeps their accumulated score (it is neither zeroed nor docked a
|
||||
// rack adjustment), and their rack is disposed of per the game's DropoutTiles
|
||||
// setting without ever being revealed to the remaining players. In a game with
|
||||
// three or more seats the others play on with the resigned seat skipped, until
|
||||
// one active seat is left (it wins) or the game ends by the ordinary conditions;
|
||||
// the game finishes with EndResign only once a single active seat remains. A
|
||||
// two-player game therefore ends on the first resignation, the other player
|
||||
// winning regardless of score. A missed-turn timeout reuses Resign in the game
|
||||
// domain, so it inherits this win/loss.
|
||||
func (g *Game) Resign() (MoveRecord, error) {
|
||||
if g.over {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
player := g.toMove
|
||||
g.resignedSeat = player
|
||||
g.resigned[player] = true
|
||||
g.disposeHand(player)
|
||||
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
|
||||
g.log = append(g.log, rec)
|
||||
g.finish(EndResign)
|
||||
if g.activeCount() <= 1 {
|
||||
g.finish(EndResign)
|
||||
} else {
|
||||
g.advance()
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
@@ -330,20 +380,55 @@ func (g *Game) endTurnAfterScoreless() {
|
||||
g.advance()
|
||||
}
|
||||
|
||||
// advance moves play to the next seat.
|
||||
func (g *Game) advance() { g.toMove = (g.toMove + 1) % len(g.hands) }
|
||||
// advance moves play to the next active (non-resigned) seat. While a game is in
|
||||
// progress at least two seats are active, so a next active seat always exists;
|
||||
// the loop leaves toMove unchanged in the degenerate all-but-one-resigned case,
|
||||
// which Resign turns into a finished game instead.
|
||||
func (g *Game) advance() {
|
||||
n := len(g.hands)
|
||||
for i := 1; i <= n; i++ {
|
||||
next := (g.toMove + i) % n
|
||||
if !g.resigned[next] {
|
||||
g.toMove = next
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// activeCount returns the number of seats that have not resigned.
|
||||
func (g *Game) activeCount() int {
|
||||
n := 0
|
||||
for _, r := range g.resigned {
|
||||
if !r {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// disposeHand empties a resigned player's rack per the game's DropoutTiles
|
||||
// setting: it returns the tiles to the bag or removes them from play. Either way
|
||||
// the hand is cleared, so the end-game rack adjustment ignores the seat and the
|
||||
// rack is never exposed.
|
||||
func (g *Game) disposeHand(player int) {
|
||||
if g.dropoutTiles == DropoutReturn {
|
||||
g.bag.Return(g.hands[player])
|
||||
}
|
||||
g.hands[player] = nil
|
||||
}
|
||||
|
||||
// winner returns the index of the single highest-scoring player, or -1 on a tie
|
||||
// for the lead or while the game is unfinished. After a resignation the resigner
|
||||
// is excluded, so a two-player game returns the remaining player even when the
|
||||
// resigner led on score.
|
||||
// for the lead or while the game is unfinished. Resigned (dropped-out) seats are
|
||||
// always excluded, so a two-player game returns the remaining player even when
|
||||
// the resigner led on score, and a multi-player game never awards the win to a
|
||||
// seat that left.
|
||||
func (g *Game) winner() int {
|
||||
if !g.over {
|
||||
return -1
|
||||
}
|
||||
best, tie := -1, false
|
||||
for i := range g.scores {
|
||||
if g.reason == EndResign && i == g.resignedSeat {
|
||||
if g.resigned[i] {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
|
||||
@@ -79,3 +79,200 @@ func TestResignOnFinishedGame(t *testing.T) {
|
||||
t.Error("resign on a finished game must error")
|
||||
}
|
||||
}
|
||||
|
||||
// openingGameN returns a players-seat English game whose opening rack has a legal
|
||||
// move, searching a deterministic range of seeds.
|
||||
func openingGameN(t *testing.T, players int, dt DropoutTiles) *Game {
|
||||
t.Helper()
|
||||
for seed := int64(1); seed <= 100; seed++ {
|
||||
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: players, Seed: seed, DropoutTiles: dt})
|
||||
if err != nil {
|
||||
t.Fatalf("new game: %v", err)
|
||||
}
|
||||
if len(g.GenerateMoves()) > 0 {
|
||||
return g
|
||||
}
|
||||
}
|
||||
t.Fatal("no opening move found in seeds 1..100")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestMultiplayerResignContinues proves that in a three-player game one
|
||||
// resignation does not end the game and the resigned seat is skipped in rotation.
|
||||
func TestMultiplayerResignContinues(t *testing.T) {
|
||||
g := openingGameN(t, 3, DropoutRemove)
|
||||
if _, err := g.Resign(); err != nil { // seat 0
|
||||
t.Fatalf("seat 0 resign: %v", err)
|
||||
}
|
||||
if g.Over() {
|
||||
t.Fatal("a three-player game must continue after one resignation")
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1 (seat 0 skipped)", g.ToMove())
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 1
|
||||
t.Fatalf("seat 1 pass: %v", err)
|
||||
}
|
||||
if g.ToMove() != 2 {
|
||||
t.Errorf("to move = %d, want 2", g.ToMove())
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 2
|
||||
t.Fatalf("seat 2 pass: %v", err)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1 (seat 0 skipped on wrap)", g.ToMove())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiplayerLastActiveWins proves that as seats drop out the sole survivor
|
||||
// wins even when trailing, and resigners keep their (frozen) scores.
|
||||
func TestMultiplayerLastActiveWins(t *testing.T) {
|
||||
g := openingGameN(t, 3, DropoutRemove)
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 takes the lead
|
||||
if err != nil {
|
||||
t.Fatalf("seat 0 play: %v", err)
|
||||
}
|
||||
if played.Score == 0 {
|
||||
t.Fatal("opening play scored 0; pick a different seed")
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 1
|
||||
t.Fatalf("seat 1 pass: %v", err)
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 2
|
||||
t.Fatalf("seat 2 pass: %v", err)
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 0 (leader) drops out
|
||||
t.Fatalf("seat 0 resign: %v", err)
|
||||
}
|
||||
if g.Over() {
|
||||
t.Fatal("game must continue with two active seats")
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Fatalf("to move = %d, want 1", g.ToMove())
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 1 drops out, leaving only seat 2
|
||||
t.Fatalf("seat 1 resign: %v", err)
|
||||
}
|
||||
if !g.Over() || g.Reason() != EndResign {
|
||||
t.Fatalf("over=%v reason=%v, want over with resign", g.Over(), g.Reason())
|
||||
}
|
||||
res := g.Result()
|
||||
if res.Winner != 2 {
|
||||
t.Errorf("winner = %d, want 2 (sole survivor) despite trailing", res.Winner)
|
||||
}
|
||||
if g.Score(0) != played.Score {
|
||||
t.Errorf("resigner seat 0 score = %d, want frozen at %d", g.Score(0), played.Score)
|
||||
}
|
||||
if g.Score(2) != 0 {
|
||||
t.Errorf("survivor seat 2 score = %d, want 0", g.Score(2))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropoutTileDisposition proves the per-game setting governs the bag: remove
|
||||
// leaves it unchanged, return adds the leaver's full rack back.
|
||||
func TestDropoutTileDisposition(t *testing.T) {
|
||||
const seed = 7
|
||||
remove, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 3, Seed: seed, DropoutTiles: DropoutRemove})
|
||||
if err != nil {
|
||||
t.Fatalf("new remove game: %v", err)
|
||||
}
|
||||
ret, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 3, Seed: seed, DropoutTiles: DropoutReturn})
|
||||
if err != nil {
|
||||
t.Fatalf("new return game: %v", err)
|
||||
}
|
||||
bagBefore := remove.BagLen()
|
||||
if ret.BagLen() != bagBefore {
|
||||
t.Fatalf("identical seeds must start with equal bags: %d vs %d", remove.BagLen(), ret.BagLen())
|
||||
}
|
||||
rackSize := remove.rules.RackSize // seat 0 holds a full rack on the opening turn
|
||||
|
||||
if _, err := remove.Resign(); err != nil {
|
||||
t.Fatalf("remove resign: %v", err)
|
||||
}
|
||||
if _, err := ret.Resign(); err != nil {
|
||||
t.Fatalf("return resign: %v", err)
|
||||
}
|
||||
if remove.BagLen() != bagBefore {
|
||||
t.Errorf("remove: bag = %d, want unchanged %d", remove.BagLen(), bagBefore)
|
||||
}
|
||||
if ret.BagLen() != bagBefore+rackSize {
|
||||
t.Errorf("return: bag = %d, want %d (rack returned)", ret.BagLen(), bagBefore+rackSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResignedSeatExcludedFromWinOnScorelessEnd proves a resigned seat never wins
|
||||
// even when the game ends by the scoreless limit rather than by the resignation.
|
||||
func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
|
||||
g := openingGameN(t, 3, DropoutRemove)
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 leads
|
||||
if err != nil {
|
||||
t.Fatalf("seat 0 play: %v", err)
|
||||
}
|
||||
if played.Score == 0 {
|
||||
t.Fatal("opening play scored 0; pick a different seed")
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 1
|
||||
t.Fatalf("seat 1 pass: %v", err)
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 2
|
||||
t.Fatalf("seat 2 pass: %v", err)
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 0 drops out while leading
|
||||
t.Fatalf("seat 0 resign: %v", err)
|
||||
}
|
||||
for !g.Over() { // seats 1 and 2 pass until the six-scoreless limit ends it
|
||||
if _, err := g.Pass(); err != nil {
|
||||
t.Fatalf("pass: %v", err)
|
||||
}
|
||||
}
|
||||
if g.Reason() != EndScoreless {
|
||||
t.Fatalf("reason = %v, want scoreless", g.Reason())
|
||||
}
|
||||
if res := g.Result(); res.Winner == 0 {
|
||||
t.Error("winner = 0, but the resigned leader must be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFourPlayerDropToTwoContinues proves two drop-outs in a four-player game
|
||||
// leave the remaining two playing on, skipping both resigned seats.
|
||||
func TestFourPlayerDropToTwoContinues(t *testing.T) {
|
||||
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 4, Seed: 3, DropoutTiles: DropoutRemove})
|
||||
if err != nil {
|
||||
t.Fatalf("new game: %v", err)
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 0
|
||||
t.Fatalf("seat 0 resign: %v", err)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Fatalf("to move = %d, want 1", g.ToMove())
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 1
|
||||
t.Fatalf("seat 1 resign: %v", err)
|
||||
}
|
||||
if g.Over() {
|
||||
t.Fatal("game with two active seats must continue")
|
||||
}
|
||||
if g.ToMove() != 2 {
|
||||
t.Errorf("to move = %d, want 2", g.ToMove())
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 2
|
||||
t.Fatalf("seat 2 pass: %v", err)
|
||||
}
|
||||
if g.ToMove() != 3 {
|
||||
t.Errorf("to move = %d, want 3", g.ToMove())
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 3
|
||||
t.Fatalf("seat 3 pass: %v", err)
|
||||
}
|
||||
if g.ToMove() != 2 {
|
||||
t.Errorf("to move = %d, want 2 (seats 0,1 skipped)", g.ToMove())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,11 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
seed = svc.rng()
|
||||
}
|
||||
g, err := engine.New(svc.registry, engine.Options{
|
||||
Variant: params.Variant,
|
||||
Version: svc.version,
|
||||
Players: len(params.Seats),
|
||||
Seed: seed,
|
||||
Variant: params.Variant,
|
||||
Version: svc.version,
|
||||
Players: len(params.Seats),
|
||||
Seed: seed,
|
||||
DropoutTiles: params.DropoutTiles,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
|
||||
@@ -114,6 +115,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
turnTimeoutSecs: int(timeout / time.Second),
|
||||
hintsAllowed: params.HintsAllowed,
|
||||
hintsPerPlayer: params.HintsPerPlayer,
|
||||
dropoutTiles: params.DropoutTiles.String(),
|
||||
}
|
||||
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
|
||||
return Game{}, err
|
||||
@@ -455,6 +457,22 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Participants returns the seated account IDs in seat order, the seat index whose
|
||||
// turn it is, and the game status. It is a snapshot read (no engine, no lock) that
|
||||
// lets the social package gate per-game chat and nudges without importing the
|
||||
// engine or the game's private state.
|
||||
func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
seats := make([]uuid.UUID, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
seats[s.Seat] = s.AccountID
|
||||
}
|
||||
return seats, g.ToMove, g.Status, nil
|
||||
}
|
||||
|
||||
// History returns a game's full, dictionary-independent move journal.
|
||||
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
@@ -506,10 +524,11 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
|
||||
return nil, err
|
||||
}
|
||||
g, err := engine.New(svc.registry, engine.Options{
|
||||
Variant: pre.Variant,
|
||||
Version: pre.DictVersion,
|
||||
Players: pre.Players,
|
||||
Seed: seed,
|
||||
Variant: pre.Variant,
|
||||
Version: pre.DictVersion,
|
||||
Players: pre.Players,
|
||||
Seed: seed,
|
||||
DropoutTiles: pre.DropoutTiles,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -37,6 +37,7 @@ type gameInsert struct {
|
||||
turnTimeoutSecs int
|
||||
hintsAllowed bool
|
||||
hintsPerPlayer int
|
||||
dropoutTiles string
|
||||
}
|
||||
|
||||
// statDelta is one account's contribution to its statistics on a game finish.
|
||||
@@ -91,7 +92,8 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUI
|
||||
gi := table.Games.INSERT(
|
||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||
table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer)
|
||||
table.Games.DropoutTiles,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles)
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
}
|
||||
@@ -367,6 +369,10 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
if err != nil {
|
||||
return Game{}, fmt.Errorf("game: %s: %w", g.GameID, err)
|
||||
}
|
||||
dropout, err := engine.ParseDropoutTiles(g.DropoutTiles)
|
||||
if err != nil {
|
||||
return Game{}, fmt.Errorf("game: %s: %w", g.GameID, err)
|
||||
}
|
||||
out := Game{
|
||||
ID: g.GameID,
|
||||
Variant: variant,
|
||||
@@ -378,6 +384,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
TurnTimeout: time.Duration(g.TurnTimeoutSecs) * time.Second,
|
||||
HintsAllowed: g.HintsAllowed,
|
||||
HintsPerPlayer: int(g.HintsPerPlayer),
|
||||
DropoutTiles: dropout,
|
||||
MoveCount: int(g.MoveCount),
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
|
||||
@@ -60,8 +60,9 @@ type CreateParams struct {
|
||||
Seats []uuid.UUID
|
||||
TurnTimeout time.Duration // one of AllowedTurnTimeouts; zero → DefaultTurnTimeout
|
||||
HintsAllowed bool
|
||||
HintsPerPlayer int // starting per-seat hint allowance
|
||||
Seed int64 // zero → a random seed is chosen
|
||||
HintsPerPlayer int // starting per-seat hint allowance
|
||||
DropoutTiles engine.DropoutTiles // disposition of a dropped-out seat's tiles (3+ players); zero → remove
|
||||
Seed int64 // zero → a random seed is chosen
|
||||
}
|
||||
|
||||
// Game is the persisted state of a match: the games row joined with its seats.
|
||||
@@ -76,6 +77,7 @@ type Game struct {
|
||||
TurnTimeout time.Duration
|
||||
HintsAllowed bool
|
||||
HintsPerPlayer int
|
||||
DropoutTiles engine.DropoutTiles
|
||||
MoveCount int
|
||||
EndReason string // "" while active
|
||||
Seats []Seat
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
|
||||
// capturingMailer records the last message instead of sending it, so tests can
|
||||
// recover the confirm-code from the body.
|
||||
type capturingMailer struct{ lastBody string }
|
||||
|
||||
func (m *capturingMailer) Send(_ context.Context, _, _, body string) error {
|
||||
m.lastBody = body
|
||||
return nil
|
||||
}
|
||||
|
||||
var sixDigit = regexp.MustCompile(`\d{6}`)
|
||||
|
||||
// TestEmailConfirmFlow covers the happy path: request a code, confirm it, and the
|
||||
// email becomes a confirmed identity of the account.
|
||||
func TestEmailConfirmFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer)
|
||||
|
||||
acc := provisionAccount(t)
|
||||
email := "user-" + uuid.NewString() + "@example.com"
|
||||
|
||||
if err := svc.RequestCode(ctx, acc, email); err != nil {
|
||||
t.Fatalf("request code: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if code == "" {
|
||||
t.Fatalf("no code in mail body %q", mailer.lastBody)
|
||||
}
|
||||
|
||||
// A wrong code is rejected without confirming.
|
||||
if _, err := svc.ConfirmCode(ctx, acc, email, "000000"); !errors.Is(err, account.ErrCodeMismatch) && !errors.Is(err, account.ErrTooManyAttempts) {
|
||||
t.Fatalf("wrong code = %v, want mismatch", err)
|
||||
}
|
||||
got, err := svc.ConfirmCode(ctx, acc, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm code: %v", err)
|
||||
}
|
||||
if got.ID != acc {
|
||||
t.Errorf("confirmed account = %s, want %s", got.ID, acc)
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("email identity must be confirmed after a correct code")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailAlreadyTakenByAnotherAccount refuses to bind an email confirmed by a
|
||||
// different account (merge is a later stage).
|
||||
func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer)
|
||||
|
||||
owner := provisionAccount(t)
|
||||
email := "taken-" + uuid.NewString() + "@example.com"
|
||||
if err := svc.RequestCode(ctx, owner, email); err != nil {
|
||||
t.Fatalf("owner request: %v", err)
|
||||
}
|
||||
if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
|
||||
t.Fatalf("owner confirm: %v", err)
|
||||
}
|
||||
|
||||
other := provisionAccount(t)
|
||||
if err := svc.RequestCode(ctx, other, email); !errors.Is(err, account.ErrEmailTaken) {
|
||||
t.Fatalf("other request = %v, want ErrEmailTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailCodeExpires rejects a code past its TTL (backdated directly).
|
||||
func TestEmailCodeExpires(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer)
|
||||
|
||||
acc := provisionAccount(t)
|
||||
email := "expire-" + uuid.NewString() + "@example.com"
|
||||
if err := svc.RequestCode(ctx, acc, email); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.email_confirmations SET expires_at = now() - interval '1 minute' WHERE account_id = $1`, acc); err != nil {
|
||||
t.Fatalf("backdate expiry: %v", err)
|
||||
}
|
||||
if _, err := svc.ConfirmCode(ctx, acc, email, code); !errors.Is(err, account.ErrCodeExpired) {
|
||||
t.Fatalf("confirm expired = %v, want ErrCodeExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailTooManyAttempts locks a code after the attempt cap.
|
||||
func TestEmailTooManyAttempts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer)
|
||||
|
||||
acc := provisionAccount(t)
|
||||
email := "lock-" + uuid.NewString() + "@example.com"
|
||||
if err := svc.RequestCode(ctx, acc, email); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
// Five wrong tries are mismatches; the sixth is locked out.
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.ConfirmCode(ctx, acc, email, "999999"); !errors.Is(err, account.ErrCodeMismatch) {
|
||||
t.Fatalf("attempt %d = %v, want ErrCodeMismatch", i+1, err)
|
||||
}
|
||||
}
|
||||
if _, err := svc.ConfirmCode(ctx, acc, email, "999999"); !errors.Is(err, account.ErrTooManyAttempts) {
|
||||
t.Fatalf("after cap = %v, want ErrTooManyAttempts", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateProfilePersists writes a full profile and reads it back.
|
||||
func TestUpdateProfilePersists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
acc := provisionAccount(t)
|
||||
|
||||
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
|
||||
DisplayName: "Kaya",
|
||||
PreferredLanguage: "ru",
|
||||
TimeZone: "Europe/Moscow",
|
||||
BlockChat: true,
|
||||
BlockFriendRequests: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update profile: %v", err)
|
||||
}
|
||||
if updated.DisplayName != "Kaya" || updated.PreferredLanguage != "ru" || updated.TimeZone != "Europe/Moscow" {
|
||||
t.Errorf("profile not applied: %+v", updated)
|
||||
}
|
||||
if !updated.BlockChat || !updated.BlockFriendRequests {
|
||||
t.Errorf("block toggles not applied: %+v", updated)
|
||||
}
|
||||
reloaded, err := store.GetByID(ctx, acc)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat {
|
||||
t.Errorf("profile did not persist: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/lobby"
|
||||
)
|
||||
|
||||
// newInvitationService builds an invitation service over the shared pool, starting
|
||||
// games through a real game service and reading blocks through a social service.
|
||||
func newInvitationService() *lobby.InvitationService {
|
||||
return lobby.NewInvitationService(lobby.NewStore(testDB), newGameService(), account.NewStore(testDB), newSocialService())
|
||||
}
|
||||
|
||||
func englishInvite() lobby.InvitationSettings {
|
||||
return lobby.InvitationSettings{
|
||||
Variant: engine.VariantEnglish,
|
||||
TurnTimeout: 24 * time.Hour,
|
||||
HintsAllowed: true,
|
||||
HintsPerPlayer: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakingPairsAndStartsGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := lobby.NewMatchmaker(newGameService())
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must wait")
|
||||
}
|
||||
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue b: %v", err)
|
||||
}
|
||||
if !r2.Matched {
|
||||
t.Fatal("second enqueue must match")
|
||||
}
|
||||
seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != "active" || len(seats) != 2 {
|
||||
t.Fatalf("matched game state: status %q seats %v", status, seats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationAllAcceptStartsGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if inv.Status != "pending" || len(inv.Invitees) != 2 {
|
||||
t.Fatalf("unexpected invitation: %+v", inv)
|
||||
}
|
||||
|
||||
if got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); err != nil || got.Status != "pending" {
|
||||
t.Fatalf("first accept: status %q err %v", got.Status, err)
|
||||
}
|
||||
final, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true)
|
||||
if err != nil {
|
||||
t.Fatalf("second accept: %v", err)
|
||||
}
|
||||
if final.Status != "started" || final.GameID == nil {
|
||||
t.Fatalf("invitation not started: %+v", final)
|
||||
}
|
||||
seats, _, status, err := newGameService().Participants(ctx, *final.GameID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != "active" || len(seats) != 3 || seats[0] != inviter {
|
||||
t.Fatalf("started game: status %q seats %v (inviter %s)", status, seats, inviter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationDeclineCancels(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], false)
|
||||
if err != nil {
|
||||
t.Fatalf("decline: %v", err)
|
||||
}
|
||||
if got.Status != "declined" || got.GameID != nil {
|
||||
t.Fatalf("after decline: %+v", got)
|
||||
}
|
||||
// A further response is refused.
|
||||
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
|
||||
t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationLazyExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t)}
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.game_invitations SET expires_at = now() - interval '1 minute' WHERE invitation_id = $1`, inv.ID); err != nil {
|
||||
t.Fatalf("backdate expiry: %v", err)
|
||||
}
|
||||
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationExpired) {
|
||||
t.Fatalf("respond expired = %v, want ErrInvitationExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationBlockedInvitee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
social := newSocialService()
|
||||
inviter := provisionAccount(t)
|
||||
invitee := provisionAccount(t)
|
||||
if err := social.Block(ctx, invitee, inviter); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationCancelByInviter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t)}
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
// A non-inviter cannot cancel.
|
||||
if err := svc.CancelInvitation(ctx, inv.ID, invitees[0]); !errors.Is(err, lobby.ErrNotInviter) {
|
||||
t.Fatalf("stranger cancel = %v, want ErrNotInviter", err)
|
||||
}
|
||||
if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil {
|
||||
t.Fatalf("inviter cancel: %v", err)
|
||||
}
|
||||
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
|
||||
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestMultiplayerTimeoutContinues drives a three-player game through the domain:
|
||||
// the first timeout drops a seat but the game plays on, and the second leaves a
|
||||
// sole survivor who wins. Empty away windows make the timeouts deterministic.
|
||||
func TestMultiplayerTimeoutContinues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t), provisionAccount(t)}
|
||||
for _, s := range seats {
|
||||
setAway(t, s, "UTC", "00:00", "00:00") // empty window → no away grace
|
||||
}
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 42,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if g.Players != 3 {
|
||||
t.Fatalf("players = %d, want 3", g.Players)
|
||||
}
|
||||
|
||||
// Seat 0 (to move) goes overdue. It times out, but two seats remain, so the
|
||||
// game continues and the turn advances off the dropped seat.
|
||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||
if _, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("first sweep: %v", err)
|
||||
}
|
||||
h, err := svc.History(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if h.Game.Status != game.StatusActive {
|
||||
t.Fatalf("a three-player game must continue after one timeout, status %q", h.Game.Status)
|
||||
}
|
||||
if h.Game.ToMove == 0 {
|
||||
t.Errorf("to-move should advance off the timed-out seat 0, got %d", h.Game.ToMove)
|
||||
}
|
||||
|
||||
// The next seat to move also times out, leaving a single active seat: the game
|
||||
// finishes and the sole survivor wins.
|
||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||
if _, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("second sweep: %v", err)
|
||||
}
|
||||
h2, err := svc.History(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("history 2: %v", err)
|
||||
}
|
||||
if h2.Game.Status != game.StatusFinished || h2.Game.EndReason != "timeout" {
|
||||
t.Fatalf("game should finish on the second timeout: status %q reason %q", h2.Game.Status, h2.Game.EndReason)
|
||||
}
|
||||
winners := 0
|
||||
for _, s := range h2.Game.Seats {
|
||||
if s.IsWinner {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Errorf("want exactly one surviving winner, got %d", winners)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// newSocialService builds a social service over the shared pool, reading game
|
||||
// state through a real game service.
|
||||
func newSocialService() *social.Service {
|
||||
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
|
||||
}
|
||||
|
||||
// newGameWithSeats creates a started game seating n fresh accounts and returns the
|
||||
// game id and the seated account ids in seat order.
|
||||
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
||||
t.Helper()
|
||||
seats := make([]uuid.UUID, n)
|
||||
for i := range seats {
|
||||
seats[i] = provisionAccount(t)
|
||||
}
|
||||
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create game: %v", err)
|
||||
}
|
||||
return g.ID, seats
|
||||
}
|
||||
|
||||
func TestFriendRequestLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
// A duplicate request in either direction is refused.
|
||||
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestExists) {
|
||||
t.Fatalf("duplicate = %v, want ErrRequestExists", err)
|
||||
}
|
||||
if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 1 || got[0] != a {
|
||||
t.Fatalf("incoming for b = %v, want [a]", got)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
for _, who := range []uuid.UUID{a, b} {
|
||||
friends, err := svc.ListFriends(ctx, who)
|
||||
if err != nil {
|
||||
t.Fatalf("list friends: %v", err)
|
||||
}
|
||||
if len(friends) != 1 {
|
||||
t.Fatalf("friends of %s = %v, want one", who, friends)
|
||||
}
|
||||
}
|
||||
if err := svc.Unfriend(ctx, a, b); err != nil {
|
||||
t.Fatalf("unfriend: %v", err)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
|
||||
t.Errorf("friends after unfriend = %v, want none", friends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
store := account.NewStore(testDB)
|
||||
|
||||
// Toggle: the addressee does not accept friend requests.
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil {
|
||||
t.Fatalf("set toggle: %v", err)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) {
|
||||
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
|
||||
}
|
||||
|
||||
// Block: the addressee has blocked the requester.
|
||||
c, d := provisionAccount(t), provisionAccount(t)
|
||||
if err := svc.Block(ctx, d, c); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
|
||||
t.Fatalf("blocked send = %v, want ErrRequestBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockSeversFriendship(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if err := svc.Block(ctx, a, b); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
|
||||
t.Errorf("friendship must be severed by a block, got %v", friends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatPostListAndBlocks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
store := account.NewStore(testDB)
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
|
||||
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.7"); err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
msgs, err := svc.Messages(ctx, gameID, seats[1])
|
||||
if err != nil {
|
||||
t.Fatalf("messages: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].Body != "good luck" || msgs[0].SenderIP != "203.0.113.7" {
|
||||
t.Fatalf("unexpected messages: %+v", msgs)
|
||||
}
|
||||
|
||||
// A per-user block hides the blocked sender's messages from the viewer.
|
||||
if err := svc.Block(ctx, seats[1], seats[0]); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if msgs, _ := svc.Messages(ctx, gameID, seats[1]); len(msgs) != 0 {
|
||||
t.Errorf("blocked sender's messages still visible: %+v", msgs)
|
||||
}
|
||||
|
||||
// A viewer who disabled chat sees no messages.
|
||||
other, seats2 := newGameWithSeats(t, 2)
|
||||
if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil {
|
||||
t.Fatalf("post 2: %v", err)
|
||||
}
|
||||
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil {
|
||||
t.Fatalf("set block_chat: %v", err)
|
||||
}
|
||||
if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 {
|
||||
t.Errorf("block_chat viewer should see no messages, got %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRejectsBadContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
|
||||
if _, err := svc.PostMessage(ctx, gameID, seats[0], "join evil.example.com now", ""); !errors.Is(err, social.ErrForbiddenContent) {
|
||||
t.Fatalf("link post = %v, want ErrForbiddenContent", err)
|
||||
}
|
||||
if _, err := svc.PostMessage(ctx, gameID, seats[0], strings.Repeat("a", 61), ""); !errors.Is(err, social.ErrMessageTooLong) {
|
||||
t.Fatalf("long post = %v, want ErrMessageTooLong", err)
|
||||
}
|
||||
// A non-participant cannot post.
|
||||
if _, err := svc.PostMessage(ctx, gameID, provisionAccount(t), "hi", ""); !errors.Is(err, social.ErrNotParticipant) {
|
||||
t.Fatalf("stranger post = %v, want ErrNotParticipant", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeRulesAndRateLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the start
|
||||
|
||||
// The player to move cannot nudge; the waiting opponent can.
|
||||
if _, err := svc.Nudge(ctx, gameID, seats[0]); !errors.Is(err, social.ErrNudgeOnOwnTurn) {
|
||||
t.Fatalf("to-move nudge = %v, want ErrNudgeOnOwnTurn", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
|
||||
t.Fatalf("opponent nudge: %v", err)
|
||||
}
|
||||
// A second nudge within the hour is refused.
|
||||
if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) {
|
||||
t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err)
|
||||
}
|
||||
// Backdating the last nudge past the window allows another.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.chat_messages SET created_at = now() - interval '2 hours' WHERE game_id = $1 AND kind = 'nudge'`, gameID); err != nil {
|
||||
t.Fatalf("backdate nudge: %v", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
|
||||
t.Fatalf("nudge after window: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// invitationTTL is how long an unanswered invitation stays open before it lazily
|
||||
// expires.
|
||||
const invitationTTL = 7 * 24 * time.Hour
|
||||
|
||||
// Invitation statuses.
|
||||
const (
|
||||
invitationPending = "pending"
|
||||
invitationDeclined = "declined"
|
||||
invitationCancelled = "cancelled"
|
||||
invitationExpired = "expired"
|
||||
invitationStarted = "started"
|
||||
)
|
||||
|
||||
// Invitee responses.
|
||||
const (
|
||||
inviteePending = "pending"
|
||||
inviteeAccepted = "accepted"
|
||||
inviteeDeclined = "declined"
|
||||
)
|
||||
|
||||
// InvitationSettings are the game settings an inviter chooses. A zero TurnTimeout
|
||||
// defaults to game.DefaultTurnTimeout; the zero DropoutTiles removes a leaver's
|
||||
// tiles from play.
|
||||
type InvitationSettings struct {
|
||||
Variant engine.Variant
|
||||
TurnTimeout time.Duration
|
||||
HintsAllowed bool
|
||||
HintsPerPlayer int
|
||||
DropoutTiles engine.DropoutTiles
|
||||
}
|
||||
|
||||
// Invitee is one invited player's seat and response.
|
||||
type Invitee struct {
|
||||
AccountID uuid.UUID
|
||||
Seat int
|
||||
Response string
|
||||
}
|
||||
|
||||
// Invitation is a friend-game invitation with its invitees.
|
||||
type Invitation struct {
|
||||
ID uuid.UUID
|
||||
InviterID uuid.UUID
|
||||
Settings InvitationSettings
|
||||
Status string
|
||||
GameID *uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
Invitees []Invitee
|
||||
}
|
||||
|
||||
// InvitationService creates and resolves friend-game invitations, starting the
|
||||
// game through a GameCreator once every invitee has accepted.
|
||||
type InvitationService struct {
|
||||
store *Store
|
||||
games GameCreator
|
||||
accounts *account.Store
|
||||
blocker Blocker
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewInvitationService constructs an InvitationService. store owns the invitation
|
||||
// tables; games starts the accepted game; accounts validates invitees; blocker
|
||||
// refuses invitations across a block.
|
||||
func NewInvitationService(store *Store, games GameCreator, accounts *account.Store, blocker Blocker) *InvitationService {
|
||||
return &InvitationService{
|
||||
store: store,
|
||||
games: games,
|
||||
accounts: accounts,
|
||||
blocker: blocker,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
// CreateInvitation records a pending invitation from inviterID to inviteeIDs (in
|
||||
// seat order, 1..N) with the given settings. The total seat count must be 2-4,
|
||||
// invitees distinct and not the inviter, every invitee an existing account with no
|
||||
// block standing between them, and the settings acceptable.
|
||||
func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uuid.UUID, inviteeIDs []uuid.UUID, settings InvitationSettings) (Invitation, error) {
|
||||
if n := len(inviteeIDs) + 1; n < 2 || n > 4 {
|
||||
return Invitation{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidInvitation, n)
|
||||
}
|
||||
if settings.HintsPerPlayer < 0 {
|
||||
return Invitation{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidInvitation)
|
||||
}
|
||||
if settings.TurnTimeout == 0 {
|
||||
settings.TurnTimeout = game.DefaultTurnTimeout
|
||||
}
|
||||
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
|
||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||
}
|
||||
seen := map[uuid.UUID]bool{inviterID: true}
|
||||
for _, id := range inviteeIDs {
|
||||
if seen[id] {
|
||||
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
|
||||
}
|
||||
seen[id] = true
|
||||
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
return Invitation{}, fmt.Errorf("%w: invitee %s not found", ErrInvalidInvitation, id)
|
||||
}
|
||||
return Invitation{}, err
|
||||
}
|
||||
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if blocked {
|
||||
return Invitation{}, ErrInvitationBlocked
|
||||
}
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Invitation{}, fmt.Errorf("lobby: new invitation id: %w", err)
|
||||
}
|
||||
ins := invitationInsert{
|
||||
id: id,
|
||||
inviterID: inviterID,
|
||||
variant: settings.Variant.String(),
|
||||
turnTimeoutSecs: int(settings.TurnTimeout / time.Second),
|
||||
hintsAllowed: settings.HintsAllowed,
|
||||
hintsPerPlayer: settings.HintsPerPlayer,
|
||||
dropoutTiles: settings.DropoutTiles.String(),
|
||||
expiresAt: svc.now().Add(invitationTTL),
|
||||
}
|
||||
if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
return svc.store.loadInvitation(ctx, id)
|
||||
}
|
||||
|
||||
// RespondInvitation records accountID's accept or decline of an invitation. A
|
||||
// decline cancels the whole invitation; the accept that completes the set starts
|
||||
// the game and marks the invitation started.
|
||||
func (svc *InvitationService) RespondInvitation(ctx context.Context, invitationID, accountID uuid.UUID, accept bool) (Invitation, error) {
|
||||
res, err := svc.store.respondTx(ctx, invitationID, accountID, accept, svc.now())
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if accept && res.allAccepted {
|
||||
if err := svc.startGame(ctx, invitationID); err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
}
|
||||
return svc.store.loadInvitation(ctx, invitationID)
|
||||
}
|
||||
|
||||
// startGame creates the game for a fully-accepted invitation and marks it started.
|
||||
func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.UUID) error {
|
||||
inv, err := svc.store.loadInvitation(ctx, invitationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seats := make([]uuid.UUID, len(inv.Invitees)+1)
|
||||
seats[0] = inv.InviterID
|
||||
for _, iv := range inv.Invitees {
|
||||
if iv.Seat < 1 || iv.Seat >= len(seats) {
|
||||
return fmt.Errorf("lobby: invitation %s has out-of-range seat %d", invitationID, iv.Seat)
|
||||
}
|
||||
seats[iv.Seat] = iv.AccountID
|
||||
}
|
||||
g, err := svc.games.Create(ctx, game.CreateParams{
|
||||
Variant: inv.Settings.Variant,
|
||||
Seats: seats,
|
||||
TurnTimeout: inv.Settings.TurnTimeout,
|
||||
HintsAllowed: inv.Settings.HintsAllowed,
|
||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||
DropoutTiles: inv.Settings.DropoutTiles,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelInvitation lets the inviter withdraw a pending invitation.
|
||||
func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error {
|
||||
return svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now())
|
||||
}
|
||||
|
||||
// GetInvitation loads an invitation with its invitees.
|
||||
func (svc *InvitationService) GetInvitation(ctx context.Context, invitationID uuid.UUID) (Invitation, error) {
|
||||
return svc.store.loadInvitation(ctx, invitationID)
|
||||
}
|
||||
|
||||
// invitationInsert carries the immutable fields of a new invitation.
|
||||
type invitationInsert struct {
|
||||
id uuid.UUID
|
||||
inviterID uuid.UUID
|
||||
variant string
|
||||
turnTimeoutSecs int
|
||||
hintsAllowed bool
|
||||
hintsPerPlayer int
|
||||
dropoutTiles string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// respondResult reports the state after an invitee response.
|
||||
type respondResult struct {
|
||||
allAccepted bool
|
||||
}
|
||||
|
||||
// insertInvitation inserts the invitation and one invitee row per id (seats 1..N).
|
||||
func (s *Store) insertInvitation(ctx context.Context, ins invitationInsert, inviteeIDs []uuid.UUID) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
ii := table.GameInvitations.INSERT(
|
||||
table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant,
|
||||
table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer,
|
||||
table.GameInvitations.DropoutTiles, table.GameInvitations.ExpiresAt,
|
||||
).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.expiresAt)
|
||||
if _, err := ii.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert invitation: %w", err)
|
||||
}
|
||||
for i, id := range inviteeIDs {
|
||||
pi := table.GameInvitationInvitees.INSERT(
|
||||
table.GameInvitationInvitees.InvitationID, table.GameInvitationInvitees.AccountID, table.GameInvitationInvitees.Seat,
|
||||
).VALUES(ins.id, id, i+1)
|
||||
if _, err := pi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert invitee %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// loadInvitation reads an invitation and its invitees ordered by seat.
|
||||
func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, error) {
|
||||
isel := postgres.SELECT(table.GameInvitations.AllColumns).
|
||||
FROM(table.GameInvitations).
|
||||
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(id))).
|
||||
LIMIT(1)
|
||||
var row model.GameInvitations
|
||||
if err := isel.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Invitation{}, ErrInvitationNotFound
|
||||
}
|
||||
return Invitation{}, fmt.Errorf("lobby: load invitation %s: %w", id, err)
|
||||
}
|
||||
variant, err := engine.ParseVariant(row.Variant)
|
||||
if err != nil {
|
||||
return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
|
||||
}
|
||||
dropout, err := engine.ParseDropoutTiles(row.DropoutTiles)
|
||||
if err != nil {
|
||||
return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
|
||||
}
|
||||
inv := Invitation{
|
||||
ID: row.InvitationID,
|
||||
InviterID: row.InviterID,
|
||||
Settings: InvitationSettings{
|
||||
Variant: variant,
|
||||
TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second,
|
||||
HintsAllowed: row.HintsAllowed,
|
||||
HintsPerPlayer: int(row.HintsPerPlayer),
|
||||
DropoutTiles: dropout,
|
||||
},
|
||||
Status: row.Status,
|
||||
GameID: row.GameID,
|
||||
ExpiresAt: row.ExpiresAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
psel := postgres.SELECT(table.GameInvitationInvitees.AllColumns).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(id))).
|
||||
ORDER_BY(table.GameInvitationInvitees.Seat.ASC())
|
||||
var prows []model.GameInvitationInvitees
|
||||
if err := psel.QueryContext(ctx, s.db, &prows); err != nil {
|
||||
return Invitation{}, fmt.Errorf("lobby: load invitees %s: %w", id, err)
|
||||
}
|
||||
for _, p := range prows {
|
||||
inv.Invitees = append(inv.Invitees, Invitee{AccountID: p.AccountID, Seat: int(p.Seat), Response: p.Response})
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// respondTx applies an invitee's response inside a row-locked transaction so
|
||||
// concurrent responses serialise and exactly one accept can complete the set.
|
||||
func (s *Store) respondTx(ctx context.Context, invitationID, accountID uuid.UUID, accept bool, now time.Time) (respondResult, error) {
|
||||
var res respondResult
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
isel := postgres.SELECT(table.GameInvitations.Status, table.GameInvitations.ExpiresAt).
|
||||
FROM(table.GameInvitations).
|
||||
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID))).
|
||||
FOR(postgres.UPDATE())
|
||||
var inv model.GameInvitations
|
||||
if err := isel.QueryContext(ctx, tx, &inv); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return ErrInvitationNotFound
|
||||
}
|
||||
return fmt.Errorf("lock invitation: %w", err)
|
||||
}
|
||||
if inv.Status == invitationPending && now.After(inv.ExpiresAt) {
|
||||
if err := setInvitationStatus(ctx, tx, invitationID, invitationExpired, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrInvitationExpired
|
||||
}
|
||||
if inv.Status != invitationPending {
|
||||
return ErrInvitationNotPending
|
||||
}
|
||||
|
||||
psel := postgres.SELECT(table.GameInvitationInvitees.Response).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(
|
||||
table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
|
||||
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
|
||||
).LIMIT(1)
|
||||
var invitee model.GameInvitationInvitees
|
||||
if err := psel.QueryContext(ctx, tx, &invitee); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return ErrNotInvited
|
||||
}
|
||||
return fmt.Errorf("load invitee: %w", err)
|
||||
}
|
||||
if invitee.Response != inviteePending {
|
||||
return ErrAlreadyResponded
|
||||
}
|
||||
|
||||
if !accept {
|
||||
if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeDeclined, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return setInvitationStatus(ctx, tx, invitationID, invitationDeclined, now)
|
||||
}
|
||||
|
||||
if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeAccepted, now); err != nil {
|
||||
return err
|
||||
}
|
||||
remaining, err := unacceptedInvitees(ctx, tx, invitationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.allAccepted = remaining == 0
|
||||
return nil
|
||||
})
|
||||
return res, err
|
||||
}
|
||||
|
||||
// markStarted stamps a fully-accepted invitation as started, only while it is
|
||||
// still pending, and reports whether it did.
|
||||
func (s *Store) markStarted(ctx context.Context, invitationID, gameID uuid.UUID, now time.Time) (bool, error) {
|
||||
stmt := table.GameInvitations.
|
||||
UPDATE(table.GameInvitations.Status, table.GameInvitations.GameID, table.GameInvitations.UpdatedAt).
|
||||
SET(postgres.String(invitationStarted), postgres.UUID(gameID), postgres.TimestampzT(now)).
|
||||
WHERE(
|
||||
table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
|
||||
AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lobby: mark started: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lobby: mark started rows: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// cancelInvitation withdraws a pending invitation on behalf of its inviter.
|
||||
func (s *Store) cancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID, now time.Time) error {
|
||||
stmt := table.GameInvitations.
|
||||
UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
|
||||
SET(postgres.String(invitationCancelled), postgres.TimestampzT(now)).
|
||||
WHERE(
|
||||
table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
|
||||
AND(table.GameInvitations.InviterID.EQ(postgres.UUID(inviterID))).
|
||||
AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lobby: cancel invitation: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lobby: cancel invitation rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
// Either the invitation is gone, not the caller's, or no longer pending.
|
||||
inv, err := s.loadInvitation(ctx, invitationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inv.InviterID != inviterID {
|
||||
return ErrNotInviter
|
||||
}
|
||||
return ErrInvitationNotPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unacceptedInvitees counts the invitees of an invitation not yet accepted.
|
||||
func unacceptedInvitees(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID) (int, error) {
|
||||
stmt := postgres.SELECT(table.GameInvitationInvitees.Response).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)))
|
||||
var rows []model.GameInvitationInvitees
|
||||
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
return 0, fmt.Errorf("count invitees: %w", err)
|
||||
}
|
||||
remaining := 0
|
||||
for _, r := range rows {
|
||||
if r.Response != inviteeAccepted {
|
||||
remaining++
|
||||
}
|
||||
}
|
||||
return remaining, nil
|
||||
}
|
||||
|
||||
// setInvitationStatus updates an invitation's status and updated_at.
|
||||
func setInvitationStatus(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID, status string, now time.Time) error {
|
||||
stmt := table.GameInvitations.
|
||||
UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
|
||||
SET(postgres.String(status), postgres.TimestampzT(now)).
|
||||
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)))
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("set invitation status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setInviteeResponse updates one invitee's response and responded_at.
|
||||
func setInviteeResponse(ctx context.Context, tx *sql.Tx, invitationID, accountID uuid.UUID, response string, now time.Time) error {
|
||||
stmt := table.GameInvitationInvitees.
|
||||
UPDATE(table.GameInvitationInvitees.Response, table.GameInvitationInvitees.RespondedAt).
|
||||
SET(postgres.String(response), postgres.TimestampzT(now)).
|
||||
WHERE(
|
||||
table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
|
||||
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("set invitee response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package lobby forms games: an in-memory matchmaking pool that pairs two humans
|
||||
// for an auto-match, and friend-game invitations (invite -> accept) that start a
|
||||
// 2-4 player game once every invitee has accepted. Both produce a game through the
|
||||
// game domain (a GameCreator); neither imports the engine. The matchmaking pool
|
||||
// is in-memory and lost on restart (players re-queue); the robot that substitutes
|
||||
// for a missing human after a short wait is added in a later stage.
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
||||
// game. game.Service satisfies it.
|
||||
type GameCreator interface {
|
||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||
}
|
||||
|
||||
// Blocker reports whether two accounts have a block between them (either
|
||||
// direction). social.Service satisfies it; the lobby uses it to refuse
|
||||
// invitations between blocked accounts.
|
||||
type Blocker interface {
|
||||
IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// Auto-match defaults: a casual two-player game on the longest move clock with one
|
||||
// hint per player (docs/ARCHITECTURE.md §6). The drop-out tile disposition is moot
|
||||
// for two players, so the engine default (remove) applies.
|
||||
const (
|
||||
autoMatchHintsAllowed = true
|
||||
autoMatchHintsPerPlayer = 1
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the lobby.
|
||||
var (
|
||||
// ErrAlreadyQueued is returned when an account already waits in a pool.
|
||||
ErrAlreadyQueued = errors.New("lobby: account already in the matchmaking pool")
|
||||
// ErrInvalidInvitation is returned for a malformed invitation (bad player
|
||||
// count, duplicate or self invitee, or unacceptable settings).
|
||||
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
|
||||
// ErrInvitationBlocked is returned when a block stands between the inviter and
|
||||
// an invitee.
|
||||
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
|
||||
// ErrInvitationNotFound is returned when no invitation matches the lookup.
|
||||
ErrInvitationNotFound = errors.New("lobby: invitation not found")
|
||||
// ErrInvitationNotPending is returned when an invitation is no longer open.
|
||||
ErrInvitationNotPending = errors.New("lobby: invitation is not pending")
|
||||
// ErrInvitationExpired is returned when an invitation has passed its deadline.
|
||||
ErrInvitationExpired = errors.New("lobby: invitation has expired")
|
||||
// ErrNotInvited is returned when an account is not an invitee of the invitation.
|
||||
ErrNotInvited = errors.New("lobby: account was not invited")
|
||||
// ErrAlreadyResponded is returned when an invitee has already accepted or declined.
|
||||
ErrAlreadyResponded = errors.New("lobby: invitee has already responded")
|
||||
// ErrNotInviter is returned when a non-inviter tries to cancel an invitation.
|
||||
ErrNotInviter = errors.New("lobby: only the inviter may cancel")
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
|
||||
// the next two humans into a two-player game. It holds no database state and is
|
||||
// lost on restart (players simply re-queue). It is safe for concurrent use.
|
||||
//
|
||||
// Auto-match is anonymous, so the pool does not consult per-user blocks (those
|
||||
// govern friends, chat and invitations between known players). Robot substitution
|
||||
// for a missing human is added in a later stage.
|
||||
type Matchmaker struct {
|
||||
games GameCreator
|
||||
|
||||
mu sync.Mutex
|
||||
queues map[engine.Variant][]uuid.UUID
|
||||
queued map[uuid.UUID]engine.Variant
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
// NewMatchmaker constructs a Matchmaker that starts matched games through games.
|
||||
func NewMatchmaker(games GameCreator) *Matchmaker {
|
||||
return &Matchmaker{
|
||||
games: games,
|
||||
queues: make(map[engine.Variant][]uuid.UUID),
|
||||
queued: make(map[uuid.UUID]engine.Variant),
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
// EnqueueResult reports the outcome of joining the pool: either a started game or a
|
||||
// queued ticket awaiting an opponent.
|
||||
type EnqueueResult struct {
|
||||
Matched bool
|
||||
Game game.Game
|
||||
}
|
||||
|
||||
// Enqueue joins accountID to the variant pool. If an opponent already waits, the
|
||||
// two are paired (seat order randomised for first-move fairness) and a game starts
|
||||
// immediately; otherwise the account waits. An account already waiting in any pool
|
||||
// gets ErrAlreadyQueued.
|
||||
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) {
|
||||
m.mu.Lock()
|
||||
if _, ok := m.queued[accountID]; ok {
|
||||
m.mu.Unlock()
|
||||
return EnqueueResult{}, ErrAlreadyQueued
|
||||
}
|
||||
q := m.queues[variant]
|
||||
if len(q) == 0 {
|
||||
m.queues[variant] = append(q, accountID)
|
||||
m.queued[accountID] = variant
|
||||
m.mu.Unlock()
|
||||
return EnqueueResult{}, nil
|
||||
}
|
||||
opponent := q[0]
|
||||
m.queues[variant] = q[1:]
|
||||
delete(m.queued, opponent)
|
||||
seats := []uuid.UUID{opponent, accountID}
|
||||
if m.rng.Intn(2) == 0 {
|
||||
seats[0], seats[1] = seats[1], seats[0]
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
g, err := m.games.Create(ctx, game.CreateParams{
|
||||
Variant: variant,
|
||||
Seats: seats,
|
||||
TurnTimeout: game.DefaultTurnTimeout,
|
||||
HintsAllowed: autoMatchHintsAllowed,
|
||||
HintsPerPlayer: autoMatchHintsPerPlayer,
|
||||
})
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
return EnqueueResult{Matched: true, Game: g}, nil
|
||||
}
|
||||
|
||||
// Cancel removes accountID from whatever pool it waits in, reporting whether it
|
||||
// was queued.
|
||||
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
variant, ok := m.queued[accountID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(m.queued, accountID)
|
||||
q := m.queues[variant]
|
||||
for i, id := range q {
|
||||
if id == accountID {
|
||||
m.queues[variant] = append(q[:i], q[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// QueueLen returns the number of accounts waiting in the variant pool.
|
||||
func (m *Matchmaker) QueueLen(variant engine.Variant) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.queues[variant])
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// fakeCreator records the games a matchmaker asks it to start.
|
||||
type fakeCreator struct {
|
||||
created []game.CreateParams
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, error) {
|
||||
if f.err != nil {
|
||||
return game.Game{}, f.err
|
||||
}
|
||||
f.created = append(f.created, p)
|
||||
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
||||
}
|
||||
|
||||
func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
|
||||
for _, w := range want {
|
||||
found := false
|
||||
for _, s := range seats {
|
||||
if s == w {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestMatchmakerPairsTwoHumans(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator)
|
||||
ctx := context.Background()
|
||||
a, b := uuid.New(), uuid.New()
|
||||
|
||||
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must wait, not match")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
|
||||
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue b: %v", err)
|
||||
}
|
||||
if !r2.Matched {
|
||||
t.Fatal("second enqueue must match")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
||||
t.Fatalf("queue len = %d, want 0 after match", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
p := creator.created[0]
|
||||
if len(p.Seats) != 2 || !seatsContain(p.Seats, a, b) {
|
||||
t.Errorf("seats = %v, want both %s and %s", p.Seats, a, b)
|
||||
}
|
||||
if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer {
|
||||
t.Errorf("auto-match defaults not applied: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerAlreadyQueued(t *testing.T) {
|
||||
mm := NewMatchmaker(&fakeCreator{})
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); !errors.Is(err, ErrAlreadyQueued) {
|
||||
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerCancel(t *testing.T) {
|
||||
mm := NewMatchmaker(&fakeCreator{})
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if !mm.Cancel(ctx, a) {
|
||||
t.Fatal("cancel of a queued account must report true")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
||||
t.Fatalf("queue len = %d, want 0 after cancel", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
if mm.Cancel(ctx, a) {
|
||||
t.Fatal("cancel of an unqueued account must report false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerVariantsAreSeparate(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator)
|
||||
ctx := context.Background()
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue en: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble); err != nil {
|
||||
t.Fatalf("enqueue ru: %v", err)
|
||||
}
|
||||
if len(creator.created) != 0 {
|
||||
t.Fatalf("different variants must not match; created %d", len(creator.created))
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 || mm.QueueLen(engine.VariantRussianScrabble) != 1 {
|
||||
t.Errorf("each variant pool should hold one waiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerFIFO(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator)
|
||||
ctx := context.Background()
|
||||
a, b, c := uuid.New(), uuid.New(), uuid.New()
|
||||
for _, id := range []uuid.UUID{a, b, c} {
|
||||
if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// a waited, b matched a (oldest), c waits.
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
if !seatsContain(creator.created[0].Seats, a, b) {
|
||||
t.Errorf("FIFO match should pair a and b, got %v", creator.created[0].Seats)
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed query surface for friend-game invitations.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Blocks struct {
|
||||
BlockerID uuid.UUID `sql:"primary_key"`
|
||||
BlockedID uuid.UUID `sql:"primary_key"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChatMessages struct {
|
||||
MessageID uuid.UUID `sql:"primary_key"`
|
||||
GameID uuid.UUID
|
||||
SenderID uuid.UUID
|
||||
Kind string
|
||||
Body string
|
||||
SenderIP *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EmailConfirmations struct {
|
||||
ConfirmationID uuid.UUID `sql:"primary_key"`
|
||||
AccountID uuid.UUID
|
||||
Email string
|
||||
CodeHash string
|
||||
ExpiresAt time.Time
|
||||
Attempts int16
|
||||
ConsumedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Friendships struct {
|
||||
RequesterID uuid.UUID `sql:"primary_key"`
|
||||
AddresseeID uuid.UUID `sql:"primary_key"`
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
RespondedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GameInvitationInvitees struct {
|
||||
InvitationID uuid.UUID `sql:"primary_key"`
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
Seat int16
|
||||
Response string
|
||||
RespondedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GameInvitations struct {
|
||||
InvitationID uuid.UUID `sql:"primary_key"`
|
||||
InviterID uuid.UUID
|
||||
Variant string
|
||||
TurnTimeoutSecs int32
|
||||
HintsAllowed bool
|
||||
HintsPerPlayer int16
|
||||
DropoutTiles string
|
||||
Status string
|
||||
GameID *uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -29,4 +29,5 @@ type Games struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
DropoutTiles string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Blocks = newBlocksTable("backend", "blocks", "")
|
||||
|
||||
type blocksTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
BlockerID postgres.ColumnString
|
||||
BlockedID postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type BlocksTable struct {
|
||||
blocksTable
|
||||
|
||||
EXCLUDED blocksTable
|
||||
}
|
||||
|
||||
// AS creates new BlocksTable with assigned alias
|
||||
func (a BlocksTable) AS(alias string) *BlocksTable {
|
||||
return newBlocksTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new BlocksTable with assigned schema name
|
||||
func (a BlocksTable) FromSchema(schemaName string) *BlocksTable {
|
||||
return newBlocksTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new BlocksTable with assigned table prefix
|
||||
func (a BlocksTable) WithPrefix(prefix string) *BlocksTable {
|
||||
return newBlocksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new BlocksTable with assigned table suffix
|
||||
func (a BlocksTable) WithSuffix(suffix string) *BlocksTable {
|
||||
return newBlocksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newBlocksTable(schemaName, tableName, alias string) *BlocksTable {
|
||||
return &BlocksTable{
|
||||
blocksTable: newBlocksTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newBlocksTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newBlocksTableImpl(schemaName, tableName, alias string) blocksTable {
|
||||
var (
|
||||
BlockerIDColumn = postgres.StringColumn("blocker_id")
|
||||
BlockedIDColumn = postgres.StringColumn("blocked_id")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{BlockerIDColumn, BlockedIDColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
)
|
||||
|
||||
return blocksTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
BlockerID: BlockerIDColumn,
|
||||
BlockedID: BlockedIDColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var ChatMessages = newChatMessagesTable("backend", "chat_messages", "")
|
||||
|
||||
type chatMessagesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
MessageID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
SenderID postgres.ColumnString
|
||||
Kind postgres.ColumnString
|
||||
Body postgres.ColumnString
|
||||
SenderIP postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ChatMessagesTable struct {
|
||||
chatMessagesTable
|
||||
|
||||
EXCLUDED chatMessagesTable
|
||||
}
|
||||
|
||||
// AS creates new ChatMessagesTable with assigned alias
|
||||
func (a ChatMessagesTable) AS(alias string) *ChatMessagesTable {
|
||||
return newChatMessagesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ChatMessagesTable with assigned schema name
|
||||
func (a ChatMessagesTable) FromSchema(schemaName string) *ChatMessagesTable {
|
||||
return newChatMessagesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ChatMessagesTable with assigned table prefix
|
||||
func (a ChatMessagesTable) WithPrefix(prefix string) *ChatMessagesTable {
|
||||
return newChatMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ChatMessagesTable with assigned table suffix
|
||||
func (a ChatMessagesTable) WithSuffix(suffix string) *ChatMessagesTable {
|
||||
return newChatMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newChatMessagesTable(schemaName, tableName, alias string) *ChatMessagesTable {
|
||||
return &ChatMessagesTable{
|
||||
chatMessagesTable: newChatMessagesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newChatMessagesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
|
||||
var (
|
||||
MessageIDColumn = postgres.StringColumn("message_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
SenderIDColumn = postgres.StringColumn("sender_id")
|
||||
KindColumn = postgres.StringColumn("kind")
|
||||
BodyColumn = postgres.StringColumn("body")
|
||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return chatMessagesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
MessageID: MessageIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
SenderID: SenderIDColumn,
|
||||
Kind: KindColumn,
|
||||
Body: BodyColumn,
|
||||
SenderIP: SenderIPColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var EmailConfirmations = newEmailConfirmationsTable("backend", "email_confirmations", "")
|
||||
|
||||
type emailConfirmationsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ConfirmationID postgres.ColumnString
|
||||
AccountID postgres.ColumnString
|
||||
Email postgres.ColumnString
|
||||
CodeHash postgres.ColumnString
|
||||
ExpiresAt postgres.ColumnTimestampz
|
||||
Attempts postgres.ColumnInteger
|
||||
ConsumedAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type EmailConfirmationsTable struct {
|
||||
emailConfirmationsTable
|
||||
|
||||
EXCLUDED emailConfirmationsTable
|
||||
}
|
||||
|
||||
// AS creates new EmailConfirmationsTable with assigned alias
|
||||
func (a EmailConfirmationsTable) AS(alias string) *EmailConfirmationsTable {
|
||||
return newEmailConfirmationsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new EmailConfirmationsTable with assigned schema name
|
||||
func (a EmailConfirmationsTable) FromSchema(schemaName string) *EmailConfirmationsTable {
|
||||
return newEmailConfirmationsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new EmailConfirmationsTable with assigned table prefix
|
||||
func (a EmailConfirmationsTable) WithPrefix(prefix string) *EmailConfirmationsTable {
|
||||
return newEmailConfirmationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new EmailConfirmationsTable with assigned table suffix
|
||||
func (a EmailConfirmationsTable) WithSuffix(suffix string) *EmailConfirmationsTable {
|
||||
return newEmailConfirmationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newEmailConfirmationsTable(schemaName, tableName, alias string) *EmailConfirmationsTable {
|
||||
return &EmailConfirmationsTable{
|
||||
emailConfirmationsTable: newEmailConfirmationsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newEmailConfirmationsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailConfirmationsTable {
|
||||
var (
|
||||
ConfirmationIDColumn = postgres.StringColumn("confirmation_id")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
EmailColumn = postgres.StringColumn("email")
|
||||
CodeHashColumn = postgres.StringColumn("code_hash")
|
||||
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
||||
AttemptsColumn = postgres.IntegerColumn("attempts")
|
||||
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return emailConfirmationsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ConfirmationID: ConfirmationIDColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
Email: EmailColumn,
|
||||
CodeHash: CodeHashColumn,
|
||||
ExpiresAt: ExpiresAtColumn,
|
||||
Attempts: AttemptsColumn,
|
||||
ConsumedAt: ConsumedAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Friendships = newFriendshipsTable("backend", "friendships", "")
|
||||
|
||||
type friendshipsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
RequesterID postgres.ColumnString
|
||||
AddresseeID postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
RespondedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type FriendshipsTable struct {
|
||||
friendshipsTable
|
||||
|
||||
EXCLUDED friendshipsTable
|
||||
}
|
||||
|
||||
// AS creates new FriendshipsTable with assigned alias
|
||||
func (a FriendshipsTable) AS(alias string) *FriendshipsTable {
|
||||
return newFriendshipsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new FriendshipsTable with assigned schema name
|
||||
func (a FriendshipsTable) FromSchema(schemaName string) *FriendshipsTable {
|
||||
return newFriendshipsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new FriendshipsTable with assigned table prefix
|
||||
func (a FriendshipsTable) WithPrefix(prefix string) *FriendshipsTable {
|
||||
return newFriendshipsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new FriendshipsTable with assigned table suffix
|
||||
func (a FriendshipsTable) WithSuffix(suffix string) *FriendshipsTable {
|
||||
return newFriendshipsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newFriendshipsTable(schemaName, tableName, alias string) *FriendshipsTable {
|
||||
return &FriendshipsTable{
|
||||
friendshipsTable: newFriendshipsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newFriendshipsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newFriendshipsTableImpl(schemaName, tableName, alias string) friendshipsTable {
|
||||
var (
|
||||
RequesterIDColumn = postgres.StringColumn("requester_id")
|
||||
AddresseeIDColumn = postgres.StringColumn("addressee_id")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
RespondedAtColumn = postgres.TimestampzColumn("responded_at")
|
||||
allColumns = postgres.ColumnList{RequesterIDColumn, AddresseeIDColumn, StatusColumn, CreatedAtColumn, RespondedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, RespondedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return friendshipsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
RequesterID: RequesterIDColumn,
|
||||
AddresseeID: AddresseeIDColumn,
|
||||
Status: StatusColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
RespondedAt: RespondedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var GameInvitationInvitees = newGameInvitationInviteesTable("backend", "game_invitation_invitees", "")
|
||||
|
||||
type gameInvitationInviteesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
InvitationID postgres.ColumnString
|
||||
AccountID postgres.ColumnString
|
||||
Seat postgres.ColumnInteger
|
||||
Response postgres.ColumnString
|
||||
RespondedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GameInvitationInviteesTable struct {
|
||||
gameInvitationInviteesTable
|
||||
|
||||
EXCLUDED gameInvitationInviteesTable
|
||||
}
|
||||
|
||||
// AS creates new GameInvitationInviteesTable with assigned alias
|
||||
func (a GameInvitationInviteesTable) AS(alias string) *GameInvitationInviteesTable {
|
||||
return newGameInvitationInviteesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GameInvitationInviteesTable with assigned schema name
|
||||
func (a GameInvitationInviteesTable) FromSchema(schemaName string) *GameInvitationInviteesTable {
|
||||
return newGameInvitationInviteesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GameInvitationInviteesTable with assigned table prefix
|
||||
func (a GameInvitationInviteesTable) WithPrefix(prefix string) *GameInvitationInviteesTable {
|
||||
return newGameInvitationInviteesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GameInvitationInviteesTable with assigned table suffix
|
||||
func (a GameInvitationInviteesTable) WithSuffix(suffix string) *GameInvitationInviteesTable {
|
||||
return newGameInvitationInviteesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGameInvitationInviteesTable(schemaName, tableName, alias string) *GameInvitationInviteesTable {
|
||||
return &GameInvitationInviteesTable{
|
||||
gameInvitationInviteesTable: newGameInvitationInviteesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGameInvitationInviteesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGameInvitationInviteesTableImpl(schemaName, tableName, alias string) gameInvitationInviteesTable {
|
||||
var (
|
||||
InvitationIDColumn = postgres.StringColumn("invitation_id")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
SeatColumn = postgres.IntegerColumn("seat")
|
||||
ResponseColumn = postgres.StringColumn("response")
|
||||
RespondedAtColumn = postgres.TimestampzColumn("responded_at")
|
||||
allColumns = postgres.ColumnList{InvitationIDColumn, AccountIDColumn, SeatColumn, ResponseColumn, RespondedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{SeatColumn, ResponseColumn, RespondedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{ResponseColumn}
|
||||
)
|
||||
|
||||
return gameInvitationInviteesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
InvitationID: InvitationIDColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
Seat: SeatColumn,
|
||||
Response: ResponseColumn,
|
||||
RespondedAt: RespondedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var GameInvitations = newGameInvitationsTable("backend", "game_invitations", "")
|
||||
|
||||
type gameInvitationsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
InvitationID postgres.ColumnString
|
||||
InviterID postgres.ColumnString
|
||||
Variant postgres.ColumnString
|
||||
TurnTimeoutSecs postgres.ColumnInteger
|
||||
HintsAllowed postgres.ColumnBool
|
||||
HintsPerPlayer postgres.ColumnInteger
|
||||
DropoutTiles postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
ExpiresAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GameInvitationsTable struct {
|
||||
gameInvitationsTable
|
||||
|
||||
EXCLUDED gameInvitationsTable
|
||||
}
|
||||
|
||||
// AS creates new GameInvitationsTable with assigned alias
|
||||
func (a GameInvitationsTable) AS(alias string) *GameInvitationsTable {
|
||||
return newGameInvitationsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GameInvitationsTable with assigned schema name
|
||||
func (a GameInvitationsTable) FromSchema(schemaName string) *GameInvitationsTable {
|
||||
return newGameInvitationsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GameInvitationsTable with assigned table prefix
|
||||
func (a GameInvitationsTable) WithPrefix(prefix string) *GameInvitationsTable {
|
||||
return newGameInvitationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GameInvitationsTable with assigned table suffix
|
||||
func (a GameInvitationsTable) WithSuffix(suffix string) *GameInvitationsTable {
|
||||
return newGameInvitationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGameInvitationsTable(schemaName, tableName, alias string) *GameInvitationsTable {
|
||||
return &GameInvitationsTable{
|
||||
gameInvitationsTable: newGameInvitationsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGameInvitationsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvitationsTable {
|
||||
var (
|
||||
InvitationIDColumn = postgres.StringColumn("invitation_id")
|
||||
InviterIDColumn = postgres.StringColumn("inviter_id")
|
||||
VariantColumn = postgres.StringColumn("variant")
|
||||
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
|
||||
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
|
||||
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
|
||||
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return gameInvitationsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
InvitationID: InvitationIDColumn,
|
||||
InviterID: InviterIDColumn,
|
||||
Variant: VariantColumn,
|
||||
TurnTimeoutSecs: TurnTimeoutSecsColumn,
|
||||
HintsAllowed: HintsAllowedColumn,
|
||||
HintsPerPlayer: HintsPerPlayerColumn,
|
||||
DropoutTiles: DropoutTilesColumn,
|
||||
Status: StatusColumn,
|
||||
GameID: GameIDColumn,
|
||||
ExpiresAt: ExpiresAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type gamesTable struct {
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
FinishedAt postgres.ColumnTimestampz
|
||||
DropoutTiles postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -90,9 +91,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
|
||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn}
|
||||
)
|
||||
|
||||
return gamesTable{
|
||||
@@ -115,6 +117,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
FinishedAt: FinishedAtColumn,
|
||||
DropoutTiles: DropoutTilesColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -12,7 +12,13 @@ package table
|
||||
func UseSchema(schema string) {
|
||||
AccountStats = AccountStats.FromSchema(schema)
|
||||
Accounts = Accounts.FromSchema(schema)
|
||||
Blocks = Blocks.FromSchema(schema)
|
||||
ChatMessages = ChatMessages.FromSchema(schema)
|
||||
Complaints = Complaints.FromSchema(schema)
|
||||
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
||||
Friendships = Friendships.FromSchema(schema)
|
||||
GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema)
|
||||
GameInvitations = GameInvitations.FromSchema(schema)
|
||||
GameMoves = GameMoves.FromSchema(schema)
|
||||
GamePlayers = GamePlayers.FromSchema(schema)
|
||||
Games = Games.FromSchema(schema)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
-- +goose Up
|
||||
-- Stage 4 lobby & social: the friend graph, per-user blocks, per-game chat (with
|
||||
-- nudge folded in as a message kind), email confirm-codes, and friend-game
|
||||
-- invitations -- plus the per-game drop-out tile disposition the multi-player
|
||||
-- engine needs. Matchmaking is an in-memory pool and persists nothing.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
-- The disposition of a dropped-out player's tiles in a game with three or more
|
||||
-- seats (docs/ARCHITECTURE.md §6), chosen at creation: 'remove' burns them
|
||||
-- (default), 'return' puts them back in the bag. Moot for a two-player game,
|
||||
-- which ends on the first drop-out. engine.DropoutTiles owns the stable labels.
|
||||
ALTER TABLE games
|
||||
ADD COLUMN dropout_tiles text NOT NULL DEFAULT 'remove',
|
||||
ADD CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'));
|
||||
|
||||
-- The friend graph. A row is created by the requester as 'pending' and flipped to
|
||||
-- 'accepted' by the addressee; declining, cancelling or unfriending deletes the
|
||||
-- row. Friendship is symmetric: a player's friends are the accepted rows in
|
||||
-- either direction. A pair has at most one row (guarded in Go against either
|
||||
-- direction existing).
|
||||
CREATE TABLE friendships (
|
||||
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
responded_at timestamptz,
|
||||
PRIMARY KEY (requester_id, addressee_id),
|
||||
CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted')),
|
||||
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
|
||||
);
|
||||
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
|
||||
|
||||
-- Per-user blocks. blocker_id has blocked blocked_id; the effect is applied
|
||||
-- mutually by the social checks (a block in either direction suppresses chat
|
||||
-- visibility and prevents requests/invitations between the pair).
|
||||
CREATE TABLE blocks (
|
||||
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (blocker_id, blocked_id),
|
||||
CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
|
||||
);
|
||||
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);
|
||||
|
||||
-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty
|
||||
-- body, so one journal carries both chatter and nudges. body is capped at 60
|
||||
-- runes (enforced again in Go on input, where the content filter also rejects
|
||||
-- links/emails/phone numbers). sender_ip holds the gateway-forwarded client IP as
|
||||
-- a validated string (text, not inet, to avoid go-jet literal friction; the
|
||||
-- gateway populates it in Stage 6). Chat is part of the game archive and is never
|
||||
-- purged; it cascades away only with its game.
|
||||
CREATE TABLE chat_messages (
|
||||
message_id uuid PRIMARY KEY,
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
sender_id uuid NOT NULL REFERENCES accounts (account_id),
|
||||
kind text NOT NULL DEFAULT 'message',
|
||||
body text NOT NULL DEFAULT '',
|
||||
sender_ip text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
|
||||
CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
|
||||
CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
|
||||
);
|
||||
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
|
||||
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
|
||||
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
|
||||
WHERE kind = 'nudge';
|
||||
|
||||
-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the
|
||||
-- 6-digit code (the plaintext is never stored, matching the session model);
|
||||
-- expires_at bounds the TTL and attempts caps brute force. A row is consumed
|
||||
-- (consumed_at stamped) on success. A re-request deletes the prior pending row
|
||||
-- for the same (account, lowercased email) and inserts a fresh one.
|
||||
CREATE TABLE email_confirmations (
|
||||
confirmation_id uuid PRIMARY KEY,
|
||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
email text NOT NULL,
|
||||
code_hash text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
attempts smallint NOT NULL DEFAULT 0,
|
||||
consumed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
|
||||
);
|
||||
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);
|
||||
|
||||
-- A friend-game invitation. The inviter (seat 0) proposes the game settings to
|
||||
-- 1..3 invitees; the game starts only when every invitee has accepted, and any
|
||||
-- decline cancels the whole invitation. Lazily expired after expires_at (no
|
||||
-- background sweep). game_id is set when the game is started.
|
||||
CREATE TABLE game_invitations (
|
||||
invitation_id uuid PRIMARY KEY,
|
||||
inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
variant text NOT NULL,
|
||||
turn_timeout_secs integer NOT NULL,
|
||||
hints_allowed boolean NOT NULL DEFAULT true,
|
||||
hints_per_player smallint NOT NULL DEFAULT 1,
|
||||
dropout_tiles text NOT NULL DEFAULT 'remove',
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
|
||||
CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
|
||||
CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
|
||||
CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
|
||||
CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
|
||||
);
|
||||
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);
|
||||
|
||||
-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's
|
||||
-- seat in the started game (1..3, in invitation order). response tracks each
|
||||
-- invitee's pending/accepted/declined decision.
|
||||
CREATE TABLE game_invitation_invitees (
|
||||
invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
|
||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
seat smallint NOT NULL,
|
||||
response text NOT NULL DEFAULT 'pending',
|
||||
responded_at timestamptz,
|
||||
PRIMARY KEY (invitation_id, account_id),
|
||||
CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
|
||||
CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
|
||||
);
|
||||
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE game_invitation_invitees;
|
||||
DROP TABLE game_invitations;
|
||||
DROP TABLE email_confirmations;
|
||||
DROP TABLE chat_messages;
|
||||
DROP TABLE blocks;
|
||||
DROP TABLE friendships;
|
||||
ALTER TABLE games
|
||||
DROP CONSTRAINT games_dropout_tiles_chk,
|
||||
DROP COLUMN dropout_tiles;
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
|
||||
@@ -39,6 +42,14 @@ type Deps struct {
|
||||
// SessionsReady reports whether the session cache has been warmed. A nil
|
||||
// func skips the session-readiness check.
|
||||
SessionsReady func() bool
|
||||
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services.
|
||||
// They are held for the REST/stream handlers the gateway adds in Stage 6 (like
|
||||
// the route groups, this is scaffolding exposed via accessors); the server
|
||||
// itself does not route to them yet.
|
||||
Social *social.Service
|
||||
Matchmaker *lobby.Matchmaker
|
||||
Invitations *lobby.InvitationService
|
||||
Emails *account.EmailService
|
||||
}
|
||||
|
||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||
@@ -50,6 +61,11 @@ type Server struct {
|
||||
pingTimeout time.Duration
|
||||
sessionsReady func() bool
|
||||
|
||||
social *social.Service
|
||||
matchmaker *lobby.Matchmaker
|
||||
invitations *lobby.InvitationService
|
||||
emails *account.EmailService
|
||||
|
||||
public *gin.RouterGroup
|
||||
user *gin.RouterGroup
|
||||
internal *gin.RouterGroup
|
||||
@@ -78,6 +94,10 @@ func New(addr string, deps Deps) *Server {
|
||||
db: deps.DB,
|
||||
pingTimeout: pingTimeout,
|
||||
sessionsReady: deps.SessionsReady,
|
||||
social: deps.Social,
|
||||
matchmaker: deps.Matchmaker,
|
||||
invitations: deps.Invitations,
|
||||
emails: deps.Emails,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
}
|
||||
s.registerProbes(engine)
|
||||
@@ -136,6 +156,18 @@ func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
|
||||
// AdminGroup returns the admin route group (authenticated at the gateway).
|
||||
func (s *Server) AdminGroup() *gin.RouterGroup { return s.admin }
|
||||
|
||||
// Social returns the social domain service for the handlers added in Stage 6.
|
||||
func (s *Server) Social() *social.Service { return s.social }
|
||||
|
||||
// Matchmaker returns the in-memory matchmaking pool for the Stage 6 handlers.
|
||||
func (s *Server) Matchmaker() *lobby.Matchmaker { return s.matchmaker }
|
||||
|
||||
// Invitations returns the friend-game invitation service for the Stage 6 handlers.
|
||||
func (s *Server) Invitations() *lobby.InvitationService { return s.invitations }
|
||||
|
||||
// Emails returns the email confirm-code service for the Stage 6 handlers.
|
||||
func (s *Server) Emails() *account.EmailService { return s.emails }
|
||||
|
||||
// Handler returns the underlying HTTP handler. It lets tests drive the server
|
||||
// without binding a socket and lets later stages compose the backend behind
|
||||
// another listener.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Block records that blockerID has blocked blockedID. Blocking severs any
|
||||
// friendship or pending request between the two and, through the mutual block
|
||||
// checks, suppresses chat visibility and new requests/invitations in both
|
||||
// directions. It is idempotent.
|
||||
func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error {
|
||||
if blockerID == blockedID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
return svc.store.insertBlock(ctx, blockerID, blockedID)
|
||||
}
|
||||
|
||||
// Unblock removes blockerID's block on blockedID. It is idempotent.
|
||||
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error {
|
||||
return svc.store.deleteBlock(ctx, blockerID, blockedID)
|
||||
}
|
||||
|
||||
// ListBlocks returns the account IDs blockerID has blocked.
|
||||
func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listBlocks(ctx, blockerID)
|
||||
}
|
||||
|
||||
// IsBlocked reports whether a block stands between a and b in either direction.
|
||||
func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
return svc.store.isBlocked(ctx, a, b)
|
||||
}
|
||||
|
||||
// isBlocked reports whether a block row exists between a and b in either direction.
|
||||
func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
FROM(table.Blocks).
|
||||
WHERE(
|
||||
table.Blocks.BlockerID.EQ(postgres.UUID(a)).AND(table.Blocks.BlockedID.EQ(postgres.UUID(b))).
|
||||
OR(table.Blocks.BlockerID.EQ(postgres.UUID(b)).AND(table.Blocks.BlockedID.EQ(postgres.UUID(a)))),
|
||||
).LIMIT(1)
|
||||
var row model.Blocks
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("social: is blocked: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// insertBlock severs any friendship between the pair and inserts the block, in one
|
||||
// transaction; a duplicate block is ignored.
|
||||
func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
del := table.Friendships.DELETE().WHERE(edgeEither(blocker, blocked))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("clear friendship on block: %w", err)
|
||||
}
|
||||
ins := table.Blocks.
|
||||
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
|
||||
VALUES(blocker, blocked).
|
||||
ON_CONFLICT(table.Blocks.BlockerID, table.Blocks.BlockedID).DO_NOTHING()
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert block: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// deleteBlock removes a block. It is idempotent.
|
||||
func (s *Store) deleteBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
|
||||
stmt := table.Blocks.DELETE().WHERE(
|
||||
table.Blocks.BlockerID.EQ(postgres.UUID(blocker)).
|
||||
AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked))),
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("social: delete block: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listBlocks returns the accounts blocker has blocked.
|
||||
func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockedID).
|
||||
FROM(table.Blocks).
|
||||
WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)))
|
||||
var rows []model.Blocks
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("social: list blocks: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.BlockedID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxChatRunes caps a chat message's length, keeping it to a quick reaction.
|
||||
maxChatRunes = 60
|
||||
// nudgeInterval is the minimum gap between two nudges by the same player in a game.
|
||||
nudgeInterval = time.Hour
|
||||
// kindMessage and kindNudge are the chat_messages.kind values.
|
||||
kindMessage = "message"
|
||||
kindNudge = "nudge"
|
||||
// statusActive mirrors game.StatusActive: the status string a live game reports.
|
||||
statusActive = "active"
|
||||
)
|
||||
|
||||
// Message is one persisted per-game chat entry. A nudge is a Message with Kind
|
||||
// nudge and an empty Body. SenderIP is the gateway-forwarded client IP (empty when
|
||||
// unknown), kept for moderation.
|
||||
type Message struct {
|
||||
ID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
SenderID uuid.UUID
|
||||
Kind string
|
||||
Body string
|
||||
SenderIP string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PostMessage stores a chat message from senderID in gameID. The sender must be a
|
||||
// seated player who has not disabled chat; the body must be non-empty, within the
|
||||
// rune limit, and free of links/emails/phone numbers (the content filter). The
|
||||
// gateway-forwarded senderIP is validated and stored for moderation.
|
||||
func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) {
|
||||
seats, _, _, err := svc.games.Participants(ctx, gameID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
if !slices.Contains(seats, senderID) {
|
||||
return Message{}, ErrNotParticipant
|
||||
}
|
||||
sender, err := svc.accounts.GetByID(ctx, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
if sender.BlockChat {
|
||||
return Message{}, ErrChatBlocked
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return Message{}, ErrEmptyMessage
|
||||
}
|
||||
if utf8.RuneCountInString(body) > maxChatRunes {
|
||||
return Message{}, ErrMessageTooLong
|
||||
}
|
||||
if err := Clean(body); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP))
|
||||
}
|
||||
|
||||
// Nudge records a nudge from senderID toward the player whose turn is awaited. The
|
||||
// game must be active, the sender a seated player whose turn it is not, and the
|
||||
// once-per-hour-per-game limit not yet hit.
|
||||
func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Message, error) {
|
||||
seats, toMove, status, err := svc.games.Participants(ctx, gameID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
if status != statusActive {
|
||||
return Message{}, ErrGameNotActive
|
||||
}
|
||||
idx := slices.Index(seats, senderID)
|
||||
if idx < 0 {
|
||||
return Message{}, ErrNotParticipant
|
||||
}
|
||||
if idx == toMove {
|
||||
return Message{}, ErrNudgeOnOwnTurn
|
||||
}
|
||||
last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
if ok && svc.now().Sub(last) < nudgeInterval {
|
||||
return Message{}, ErrNudgeTooSoon
|
||||
}
|
||||
return svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
|
||||
}
|
||||
|
||||
// Messages returns the per-game chat visible to viewerID: the viewer must be a
|
||||
// seated player. Messages from a sender the viewer has a block with (either
|
||||
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
|
||||
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
|
||||
seats, _, _, err := svc.games.Participants(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !slices.Contains(seats, viewerID) {
|
||||
return nil, ErrNotParticipant
|
||||
}
|
||||
viewer, err := svc.accounts.GetByID(ctx, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocked := make(map[uuid.UUID]bool)
|
||||
for _, seat := range seats {
|
||||
if seat == viewerID {
|
||||
continue
|
||||
}
|
||||
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if yes {
|
||||
blocked[seat] = true
|
||||
}
|
||||
}
|
||||
all, err := svc.store.listChatMessages(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Message, 0, len(all))
|
||||
for _, m := range all {
|
||||
if blocked[m.SenderID] {
|
||||
continue
|
||||
}
|
||||
if m.Kind == kindMessage && viewer.BlockChat {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseIP returns a validated canonical IP string, or nil when raw is empty or
|
||||
// not a valid address.
|
||||
func parseIP(raw string) *string {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
canon := addr.String()
|
||||
return &canon
|
||||
}
|
||||
|
||||
// insertChatMessage stores one chat row and returns it.
|
||||
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string) (Message, error) {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("social: new message id: %w", err)
|
||||
}
|
||||
var ipVal any = postgres.NULL
|
||||
if ip != nil {
|
||||
ipVal = postgres.String(*ip)
|
||||
}
|
||||
stmt := table.ChatMessages.INSERT(
|
||||
table.ChatMessages.MessageID, table.ChatMessages.GameID, table.ChatMessages.SenderID,
|
||||
table.ChatMessages.Kind, table.ChatMessages.Body, table.ChatMessages.SenderIP,
|
||||
).VALUES(id, gameID, senderID, kind, body, ipVal).
|
||||
RETURNING(table.ChatMessages.AllColumns)
|
||||
var row model.ChatMessages
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return Message{}, fmt.Errorf("social: insert chat message: %w", err)
|
||||
}
|
||||
return messageFromRow(row), nil
|
||||
}
|
||||
|
||||
// listChatMessages returns a game's chat in chronological order.
|
||||
func (s *Store) listChatMessages(ctx context.Context, gameID uuid.UUID) ([]Message, error) {
|
||||
stmt := postgres.SELECT(table.ChatMessages.AllColumns).
|
||||
FROM(table.ChatMessages).
|
||||
WHERE(table.ChatMessages.GameID.EQ(postgres.UUID(gameID))).
|
||||
ORDER_BY(table.ChatMessages.CreatedAt.ASC(), table.ChatMessages.MessageID.ASC())
|
||||
var rows []model.ChatMessages
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("social: list chat: %w", err)
|
||||
}
|
||||
out := make([]Message, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, messageFromRow(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lastNudgeAt returns the time of senderID's most recent nudge in gameID, if any.
|
||||
func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
|
||||
stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
|
||||
FROM(table.ChatMessages).
|
||||
WHERE(
|
||||
table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
|
||||
AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
|
||||
AND(table.ChatMessages.Kind.EQ(postgres.String(kindNudge))),
|
||||
).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
|
||||
var row model.ChatMessages
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return time.Time{}, false, fmt.Errorf("social: last nudge: %w", err)
|
||||
}
|
||||
return row.CreatedAt, true, nil
|
||||
}
|
||||
|
||||
// messageFromRow projects a generated row into the public Message.
|
||||
func messageFromRow(r model.ChatMessages) Message {
|
||||
m := Message{
|
||||
ID: r.MessageID,
|
||||
GameID: r.GameID,
|
||||
SenderID: r.SenderID,
|
||||
Kind: r.Kind,
|
||||
Body: r.Body,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
if r.SenderIP != nil {
|
||||
m.SenderIP = *r.SenderIP
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"mvdan.cc/xurls/v2"
|
||||
)
|
||||
|
||||
// ErrForbiddenContent is returned when a chat message contains a web link, email
|
||||
// address or phone number — including lightly obfuscated forms. The chat is for
|
||||
// quick in-game reactions, not for exchanging contact details.
|
||||
var ErrForbiddenContent = errors.New("social: message contains a link, email or phone number")
|
||||
|
||||
// phoneDigits is the minimum run of consecutive digits treated as a phone number.
|
||||
const phoneDigits = 7
|
||||
|
||||
// relaxedURL matches URLs, bare domains and email addresses (xurls relaxed mode).
|
||||
var relaxedURL = xurls.Relaxed()
|
||||
|
||||
// spelledSeparators rewrites the words people use to dodge a detector back into
|
||||
// their symbols, so "gmail dot com" and "user at host" are still caught.
|
||||
var spelledSeparators = strings.NewReplacer(
|
||||
" dot ", ".", "(dot)", ".", "[dot]", ".", " punto ", ".", " точка ", ".",
|
||||
" at ", "@", "(at)", "@", "[at]", "@", " собака ", "@", " собачка ", "@",
|
||||
)
|
||||
|
||||
// leet folds digits and symbols commonly substituted for letters, so "g00gl3.com"
|
||||
// is recognised. It leaves '.' and '@' untouched, since those carry the link and
|
||||
// email structure the matcher relies on.
|
||||
var leet = strings.NewReplacer(
|
||||
"0", "o", "1", "i", "3", "e", "4", "a", "5", "s", "7", "t", "8", "b",
|
||||
"$", "s", "!", "i", "|", "l",
|
||||
)
|
||||
|
||||
// spaceAroundPunct collapses whitespace around '.' and '@' so "gmail . com" and
|
||||
// "user @ host" close up into a detectable address.
|
||||
var spaceAroundPunct = regexp.MustCompile(`\s*([.@])\s*`)
|
||||
|
||||
// phoneSeparators are the characters stripped before counting a digit run, so a
|
||||
// grouped number like "8 (900) 123-45-67" collapses to a single run.
|
||||
var phoneSeparators = regexp.MustCompile(`[\s\-.()+]`)
|
||||
|
||||
// Clean reports whether body is free of links, emails and phone numbers, returning
|
||||
// ErrForbiddenContent (naming the category) otherwise. Detection is best-effort
|
||||
// over a short, rune-limited message: it folds common letter/digit obfuscation
|
||||
// and spelled-out separators before matching, but does not claim to defeat every
|
||||
// evasion.
|
||||
func Clean(body string) error {
|
||||
lower := strings.ToLower(body)
|
||||
if hasLinkOrEmail(lower) {
|
||||
return fmt.Errorf("%w: link or email", ErrForbiddenContent)
|
||||
}
|
||||
if hasPhone(lower) {
|
||||
return fmt.Errorf("%w: phone number", ErrForbiddenContent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasLinkOrEmail matches the lower-cased text both as written and after folding
|
||||
// spelled separators, spacing and leet substitutions.
|
||||
func hasLinkOrEmail(lower string) bool {
|
||||
if relaxedURL.MatchString(lower) {
|
||||
return true
|
||||
}
|
||||
deobfuscated := leet.Replace(spaceAroundPunct.ReplaceAllString(spelledSeparators.Replace(lower), "$1"))
|
||||
return relaxedURL.MatchString(deobfuscated)
|
||||
}
|
||||
|
||||
// hasPhone reports a run of phoneDigits or more digits once phone-style separators
|
||||
// are removed.
|
||||
func hasPhone(lower string) bool {
|
||||
stripped := phoneSeparators.ReplaceAllString(lower, "")
|
||||
run := 0
|
||||
for _, r := range stripped {
|
||||
if r >= '0' && r <= '9' {
|
||||
run++
|
||||
if run >= phoneDigits {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
run = 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCleanAllowsOrdinaryChat(t *testing.T) {
|
||||
clean := []string{
|
||||
"",
|
||||
"nice move!",
|
||||
"gg wp",
|
||||
"хороший ход, поздравляю",
|
||||
"unlucky draw this round",
|
||||
"I scored 42 points",
|
||||
"only 6 digits here: 123456",
|
||||
"see you at 5",
|
||||
"three vowels in my rack",
|
||||
"well played :)",
|
||||
}
|
||||
for _, body := range clean {
|
||||
if err := Clean(body); err != nil {
|
||||
t.Errorf("Clean(%q) = %v, want nil", body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanRejectsLinksEmailsPhones(t *testing.T) {
|
||||
forbidden := []string{
|
||||
// Plain links and bare domains.
|
||||
"http://evil.example.com",
|
||||
"visit https://x.io/abc now",
|
||||
"join discord.gg/xyzqwer",
|
||||
"check scrabblecheat.com",
|
||||
// Emails, plain and obfuscated.
|
||||
"mail me a@b.com",
|
||||
"john at gmail dot com",
|
||||
"j0hn at gma1l dot c0m",
|
||||
// Obfuscated domains.
|
||||
"g00gle dot com",
|
||||
"site . com please",
|
||||
// Phone numbers, plain and grouped.
|
||||
"call 89001234567",
|
||||
"+7 900 123 45 67",
|
||||
"8 (900) 123-45-67",
|
||||
"my number is 1234567",
|
||||
}
|
||||
for _, body := range forbidden {
|
||||
if err := Clean(body); !errors.Is(err, ErrForbiddenContent) {
|
||||
t.Errorf("Clean(%q) = %v, want ErrForbiddenContent", body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Friendship statuses persisted in friendships.status.
|
||||
const (
|
||||
friendPending = "pending"
|
||||
friendAccepted = "accepted"
|
||||
)
|
||||
|
||||
// SendFriendRequest records a pending friend request from requesterID to
|
||||
// addresseeID. It refuses a self-request, a request blocked by either a per-user
|
||||
// block or the addressee's block_friend_requests toggle, and a duplicate of an
|
||||
// existing request or friendship in either direction.
|
||||
func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
|
||||
if requesterID == addresseeID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addressee, err := svc.accounts.GetByID(ctx, addresseeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
return account.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if blocked || addressee.BlockFriendRequests {
|
||||
return ErrRequestBlocked
|
||||
}
|
||||
exists, err := svc.store.friendshipExists(ctx, requesterID, addresseeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return ErrRequestExists
|
||||
}
|
||||
if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrRequestExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespondFriendRequest lets addresseeID accept or decline the pending request
|
||||
// from requesterID. Accepting flips it to a friendship; declining deletes it.
|
||||
// Either way ErrRequestNotFound is returned when no pending request matches.
|
||||
func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, requesterID uuid.UUID, accept bool) error {
|
||||
var ok bool
|
||||
var err error
|
||||
if accept {
|
||||
ok, err = svc.store.acceptFriendRequest(ctx, requesterID, addresseeID, svc.now())
|
||||
} else {
|
||||
ok, err = svc.store.deletePendingRequest(ctx, requesterID, addresseeID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrRequestNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelFriendRequest withdraws requesterID's own pending request to addresseeID.
|
||||
func (svc *Service) CancelFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
|
||||
ok, err := svc.store.deletePendingRequest(ctx, requesterID, addresseeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrRequestNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unfriend removes the friendship between the two accounts, in either direction.
|
||||
// It is idempotent: removing a non-existent friendship is not an error.
|
||||
func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID) error {
|
||||
return svc.store.deleteFriendship(ctx, accountID, otherID)
|
||||
}
|
||||
|
||||
// ListFriends returns the account IDs that are accepted friends of accountID.
|
||||
func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listFriends(ctx, accountID)
|
||||
}
|
||||
|
||||
// ListIncomingRequests returns the account IDs that have a pending friend request
|
||||
// awaiting accountID's response.
|
||||
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listIncomingRequests(ctx, accountID)
|
||||
}
|
||||
|
||||
// friendshipExists reports whether any friendship row (pending or accepted) exists
|
||||
// between a and b in either direction.
|
||||
func (s *Store) friendshipExists(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
stmt := postgres.SELECT(table.Friendships.Status).
|
||||
FROM(table.Friendships).
|
||||
WHERE(edgeEither(a, b)).
|
||||
LIMIT(1)
|
||||
var row model.Friendships
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("social: friendship exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// insertFriendRequest inserts a pending request from requester to addressee.
|
||||
func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID) error {
|
||||
stmt := table.Friendships.INSERT(
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status,
|
||||
).VALUES(requester, addressee, friendPending)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("social: insert friend request: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// acceptFriendRequest flips a pending request to accepted and reports whether a
|
||||
// row matched.
|
||||
func (s *Store) acceptFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) (bool, error) {
|
||||
stmt := table.Friendships.
|
||||
UPDATE(table.Friendships.Status, table.Friendships.RespondedAt).
|
||||
SET(postgres.String(friendAccepted), postgres.TimestampzT(now)).
|
||||
WHERE(
|
||||
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
|
||||
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
|
||||
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
|
||||
)
|
||||
return execAffected(ctx, s.db, stmt, "social: accept friend request")
|
||||
}
|
||||
|
||||
// deletePendingRequest removes a pending request and reports whether a row matched.
|
||||
func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee uuid.UUID) (bool, error) {
|
||||
stmt := table.Friendships.DELETE().WHERE(
|
||||
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
|
||||
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
|
||||
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
|
||||
)
|
||||
return execAffected(ctx, s.db, stmt, "social: delete friend request")
|
||||
}
|
||||
|
||||
// deleteFriendship removes an accepted friendship in either direction.
|
||||
func (s *Store) deleteFriendship(ctx context.Context, a, b uuid.UUID) error {
|
||||
stmt := table.Friendships.DELETE().WHERE(
|
||||
edgeEither(a, b).AND(table.Friendships.Status.EQ(postgres.String(friendAccepted))),
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("social: delete friendship: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listFriends returns the other side of every accepted edge touching accountID.
|
||||
func (s *Store) listFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.Friendships.RequesterID, table.Friendships.AddresseeID).
|
||||
FROM(table.Friendships).
|
||||
WHERE(
|
||||
table.Friendships.Status.EQ(postgres.String(friendAccepted)).
|
||||
AND(table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
|
||||
OR(table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)))),
|
||||
)
|
||||
var rows []model.Friendships
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("social: list friends: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.RequesterID == accountID {
|
||||
out = append(out, r.AddresseeID)
|
||||
} else {
|
||||
out = append(out, r.RequesterID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listIncomingRequests returns the requesters of every pending request to accountID.
|
||||
func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.Friendships.RequesterID).
|
||||
FROM(table.Friendships).
|
||||
WHERE(
|
||||
table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
|
||||
)
|
||||
var rows []model.Friendships
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("social: list incoming requests: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.RequesterID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// edgeEither matches a friendship row between a and b in either direction.
|
||||
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
|
||||
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
|
||||
OR(table.Friendships.RequesterID.EQ(postgres.UUID(b)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(a))))
|
||||
}
|
||||
|
||||
// execAffected runs a mutating statement and reports whether it changed a row.
|
||||
func execAffected(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) (bool, error) {
|
||||
res, err := stmt.ExecContext(ctx, db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s rows: %w", what, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package social owns the player-facing social fabric around games: the friend
|
||||
// graph (request/accept), per-user blocks, and per-game chat with nudges folded
|
||||
// in as a message kind. It owns the friendships, blocks and chat_messages tables,
|
||||
// reads the account-level block toggles through account.Store, and gates chat and
|
||||
// nudge on game state through a GameReader so it never imports the engine. The
|
||||
// live delivery of chat and nudges (push / in-app stream) belongs to the gateway
|
||||
// in a later stage; this package only persists and reads them.
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
|
||||
// GameReader is the slice of the game domain the social package needs: the seated
|
||||
// accounts in seat order, the seat index whose turn it is, and the game status.
|
||||
// game.Service satisfies it, so chat and nudge gate on game state without a
|
||||
// dependency on the engine or the game's private state.
|
||||
type GameReader interface {
|
||||
Participants(ctx context.Context, gameID uuid.UUID) (seats []uuid.UUID, toMove int, status string, err error)
|
||||
}
|
||||
|
||||
// Sentinel errors returned by the service.
|
||||
var (
|
||||
// ErrSelfRelation is returned when an account targets itself.
|
||||
ErrSelfRelation = errors.New("social: cannot target yourself")
|
||||
// ErrRequestExists is returned when a friend request or friendship already
|
||||
// exists between the two accounts (in either direction).
|
||||
ErrRequestExists = errors.New("social: a friend request or friendship already exists")
|
||||
// ErrRequestBlocked is returned when the addressee does not accept friend
|
||||
// requests (their global toggle) or a block stands between the two accounts.
|
||||
ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests")
|
||||
// ErrRequestNotFound is returned when no pending friend request matches.
|
||||
ErrRequestNotFound = errors.New("social: no pending friend request")
|
||||
// ErrNotParticipant is returned when an account is not seated in the game.
|
||||
ErrNotParticipant = errors.New("social: account is not a player in this game")
|
||||
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
|
||||
ErrChatBlocked = errors.New("social: chat is disabled for this account")
|
||||
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
|
||||
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
|
||||
// ErrEmptyMessage is returned when a chat message is blank after trimming.
|
||||
ErrEmptyMessage = errors.New("social: message is empty")
|
||||
// ErrNudgeOnOwnTurn is returned when a player tries to nudge while it is their
|
||||
// own turn (there is no awaited opponent to nudge).
|
||||
ErrNudgeOnOwnTurn = errors.New("social: cannot nudge while it is your turn")
|
||||
// ErrNudgeTooSoon is returned when the per-game once-per-hour nudge limit is hit.
|
||||
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
|
||||
// ErrGameNotActive is returned when a nudge is attempted on a finished game.
|
||||
ErrGameNotActive = errors.New("social: game is not active")
|
||||
)
|
||||
|
||||
// Service is the social domain. It is the only writer of the friendships, blocks
|
||||
// and chat_messages tables and is safe for concurrent use.
|
||||
type Service struct {
|
||||
store *Store
|
||||
accounts *account.Store
|
||||
games GameReader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService constructs a Service. store owns the social tables; accounts supplies
|
||||
// the block toggles; games gates chat and nudge on game state.
|
||||
func NewService(store *Store, accounts *account.Store, games GameReader) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
accounts: accounts,
|
||||
games: games,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation.
|
||||
const uniqueViolation = "23505"
|
||||
|
||||
// Store is the Postgres-backed query surface for the friend graph, per-user
|
||||
// blocks and per-game chat.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint
|
||||
// violation, used to collapse a request/insert race into a friendly error.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user