Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

Lobby: group the my-games list into your-turn / opponent-turn / finished
(empty sections hidden), ordered by last activity (your-turn oldest-first,
the other two newest-first), as a compact line-separated list. gameDTO and
FB GameView gain last_activity_unix (turn start while active, finish time
once finished); a pure lib/lobbysort.ts holds the grouping/ordering.

Friends: the in-game 'add to friends' item is now server-derived via a new
GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with
a pending OR declined request (both read as 'request sent'), so it is correct
across reloads; it shows a disabled '✓ in friends' once accepted. It
live-updates when the opponent answers: RespondFriendRequest now publishes
friend_added (accept) / friend_declined (new notify sub-kind, decline) to the
original requester, whose open game re-derives its friend state.

Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests;
backend integration ListOutgoingRequests + respond-publishes-to-requester;
e2e updated for the new lobby section labels + a non-friend active opponent.
Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN.
This commit is contained in:
Ilia Denisov
2026-06-08 19:23:48 +02:00
parent b720907db2
commit 6b6baf5710
40 changed files with 743 additions and 81 deletions
+39
View File
@@ -124,6 +124,14 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque
if !ok {
return ErrRequestNotFound
}
// Tell the original requester their request was answered, so a game screen watching
// this opponent re-derives its "add to friends" state (accepted -> friends, declined
// -> stays "request sent").
if accept {
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded))
} else {
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined))
}
return nil
}
@@ -156,6 +164,14 @@ func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUI
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
}
// ListOutgoingRequests returns the account IDs the caller has already requested and
// cannot (re-)request: a live (not yet expired) pending request, or one the addressee
// permanently declined. The game's "add to friends" item reads it to stay disabled
// across reloads (a declined request reads identically to a still-pending one).
func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listOutgoingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
}
// loadEdges returns every friendship row between a and b in either direction (at
// most one per direction). It feeds SendFriendRequest's re-send classification.
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
@@ -294,6 +310,29 @@ func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, c
return out, nil
}
// listOutgoingRequests returns the addressees of the caller's requests that block a
// re-send: a live (created after cutoff) pending request, or a permanently declined
// one. An ignored pending request that has lazily expired is omitted (it may be re-sent).
func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.AddresseeID).
FROM(table.Friendships).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)).
OR(table.Friendships.Status.EQ(postgres.String(friendPending)).
AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list outgoing requests: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.AddresseeID)
}
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))).