musora-content-services 2.188.0 → 2.190.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [2.190.0](https://github.com/railroadmedia/musora-content-services/compare/v2.189.0...v2.190.0) (2026-10-20)
6
+
7
+
8
+ ### Features
9
+
10
+ * **MU2-2024:** resolve placement copy in fetchAvailablePromotionalCampaigns ([ac69d37](https://github.com/railroadmedia/musora-content-services/commit/ac69d379a264e031b1f5bd999b6bed8c78fbfb58))
11
+
12
+ ## [2.189.0](https://github.com/railroadmedia/musora-content-services/compare/v2.188.0...v2.189.0) (2026-10-20)
13
+
14
+
15
+ ### Features
16
+
17
+ * **MU2-2024:** add placeholder resolution for promotional campaign copy ([f5cebb3](https://github.com/railroadmedia/musora-content-services/commit/f5cebb3888ce1228ecba5ec17412c8bcfaff8c35))
18
+
5
19
  ## [2.188.0](https://github.com/railroadmedia/musora-content-services/compare/v2.187.0...v2.188.0) (2026-10-20)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.188.0",
3
+ "version": "2.190.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.d.ts CHANGED
@@ -221,7 +221,8 @@ import {
221
221
  dismissPromotionalCampaign,
222
222
  fetchAvailablePromotionalCampaigns,
223
223
  fetchPromotionalCampaigns,
224
- fetchUserCampaignStates
224
+ fetchUserCampaignStates,
225
+ resolveCampaignPlaceholders
225
226
  } from './services/marketing/promotions.ts';
226
227
 
227
228
  import {
@@ -901,6 +902,7 @@ declare module 'musora-content-services' {
901
902
  resetAllAwards,
902
903
  resetAllLearningPaths,
903
904
  resetPassword,
905
+ resolveCampaignPlaceholders,
904
906
  restoreComment,
905
907
  restoreItemFromPlaylist,
906
908
  restoreNotification,
package/src/index.js CHANGED
@@ -225,7 +225,8 @@ import {
225
225
  dismissPromotionalCampaign,
226
226
  fetchAvailablePromotionalCampaigns,
227
227
  fetchPromotionalCampaigns,
228
- fetchUserCampaignStates
228
+ fetchUserCampaignStates,
229
+ resolveCampaignPlaceholders
229
230
  } from './services/marketing/promotions.ts';
230
231
 
231
232
  import {
@@ -900,6 +901,7 @@ export {
900
901
  resetAllAwards,
901
902
  resetAllLearningPaths,
902
903
  resetPassword,
904
+ resolveCampaignPlaceholders,
903
905
  restoreComment,
904
906
  restoreItemFromPlaylist,
905
907
  restoreNotification,
@@ -117,10 +117,10 @@ export type PromotionalCampaignDocument = {
117
117
  title?: string
118
118
  body?: string
119
119
  disclaimer?: string
120
- /** Resolved to a CDN url by the promotions query projection. */
121
- dark_mode_image?: string
122
- /** Resolved to a CDN url by the promotions query projection. */
123
- light_mode_image?: string
120
+ /** Dark Image */
121
+ dark_mode_image?: { _type: "image"; asset: { _type: "reference"; _ref: string } }
122
+ /** Light Image */
123
+ light_mode_image?: { _type: "image"; asset: { _type: "reference"; _ref: string } }
124
124
  /** CTA Label */
125
125
  cta_label?: string
126
126
  /** Dismissible? */
@@ -19,6 +19,21 @@ const excludeFromGeneratedIndex = [
19
19
  // TYPES
20
20
  // ============================================
21
21
 
22
+ type PlacementDocument = PromotionalCampaignDocument['placements'][number]
23
+
24
+ /** A placement as `campaignProjection` returns it, with both images resolved to CDN urls. */
25
+ export type PromotionalCampaignPlacement = Omit<
26
+ PlacementDocument,
27
+ 'dark_mode_image' | 'light_mode_image'
28
+ > & {
29
+ dark_mode_image?: string
30
+ light_mode_image?: string
31
+ }
32
+
33
+ export type PromotionalCampaign = Omit<PromotionalCampaignDocument, 'placements'> & {
34
+ placements: PromotionalCampaignPlacement[]
35
+ }
36
+
22
37
  export const USER_CAMPAIGN_STATE_FIELD = 'user_state' as const
23
38
 
24
39
  /**
@@ -31,7 +46,7 @@ export interface UserCampaignState {
31
46
  eligible: boolean
32
47
  }
33
48
 
34
- export type WithUserCampaignState<T extends PromotionalCampaignDocument> = T & {
49
+ export type WithUserCampaignState<T extends PromotionalCampaign> = T & {
35
50
  user_state: UserCampaignState
36
51
  }
37
52
 
@@ -48,13 +63,18 @@ export interface CampaignQueryOptions {
48
63
  active?: boolean
49
64
  }
50
65
 
66
+ export interface AvailableCampaignOptions extends CampaignQueryOptions {
67
+ /** Values for the `{name}` placeholders in placement copy. See `resolveCampaignPlaceholders`. */
68
+ placeholderValues?: CampaignPlaceholderValues
69
+ }
70
+
51
71
  const UNTOUCHED: UserCampaignState = { claimed: false, dismissed: false, eligible: true }
52
72
 
53
73
  // ============================================
54
74
  // UTILITIES
55
75
  // ============================================
56
76
 
57
- const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaignDocument>[]) =>
77
+ const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaign>[]) =>
58
78
  campaigns.filter(
59
79
  (campaign) =>
60
80
  campaign.user_state.eligible && !campaign.user_state.claimed && !campaign.user_state.dismissed
@@ -79,10 +99,10 @@ function slugFilter(slug: CampaignQueryOptions['slug']): string {
79
99
 
80
100
  export function campaignQuery(
81
101
  options: CampaignQueryOptions = {}
82
- ): GroqBuilder<PromotionalCampaignDocument[]> {
102
+ ): GroqBuilder<PromotionalCampaign[]> {
83
103
  const { brand, slug, on = new Date(), running = true, active = true } = options
84
104
 
85
- return groq<PromotionalCampaignDocument[]>()
105
+ return groq<PromotionalCampaign[]>()
86
106
  .and(
87
107
  f.combine(
88
108
  f.type('promotional-campaign'),
@@ -103,7 +123,7 @@ export function campaignQuery(
103
123
 
104
124
  export async function fetchPromotionalCampaigns(
105
125
  options: CampaignQueryOptions = {}
106
- ): Promise<PromotionalCampaignDocument[]> {
126
+ ): Promise<PromotionalCampaign[]> {
107
127
  return campaignQuery(options).run().recover([])
108
128
  }
109
129
 
@@ -111,12 +131,17 @@ export async function fetchPromotionalCampaigns(
111
131
  * Every running campaign the user has not already claimed or dismissed, and is eligible.
112
132
  */
113
133
  export async function fetchAvailablePromotionalCampaigns(
114
- options: CampaignQueryOptions = {}
115
- ): Promise<WithUserCampaignState<PromotionalCampaignDocument>[]> {
116
- return campaignQuery(options)
134
+ options: AvailableCampaignOptions = {}
135
+ ): Promise<WithUserCampaignState<PromotionalCampaign>[]> {
136
+ const { placeholderValues = {}, ...queryOptions } = options
137
+
138
+ return campaignQuery(queryOptions)
117
139
  .run()
118
140
  .flatMap(decorateUserCampaignState)
119
141
  .map(filterCampaignsForUser)
142
+ .map((campaigns) =>
143
+ campaigns.map((campaign) => resolveCampaignPlaceholders(campaign, placeholderValues))
144
+ )
120
145
  .recover([])
121
146
  }
122
147
 
@@ -144,7 +169,7 @@ export function userCampaignStateDecorator(
144
169
  return {
145
170
  field: USER_CAMPAIGN_STATE_FIELD,
146
171
  compute: async (item) => {
147
- const campaign = item as unknown as PromotionalCampaignDocument
172
+ const campaign = item as unknown as PromotionalCampaign
148
173
 
149
174
  return states[campaign.slug?.current] ?? UNTOUCHED
150
175
  },
@@ -154,7 +179,7 @@ export function userCampaignStateDecorator(
154
179
  /**
155
180
  * Attaches each campaign's claim and dismiss state in a single backend request.
156
181
  */
157
- export function decorateUserCampaignState<T extends PromotionalCampaignDocument>(
182
+ export function decorateUserCampaignState<T extends PromotionalCampaign>(
158
183
  campaigns: T[]
159
184
  ): AsyncEither<Error, WithUserCampaignState<T>[]> {
160
185
  return AsyncEither.attempt(async () => {
@@ -168,6 +193,50 @@ export function decorateUserCampaignState<T extends PromotionalCampaignDocument>
168
193
  })
169
194
  }
170
195
 
196
+ // ============================================
197
+ // PLACEHOLDERS
198
+ // ============================================
199
+
200
+ export type CampaignPlaceholderValues = Record<string, string>
201
+
202
+ const PLACEHOLDER = /\{(\w+)\}/g
203
+
204
+ const COPY_FIELDS = ['title', 'body', 'disclaimer', 'cta_label'] as const
205
+
206
+ const fillPlaceholders = (text: string, values: CampaignPlaceholderValues) =>
207
+ text.replace(PLACEHOLDER, (placeholder, name: string) => values[name] ?? placeholder)
208
+
209
+ const hasPlaceholder = (text: string | undefined) => !!text && new RegExp(PLACEHOLDER).test(text)
210
+
211
+ function resolvePlacementPlaceholders(
212
+ placement: PromotionalCampaignPlacement,
213
+ values: CampaignPlaceholderValues
214
+ ): PromotionalCampaignPlacement {
215
+ const resolved = { ...placement }
216
+
217
+ for (const field of COPY_FIELDS) {
218
+ if (resolved[field]) resolved[field] = fillPlaceholders(resolved[field], values)
219
+ }
220
+
221
+ return resolved
222
+ }
223
+
224
+ /**
225
+ * Replaces `{name}` placeholders in each placement's copy with the given values. A placement left
226
+ * with a placeholder that has no value is dropped, so raw `{name}` text never reaches the user.
227
+ */
228
+ export function resolveCampaignPlaceholders<T extends PromotionalCampaign>(
229
+ campaign: T,
230
+ values: CampaignPlaceholderValues
231
+ ): T {
232
+ return {
233
+ ...campaign,
234
+ placements: (campaign.placements ?? [])
235
+ .map((placement) => resolvePlacementPlaceholders(placement, values))
236
+ .filter((placement) => !COPY_FIELDS.some((field) => hasPlaceholder(placement[field]))),
237
+ }
238
+ }
239
+
171
240
  // ============================================
172
241
  // MUTATIONS
173
242
  // ============================================
@@ -1 +1 @@
1
- export const MCS_VERSION = '2.188.0'
1
+ export const MCS_VERSION = '2.190.0'