feat(profile): carry linked identities in the profile (email, telegram, vk)

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.
This commit is contained in:
Ilia Denisov
2026-07-03 09:17:17 +02:00
parent 76e7916ff6
commit 3a823ca7ef
10 changed files with 130 additions and 2 deletions
+25
View File
@@ -45,9 +45,34 @@ type bannerTimingsDTO struct {
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc) r := profileResponseFor(acc)
r.Banner = s.bannerFor(ctx, acc) r.Banner = s.bannerFor(ctx, acc)
s.fillLinkedIdentities(ctx, &r, acc.ID)
return r 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 // 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 // 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) // see a banner. The message language follows the account's bot (service)
+7
View File
@@ -56,6 +56,13 @@ type profileResponse struct {
// see the banner (a free account with an empty hint wallet and without the // see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go. // no_banner role), absent otherwise. See banner.go.
Banner *bannerDTO `json:"banner,omitempty"` 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. // tileDTO is one placed (or to-place) tile.
+5
View File
@@ -37,6 +37,11 @@ type ProfileResp struct {
// Banner is the advertising-banner block, present only for a viewer eligible to // 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. // see the banner. The gateway forwards it verbatim into the Profile payload.
Banner *BannerResp `json:"banner,omitempty"` 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 // BannerResp is the advertising-banner block of an eligible viewer's profile: the
+4
View File
@@ -76,6 +76,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
tz := b.CreateString(p.TimeZone) tz := b.CreateString(p.TimeZone)
awayStart := b.CreateString(p.AwayStart) awayStart := b.CreateString(p.AwayStart)
awayEnd := b.CreateString(p.AwayEnd) awayEnd := b.CreateString(p.AwayEnd)
email := b.CreateString(p.Email)
// Build the banner table (and its children) before opening Profile: FlatBuffers // Build the banner table (and its children) before opening Profile: FlatBuffers
// forbids a nested table while another is under construction. // forbids a nested table while another is under construction.
var banner flatbuffers.UOffsetT var banner flatbuffers.UOffsetT
@@ -96,6 +97,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
fb.ProfileAddAwayEnd(b, awayEnd) fb.ProfileAddAwayEnd(b, awayEnd)
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly) fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
fb.ProfileAddVariantPreferences(b, prefs) fb.ProfileAddVariantPreferences(b, prefs)
fb.ProfileAddEmail(b, email)
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
fb.ProfileAddVkLinked(b, p.VkLinked)
if p.Banner != nil { if p.Banner != nil {
fb.ProfileAddBanner(b, banner) fb.ProfileAddBanner(b, banner)
} }
+6
View File
@@ -224,6 +224,12 @@ table Profile {
// variant_preferences is the set of game variants the player allows themselves to be // 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. // matched into (engine.Variant labels), Erudit-first; the New Game picker is gated by it.
variant_preferences:[string]; 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 // BlockStatus reports the caller's current manual block. The UI fetches it after any operation
+42 -1
View File
@@ -179,8 +179,40 @@ func (rcv *Profile) VariantPreferencesLength() int {
return 0 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) { func ProfileStart(builder *flatbuffers.Builder) {
builder.StartObject(13) builder.StartObject(16)
} }
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) 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 { func ProfileStartVariantPreferencesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) 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 { func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+30 -1
View File
@@ -107,8 +107,25 @@ variantPreferencesLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; 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) { static startProfile(builder:flatbuffers.Builder) {
builder.startObject(13); builder.startObject(16);
} }
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -175,6 +192,18 @@ static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:numbe
builder.startVector(4, numElems, 4); 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 { static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
+3
View File
@@ -354,6 +354,9 @@ export function decodeProfile(buf: Uint8Array): Profile {
notificationsInAppOnly: p.notificationsInAppOnly(), notificationsInAppOnly: p.notificationsInAppOnly(),
variantPreferences: decodeVariantPreferences(p), variantPreferences: decodeVariantPreferences(p),
banner: decodeBanner(p), banner: decodeBanner(p),
email: s(p.email()),
telegramLinked: p.telegramLinked(),
vkLinked: p.vkLinked(),
}; };
} }
+3
View File
@@ -39,6 +39,9 @@ export const PROFILE: Profile = {
notificationsInAppOnly: true, notificationsInAppOnly: true,
// Every variant enabled, so the mock-driven UI offers the full New Game picker. // Every variant enabled, so the mock-driven UI offers the full New Game picker.
variantPreferences: ['erudit_ru', 'scrabble_ru', 'scrabble_en'], 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 // Seed social/account data for the mock (pnpm start + Playwright). The mock profile
+5
View File
@@ -156,6 +156,11 @@ export interface Profile {
variantPreferences: Variant[]; variantPreferences: Variant[];
/** The advertising-banner block, present only for a viewer eligible to see it. */ /** The advertising-banner block, present only for a viewer eligible to see it. */
banner?: Banner; 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. */ /** Banner is the advertising-banner block of an eligible viewer's profile. */