1import * as datamanager from "@distilled.cloud/gcp/datamanager_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5
6export const DEFAULT_ACCOUNT_TYPE = "GOOGLE_ADS";
7export const DEFAULT_MEMBERSHIP_STATUS = "OPEN";
8export const DEFAULT_UPLOAD_KEY_TYPES: datamanager.IngestedUserListInfoUploadKeyTypesItemEnumList =
9 ["CONTACT_ID"];
10export const PROBE_PARENT = "accountTypes/GOOGLE_ADS/accounts/0";
11export const PROBE_NAME = `${PROBE_PARENT}/userLists/0`;
12
13export type AccountType =
14 | datamanager.ProductAccountAccountTypeEnum
15 | (string & {});
16
17export type MembershipStatus =
18 | datamanager.UserListMembershipStatusEnum
19 | (string & {});
20
21export type AccountAccessStatus =
22 | datamanager.UserListAccountAccessStatusEnum
23 | (string & {});
24
25export type UploadKeyType =
26 | datamanager.IngestedUserListInfoUploadKeyTypesItemEnum
27 | (string & {});
28
29export type TargetNetworkInfoProps = {
30 /** Whether the list is eligible for the Google Display Network. */
31 eligibleForDisplay?: boolean;
32 /** Whether the list is eligible for Google Search. */
33 eligibleForSearch?: boolean;
34};
35
36export type PartnerAudienceInfoProps = {
37 /** Immutable source of the partner audience. */
38 partnerAudienceSource?:
39 | datamanager.PartnerAudienceInfoPartnerAudienceSourceEnum
40 | (string & {});
41 /** Commerce partner name. Only for `COMMERCE_AUDIENCE`. */
42 commercePartner?: string;
43};
44
45export type MobileIdInfoProps = {
46 /** Immutable mobile ID key space (`IOS` or `ANDROID`). */
47 keySpace?: datamanager.MobileIdInfoKeySpaceEnum | (string & {});
48 /** Immutable app id the mobile IDs were collected from. */
49 appId?: string;
50 /** Immutable source of the upload data. */
51 dataSourceType?: datamanager.MobileIdInfoDataSourceTypeEnum | (string & {});
52};
53
54export type PairIdInfoProps = {
55 /** Immutable publisher id in the clean room. */
56 publisherId?: string;
57 /** Membership match percentage (0-100). */
58 matchRatePercentage?: number;
59 /** Publisher display name. */
60 publisherName?: string;
61 /** Count of advertiser first-party records uploaded. */
62 advertiserIdentifierCount?: string;
63 /** Immutable advertiser-to-publisher clean room identifier. */
64 cleanRoomIdentifier?: string;
65};
66
67export type ContactIdInfoProps = {
68 /** Immutable source of the upload data. */
69 dataSourceType?: datamanager.ContactIdInfoDataSourceTypeEnum | (string & {});
70};
71
72export type UserIdInfoProps = {
73 /** Immutable source of the upload data. */
74 dataSourceType?: datamanager.UserIdInfoDataSourceTypeEnum | (string & {});
75};
76
77export type IngestedUserListInfoProps = {
78 /**
79 * Immutable upload key types (`CONTACT_ID`, `MOBILE_ID`, `USER_ID`,
80 * `PAIR_ID`, `PSEUDONYMOUS_ID`).
81 * @default ["CONTACT_ID"]
82 */
83 uploadKeyTypes?: UploadKeyType[];
84 /** Partner-audience metadata. Data partners only. */
85 partnerAudienceInfo?: PartnerAudienceInfoProps;
86 /** Extra fields when `MOBILE_ID` is an upload key type. */
87 mobileIdInfo?: MobileIdInfoProps;
88 /** Extra fields when `PAIR_ID` is an upload key type. Data partners only. */
89 pairIdInfo?: PairIdInfoProps;
90 /** Extra fields when `CONTACT_ID` is an upload key type. */
91 contactIdInfo?: ContactIdInfoProps;
92 /** Extra fields when `USER_ID` is an upload key type. */
93 userIdInfo?: UserIdInfoProps;
94};
95
96export const lastSegment = (value: string) => {
97 const trimmed = value.replace(/\/+$/, "");
98 const parts = trimmed.split("/");
99 return parts[parts.length - 1] || trimmed;
100};
101
102export const normalizeResourceName = (value: string) =>
103 value.replace(/\/+$/, "").trim();
104
105export const sameText = (left: string | undefined, right: string | undefined) =>
106 (left ?? "") === (right ?? "");
107
108export const jsonEqual = (left: unknown, right: unknown) =>
109 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
110
111export const updateMaskOf = (...fields: Array<string | undefined>) =>
112 fields.filter((field): field is string => field !== undefined).join(",");
113
114export const accountIdOf = (value: string) =>
115 lastSegment(value).replace(/-/g, "");
116
117export const parentOf = (name: string) => {
118 const parts = name.split("/").filter((part) => part.length > 0);
119 const index = parts.lastIndexOf("userLists");
120 if (index <= 0) return "";
121 return parts.slice(0, index).join("/");
122};
123
124export const accountTypeOf = (parent: string) => {
125 const parts = parent.split("/").filter((part) => part.length > 0);
126 const index = parts.indexOf("accountTypes");
127 if (index < 0 || parts[index + 1] === undefined) return DEFAULT_ACCOUNT_TYPE;
128 return parts[index + 1]!;
129};
130
131export const accountOf = (parent: string) => {
132 const parts = parent.split("/").filter((part) => part.length > 0);
133 const index = parts.indexOf("accounts");
134 if (index < 0 || parts[index + 1] === undefined) return "";
135 return parts[index + 1]!;
136};
137
138export const resourceName = (parent: string, userListId: string) =>
139 `${normalizeResourceName(parent)}/userLists/${userListId}`;
140
141export const parentName = (accountType: string, account: string) => {
142 const trimmed = normalizeResourceName(account);
143 if (trimmed.includes("/accounts/")) return trimmed;
144 return `accountTypes/${accountType}/accounts/${accountIdOf(trimmed)}`;
145};
146
147export const resolveParent = (input: {
148 parent?: string;
149 accountType?: string;
150 account?: string;
151}) => {
152 if (input.parent && input.parent.length > 0) {
153 return normalizeResourceName(input.parent);
154 }
155 if (input.account && input.account.length > 0) {
156 return parentName(input.accountType ?? DEFAULT_ACCOUNT_TYPE, input.account);
157 }
158 return "";
159};
160
161export const replaceOnIdentity = (input: {
162 previousParent?: string;
163 nextParent: string;
164 previousId?: string;
165 nextId?: string;
166 previousIngested?: unknown;
167 nextIngested?: unknown;
168}) => {
169 if (
170 input.previousParent !== undefined &&
171 input.previousParent.length > 0 &&
172 input.nextParent.length > 0 &&
173 input.previousParent !== input.nextParent
174 ) {
175 return { action: "replace" as const, deleteFirst: false };
176 }
177 if (
178 input.previousId !== undefined &&
179 input.nextId !== undefined &&
180 input.previousId !== input.nextId
181 ) {
182 return { action: "replace" as const, deleteFirst: false };
183 }
184 if (
185 input.nextIngested !== undefined &&
186 input.previousIngested !== undefined &&
187 !jsonEqual(input.previousIngested, input.nextIngested)
188 ) {
189 return { action: "replace" as const, deleteFirst: false };
190 }
191 return undefined;
192};
193
194export const toDisplayName = (
195 id: string,
196 requested: string | undefined,
197 existing: string | undefined,
198) =>
199 Effect.gen(function* () {
200 if (requested !== undefined && requested.length > 0) return requested;
201 if (existing !== undefined && existing.length > 0) return existing;
202 return yield* createPhysicalName({
203 id,
204 maxLength: 63,
205 lowercase: true,
206 });
207 });
208
209const emptyList = <A>() => Effect.succeed([] as A[]);
210
211export const collectPages = <A, Page, E, R>(
212 pages: Stream.Stream<Page, E, R>,
213 pick: (page: Page) => readonly A[] | undefined,
214) =>
215 pages.pipe(
216 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
217 Stream.runCollect,
218 Effect.map((chunk) => Array.from(chunk)),
219 );
220
221export const ignoreMissing = <A, R>(
222 effect: Effect.Effect<
223 A,
224 datamanager.DeleteAccountTypesAccountsUserListsError,
225 R
226 >,
227) => effect.pipe(Effect.catchTag("NotFound", () => Effect.void));
228
229export const getUserList = (name: string) =>
230 name.length === 0
231 ? Effect.succeed(undefined)
232 : datamanager
233 .getAccountTypesAccountsUserLists({ name })
234 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
235
236export const listUserLists = (parent: string) =>
237 parent.length === 0
238 ? emptyList<datamanager.UserList>()
239 : collectPages(
240 datamanager.listAccountTypesAccountsUserLists.pages({
241 parent,
242 pageSize: 200,
243 }),
244 (page) => page.userLists,
245 ).pipe(
246 // A missing account has no lists.
247 Effect.catchTag("NotFound", () => emptyList<datamanager.UserList>()),
248 );
249
250export const findUserListByDisplayName = (
251 displayName: string,
252 parent: string,
253) =>
254 listUserLists(parent).pipe(
255 Effect.map((rows) => rows.find((row) => row.displayName === displayName)),
256 );
257
258export const ingestedIdentity = (
259 info: IngestedUserListInfoProps | undefined,
260) => {
261 if (info === undefined) return undefined;
262 return {
263 uploadKeyTypes: [...(info.uploadKeyTypes ?? [])].slice().sort(),
264 partnerAudienceInfo: info.partnerAudienceInfo
265 ? {
266 partnerAudienceSource: info.partnerAudienceInfo.partnerAudienceSource,
267 commercePartner: info.partnerAudienceInfo.commercePartner,
268 }
269 : undefined,
270 mobileIdInfo: info.mobileIdInfo
271 ? {
272 keySpace: info.mobileIdInfo.keySpace,
273 appId: info.mobileIdInfo.appId,
274 dataSourceType: info.mobileIdInfo.dataSourceType,
275 }
276 : undefined,
277 pairIdInfo: info.pairIdInfo
278 ? {
279 publisherId: info.pairIdInfo.publisherId,
280 publisherName: info.pairIdInfo.publisherName,
281 matchRatePercentage: info.pairIdInfo.matchRatePercentage,
282 advertiserIdentifierCount: info.pairIdInfo.advertiserIdentifierCount,
283 cleanRoomIdentifier: info.pairIdInfo.cleanRoomIdentifier,
284 }
285 : undefined,
286 contactIdInfo: info.contactIdInfo
287 ? { dataSourceType: info.contactIdInfo.dataSourceType }
288 : undefined,
289 userIdInfo: info.userIdInfo
290 ? { dataSourceType: info.userIdInfo.dataSourceType }
291 : undefined,
292 };
293};
294
295export const ingestedFromRow = (
296 info: datamanager.IngestedUserListInfo | undefined,
297): IngestedUserListInfoProps | undefined => {
298 if (info === undefined) return undefined;
299 return {
300 uploadKeyTypes: info.uploadKeyTypes,
301 partnerAudienceInfo: info.partnerAudienceInfo
302 ? {
303 partnerAudienceSource: info.partnerAudienceInfo.partnerAudienceSource,
304 commercePartner: info.partnerAudienceInfo.commercePartner,
305 }
306 : undefined,
307 mobileIdInfo: info.mobileIdInfo
308 ? {
309 keySpace: info.mobileIdInfo.keySpace,
310 appId: info.mobileIdInfo.appId,
311 dataSourceType: info.mobileIdInfo.dataSourceType,
312 }
313 : undefined,
314 pairIdInfo: info.pairIdInfo
315 ? {
316 publisherId: info.pairIdInfo.publisherId,
317 matchRatePercentage: info.pairIdInfo.matchRatePercentage,
318 publisherName: info.pairIdInfo.publisherName,
319 advertiserIdentifierCount: info.pairIdInfo.advertiserIdentifierCount,
320 cleanRoomIdentifier: info.pairIdInfo.cleanRoomIdentifier,
321 }
322 : undefined,
323 contactIdInfo: info.contactIdInfo
324 ? { dataSourceType: info.contactIdInfo.dataSourceType }
325 : undefined,
326 userIdInfo: info.userIdInfo
327 ? { dataSourceType: info.userIdInfo.dataSourceType }
328 : undefined,
329 };
330};
331
332export const toIngestedBody = (
333 info: IngestedUserListInfoProps | undefined,
334): datamanager.IngestedUserListInfo => ({
335 uploadKeyTypes: info?.uploadKeyTypes ?? DEFAULT_UPLOAD_KEY_TYPES,
336 partnerAudienceInfo: info?.partnerAudienceInfo,
337 mobileIdInfo: info?.mobileIdInfo,
338 pairIdInfo: info?.pairIdInfo,
339 contactIdInfo: info?.contactIdInfo,
340 userIdInfo: info?.userIdInfo,
341});