Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
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
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.
This commit is contained in:
+5
-1
@@ -67,7 +67,11 @@ list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||
`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a
|
||||
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
||||
gateway.
|
||||
gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
|
||||
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
|
||||
seed a new account's language and display name from the launch fields, and adds
|
||||
migration `00007` (`accounts.notifications_in_app_only`, default true).
|
||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
||||
with no identity, excluded from statistics. The shared wire contracts live in the
|
||||
sibling [`../pkg`](../pkg) module.
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
@@ -56,9 +58,13 @@ type Account struct {
|
||||
BlockFriendRequests bool
|
||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||
// excluded from statistics, friends and history.
|
||||
IsGuest bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
IsGuest bool
|
||||
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
||||
// true (the default): the platform side-service skips out-of-app push for the
|
||||
// account (Stage 9).
|
||||
NotificationsInAppOnly bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Store is the Postgres-backed query surface for accounts and identities.
|
||||
@@ -77,6 +83,22 @@ func NewStore(db *sql.DB) *Store {
|
||||
// resolved by re-reading the winner's account. A platform identity is recorded
|
||||
// as confirmed; an email identity starts unconfirmed.
|
||||
func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
return s.provision(ctx, kind, externalID, provisionSeed{})
|
||||
}
|
||||
|
||||
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
||||
// identity. On first contact only, it seeds the new account's preferred language
|
||||
// from the Telegram client languageCode (when it maps to a supported language) and
|
||||
// its display name from firstName (falling back to username); an already-existing
|
||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
||||
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
||||
}
|
||||
|
||||
// provision finds the account for (kind, externalID) or creates it with seed,
|
||||
// collapsing a concurrent-create race on the identity unique constraint into a
|
||||
// re-read of the winner's account.
|
||||
func (s *Store) provision(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
|
||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||
if err == nil {
|
||||
return acc, nil
|
||||
@@ -85,7 +107,7 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
||||
return Account{}, err
|
||||
}
|
||||
|
||||
acc, err = s.create(ctx, kind, externalID)
|
||||
acc, err = s.create(ctx, kind, externalID, seed)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
// A concurrent caller created the identity first; return theirs.
|
||||
@@ -96,6 +118,35 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
// provisionSeed carries the optional create-time profile seed for a brand-new
|
||||
// account (Telegram first contact). Empty fields fall back to the accounts table
|
||||
// defaults, so an unknown language keeps the 'en' default and an empty name keeps
|
||||
// the ” default.
|
||||
type provisionSeed struct {
|
||||
preferredLanguage string
|
||||
displayName string
|
||||
}
|
||||
|
||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
|
||||
// username (capped to maxDisplayName runes).
|
||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
var seed provisionSeed
|
||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||
seed.preferredLanguage = lang
|
||||
}
|
||||
name := strings.TrimSpace(firstName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(username)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||
name = string([]rune(name)[:maxDisplayName])
|
||||
}
|
||||
seed.displayName = name
|
||||
return seed
|
||||
}
|
||||
|
||||
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
|
||||
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
||||
@@ -113,6 +164,29 @@ func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// IdentityExternalID returns the external_id of the account's identity of the
|
||||
// given kind, or ErrNotFound when the account has no such identity. The Telegram
|
||||
// side-service uses it (through the gateway push-target lookup) to address an
|
||||
// out-of-app notification to a recipient's Telegram chat.
|
||||
func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kind string) (string, error) {
|
||||
stmt := postgres.SELECT(table.Identities.ExternalID).
|
||||
FROM(table.Identities).
|
||||
WHERE(
|
||||
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Identities.Kind.EQ(postgres.String(kind))),
|
||||
).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.Identities
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", fmt.Errorf("account: identity external id (%s, %s): %w", accountID, kind, err)
|
||||
}
|
||||
return row.ExternalID, nil
|
||||
}
|
||||
|
||||
// findByIdentity joins identities to accounts and returns the matching account,
|
||||
// or ErrNotFound.
|
||||
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
@@ -137,9 +211,9 @@ func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Ac
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// create inserts a new account and its first identity inside one transaction
|
||||
// and returns the persisted account row.
|
||||
func (s *Store) create(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
// create inserts a new account (seeded from seed) and its first identity inside
|
||||
// one transaction and returns the persisted account row.
|
||||
func (s *Store) create(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
|
||||
accountID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: new account id: %w", err)
|
||||
@@ -151,9 +225,16 @@ func (s *Store) create(ctx context.Context, kind, externalID string) (Account, e
|
||||
|
||||
var created Account
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
// Seed the new row's display name and language (Telegram first contact); an
|
||||
// empty seed reproduces the table defaults ('' and 'en') the other callers
|
||||
// relied on, so their behaviour is unchanged.
|
||||
lang := seed.preferredLanguage
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
insertAccount := table.Accounts.
|
||||
INSERT(table.Accounts.AccountID).
|
||||
VALUES(accountID).
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
|
||||
VALUES(accountID, seed.displayName, lang).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
@@ -230,18 +311,19 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
// modelToAccount projects a generated model row into the public Account struct.
|
||||
func modelToAccount(row model.Accounts) Account {
|
||||
return Account{
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
PreferredLanguage: row.PreferredLanguage,
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
HintBalance: int(row.HintBalance),
|
||||
BlockChat: row.BlockChat,
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
PreferredLanguage: row.PreferredLanguage,
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
HintBalance: int(row.HintBalance),
|
||||
BlockChat: row.BlockChat,
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,13 +39,14 @@ var ErrInvalidProfile = errors.New("account: invalid profile")
|
||||
// and AwayEnd carry only the hour and minute of the daily away window, in the
|
||||
// account's TimeZone.
|
||||
type ProfileUpdate struct {
|
||||
DisplayName string
|
||||
PreferredLanguage string // "en" or "ru"
|
||||
TimeZone string // an IANA location name
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
DisplayName string
|
||||
PreferredLanguage string // "en" or "ru"
|
||||
TimeZone string // an IANA location name
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
NotificationsInAppOnly bool
|
||||
}
|
||||
|
||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||
@@ -71,11 +72,13 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
|
||||
table.Accounts.AwayStart, table.Accounts.AwayEnd,
|
||||
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
|
||||
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
|
||||
table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
postgres.String(name), postgres.String(lang), postgres.String(tz),
|
||||
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
|
||||
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), postgres.TimestampzT(time.Now().UTC()),
|
||||
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
|
||||
postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
||||
// the first-name / username display-name precedence, and trimming.
|
||||
func TestTelegramSeed(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, username, firstName string
|
||||
wantLang, wantName string
|
||||
}{
|
||||
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
|
||||
"en region-tagged": {"en-US", "user", "John", "en", "John"},
|
||||
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
|
||||
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
|
||||
"empty language": {"", "neo", "Neo", "", "Neo"},
|
||||
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
||||
"username fallback": {"en", "handle", "", "en", "handle"},
|
||||
"both empty": {"en", "", "", "en", ""},
|
||||
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := telegramSeed(tc.languageCode, tc.username, tc.firstName)
|
||||
if got.preferredLanguage != tc.wantLang {
|
||||
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
|
||||
}
|
||||
if got.displayName != tc.wantName {
|
||||
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
|
||||
// maxDisplayName runes (counted in runes, not bytes).
|
||||
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
||||
long := strings.Repeat("я", maxDisplayName+5)
|
||||
got := telegramSeed("ru", "", long)
|
||||
if n := utf8.RuneCountInString(got.displayName); n != maxDisplayName {
|
||||
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
|
||||
}
|
||||
}
|
||||
@@ -104,3 +104,113 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
|
||||
}
|
||||
return confirmed
|
||||
}
|
||||
|
||||
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
|
||||
// seeds the new account's language and display name from the launch fields,
|
||||
// defaults the in-app-only flag on, and never overwrites an existing account on a
|
||||
// later login (Stage 9 language seeding).
|
||||
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
ext := "tg-" + uuid.NewString()
|
||||
|
||||
acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if acc.PreferredLanguage != "ru" {
|
||||
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
|
||||
}
|
||||
if acc.DisplayName != "Иван" {
|
||||
t.Errorf("DisplayName = %q, want Иван", acc.DisplayName)
|
||||
}
|
||||
if !acc.NotificationsInAppOnly {
|
||||
t.Error("NotificationsInAppOnly should default to true")
|
||||
}
|
||||
|
||||
// A later login with different fields returns the same account, unchanged.
|
||||
again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
|
||||
if err != nil {
|
||||
t.Fatalf("re-provision telegram: %v", err)
|
||||
}
|
||||
if again.ID != acc.ID {
|
||||
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
|
||||
}
|
||||
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" {
|
||||
t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
|
||||
// client language falls back to the account default rather than failing the
|
||||
// language CHECK.
|
||||
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
acc, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if acc.PreferredLanguage != "en" {
|
||||
t.Errorf("PreferredLanguage = %q, want default en", acc.PreferredLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentityExternalID covers the reverse identity lookup the push-target route
|
||||
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
|
||||
// including for a guest that carries no identity.
|
||||
func TestIdentityExternalID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
ext := "tg-" + uuid.NewString()
|
||||
acc, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
got, err := store.IdentityExternalID(ctx, acc.ID, account.KindTelegram)
|
||||
if err != nil {
|
||||
t.Fatalf("identity external id: %v", err)
|
||||
}
|
||||
if got != ext {
|
||||
t.Errorf("external id = %q, want %q", got, ext)
|
||||
}
|
||||
if _, err := store.IdentityExternalID(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrNotFound) {
|
||||
t.Errorf("email lookup = %v, want ErrNotFound", err)
|
||||
}
|
||||
guest := provisionGuest(t)
|
||||
if _, err := store.IdentityExternalID(ctx, guest, account.KindTelegram); !errors.Is(err, account.ErrNotFound) {
|
||||
t.Errorf("guest lookup = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists
|
||||
// through UpdateProfile and reads back through GetByID.
|
||||
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if !acc.NotificationsInAppOnly {
|
||||
t.Fatal("default should be in-app-only true")
|
||||
}
|
||||
updated, err := store.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
|
||||
DisplayName: "Player",
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "UTC",
|
||||
NotificationsInAppOnly: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update profile: %v", err)
|
||||
}
|
||||
if updated.NotificationsInAppOnly {
|
||||
t.Error("update did not clear NotificationsInAppOnly")
|
||||
}
|
||||
got, err := store.GetByID(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got.NotificationsInAppOnly {
|
||||
t.Error("GetByID still reports in-app-only after clearing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +312,13 @@ func TestTimeoutSweep(t *testing.T) {
|
||||
}
|
||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||
|
||||
// Disable the to-move account's away window: with the default 00:00–07:00
|
||||
// window the sweeper (correctly) declines to time out a player whose deadline
|
||||
// fell while they were asleep, which made this test fail whenever CI ran with
|
||||
// now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test
|
||||
// deterministic regardless of the time of day.
|
||||
setAway(t, seats[0], "UTC", "00:00", "00:00")
|
||||
|
||||
// The sweep is global over the shared pool; assert the target game itself,
|
||||
// not the count, since other tests leave active games behind.
|
||||
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
||||
|
||||
@@ -13,16 +13,17 @@ import (
|
||||
)
|
||||
|
||||
type Accounts struct {
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
DisplayName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int32
|
||||
IsGuest bool
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
DisplayName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int32
|
||||
IsGuest bool
|
||||
NotificationsInAppOnly bool
|
||||
}
|
||||
|
||||
@@ -17,18 +17,19 @@ type accountsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AccountID postgres.ColumnString
|
||||
DisplayName postgres.ColumnString
|
||||
PreferredLanguage postgres.ColumnString
|
||||
TimeZone postgres.ColumnString
|
||||
BlockChat postgres.ColumnBool
|
||||
BlockFriendRequests postgres.ColumnBool
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
AwayStart postgres.ColumnTime
|
||||
AwayEnd postgres.ColumnTime
|
||||
HintBalance postgres.ColumnInteger
|
||||
IsGuest postgres.ColumnBool
|
||||
AccountID postgres.ColumnString
|
||||
DisplayName postgres.ColumnString
|
||||
PreferredLanguage postgres.ColumnString
|
||||
TimeZone postgres.ColumnString
|
||||
BlockChat postgres.ColumnBool
|
||||
BlockFriendRequests postgres.ColumnBool
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
AwayStart postgres.ColumnTime
|
||||
AwayEnd postgres.ColumnTime
|
||||
HintBalance postgres.ColumnInteger
|
||||
IsGuest postgres.ColumnBool
|
||||
NotificationsInAppOnly postgres.ColumnBool
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -70,39 +71,41 @@ func newAccountsTable(schemaName, tableName, alias string) *AccountsTable {
|
||||
|
||||
func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
var (
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||
PreferredLanguageColumn = postgres.StringColumn("preferred_language")
|
||||
TimeZoneColumn = postgres.StringColumn("time_zone")
|
||||
BlockChatColumn = postgres.BoolColumn("block_chat")
|
||||
BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
AwayStartColumn = postgres.TimeColumn("away_start")
|
||||
AwayEndColumn = postgres.TimeColumn("away_end")
|
||||
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
|
||||
IsGuestColumn = postgres.BoolColumn("is_guest")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||
PreferredLanguageColumn = postgres.StringColumn("preferred_language")
|
||||
TimeZoneColumn = postgres.StringColumn("time_zone")
|
||||
BlockChatColumn = postgres.BoolColumn("block_chat")
|
||||
BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
AwayStartColumn = postgres.TimeColumn("away_start")
|
||||
AwayEndColumn = postgres.TimeColumn("away_end")
|
||||
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
|
||||
IsGuestColumn = postgres.BoolColumn("is_guest")
|
||||
NotificationsInAppOnlyColumn = postgres.BoolColumn("notifications_in_app_only")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||
)
|
||||
|
||||
return accountsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AccountID: AccountIDColumn,
|
||||
DisplayName: DisplayNameColumn,
|
||||
PreferredLanguage: PreferredLanguageColumn,
|
||||
TimeZone: TimeZoneColumn,
|
||||
BlockChat: BlockChatColumn,
|
||||
BlockFriendRequests: BlockFriendRequestsColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
AwayStart: AwayStartColumn,
|
||||
AwayEnd: AwayEndColumn,
|
||||
HintBalance: HintBalanceColumn,
|
||||
IsGuest: IsGuestColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
DisplayName: DisplayNameColumn,
|
||||
PreferredLanguage: PreferredLanguageColumn,
|
||||
TimeZone: TimeZoneColumn,
|
||||
BlockChat: BlockChatColumn,
|
||||
BlockFriendRequests: BlockFriendRequestsColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
AwayStart: AwayStartColumn,
|
||||
AwayEnd: AwayEndColumn,
|
||||
HintBalance: HintBalanceColumn,
|
||||
IsGuest: IsGuestColumn,
|
||||
NotificationsInAppOnly: NotificationsInAppOnlyColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- Stage 9 Telegram integration: a per-account toggle that confines notifications
|
||||
-- to the in-app live stream. When notifications_in_app_only is true (the default),
|
||||
-- the platform side-service (Telegram) sends no out-of-app push; turning it off
|
||||
-- opts into out-of-app push, which the gateway delivers only while the account has
|
||||
-- no live in-app stream, so the in-app and platform channels never duplicate. Adds
|
||||
-- a column, so the generated jet code is regenerated (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN notifications_in_app_only boolean NOT NULL DEFAULT true;
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
DROP COLUMN notifications_in_app_only;
|
||||
@@ -35,16 +35,17 @@ type resolveResponse struct {
|
||||
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
|
||||
// are the daily away window's "HH:MM" local-time bounds (in TimeZone).
|
||||
type profileResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
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"`
|
||||
HintBalance int `json:"hint_balance"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
UserID string `json:"user_id"`
|
||||
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"`
|
||||
HintBalance int `json:"hint_balance"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
}
|
||||
|
||||
// tileDTO is one placed (or to-place) tile.
|
||||
@@ -148,16 +149,17 @@ func sessionResponseFor(token string, acc account.Account) sessionResponse {
|
||||
// profileResponseFor projects an account into its profile DTO.
|
||||
func profileResponseFor(acc account.Account) profileResponse {
|
||||
return profileResponse{
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
||||
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
||||
HintBalance: acc.HintBalance,
|
||||
BlockChat: acc.BlockChat,
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
IsGuest: acc.IsGuest,
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
||||
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
||||
HintBalance: acc.HintBalance,
|
||||
BlockChat: acc.BlockChat,
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
IsGuest: acc.IsGuest,
|
||||
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ func (s *Server) registerRoutes() {
|
||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||
in.POST("/sessions/resolve", s.handleResolveSession)
|
||||
in.POST("/sessions/revoke", s.handleRevokeSession)
|
||||
// Out-of-app push routing for the platform side-service (Stage 9): the
|
||||
// gateway resolves a recipient's Telegram chat + language + in-app-only flag
|
||||
// before delivering an out-of-app notification.
|
||||
in.POST("/push-target", s.handlePushTarget)
|
||||
}
|
||||
u := s.user
|
||||
if s.accounts != nil {
|
||||
|
||||
@@ -18,13 +18,14 @@ import (
|
||||
// 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"`
|
||||
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
|
||||
@@ -80,13 +81,14 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
|
||||
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,
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
@@ -14,21 +16,26 @@ import (
|
||||
// account and mint the opaque session. The backend trusts the gateway on this
|
||||
// segment (docs/ARCHITECTURE.md §12).
|
||||
|
||||
// telegramAuthRequest carries the platform user id the gateway extracted from a
|
||||
// validated initData payload.
|
||||
// telegramAuthRequest carries the identity the connector extracted from a
|
||||
// validated initData payload. Username, FirstName and LanguageCode seed a
|
||||
// brand-new account's display name and language (first contact only).
|
||||
type telegramAuthRequest struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
}
|
||||
|
||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||
// identity and mints a session for it.
|
||||
// identity and mints a session for it, seeding a new account's display name and
|
||||
// language from the supplied Telegram fields.
|
||||
func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||
var req telegramAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
abortBadRequest(c, "external_id is required")
|
||||
return
|
||||
}
|
||||
acc, err := s.accounts.ProvisionByIdentity(c.Request.Context(), account.KindTelegram, req.ExternalID)
|
||||
acc, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
@@ -36,6 +43,53 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
||||
type pushTargetRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// pushTargetResponse carries what the gateway needs to route an out-of-app push:
|
||||
// the recipient's Telegram external_id (empty when they have no Telegram
|
||||
// identity, e.g. a guest or email-only account), the preferred language for the
|
||||
// message template, and whether they confined notifications to the in-app stream.
|
||||
type pushTargetResponse struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
Language string `json:"language"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
}
|
||||
|
||||
// handlePushTarget resolves a user id to the data the gateway needs to deliver an
|
||||
// out-of-app Telegram notification — the gateway-only internal counterpart of the
|
||||
// in-app push stream. A user with no Telegram identity yields an empty external_id,
|
||||
// which the gateway treats as "no out-of-app channel".
|
||||
func (s *Server) handlePushTarget(c *gin.Context) {
|
||||
var req pushTargetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == "" {
|
||||
abortBadRequest(c, "user_id is required")
|
||||
return
|
||||
}
|
||||
uid, err := uuid.Parse(req.UserID)
|
||||
if err != nil {
|
||||
abortBadRequest(c, "user_id must be a uuid")
|
||||
return
|
||||
}
|
||||
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
ext, err := s.accounts.IdentityExternalID(c.Request.Context(), uid, account.KindTelegram)
|
||||
if err != nil && !errors.Is(err, account.ErrNotFound) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, pushTargetResponse{
|
||||
ExternalID: ext,
|
||||
Language: acc.PreferredLanguage,
|
||||
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
|
||||
func (s *Server) handleGuestAuth(c *gin.Context) {
|
||||
acc, err := s.accounts.ProvisionGuest(c.Request.Context())
|
||||
|
||||
Reference in New Issue
Block a user