bfa8797f8c
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.
107 lines
3.7 KiB
Go
107 lines
3.7 KiB
Go
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
|
|
}
|