1import * as rtb from "@distilled.cloud/gcp/realtimebidding_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 configIdOf,
12 encodeDisplayName,
13 expandParent,
14 findOwnedConfig,
15 getConfig,
16 hasOwnershipMarker,
17 ignoreMissing,
18 listOwnedConfigs,
19 mergeSpec,
20 ownedByAlchemy,
21 ownershipLabels,
22 parentOfName,
23 parseDisplayName,
24 replaceOnIdentity,
25 resourceName,
26 specFromRow,
27 specOf,
28 toConfigBody,
29 toDisplayName,
30 updateMaskOf,
31 type AppTargetingValue,
32 type CreativeDimensionsValue,
33 type NumericTargetingDimensionValue,
34 type PretargetingState,
35 type StringTargetingDimensionValue,
36} from "./internal.ts";
37
38export type StringTargetingDimension = StringTargetingDimensionValue;
39export type NumericTargetingDimension = NumericTargetingDimensionValue;
40export type CreativeDimensions = CreativeDimensionsValue;
41export type AppTargeting = AppTargetingValue;
42
43export type BiddersPretargetingConfigProps = {
44 /**
45 * Parent bidder, as `bidders/{bidder}` or the bidder account id.
46 * Immutable — changing it replaces the config.
47 */
48 parent: string;
49 /**
50 * Server-assigned config id (last path segment). Omit on create; pass
51 * the observed id to keep the same config. Immutable — changing it
52 * replaces the config.
53 */
54 configId?: string;
55 /**
56 * Human-readable display name. Must be unique among the bidder's
57 * pretargeting configs. If omitted, a unique name is generated from
58 * the stack, stage, and logical id. Pretargeting configs have no
59 * labels field, so Alchemy stamps ownership into a `[alchemy …]`
60 * prefix and strips it from attributes.
61 */
62 displayName?: string;
63 /**
64 * Desired serving state after create (`ACTIVE` or `SUSPENDED`).
65 * Create always yields `ACTIVE`. `SUSPENDED` is applied with
66 * `pretargetingConfigs.suspend`.
67 */
68 state?: PretargetingState;
69 /**
70 * Creative formats (`HTML`, `VAST`, `NATIVE`). Unset allows every
71 * format.
72 */
73 includedFormats?: string[];
74 /**
75 * Environments (`APP`, `WEB`). Unset includes every environment.
76 */
77 includedEnvironments?: string[];
78 /**
79 * Device platforms (`PERSONAL_COMPUTER`, `PHONE`, `TABLET`,
80 * `CONNECTED_TV`). Unset allows every platform.
81 */
82 includedPlatforms?: string[];
83 /**
84 * User identifier types (`GOOGLE_COOKIE`, `DEVICE_ID`, …). At least
85 * one listed type must be present on a bid request.
86 */
87 includedUserIdTypes?: string[];
88 /**
89 * Language codes to include (AdWords language codes).
90 */
91 includedLanguages?: string[];
92 /**
93 * Mobile OS ids from the Authorized Buyers mobile-os dictionary.
94 */
95 includedMobileOperatingSystemIds?: string[];
96 /**
97 * Sensitive content label ids to exclude.
98 */
99 excludedContentLabelIds?: string[];
100 /**
101 * User targeting modes a bid request must allow (`REMARKETING_ADS`,
102 * `INTEREST_BASED_TARGETING`).
103 */
104 allowedUserTargetingModes?: string[];
105 /**
106 * Interstitial filter (`ONLY_INTERSTITIAL_REQUESTS` or
107 * `ONLY_NON_INTERSTITIAL_REQUESTS`). Unset allows both.
108 */
109 interstitialTargeting?: string;
110 /**
111 * Maximum QPS for this config across bidding endpoints.
112 */
113 maximumQps?: string;
114 /**
115 * Minimum predicted viewability decile (0–10). `5` means 50%.
116 */
117 minimumViewabilityDecile?: number;
118 /**
119 * Included and excluded geo ids.
120 */
121 geoTargeting?: NumericTargetingDimension;
122 /**
123 * Included and excluded remarketing list ids.
124 */
125 userListTargeting?: NumericTargetingDimension;
126 /**
127 * Included and excluded publisher vertical ids.
128 */
129 verticalTargeting?: NumericTargetingDimension;
130 /**
131 * Inclusive or exclusive website targeting.
132 */
133 webTargeting?: StringTargetingDimension;
134 /**
135 * Inclusive or exclusive publisher-id targeting.
136 */
137 publisherTargeting?: StringTargetingDimension;
138 /**
139 * App id and app-category targeting.
140 */
141 appTargeting?: AppTargeting;
142 /**
143 * Creative pixel dimensions (HTML and Native).
144 */
145 includedCreativeDimensions?: CreativeDimensions[];
146};
147
148export type BiddersPretargetingConfig = Resource<
149 "GCP.RealTimeBidding.BiddersPretargetingConfig",
150 BiddersPretargetingConfigProps,
151 {
152 /** Full resource name `bidders/{bidder}/pretargetingConfigs/{config}`. */
153 name: string;
154 /** Server-assigned config id (last path segment). */
155 configId: string;
156 /** Parent bidder resource name `bidders/{bidder}`. */
157 parent: string;
158 /** Project id used when the config was reconciled. */
159 project: string;
160 /** Display name with the Alchemy ownership prefix stripped. */
161 displayName: string | undefined;
162 /** Server-reported state (`ACTIVE` or `SUSPENDED`). */
163 state: string | undefined;
164 /** Billing id used to attribute spend. */
165 billingId: string | undefined;
166 /** Previously targeted geos that are no longer valid. */
167 invalidGeoIds: string[] | undefined;
168 /** Creative formats. */
169 includedFormats: string[] | undefined;
170 /** Environments. */
171 includedEnvironments: string[] | undefined;
172 /** Device platforms. */
173 includedPlatforms: string[] | undefined;
174 /** User identifier types. */
175 includedUserIdTypes: string[] | undefined;
176 /** Language codes. */
177 includedLanguages: string[] | undefined;
178 /** Mobile OS ids. */
179 includedMobileOperatingSystemIds: string[] | undefined;
180 /** Excluded content label ids. */
181 excludedContentLabelIds: string[] | undefined;
182 /** Required user targeting modes. */
183 allowedUserTargetingModes: string[] | undefined;
184 /** Interstitial filter. */
185 interstitialTargeting: string | undefined;
186 /** Maximum QPS. */
187 maximumQps: string | undefined;
188 /** Minimum viewability decile. */
189 minimumViewabilityDecile: number | undefined;
190 /** Geo targeting. */
191 geoTargeting: NumericTargetingDimension | undefined;
192 /** Remarketing list targeting. */
193 userListTargeting: NumericTargetingDimension | undefined;
194 /** Vertical targeting. */
195 verticalTargeting: NumericTargetingDimension | undefined;
196 /** Website targeting. */
197 webTargeting: StringTargetingDimension | undefined;
198 /** Publisher targeting. */
199 publisherTargeting: StringTargetingDimension | undefined;
200 /** App targeting. */
201 appTargeting: AppTargeting | undefined;
202 /** Creative dimensions. */
203 includedCreativeDimensions: CreativeDimensions[] | undefined;
204 },
205 never,
206 Providers
207>;
208
209/**
210 * An Authorized Buyers Real-time Bidding pretargeting configuration
211 * (`bidders/{bidder}/pretargetingConfigs/{config}`).
212 *
213 * Pretargeting configs have no labels field, so Alchemy stamps ownership
214 * into `displayName` for `list` / nuke. Parent and config id are
215 * identity — changing either replaces the config. Display name and
216 * targeting update in place. Create yields `ACTIVE`; pass `state:
217 * "SUSPENDED"` to suspend. A bidder may own at most 10 configs.
218 *
219 * ### Creating a Pretargeting Config
220 * **Example:** Generated display name
221 * ```typescript
222 * const config = yield* GCP.RealTimeBidding.BiddersPretargetingConfig(
223 * "WebHtml",
224 * { parent: "bidders/123" },
225 * );
226 * ```
227 *
228 * **Example:** Named config for web HTML
229 * ```typescript
230 * const config = yield* GCP.RealTimeBidding.BiddersPretargetingConfig(
231 * "WebHtml",
232 * {
233 * parent: "bidders/123",
234 * displayName: "web-html",
235 * includedEnvironments: ["WEB"],
236 * includedFormats: ["HTML"],
237 * },
238 * );
239 * ```
240 *
241 * ### Updating a Pretargeting Config
242 * Change props on the same logical id; the engine keeps the physical id.
243 *
244 * **Example:** Add Native and suspend
245 * ```typescript
246 * const config = yield* GCP.RealTimeBidding.BiddersPretargetingConfig(
247 * "WebHtml",
248 * {
249 * parent: "bidders/123",
250 * displayName: "web-html-v2",
251 * includedEnvironments: ["WEB"],
252 * includedFormats: ["HTML", "NATIVE"],
253 * state: "SUSPENDED",
254 * },
255 * );
256 * ```
257 *
258 * @resource
259 * @category RealTimeBidding
260 */
261export const BiddersPretargetingConfig = Resource<BiddersPretargetingConfig>(
262 "GCP.RealTimeBidding.BiddersPretargetingConfig",
263);
264
265export class BiddersPretargetingConfigNotResolved extends Data.TaggedError(
266 "GCP.RealTimeBidding.BiddersPretargetingConfigNotResolved",
267)<{
268 parent: string;
269 name: string;
270}> {}
271
272export class BiddersPretargetingConfigParentRequired extends Data.TaggedError(
273 "GCP.RealTimeBidding.BiddersPretargetingConfigParentRequired",
274)<{
275 message: string;
276}> {}
277
278const lookupName = (
279 parent: string,
280 configId: string | undefined,
281 existingName: string | undefined,
282) => {
283 if (existingName && existingName.length > 0) return existingName;
284 if (configId && configId.length > 0 && parent.length > 0) {
285 return resourceName(parent, configId);
286 }
287 return "";
288};
289
290const specFromProps = (
291 props: BiddersPretargetingConfigProps,
292): ReturnType<typeof specOf> =>
293 specOf({
294 allowedUserTargetingModes: props.allowedUserTargetingModes,
295 excludedContentLabelIds: props.excludedContentLabelIds,
296 includedLanguages: props.includedLanguages,
297 webTargeting: props.webTargeting,
298 includedPlatforms: props.includedPlatforms,
299 includedFormats: props.includedFormats,
300 maximumQps: props.maximumQps,
301 geoTargeting: props.geoTargeting,
302 includedEnvironments: props.includedEnvironments,
303 userListTargeting: props.userListTargeting,
304 publisherTargeting: props.publisherTargeting,
305 includedUserIdTypes: props.includedUserIdTypes,
306 minimumViewabilityDecile: props.minimumViewabilityDecile,
307 verticalTargeting: props.verticalTargeting,
308 includedCreativeDimensions: props.includedCreativeDimensions,
309 interstitialTargeting: props.interstitialTargeting,
310 appTargeting: props.appTargeting,
311 includedMobileOperatingSystemIds: props.includedMobileOperatingSystemIds,
312 });
313
314const toAttrs = (row: rtb.PretargetingConfig, project: string) => {
315 const name = row.name ?? "";
316 const parent = parentOfName(name);
317 return {
318 name,
319 configId: configIdOf(name),
320 parent,
321 project,
322 displayName: parseDisplayName(row.displayName).displayName,
323 state: row.state,
324 billingId: row.billingId,
325 invalidGeoIds: row.invalidGeoIds,
326 includedFormats: row.includedFormats,
327 includedEnvironments: row.includedEnvironments,
328 includedPlatforms: row.includedPlatforms,
329 includedUserIdTypes: row.includedUserIdTypes,
330 includedLanguages: row.includedLanguages,
331 includedMobileOperatingSystemIds: row.includedMobileOperatingSystemIds,
332 excludedContentLabelIds: row.excludedContentLabelIds,
333 allowedUserTargetingModes: row.allowedUserTargetingModes,
334 interstitialTargeting: row.interstitialTargeting,
335 maximumQps: row.maximumQps,
336 minimumViewabilityDecile: row.minimumViewabilityDecile,
337 geoTargeting: row.geoTargeting,
338 userListTargeting: row.userListTargeting,
339 verticalTargeting: row.verticalTargeting,
340 webTargeting: row.webTargeting,
341 publisherTargeting: row.publisherTargeting,
342 appTargeting: row.appTargeting,
343 includedCreativeDimensions: row.includedCreativeDimensions,
344 };
345};
346
347const syncState = (
348 current: rtb.PretargetingConfig,
349 desired: string | undefined,
350) =>
351 Effect.gen(function* () {
352 if (!desired || !current.name) return current;
353 const observed = current.state ?? "";
354 if (observed === desired) return current;
355 if (desired === "ACTIVE" && observed === "SUSPENDED") {
356 return yield* rtb.activateBiddersPretargetingConfigs({
357 name: current.name,
358 });
359 }
360 if (desired === "SUSPENDED" && observed === "ACTIVE") {
361 return yield* rtb.suspendBiddersPretargetingConfigs({
362 name: current.name,
363 });
364 }
365 return current;
366 });
367
368export const BiddersPretargetingConfigProvider = () =>
369 Provider.succeed(BiddersPretargetingConfig, {
370 stables: ["name", "configId", "parent", "project", "billingId"],
371
372 diff: Effect.fn(function* ({ news, olds, output }) {
373 if (!isResolved(news)) return undefined;
374 return replaceOnIdentity({
375 previousParent: expandParent(olds?.parent ?? output?.parent ?? ""),
376 nextParent: expandParent(news.parent),
377 previousId: olds?.configId ?? output?.configId,
378 nextId: news.configId,
379 });
380 }),
381
382 read: Effect.fn(function* ({ id, olds, output }) {
383 const env = yield* GcpEnvironment.current;
384 const parent = expandParent(olds?.parent ?? output?.parent ?? "");
385 const name = lookupName(
386 parent,
387 olds?.configId ?? output?.configId,
388 output?.name,
389 );
390 let existing = yield* getConfig(name);
391 if (existing === undefined) {
392 existing = yield* findOwnedConfig(id, parent, name);
393 }
394 if (existing === undefined) return undefined;
395 const attrs = toAttrs(existing, env.project);
396 return (yield* ownedByAlchemy(id, existing.displayName))
397 ? attrs
398 : Unowned(attrs);
399 }),
400
401 list: () =>
402 Effect.gen(function* () {
403 const env = yield* GcpEnvironment.current;
404 const rows = yield* listOwnedConfigs();
405 return rows
406 .filter((row) => hasOwnershipMarker(row.displayName))
407 .map((row) => toAttrs(row, env.project));
408 }),
409
410 reconcile: Effect.fn(function* ({ id, news, output }) {
411 const env = yield* GcpEnvironment.current;
412 const parent = expandParent(news.parent);
413 if (parent.length === 0) {
414 return yield* new BiddersPretargetingConfigParentRequired({
415 message:
416 "BiddersPretargetingConfig requires parent (bidders/{bidder})",
417 });
418 }
419 const ownership = yield* ownershipLabels(id);
420 const displayName = yield* toDisplayName(
421 id,
422 news.displayName,
423 output?.displayName,
424 );
425 const stamped = encodeDisplayName(ownership, displayName);
426 const name = lookupName(
427 parent,
428 news.configId ?? output?.configId,
429 output?.name,
430 );
431
432 let current = yield* getConfig(name);
433 if (current === undefined) {
434 current = yield* findOwnedConfig(id, parent, name, stamped);
435 }
436
437 const desired = mergeSpec(
438 specFromProps(news),
439 current === undefined ? undefined : specFromRow(current),
440 );
441
442 if (current === undefined) {
443 const created = yield* rtb
444 .createBiddersPretargetingConfigs({
445 parent,
446 body: toConfigBody(stamped, desired),
447 })
448 .pipe(
449 Effect.catchTag("Conflict", () =>
450 findOwnedConfig(id, parent, name, stamped),
451 ),
452 );
453 current = created ?? undefined;
454 }
455
456 if (current === undefined) {
457 return yield* new BiddersPretargetingConfigNotResolved({
458 parent,
459 name: name || stamped,
460 });
461 }
462
463 const currentName = current.name ?? name;
464 const updateMask = updateMaskOf(current, desired, stamped);
465 if (updateMask.length > 0) {
466 current = yield* rtb.patchBiddersPretargetingConfigs({
467 name: currentName,
468 updateMask,
469 body: toConfigBody(stamped, desired),
470 });
471 }
472
473 current = yield* syncState(current, news.state);
474
475 return toAttrs(current, env.project);
476 }),
477
478 delete: Effect.fn(function* ({ output }) {
479 if (!output.name) return;
480 yield* ignoreMissing(
481 rtb.deleteBiddersPretargetingConfigs({ name: output.name }),
482 );
483 }),
484 });