1import * as cloudchannel from "@distilled.cloud/gcp/cloudchannel_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 type { Providers } from "../Providers.ts";
9import {
10 desiredCustomer,
11 findCustomerByDomain,
12 getCustomer,
13 jsonEqual,
14 replaceOnIdentity,
15 sameText,
16 toAccountName,
17 toCustomerAttrs,
18 toCustomerName,
19 toDomain,
20 toOrgDisplayName,
21 updateMaskOf,
22} from "./internal.ts";
23
24export type CustomerProps = {
25 /**
26 * Reseller Cloud Channel account. Full name `accounts/{account}` or
27 * the account id (typically `C` followed by digits). Immutable —
28 * changing it replaces the customer.
29 */
30 parent: string;
31 /**
32 * Customer id (last segment of `accounts/{account}/customers/{customer}`).
33 * Server-assigned on create. Immutable — changing it replaces the
34 * customer.
35 */
36 customerId?: string;
37 /**
38 * Organization display name. If omitted, a unique name is generated.
39 */
40 orgDisplayName?: string;
41 /**
42 * Primary domain. Must match `primaryContactInfo.email`'s domain. If
43 * omitted, a unique `{name}.example.com` domain is generated.
44 */
45 domain?: string;
46 /**
47 * Organization postal address. A US address is used when omitted.
48 */
49 orgPostalAddress?: cloudchannel.GoogleTypePostalAddress;
50 /**
51 * Primary contact. Email defaults to `admin@{domain}`.
52 */
53 primaryContactInfo?: cloudchannel.GoogleCloudChannelV1ContactInfo;
54 /**
55 * Secondary contact email used as a recovery address.
56 */
57 alternateEmail?: string;
58 /**
59 * BCP-47 language code such as `en-US`.
60 * @default "en-US"
61 */
62 languageCode?: string;
63 /**
64 * External CRM id.
65 */
66 correlationId?: string;
67 /**
68 * Attestation that provided information is correct. Required for GCP
69 * entitlements.
70 */
71 customerAttestationState?:
72 | cloudchannel.GoogleCloudChannelV1CustomerCustomerAttestationStateEnum
73 | (string & {});
74 /**
75 * Cloud Identity id of the customer's channel partner, when this
76 * customer is sold through a partner.
77 */
78 channelPartnerId?: string;
79};
80
81export type Customer = Resource<
82 "GCP.CloudChannel.Customer",
83 CustomerProps,
84 {
85 /** Resource name `accounts/{account}/customers/{customer}`. */
86 name: string;
87 /** Customer id (last path segment). */
88 customerId: string;
89 /** Account or channel-partner parent used on create. */
90 parent: string;
91 /** Reseller account `accounts/{account}`. */
92 account: string;
93 /** Organization display name. */
94 orgDisplayName: string | undefined;
95 /** Primary domain. */
96 domain: string | undefined;
97 /** Organization postal address. */
98 orgPostalAddress: cloudchannel.GoogleTypePostalAddress | undefined;
99 /** Primary contact. */
100 primaryContactInfo:
101 | cloudchannel.GoogleCloudChannelV1ContactInfo
102 | undefined;
103 /** Alternate email. */
104 alternateEmail: string | undefined;
105 /** BCP-47 language code. */
106 languageCode: string | undefined;
107 /** External CRM id. */
108 correlationId: string | undefined;
109 /** Customer attestation state. */
110 customerAttestationState: string | undefined;
111 /** Channel partner Cloud Identity id, if any. */
112 channelPartnerId: string | undefined;
113 /** Cloud Identity id, once provisioned. */
114 cloudIdentityId: string | undefined;
115 /** RFC3339 creation timestamp. */
116 createTime: string | undefined;
117 /** RFC3339 last-update timestamp. */
118 updateTime: string | undefined;
119 },
120 never,
121 Providers
122>;
123
124/**
125 * A Cloud Channel customer of a reseller or distributor.
126 *
127 * Customers have no labels field, so ownership rests on the deterministic
128 * domain: `read` reports a customer it finds without prior state as
129 * unowned (adopt it with `--adopt`). The reseller account (`parent`)
130 * and customer id are identity. Display name, address, contact, domain,
131 * language, and attestation update in place.
132 *
133 * Creating customers requires Cloud Channel reseller access.
134 *
135 * ### Creating a Customer
136 * **Example:** Generated domain
137 * ```typescript
138 * const customer = yield* GCP.CloudChannel.Customer("Acme", {
139 * parent: "accounts/C012345",
140 * orgDisplayName: "Acme Corp",
141 * });
142 * ```
143 *
144 * **Example:** Explicit domain and contact
145 * ```typescript
146 * const customer = yield* GCP.CloudChannel.Customer("Acme", {
147 * parent: "accounts/C012345",
148 * orgDisplayName: "Acme Corp",
149 * domain: "acme.example.com",
150 * primaryContactInfo: {
151 * firstName: "Ada",
152 * lastName: "Lovelace",
153 * email: "admin@acme.example.com",
154 * },
155 * orgPostalAddress: {
156 * regionCode: "US",
157 * postalCode: "94105",
158 * locality: "San Francisco",
159 * administrativeArea: "CA",
160 * addressLines: ["100 Market Street"],
161 * },
162 * });
163 * ```
164 *
165 * ### Updating a Customer
166 * **Example:** Rename
167 * ```typescript
168 * // Same logical id as before; only the display name changes.
169 * const customer = yield* GCP.CloudChannel.Customer("Acme", {
170 * parent: "accounts/C012345",
171 * orgDisplayName: "Acme Corporation",
172 * domain: "acme.example.com",
173 * });
174 * ```
175 *
176 * @resource
177 * @category CloudChannel
178 */
179export const Customer = Resource<Customer>("GCP.CloudChannel.Customer");
180
181export class CustomerNotResolved extends Data.TaggedError(
182 "GCP.CloudChannel.CustomerNotResolved",
183)<{
184 name: string;
185}> {}
186
187export const CustomerProvider = () =>
188 Provider.succeed(Customer, {
189 stables: ["name", "customerId", "account", "createTime"],
190
191 diff: Effect.fn(function* ({ news, olds, output }) {
192 if (!isResolved(news)) return undefined;
193 const previousParent = olds?.parent ?? output?.parent ?? output?.account;
194 const nextParent = toAccountName(news.parent);
195 return replaceOnIdentity({
196 previousId: olds?.customerId ?? output?.customerId,
197 nextId: news.customerId,
198 previousParent:
199 previousParent !== undefined
200 ? toAccountName(previousParent)
201 : undefined,
202 nextParent,
203 });
204 }),
205
206 read: Effect.fn(function* ({ id, olds, output }) {
207 const parent = toAccountName(
208 olds?.parent ?? output?.parent ?? output?.account ?? "",
209 );
210 const name = toCustomerName(
211 parent,
212 olds?.customerId ?? output?.customerId ?? output?.name,
213 );
214 let existing = yield* getCustomer(output?.name ?? name);
215 if (existing === undefined) {
216 const domain = yield* toDomain(id, olds?.domain, output?.domain);
217 existing = yield* findCustomerByDomain(parent, domain);
218 }
219 if (existing === undefined) return undefined;
220 const attrs = toCustomerAttrs(existing, parent);
221 // No labels field: without prior state the customer may not be ours.
222 return output !== undefined ? attrs : Unowned(attrs);
223 }),
224
225 reconcile: Effect.fn(function* ({ id, news, output }) {
226 const parent = toAccountName(news.parent);
227 const orgDisplayName = yield* toOrgDisplayName(
228 id,
229 news.orgDisplayName,
230 output?.orgDisplayName,
231 );
232 const domain = yield* toDomain(id, news.domain, output?.domain);
233 const body = desiredCustomer({
234 orgDisplayName,
235 domain,
236 orgPostalAddress: news.orgPostalAddress ?? output?.orgPostalAddress,
237 primaryContactInfo:
238 news.primaryContactInfo ?? output?.primaryContactInfo,
239 alternateEmail: news.alternateEmail,
240 languageCode: news.languageCode ?? output?.languageCode,
241 correlationId: news.correlationId,
242 customerAttestationState: news.customerAttestationState,
243 channelPartnerId: news.channelPartnerId,
244 });
245 const name = toCustomerName(
246 parent,
247 news.customerId ?? output?.customerId ?? output?.name,
248 );
249
250 let current = yield* getCustomer(output?.name ?? name);
251 if (current === undefined) {
252 current = yield* findCustomerByDomain(parent, domain);
253 }
254
255 if (current === undefined) {
256 const created = yield* cloudchannel
257 .createAccountsCustomers({
258 parent,
259 body,
260 })
261 .pipe(
262 Effect.catchTag("Conflict", () =>
263 findCustomerByDomain(parent, domain),
264 ),
265 );
266 current = created ?? undefined;
267 }
268
269 if (current === undefined) {
270 return yield* new CustomerNotResolved({
271 name: name || `${parent}/customers/${domain}`,
272 });
273 }
274
275 const currentName = current.name ?? name;
276 const displayChanged = !sameText(current.orgDisplayName, orgDisplayName);
277 const domainChanged = !sameText(current.domain, domain);
278 const addressChanged = !jsonEqual(
279 current.orgPostalAddress,
280 body.orgPostalAddress,
281 );
282 const contactChanged = !jsonEqual(
283 {
284 firstName: current.primaryContactInfo?.firstName,
285 lastName: current.primaryContactInfo?.lastName,
286 email: current.primaryContactInfo?.email,
287 phone: current.primaryContactInfo?.phone,
288 title: current.primaryContactInfo?.title,
289 },
290 {
291 firstName: body.primaryContactInfo?.firstName,
292 lastName: body.primaryContactInfo?.lastName,
293 email: body.primaryContactInfo?.email,
294 phone: body.primaryContactInfo?.phone,
295 title: body.primaryContactInfo?.title,
296 },
297 );
298 const alternateChanged = !sameText(
299 current.alternateEmail,
300 news.alternateEmail,
301 );
302 const languageChanged = !sameText(
303 current.languageCode,
304 body.languageCode,
305 );
306 const correlationChanged = !sameText(
307 current.correlationId,
308 news.correlationId,
309 );
310 const attestationChanged =
311 news.customerAttestationState !== undefined &&
312 !sameText(
313 current.customerAttestationState,
314 news.customerAttestationState,
315 );
316 const partnerChanged =
317 news.channelPartnerId !== undefined &&
318 !sameText(current.channelPartnerId, news.channelPartnerId);
319
320 const updateMask = updateMaskOf(
321 displayChanged ? "org_display_name" : undefined,
322 domainChanged ? "domain" : undefined,
323 addressChanged ? "org_postal_address" : undefined,
324 contactChanged ? "primary_contact_info" : undefined,
325 alternateChanged ? "alternate_email" : undefined,
326 languageChanged ? "language_code" : undefined,
327 correlationChanged ? "correlation_id" : undefined,
328 attestationChanged ? "customer_attestation_state" : undefined,
329 partnerChanged ? "channel_partner_id" : undefined,
330 );
331
332 if (updateMask.length > 0 && currentName.length > 0) {
333 current = yield* cloudchannel.patchAccountsCustomers({
334 name: currentName,
335 updateMask,
336 body,
337 });
338 }
339
340 return toCustomerAttrs(current, parent);
341 }),
342
343 delete: Effect.fn(function* ({ output }) {
344 const name = output.name;
345 if (name.length === 0) return;
346 yield* cloudchannel
347 .deleteAccountsCustomers({ name })
348 .pipe(Effect.catchTag("NotFound", () => Effect.void));
349 }),
350 });