Skip to content

Commit e570e06

Browse files
committed
Updated Strapi using offering instead of capability directly
1 parent 840a20e commit e570e06

8 files changed

Lines changed: 237 additions & 56 deletions

File tree

libs/free-access-program/src/lib/free-access-program.projector.spec.ts

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,12 @@ describe('normalizeWebhookEntry', () => {
203203
id: 1,
204204
documentId: 'ent-1',
205205
internalName: 'VPN beta',
206-
capabilities: [
207-
{ id: 1, slug: 'vpn-beta', services: [{ oauthClientId: 'cli' }] },
206+
offerings: [
207+
{
208+
capabilities: [
209+
{ id: 1, slug: 'vpn-beta', services: [{ oauthClientId: 'cli' }] },
210+
],
211+
},
208212
],
209213
matchers: [
210214
{
@@ -232,19 +236,63 @@ describe('normalizeWebhookEntry', () => {
232236
],
233237
});
234238
});
239+
240+
it('flattens capabilities across multiple offerings', () => {
241+
const normalized = normalizeWebhookEntry({
242+
id: 1,
243+
documentId: 'ent-1',
244+
internalName: 'VPN beta',
245+
offerings: [
246+
{
247+
capabilities: [
248+
{ slug: 'vpn-beta', services: [{ oauthClientId: 'cli-a' }] },
249+
],
250+
},
251+
{
252+
capabilities: [
253+
{ slug: 'relay-beta', services: [{ oauthClientId: 'cli-b' }] },
254+
],
255+
},
256+
],
257+
matchers: [],
258+
});
259+
260+
expect(normalized.capabilities).toEqual([
261+
{ slug: 'vpn-beta', services: [{ oauthClientId: 'cli-a' }] },
262+
{ slug: 'relay-beta', services: [{ oauthClientId: 'cli-b' }] },
263+
]);
264+
});
265+
266+
it('returns null capabilities when offerings is missing', () => {
267+
const normalized = normalizeWebhookEntry({
268+
id: 1,
269+
documentId: 'ent-1',
270+
internalName: 'VPN beta',
271+
matchers: [
272+
{
273+
__component: 'matchers.email-list',
274+
emails: { 'a@example.com': ['2026-12-31', 'd'] },
275+
},
276+
],
277+
});
278+
279+
expect(normalized.capabilities).toBeNull();
280+
});
235281
});
236282

