cf66ed7e26
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
New platform/telegram connector (own container, bot token only there): - go-telegram/bot long-poll loop: /start deep-links + Mini App launch button. - gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify (renders a localized message + deep-link button), SendToUser/SendToGameChannel (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id). - Bot API base override for Telegram's test environment; Dockerfile + compose (VPN sidecar, no public ingress); README. Gateway: - initData validation relocated from the gateway into the connector; the gateway calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token, and deletes internal/auth. - Out-of-app push: runPushPump routes events whose recipient has no live in-app stream to connector.Notify, gated by /internal/push-target + the in-app-only flag (race-free de-dup); HasSubscribers added to the push hub. Backend: - Migration 00007 accounts.notifications_in_app_only (default true) + jetgen. - ProvisionTelegram seeds a new account's language/display name from the launch fields; IdentityExternalID reverse lookup; /internal/push-target handler. UI: - Telegram Mini App launch: detect initData, apply themeParams, authTelegram, route the deep-link start_param (g/i/f); /telegram/ guard redirects outside Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle; share-to-Telegram link for a friend code. Vitest + Playwright coverage. Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only (Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN, ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
160 lines
4.5 KiB
Go
160 lines
4.5 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"scrabble/backend/internal/account"
|
|
)
|
|
|
|
// The /api/v1/user account handlers wire profile editing, email binding and the
|
|
// statistics read (Stage 8). They follow handlers_user.go: X-User-ID identity, a
|
|
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
|
|
// client sends the complete desired profile.
|
|
|
|
// updateProfileRequest is the full editable profile. away_start/away_end are
|
|
// "HH:MM" local-time bounds of the daily away window.
|
|
type updateProfileRequest struct {
|
|
DisplayName string `json:"display_name"`
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
AwayStart string `json:"away_start"`
|
|
AwayEnd string `json:"away_end"`
|
|
BlockChat bool `json:"block_chat"`
|
|
BlockFriendRequests bool `json:"block_friend_requests"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
}
|
|
|
|
// statsDTO is a durable account's lifetime statistics (the derived games-played and
|
|
// win-rate are computed client-side).
|
|
type statsDTO struct {
|
|
Wins int `json:"wins"`
|
|
Losses int `json:"losses"`
|
|
Draws int `json:"draws"`
|
|
MaxGamePoints int `json:"max_game_points"`
|
|
MaxWordPoints int `json:"max_word_points"`
|
|
}
|
|
|
|
// emailBindRequestBody starts binding an email to the caller's account.
|
|
type emailBindRequestBody struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// emailBindConfirmBody completes binding an email with its confirm code.
|
|
type emailBindConfirmBody struct {
|
|
Email string `json:"email"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// parseAwayTime parses an "HH:MM" away-window bound.
|
|
func parseAwayTime(s string) (time.Time, bool) {
|
|
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return t, true
|
|
}
|
|
|
|
// handleUpdateProfile overwrites the caller's editable profile fields.
|
|
func (s *Server) handleUpdateProfile(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req updateProfileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
awayStart, ok := parseAwayTime(req.AwayStart)
|
|
if !ok {
|
|
abortBadRequest(c, "away_start must be HH:MM")
|
|
return
|
|
}
|
|
awayEnd, ok := parseAwayTime(req.AwayEnd)
|
|
if !ok {
|
|
abortBadRequest(c, "away_end must be HH:MM")
|
|
return
|
|
}
|
|
acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{
|
|
DisplayName: req.DisplayName,
|
|
PreferredLanguage: req.PreferredLanguage,
|
|
TimeZone: req.TimeZone,
|
|
AwayStart: awayStart,
|
|
AwayEnd: awayEnd,
|
|
BlockChat: req.BlockChat,
|
|
BlockFriendRequests: req.BlockFriendRequests,
|
|
NotificationsInAppOnly: req.NotificationsInAppOnly,
|
|
})
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, profileResponseFor(acc))
|
|
}
|
|
|
|
// handleStats returns the caller's lifetime statistics.
|
|
func (s *Server) handleStats(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
st, err := s.accounts.GetStats(c.Request.Context(), uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, statsDTO{
|
|
Wins: st.Wins,
|
|
Losses: st.Losses,
|
|
Draws: st.Draws,
|
|
MaxGamePoints: st.MaxGamePoints,
|
|
MaxWordPoints: st.MaxWordPoints,
|
|
})
|
|
}
|
|
|
|
// handleEmailBindRequest issues a confirm code to bind an email to the caller.
|
|
func (s *Server) handleEmailBindRequest(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req emailBindRequestBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
if err := s.emails.RequestCode(c.Request.Context(), uid, req.Email); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
// handleEmailBindConfirm verifies the code and binds the email, returning the
|
|
// updated profile.
|
|
func (s *Server) handleEmailBindConfirm(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req emailBindConfirmBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
acc, err := s.emails.ConfirmCode(c.Request.Context(), uid, req.Email, req.Code)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, profileResponseFor(acc))
|
|
}
|