feat(gateway): confirmEmailLink RPC + language on the email request + profile event

Add the confirm-link edge method (auth.email.confirm_link): a new
EmailConfirmLinkRequest/Result fbs table, the transcode const + handler + encoder,
and the backend-client call to the existing /sessions/email/confirm-link endpoint —
it rides Execute under the existing service prefix, so no proto/Caddy change. Add a
language field to EmailRequestRequest and forward it (the backend already seeds it).
Add the NotifyProfile sub-kind + notify.ProfileChanged, published by the confirm-link
handler on a successful link so an in-app session re-fetches its profile when the
email was confirmed in another browser. Regenerated fbs bindings (Go + TS).
This commit is contained in:
Ilia Denisov
2026-07-03 04:22:09 +02:00
parent 25d80bc31d
commit 762155a55e
14 changed files with 418 additions and 41 deletions
+7
View File
@@ -155,6 +155,13 @@ func Notification(userID uuid.UUID, kind string) Intent {
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
}
// ProfileChanged is a payload-free "re-fetch your profile" signal to userID, emitted
// when the viewer's own account changed out of band — an email confirmed through the
// one-tap deeplink opened in another browser.
func ProfileChanged(userID uuid.UUID) Intent {
return Notification(userID, NotifyProfile)
}
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
// account it concerns (the requester, the new friend or the decliner), so the client updates its
// requests/friends lists and the in-game "add friend" state without a refetch.
+4
View File
@@ -69,6 +69,10 @@ const (
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
// banner set rides the profile response). In-app only.
NotifyBanner = "banner"
// NotifyProfile tells the client that the viewer's own profile changed out of band
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
// so it re-fetches profile.get. It carries no payload. In-app only.
NotifyProfile = "profile"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered
+7 -3
View File
@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
@@ -252,11 +253,14 @@ func (s *Server) handleEmailConfirmLink(c *gin.Context) {
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess})
return
}
status := "confirmed"
if res.NeedsMerge {
status = "merge_required"
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "merge_required"})
return
}
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: status})
// A free link attached the email; nudge the account's in-app session(s) to re-fetch
// the profile, so a link confirmed in another browser reflects at once.
s.notifier.Publish(notify.ProfileChanged(res.Account))
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "confirmed"})
}
// tokenRequest carries an opaque session token.