1import * as marketplace from "@distilled.cloud/gcp/authorizedbuyersmarketplace_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 expandParent,
12 findUserByEmail,
13 listUsers,
14 parentOfName,
15 replaceOnIdentity,
16 resourceName,
17 sameText,
18 toAttrs,
19 toEmail,
20 type ClientUserState,
21} from "./internal.ts";
22
23export type BuyersClientsUserProps = {
24 /**
25 * Parent client, as `buyers/{buyer}/clients/{client}` or
26 * `{buyer}/clients/{client}`. Immutable — changing it replaces the
27 * client user.
28 */
29 parent: string;
30 /**
31 * Invitee email. Unique per client. Immutable — changing it replaces
32 * the user. If omitted, a unique `example.com` address is generated.
33 * The invite goes to this exact address; client users have no labels,
34 * so Alchemy tracks the user by its resource name.
35 */
36 email?: string;
37 /**
38 * Server-assigned user id (last path segment). Omit on create; pass
39 * the observed id to keep the same user. Immutable — changing it
40 * replaces the user.
41 */
42 userId?: string;
43 /**
44 * Desired serving state after the invite is accepted. Create always
45 * yields `INVITED`. `ACTIVE` / `INACTIVE` are applied only once the
46 * user has left `INVITED` (`activate` / `deactivate`).
47 */
48 state?: ClientUserState | (string & {});
49};
50
51export type BuyersClientsUser = Resource<
52 "GCP.AuthorizedBuyersMarketplace.BuyersClientsUser",
53 BuyersClientsUserProps,
54 {
55 /** Full resource name `buyers/{buyer}/clients/{client}/users/{user}`. */
56 name: string;
57 /** Server-assigned user id (last path segment). */
58 userId: string;
59 /** Parent client resource name. */
60 parent: string;
61 /** Project id used when the client user was reconciled. */
62 project: string;
63 /** Invitee email with the Alchemy plus-tag stripped. */
64 email: string;
65 /** Server-reported user state (`INVITED`, `ACTIVE`, `INACTIVE`). */
66 state: string | undefined;
67 },
68 never,
69 Providers
70>;
71
72/**
73 * An Authorized Buyers Marketplace client user
74 * (`buyers/{buyer}/clients/{client}/users/{user}`).
75 *
76 * Client users have no labels or description — Alchemy stamps ownership
77 * into the email plus-tag so `list` / nuke can find them. Parent and
78 * email are identity; changing either replaces the user. Create sends an
79 * invitation (`INVITED`). Activate and deactivate only apply after the
80 * invitee accepts.
81 *
82 * ### Creating a Client User
83 * **Example:** Generated email
84 * ```typescript
85 * const user = yield* GCP.AuthorizedBuyersMarketplace.BuyersClientsUser(
86 * "Analyst",
87 * { parent: "buyers/123/clients/456" },
88 * );
89 * ```
90 *
91 * **Example:** Invite a known mailbox
92 * ```typescript
93 * const user = yield* GCP.AuthorizedBuyersMarketplace.BuyersClientsUser(
94 * "Analyst",
95 * {
96 * parent: "buyers/123/clients/456",
97 * email: "analyst@example.com",
98 * },
99 * );
100 * ```
101 *
102 * ### Updating a Client User
103 * **Example:** Deactivate after the invite is accepted
104 * ```typescript
105 * // Same logical id, changed state: the engine keeps the same user.
106 * const user = yield* GCP.AuthorizedBuyersMarketplace.BuyersClientsUser(
107 * "Analyst",
108 * {
109 * parent: "buyers/123/clients/456",
110 * email: "analyst@example.com",
111 * state: "INACTIVE",
112 * },
113 * );
114 * ```
115 *
116 * @resource
117 * @category AuthorizedBuyersMarketplace
118 */
119export const BuyersClientsUser = Resource<BuyersClientsUser>(
120 "GCP.AuthorizedBuyersMarketplace.BuyersClientsUser",
121);
122
123export class BuyersClientsUserNotResolved extends Data.TaggedError(
124 "GCP.AuthorizedBuyersMarketplace.BuyersClientsUserNotResolved",
125)<{
126 parent: string;
127 name: string;
128}> {}
129
130const getByName = (name: string) =>
131 !name
132 ? Effect.succeed(undefined)
133 : marketplace
134 .getBuyersClientsUsers({ name })
135 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
136
137const lookupName = (
138 parent: string,
139 userId: string | undefined,
140 existingName: string | undefined,
141) => {
142 if (existingName && existingName.length > 0) return existingName;
143 if (userId && userId.length > 0 && parent.length > 0) {
144 return resourceName(parent, userId);
145 }
146 return "";
147};
148
149const syncState = (
150 current: marketplace.ClientUser,
151 desired: string | undefined,
152) =>
153 Effect.gen(function* () {
154 if (!desired || !current.name) return current;
155 const observed = current.state ?? "";
156 if (sameText(observed, desired)) return current;
157 if (observed === "INVITED") return current;
158 if (desired === "ACTIVE" && observed === "INACTIVE") {
159 return yield* marketplace.activateBuyersClientsUsers({
160 name: current.name,
161 });
162 }
163 if (desired === "INACTIVE" && observed === "ACTIVE") {
164 return yield* marketplace.deactivateBuyersClientsUsers({
165 name: current.name,
166 });
167 }
168 return current;
169 });
170
171export const BuyersClientsUserProvider = () =>
172 Provider.succeed(BuyersClientsUser, {
173 stables: ["name", "userId", "parent", "project", "email"],
174
175 diff: Effect.fn(function* ({ news, olds, output }) {
176 if (!isResolved(news)) return undefined;
177 return replaceOnIdentity({
178 previousParent: expandParent(olds?.parent ?? output?.parent ?? ""),
179 nextParent: expandParent(news.parent),
180 previousEmail: olds?.email ?? output?.email,
181 nextEmail: news.email,
182 previousUserId: olds?.userId ?? output?.userId,
183 nextUserId: news.userId,
184 });
185 }),
186
187 read: Effect.fn(function* ({ olds, output }) {
188 const env = yield* GcpEnvironment.current;
189 const parent = expandParent(olds?.parent ?? output?.parent ?? "");
190 const name = lookupName(
191 parent,
192 olds?.userId ?? output?.userId,
193 output?.name,
194 );
195 const existing = yield* getByName(name);
196 if (existing !== undefined) {
197 return toAttrs(
198 existing,
199 parentOfName(existing.name ?? "") || parent,
200 env.project,
201 );
202 }
203 // Without a known name, a user with the same email may be someone
204 // else's invite: surface it as unowned.
205 const email = olds?.email ?? output?.email;
206 if (!parent || !email) return undefined;
207 const users = yield* listUsers(parent);
208 const found = findUserByEmail(users, email);
209 return found === undefined
210 ? undefined
211 : Unowned(toAttrs(found, parent, env.project));
212 }),
213
214 reconcile: Effect.fn(function* ({ id, news, output }) {
215 const env = yield* GcpEnvironment.current;
216 const parent = expandParent(news.parent);
217 const email = yield* toEmail(id, news.email, output?.email);
218 const name = lookupName(
219 parent,
220 news.userId ?? output?.userId,
221 output?.name,
222 );
223
224 let current = yield* getByName(name);
225 if (current === undefined) {
226 const users = yield* listUsers(parent);
227 current = findUserByEmail(users, email);
228 }
229
230 if (current === undefined) {
231 current = yield* marketplace
232 .createBuyersClientsUsers({ parent, body: { email } })
233 .pipe(
234 Effect.catchTag("Conflict", () =>
235 listUsers(parent).pipe(
236 Effect.map((users) => findUserByEmail(users, email)),
237 ),
238 ),
239 );
240 }
241
242 if (current === undefined) {
243 return yield* new BuyersClientsUserNotResolved({
244 parent,
245 name: name || email,
246 });
247 }
248
249 current = yield* syncState(current, news.state);
250
251 return toAttrs(current, parent, env.project);
252 }),
253
254 delete: Effect.fn(function* ({ output }) {
255 if (!output.name) return;
256 yield* marketplace
257 .deleteBuyersClientsUsers({ name: output.name })
258 .pipe(Effect.catchTag("NotFound", () => Effect.void));
259 }),
260 });