package server import ( "context" "net/http" "github.com/gin-gonic/gin" "github.com/google/uuid" "scrabble/backend/internal/account" "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"` } // linkVKBody carries a gateway-validated VK identity (the trusted vk user id the // gateway resolved from the VK ID code exchange). type linkVKBody 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}) } // unlinkBody carries the provider kind to detach. type unlinkBody struct { Kind string `json:"kind"` } // handleUnlink detaches a platform identity (telegram or vk) from the caller's // account, refusing to remove the last identity (ErrLastIdentity). Email is never // unlinked — it is replaced through the change-email flow — so an email kind is // rejected. It returns the refreshed profile so the client updates its controls. func (s *Server) handleUnlink(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } var req unlinkBody if err := c.ShouldBindJSON(&req); err != nil { abortBadRequest(c, "invalid request body") return } if req.Kind != account.KindTelegram && req.Kind != account.KindVK { abortBadRequest(c, "only telegram or vk can be unlinked") return } ctx := c.Request.Context() if err := s.accounts.RemoveIdentity(ctx, uid, req.Kind); err != nil { s.abortErr(c, err) return } acc, err := s.accounts.GetByID(ctx, uid) if err != nil { s.abortErr(c, err) return } r := s.profileResponse(ctx, acc) c.JSON(http.StatusOK, linkResultResponse{Status: "unlinked", Profile: &r}) } // handleChangeEmailRequest mails a confirm-code to a new address for an authenticated // email change. Like the link request it never signals "taken" up front — a conflict is // only revealed (non-disclosingly) at confirm. func (s *Server) handleChangeEmailRequest(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.emails.RequestChangeCode(c.Request.Context(), uid, req.Email); err != nil { s.abortErr(c, err) return } c.JSON(http.StatusOK, okResponse{OK: true}) } // handleChangeEmailConfirm verifies the code and atomically switches the account to the // new address, returning the refreshed profile. A new address confirmed by another // account is refused (ErrEmailTaken → the non-disclosing message), never merged. func (s *Server) handleChangeEmailConfirm(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 } ctx := c.Request.Context() acc, err := s.emails.ConfirmChange(ctx, uid, req.Email, req.Code) if err != nil { s.abortErr(c, err) return } r := s.profileResponse(ctx, acc) c.JSON(http.StatusOK, linkResultResponse{Status: "changed", Profile: &r}) } // 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)) } // handleLinkVK attaches a gateway-validated VK identity to the caller or reports a // required merge. func (s *Server) handleLinkVK(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } var req linkVKBody if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { abortBadRequest(c, "missing external_id") return } res, err := s.links.ConfirmVK(c.Request.Context(), uid, req.ExternalID) if err != nil { s.abortErr(c, err) return } c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res)) } // handleLinkVKMerge merges the account owning a gateway-validated VK identity into // the caller's. func (s *Server) handleLinkVKMerge(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } var req linkVKBody if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { abortBadRequest(c, "missing external_id") return } res, err := s.links.MergeVK(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.Merged { // A guest initiator's merge ran inline (the guest-primary rule; no confirmation step), // so render the completed merge — the client adopts the switched session and lands on // the surviving durable account as if it simply logged in. return s.mergeResultResponse(c, res.Merge) } 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 }