From 3a823ca7ef04bb3dd2f06c6b7d261c31a9ddd6f0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 09:17:17 +0200 Subject: [PATCH] feat(profile): carry linked identities in the profile (email, telegram, vk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add email / telegram_linked / vk_linked to the Profile (fbs table + regenerated Go/TS bindings, gateway ProfileResp + encodeProfile, backend DTO, UI model + decode). They are filled outside the pure projection — Server.profileResponse now reads the account's identities (like the banner seam) — and will drive the profile's Add / Unlink / change-email controls. --- backend/internal/server/banner.go | 25 ++++++++++++++++ backend/internal/server/dto.go | 7 +++++ gateway/internal/backendclient/api.go | 5 ++++ gateway/internal/transcode/encode.go | 4 +++ pkg/fbs/scrabble.fbs | 6 ++++ pkg/fbs/scrabblefb/Profile.go | 43 ++++++++++++++++++++++++++- ui/src/gen/fbs/scrabblefb/profile.ts | 31 ++++++++++++++++++- ui/src/lib/codec.ts | 3 ++ ui/src/lib/mock/data.ts | 3 ++ ui/src/lib/model.ts | 5 ++++ 10 files changed, 130 insertions(+), 2 deletions(-) diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index c87d70d..f3f5892 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -45,9 +45,34 @@ type bannerTimingsDTO struct { func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { r := profileResponseFor(acc) r.Banner = s.bannerFor(ctx, acc) + s.fillLinkedIdentities(ctx, &r, acc.ID) return r } +// fillLinkedIdentities sets the profile's confirmed email address and platform-linked +// flags from the account's identities, so the client offers the right link / unlink / +// change-email controls. A read failure leaves them zero (no controls), logged as a +// warning so the profile response still succeeds. +func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, accountID uuid.UUID) { + ids, err := s.accounts.Identities(ctx, accountID) + if err != nil { + s.log.Warn("profile: identities read failed", zap.String("account", accountID.String()), zap.Error(err)) + return + } + for _, id := range ids { + switch id.Kind { + case account.KindEmail: + if id.Confirmed { + r.Email = id.ExternalID + } + case account.KindTelegram: + r.TelegramLinked = true + case account.KindVK: + r.VkLinked = true + } + } +} + // bannerFor builds the advertising-banner block for the account's profile, or // nil when the ads service is not configured or the viewer is not eligible to // see a banner. The message language follows the account's bot (service) diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 9b97885..071e966 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -56,6 +56,13 @@ type profileResponse struct { // see the banner (a free account with an empty hint wallet and without the // no_banner role), absent otherwise. See banner.go. Banner *bannerDTO `json:"banner,omitempty"` + // Email is the account's confirmed email address ("" when none); TelegramLinked and + // VkLinked report whether a platform identity is attached. They drive the profile's + // link / unlink / change-email controls, and are filled outside the pure projection + // (they read the account's identities). See Server.profileResponse. + Email string `json:"email"` + TelegramLinked bool `json:"telegram_linked"` + VkLinked bool `json:"vk_linked"` } // tileDTO is one placed (or to-place) tile. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 2d4299c..2b4d019 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -37,6 +37,11 @@ type ProfileResp struct { // Banner is the advertising-banner block, present only for a viewer eligible to // see the banner. The gateway forwards it verbatim into the Profile payload. Banner *BannerResp `json:"banner,omitempty"` + // Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an + // attached platform identity — they drive the profile's link/unlink/change controls. + Email string `json:"email"` + TelegramLinked bool `json:"telegram_linked"` + VkLinked bool `json:"vk_linked"` } // BannerResp is the advertising-banner block of an eligible viewer's profile: the diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index e2d9f8c..8cb2562 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -76,6 +76,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte { tz := b.CreateString(p.TimeZone) awayStart := b.CreateString(p.AwayStart) awayEnd := b.CreateString(p.AwayEnd) + email := b.CreateString(p.Email) // Build the banner table (and its children) before opening Profile: FlatBuffers // forbids a nested table while another is under construction. var banner flatbuffers.UOffsetT @@ -96,6 +97,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte { fb.ProfileAddAwayEnd(b, awayEnd) fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly) fb.ProfileAddVariantPreferences(b, prefs) + fb.ProfileAddEmail(b, email) + fb.ProfileAddTelegramLinked(b, p.TelegramLinked) + fb.ProfileAddVkLinked(b, p.VkLinked) if p.Banner != nil { fb.ProfileAddBanner(b, banner) } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 5d6559f..8ca031d 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -224,6 +224,12 @@ table Profile { // variant_preferences is the set of game variants the player allows themselves to be // matched into (engine.Variant labels), Erudit-first; the New Game picker is gated by it. variant_preferences:[string]; + // email is the account's confirmed email address ("" when none); telegram_linked and + // vk_linked report whether a platform identity is attached. They drive the profile's + // link / unlink / change-email controls (all added trailing — backward-compatible). + email:string; + telegram_linked:bool; + vk_linked:bool; } // BlockStatus reports the caller's current manual block. The UI fetches it after any operation diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 209e23e..3bb715b 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -179,8 +179,40 @@ func (rcv *Profile) VariantPreferencesLength() int { return 0 } +func (rcv *Profile) Email() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(30)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Profile) TelegramLinked() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(32)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Profile) MutateTelegramLinked(n bool) bool { + return rcv._tab.MutateBoolSlot(32, n) +} + +func (rcv *Profile) VkLinked() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(34)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Profile) MutateVkLinked(n bool) bool { + return rcv._tab.MutateBoolSlot(34, n) +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(13) + builder.StartObject(16) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -224,6 +256,15 @@ func ProfileAddVariantPreferences(builder *flatbuffers.Builder, variantPreferenc func ProfileStartVariantPreferencesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func ProfileAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(email), 0) +} +func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool) { + builder.PrependBoolSlot(14, telegramLinked, false) +} +func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) { + builder.PrependBoolSlot(15, vkLinked, false) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts index 89ef96b..84d880c 100644 --- a/ui/src/gen/fbs/scrabblefb/profile.ts +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -107,8 +107,25 @@ variantPreferencesLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +email():string|null +email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +email(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 30); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +telegramLinked():boolean { + const offset = this.bb!.__offset(this.bb_pos, 32); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +vkLinked():boolean { + const offset = this.bb!.__offset(this.bb_pos, 34); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + static startProfile(builder:flatbuffers.Builder) { - builder.startObject(13); + builder.startObject(16); } static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { @@ -175,6 +192,18 @@ static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:numbe builder.startVector(4, numElems, 4); } +static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) { + builder.addFieldOffset(13, emailOffset, 0); +} + +static addTelegramLinked(builder:flatbuffers.Builder, telegramLinked:boolean) { + builder.addFieldInt8(14, +telegramLinked, +false); +} + +static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) { + builder.addFieldInt8(15, +vkLinked, +false); +} + static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index e617ee2..a7e745f 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -354,6 +354,9 @@ export function decodeProfile(buf: Uint8Array): Profile { notificationsInAppOnly: p.notificationsInAppOnly(), variantPreferences: decodeVariantPreferences(p), banner: decodeBanner(p), + email: s(p.email()), + telegramLinked: p.telegramLinked(), + vkLinked: p.vkLinked(), }; } diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 08777a0..5fdfa86 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -39,6 +39,9 @@ export const PROFILE: Profile = { notificationsInAppOnly: true, // Every variant enabled, so the mock-driven UI offers the full New Game picker. variantPreferences: ['erudit_ru', 'scrabble_ru', 'scrabble_en'], + email: 'you@example.com', + telegramLinked: false, + vkLinked: false, }; // Seed social/account data for the mock (pnpm start + Playwright). The mock profile diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 28a4510..b9bbff4 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -156,6 +156,11 @@ export interface Profile { variantPreferences: Variant[]; /** The advertising-banner block, present only for a viewer eligible to see it. */ banner?: Banner; + /** The account's confirmed email address ("" when none). */ + email: string; + /** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */ + telegramLinked: boolean; + vkLinked: boolean; } /** Banner is the advertising-banner block of an eligible viewer's profile. */