237283
describe('normalizeGraphQLAccess', () => {
238284
it('keeps only `ComponentMatchersEmailList` matchers', () => {
239285
const normalized = normalizeGraphQLAccess({
240-
__typename: 'Access',
241286
documentId: 'ent-1',
242287
internalName: 'VPN beta',
243-
capabilities: [
288+
offerings: [
244289
{
245-
__typename: 'Capability',
246-
slug: 'vpn-beta',
247-
services: [{ __typename: 'Service', oauthClientId: 'cli' }],
290+
capabilities: [
291+
{
292+
slug: 'vpn-beta',
293+
services: [{ oauthClientId: 'cli' }],
294+
},
295+
],
248296
},
249297
],
250298
matchers: [
@@ -260,4 +308,40 @@ describe('normalizeGraphQLAccess', () => {
260308
{ 'a@example.com': ['2026-12-31', 'd'] },
261309
]);
262310
});
311+
312+
it('flattens capabilities across multiple offerings', () => {
313+
const normalized = normalizeGraphQLAccess({
314+
documentId: 'ent-1',
315+
internalName: 'VPN beta',
316+
offerings: [
317+
{
318+
capabilities: [
319+
{ slug: 'vpn-beta', services: [{ oauthClientId: 'cli-a' }] },
320+
],
321+
},
322+
{
323+
capabilities: [
324+
{ slug: 'relay-beta', services: [{ oauthClientId: 'cli-b' }] },
325+
],
326+
},
327+
],
328+
matchers: [],
329+
});
330+
331+
expect(normalized.capabilities).toEqual([
332+
{ slug: 'vpn-beta', services: [{ oauthClientId: 'cli-a' }] },
333+
{ slug: 'relay-beta', services: [{ oauthClientId: 'cli-b' }] },
334+
]);
335+
});
336+
337+
it('returns null capabilities when the offerings link is empty', () => {
338+
const normalized = normalizeGraphQLAccess({
339+
documentId: 'ent-1',
340+
internalName: 'VPN beta',
341+
offerings: null,
342+
matchers: [],
343+
});
344+
345+
expect(normalized.capabilities).toBeNull();
346+
});
263347
});

libs/free-access-program/src/lib/free-access-program.projector.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,25 @@ import type { StrapiAccessWebhookPayload } from './free-access-program.webhook.t
1313
* Structural subset of a Strapi `accesses` GraphQL row. Defined locally to
1414
* avoid a private import of the generated graphql.ts type from
1515
* `@fxa/shared/cms`; the live GraphQL response is structurally assignable.
16+
*
17+
* Capabilities are nested under `offerings` since the Strapi rename: the
18+
* `access` content type no longer carries capabilities directly, and an
19+
* access may link to multiple offerings.
1620
*/
1721
export interface GraphQLAccessRow {
1822
documentId?: string | null;
1923
internalName?: string | null;
20-
capabilities?: ReadonlyArray<{
21-
slug?: string | null;
22-
services?: ReadonlyArray<{ oauthClientId?: string | null } | null> | null;
23-
} | null> | null;
24+
offerings?: ReadonlyArray<
25+
| {
26+
capabilities?: ReadonlyArray<{
27+
slug?: string | null;
28+
services?: ReadonlyArray<
29+
{ oauthClientId?: string | null } | null
30+
> | null;
31+
} | null> | null;
32+
}
33+
| null
34+
> | null;
2435
matchers?: ReadonlyArray<
2536
| {
2637
__typename?: string;
@@ -170,7 +181,7 @@ export function normalizeGraphQLAccess(
170181
return {
171182
documentId: access.documentId,
172183
internalName: access.internalName,
173-
capabilities: access.capabilities,
184+
capabilities: flattenOfferingCapabilities(access.offerings),
174185
emailLists,
175186
};
176187
}
@@ -191,11 +202,39 @@ export function normalizeWebhookEntry(
191202
return {
192203
documentId: entry.documentId,
193204
internalName: entry.internalName,
194-
capabilities: entry.capabilities,
205+
capabilities: flattenOfferingCapabilities(entry.offerings),
195206
emailLists,
196207
};
197208
}
198209

210+
/**
211+
* Strapi links each `access` to zero or more offerings, each of which has
212+
* its own capabilities. The downstream projector treats capabilities as a
213+
* flat list keyed by client id, so we concatenate them up-front and let
214+
* `collectCapabilityMap` dedupe by slug.
215+
*/
216+
function flattenOfferingCapabilities(
217+
offerings:
218+
| ReadonlyArray<
219+
| {
220+
capabilities?: NormalizedAccess['capabilities'];
221+
}
222+
| null
223+
| undefined
224+
>
225+
| null
226+
| undefined
227+
): NormalizedAccess['capabilities'] {
228+
if (!offerings) return null;
229+
const flat: NonNullable<NormalizedAccess['capabilities']>[number][] = [];
230+
for (const offering of offerings) {
231+
for (const capability of offering?.capabilities ?? []) {
232+
flat.push(capability);
233+
}
234+
}
235+
return flat;
236+
}
237+
199238
function collectCapabilityMap(
200239
capabilities: NonNullable<NormalizedAccess['capabilities']>
201240
): ClientIdCapabilityMap {

libs/free-access-program/src/lib/free-access-program.reconciler.service.spec.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,16 @@ describe('FreeAccessProgramReconcilerService', () => {
4444
__typename: 'Access',
4545
documentId: 'ent-1',
4646
internalName: 'VPN beta',
47-
capabilities: [
47+
offerings: [
4848
{
49-
__typename: 'Capability',
50-
slug: 'vpn-beta',
51-
services: [{ __typename: 'Service', oauthClientId: 'client-a' }],
49+
__typename: 'Offering',
50+
capabilities: [
51+
{
52+
__typename: 'Capability',
53+
slug: 'vpn-beta',
54+
services: [{ __typename: 'Service', oauthClientId: 'client-a' }],
55+
},
56+
],
5257
},
5358
],
5459
matchers: [
@@ -167,12 +172,17 @@ describe('FreeAccessProgramReconcilerService', () => {
167172
strapi.queryUncached.mockResolvedValue({
168173
accesses: [
169174
entitlement({
170-
capabilities: [
175+
offerings: [
171176
{
172-
__typename: 'Capability',
173-
slug: 'vpn-new',
174-
services: [
175-
{ __typename: 'Service', oauthClientId: 'client-a' },
177+
__typename: 'Offering',
178+
capabilities: [
179+
{
180+
__typename: 'Capability',
181+
slug: 'vpn-new',
182+
services: [
183+
{ __typename: 'Service', oauthClientId: 'client-a' },
184+
],
185+
},
176186
],
177187
},
178188
],

libs/free-access-program/src/lib/free-access-program.webhook.types.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
/**
66
* Webhook payload Strapi sends when an `access` entry is created /
7-
* updated / published / unpublished / deleted. The matchers and
8-
* capabilities are nested under `entry`.
7+
* updated / published / unpublished / deleted. Capabilities live under the
8+
* linked `offerings` (the `access` content type no longer carries them
9+
* directly, and an access may link to multiple offerings); the matchers
10+
* stay on `entry`.
911
*
1012
* Component shapes inside the dynamic-zone `matchers` field use
1113
* `__component: '<category>.<name>'` (REST serialization), different from
@@ -23,12 +25,17 @@ export interface StrapiAccessWebhookPayload {
2325
internalName?: string;
2426
description?: string | null;
2527
publishedAt?: string | null;
26-
capabilities?: Array<{
28+
offerings?: Array<{
2729
id?: number;
28-
slug?: string;
29-
services?: Array<{ oauthClientId?: string } | null> | null;
30+
documentId?: string;
31+
capabilities?: Array<{
32+
id?: number;
33+
slug?: string;
34+
services?: Array<{ oauthClientId?: string } | null> | null;
35+
[k: string]: unknown;
36+
}>;
3037
[k: string]: unknown;
31-
}>;
38+
} | null> | null;
3239
matchers?: Array<{
3340
__component?: string;
3441
id?: number;

libs/shared/cms/src/__generated__/gql.ts

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)