Files
scrabble-game/backend/internal/server/handlers_link.go
T
Ilia Denisov dc582e9f73
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
fix(ads): restore reliable banner fades + keep banner on profile update
Two regressions from the previous banner pass:

- Fade (#2): the manual-opacity fade could paint opacity 0 and 1 in one frame and
  skip the transition — most visible for a single (default) campaign message,
  whose only fade is the first show. Revert the fade to Svelte transition:fade
  (which forces the from-state, so even the first/only message fades), keeping it
  on its own {#if} layer independent of the scroll. A freshly-mounted view onto a
  running cycle still renders the live message instantly (inFade duration 0 once),
  so navigation does not replay the fade. Verified by opacity sampling: advances
  fade, navigation stays at opacity 1.

- Profile update (#3): the banner block was attached only to GET /profile, so a
  profile.update (e.g. a language switch) returned a profile without it and the
  banner vanished until reload. A shared profileResponse() now attaches the banner
  to GET, PUT and the link/merge profile responses. Regression test added
  (TestBannerSurvivesProfileUpdate).

Still open: the scroll position is not preserved across navigation (the view
remounts); discussed separately.
2026-06-16 06:09:35 +02:00

201 lines
6.4 KiB
Go

package server
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/link"
)
// The /api/v1/user/link handlers drive account linking & merge. The
// request step always mails a code (no pre-send "taken" signal, so a probe cannot
// enumerate registered emails); confirm reveals a required merge only after the
// code is verified; merge performs the irreversible consolidation behind an
// explicit step. A merge into a guest initiator's durable counterpart switches the
// active session — the new token rides back in the result for the client to adopt.
// linkEmailRequestBody starts a link/merge by mailing a code to email.
type linkEmailRequestBody struct {
Email string `json:"email"`
}
// linkEmailConfirmBody carries the email and its confirm code.
type linkEmailConfirmBody struct {
Email string `json:"email"`
Code string `json:"code"`
}
// linkTelegramBody carries a gateway-validated Telegram identity.
type linkTelegramBody struct {
ExternalID string `json:"external_id"`
}
// linkResultResponse is the unified result of a confirm or merge step. Status is
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
// account — the secondary_* fields summarise it for the irreversible confirmation),
// or "merged" (done; token is non-empty when the active account switched).
type linkResultResponse struct {
Status string `json:"status"`
SecondaryUserID string `json:"secondary_user_id,omitempty"`
SecondaryName string `json:"secondary_display_name,omitempty"`
SecondaryGames int `json:"secondary_games"`
SecondaryFriends int `json:"secondary_friends"`
Token string `json:"token,omitempty"`
Profile *profileResponse `json:"profile,omitempty"`
}
// handleLinkEmailRequest mails a confirm-code to email for a later link or merge.
func (s *Server) handleLinkEmailRequest(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailRequestBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if err := s.links.RequestEmail(c.Request.Context(), uid, req.Email); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
// required merge.
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailConfirmBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
res, err := s.links.ConfirmEmail(c.Request.Context(), uid, req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
}
// handleLinkEmailMerge re-verifies the code and performs the merge.
func (s *Server) handleLinkEmailMerge(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailConfirmBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
res, err := s.links.MergeEmail(c.Request.Context(), uid, req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
}
// handleLinkTelegram attaches a gateway-validated Telegram identity to the caller
// or reports a required merge.
func (s *Server) handleLinkTelegram(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkTelegramBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.ConfirmTelegram(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
}
// handleLinkTelegramMerge merges the account owning a gateway-validated Telegram
// identity into the caller's.
func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkTelegramBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.MergeTelegram(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
}
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
// or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
ctx := c.Request.Context()
if res.MergeRequired {
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
out.SecondaryName = acc.DisplayName
}
out.SecondaryGames, out.SecondaryFriends = s.secondaryCounts(ctx, res.SecondaryID)
return out
}
return linkResultResponse{Status: "linked", Profile: s.profileFor(ctx, activeID)}
}
// mergeResultResponse renders a completed merge: the surviving account's profile
// plus a switched-session token when the active account changed.
func (s *Server) mergeResultResponse(c *gin.Context, res link.MergeResult) linkResultResponse {
return linkResultResponse{
Status: "merged",
Token: res.SwitchedToken,
Profile: s.profileFor(c.Request.Context(), res.PrimaryID),
}
}
// profileFor loads an account's profile DTO, or nil when it cannot be read.
func (s *Server) profileFor(ctx context.Context, id uuid.UUID) *profileResponse {
acc, err := s.accounts.GetByID(ctx, id)
if err != nil {
return nil
}
p := s.profileResponse(ctx, acc)
return &p
}
// secondaryCounts summarises the to-be-retired account for the merge confirmation.
func (s *Server) secondaryCounts(ctx context.Context, id uuid.UUID) (games, friends int) {
if s.games != nil {
if gs, err := s.games.ListForAccount(ctx, id); err == nil {
games = len(gs)
}
}
if s.social != nil {
if fs, err := s.social.ListFriends(ctx, id); err == nil {
friends = len(fs)
}
}
return games, friends
}