diff --git a/app/components/lobbyside-widget/index.hbs b/app/components/lobbyside-widget/index.hbs index 63a4772d58..57a634b5d8 100644 --- a/app/components/lobbyside-widget/index.hbs +++ b/app/components/lobbyside-widget/index.hbs @@ -25,6 +25,7 @@ this.customFields.stages_completed this.customFields.signed_up_date this.customFields.subscription_type + this.customFields.other_emails }} {{will-destroy this.removeScript}} > diff --git a/app/components/lobbyside-widget/index.ts b/app/components/lobbyside-widget/index.ts index a33cfaab73..7961d40ca5 100644 --- a/app/components/lobbyside-widget/index.ts +++ b/app/components/lobbyside-widget/index.ts @@ -15,6 +15,7 @@ declare global { stages_completed: string; signed_up_date: string; subscription_type: string; + other_emails: string; }): void; }; } @@ -30,12 +31,15 @@ interface VisitorSource { hasActiveSubscription?: boolean; teamHasActiveSubscription?: boolean; courseParticipations?: { completedStageSlugs?: string[] }[]; + primaryEmailAddress?: string | null; + emailAddresses?: { value?: string | null }[]; } export interface LobbysideCustomFields { stages_completed: string; signed_up_date: string; subscription_type: string; + other_emails: string; } function subscriptionType(user: VisitorSource | null | undefined): string { @@ -62,6 +66,21 @@ function subscriptionType(user: VisitorSource | null | undefined): string { return 'free'; } +function otherEmails(user: VisitorSource | null | undefined): string { + const primary = (user?.primaryEmailAddress ?? '').trim(); + const seen = new Set(); + + for (const entry of user?.emailAddresses ?? []) { + const value = (entry?.value ?? '').trim(); + + if (value && value !== primary) { + seen.add(value); + } + } + + return Array.from(seen).join(', '); +} + export function lobbysideCustomFields(user: VisitorSource | null | undefined): LobbysideCustomFields { const stagesCompleted = (user?.courseParticipations ?? []).reduce( (sum, participation) => sum + (participation.completedStageSlugs?.length ?? 0), @@ -72,6 +91,7 @@ export function lobbysideCustomFields(user: VisitorSource | null | undefined): L stages_completed: String(stagesCompleted), signed_up_date: user?.createdAt ? user.createdAt.toISOString().slice(0, 10) : '', subscription_type: subscriptionType(user), + other_emails: otherEmails(user), }; } diff --git a/app/services/authenticator.ts b/app/services/authenticator.ts index ade1e9a669..9cf12c86e7 100644 --- a/app/services/authenticator.ts +++ b/app/services/authenticator.ts @@ -152,5 +152,8 @@ export default class AuthenticatorService extends Service { this.prefillBeaconEmail(); this.currentUserCacheStorage.setValues(user.id, user.username); this.cacheBuster++; + + // Populates currentUser.emailAddresses for the Lobbyside `other_emails` field. + this.store.findAll('email-address', { include: 'user' }).catch(() => {}); } } diff --git a/tests/support/verify-api-requests.js b/tests/support/verify-api-requests.js index 26898e2502..1adfbc68e9 100644 --- a/tests/support/verify-api-requests.js +++ b/tests/support/verify-api-requests.js @@ -20,6 +20,8 @@ export default class ApiRequestsVerifier { pathname !== '/api/v1/analytics-events' && // Triggered on application boot pathname !== '/api/v1/users/current' && + // Triggered on application boot (Lobbyside widget other_emails) + pathname !== '/api/v1/email-addresses' && // Triggered when header is rendered !pathname.match(/^\/api\/v1\/users\/[^/]+\/top-language-leaderboard-slugs$/) ); diff --git a/tests/unit/components/lobbyside-widget-test.ts b/tests/unit/components/lobbyside-widget-test.ts index fd8994d7ac..b627db35b7 100644 --- a/tests/unit/components/lobbyside-widget-test.ts +++ b/tests/unit/components/lobbyside-widget-test.ts @@ -20,6 +20,7 @@ module('Unit | Component | lobbyside-widget', function () { stages_completed: '0', signed_up_date: '', subscription_type: '', + other_emails: '', }); }); @@ -57,4 +58,43 @@ module('Unit | Component | lobbyside-widget', function () { assert.strictEqual(lobbysideCustomFields(createUser()).subscription_type, 'free'); }); + + test('other_emails joins every non-primary email, comma-separated', function (assert) { + const user = createUser({ + primaryEmailAddress: 'primary@example.com', + emailAddresses: [{ value: 'primary@example.com' }, { value: 'work@example.com' }, { value: 'school@example.com' }], + } as unknown as Partial); + + assert.strictEqual(lobbysideCustomFields(user).other_emails, 'work@example.com, school@example.com'); + }); + + test('other_emails excludes the primary email and is empty when it is the only one', function (assert) { + const user = createUser({ + primaryEmailAddress: 'primary@example.com', + emailAddresses: [{ value: 'primary@example.com' }], + } as unknown as Partial); + + assert.strictEqual(lobbysideCustomFields(user).other_emails, ''); + }); + + test('other_emails dedupes and drops blank or missing values', function (assert) { + const user = createUser({ + primaryEmailAddress: 'primary@example.com', + emailAddresses: [ + { value: 'work@example.com' }, + { value: ' work@example.com ' }, + { value: '' }, + { value: null }, + { value: 'alt@example.com' }, + ], + } as unknown as Partial); + + assert.strictEqual(lobbysideCustomFields(user).other_emails, 'work@example.com, alt@example.com'); + }); + + test('other_emails is empty when the user has no email addresses', function (assert) { + const user = createUser({ primaryEmailAddress: 'primary@example.com', emailAddresses: [] } as unknown as Partial); + + assert.strictEqual(lobbysideCustomFields(user).other_emails, ''); + }); }); diff --git a/tests/unit/services/authenticator-test.js b/tests/unit/services/authenticator-test.js new file mode 100644 index 0000000000..e658317078 --- /dev/null +++ b/tests/unit/services/authenticator-test.js @@ -0,0 +1,43 @@ +import { Response } from 'miragejs'; +import { module, test } from 'qunit'; +import { setupMirage } from 'codecrafters-frontend/tests/test-support/mirage'; +import { setupTest } from 'codecrafters-frontend/tests/helpers'; +import { setupWindowMock } from 'ember-window-mock/test-support'; +import { signIn } from 'codecrafters-frontend/tests/support/authentication-helpers'; +import testScenario from 'codecrafters-frontend/mirage/scenarios/test'; + +module('Unit | Service | authenticator', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + setupWindowMock(hooks); + + test('syncCurrentUser still loads the user when the email-addresses endpoint fails', async function (assert) { + testScenario(this.server); + const user = signIn(this.owner, this.server); + + // Regression: an `email-addresses` failure must never break the auth bootstrap (LSD revert of #3869). + this.server.get('/email-addresses', () => new Response(500, {}, { errors: [{ detail: 'boom' }] })); + + const authenticator = this.owner.lookup('service:authenticator'); + await authenticator.syncCurrentUser(); + + assert.ok(authenticator.currentUser, 'current user is loaded despite the failing endpoint'); + assert.strictEqual(authenticator.currentUser.id, user.id, 'the signed-in user is the current user'); + }); + + test('syncCurrentUser populates currentUser.emailAddresses for the Lobbyside widget', async function (assert) { + testScenario(this.server); + const user = signIn(this.owner, this.server); + + this.server.schema.emailAddresses.create({ user, value: 'work@example.com' }); + this.server.schema.emailAddresses.create({ user, value: 'alt@example.com' }); + + const authenticator = this.owner.lookup('service:authenticator'); + await authenticator.syncCurrentUser(); + await this.owner.lookup('service:store').findAll('email-address', { include: 'user' }); + + const values = authenticator.currentUser.emailAddresses.map((emailAddress) => emailAddress.value); + assert.true(values.includes('work@example.com'), 'secondary email is attached to the current user'); + assert.true(values.includes('alt@example.com'), 'all secondary emails are attached to the current user'); + }); +});