1import * as healthcare from "@distilled.cloud/gcp/healthcare_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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 collectPages,
14 expandParent,
15 hasAlchemyLabelMap,
16 listAlchemyConsentStores,
17 normalizeLocation,
18 parseResourceName,
19 retryTransient,
20 sameJson,
21 sameText,
22 updateMaskOf,
23 userLabels,
24 waitUntilGone,
25 withOwnershipMetadata,
26} from "./internal.ts";
27
28export type ConsentPolicy = {
29 /** Resource attributes this policy matches. */
30 resourceAttributes?: healthcare.Attribute[];
31 /** CEL authorization rule. */
32 authorizationRule?: healthcare.Expr;
33};
34
35export type DatasetsConsentStoresConsentProps = {
36 /**
37 * Parent consent store. Full name
38 * `.../consentStores/{consentStore}` or the store id (combined with
39 * `dataset` and `location`). Immutable — changing it replaces the
40 * consent.
41 */
42 consentStore: string;
43 /**
44 * Parent dataset used when `consentStore` is a bare id.
45 */
46 dataset?: string;
47 /**
48 * Region used when `consentStore` is a bare id.
49 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
50 */
51 location?: string;
52 /**
53 * User UUID supplied by the client.
54 */
55 userId: string;
56 /**
57 * Consent artifact resource name proving the end-user consent.
58 */
59 consentArtifact: string;
60 /**
61 * Consent state. Created as `DRAFT` unless set. State transitions
62 * after create use activate/revoke RPCs (`ACTIVE`, `REVOKED`).
63 * @default "DRAFT"
64 */
65 state?: healthcare.ConsentStateEnum | (string & {});
66 /**
67 * Consent metadata. Alchemy ownership keys are merged in
68 * automatically.
69 */
70 metadata?: Record<string, string>;
71 /**
72 * Authorization policies.
73 */
74 policies?: ConsentPolicy[];
75 /**
76 * Input-only TTL from create time (e.g. `"86400s"`).
77 */
78 ttl?: string;
79};
80
81export type DatasetsConsentStoresConsent = Resource<
82 "GCP.Healthcare.DatasetsConsentStoresConsent",
83 DatasetsConsentStoresConsentProps,
84 {
85 /** Full resource name `.../consents/{consent}`. */
86 name: string;
87 /** Server-assigned consent id. */
88 consentId: string;
89 /** Parent consent store resource name. */
90 consentStore: string;
91 /** Project id. */
92 project: string;
93 /** Location id. */
94 location: string;
95 /** User id. */
96 userId: string | undefined;
97 /** Consent artifact resource name. */
98 consentArtifact: string | undefined;
99 /** Consent state. */
100 state: string | undefined;
101 /** User metadata (Alchemy ownership keys stripped). */
102 metadata: Record<string, string>;
103 /** Revision id. */
104 revisionId: string | undefined;
105 /** RFC3339 revision create time. */
106 revisionCreateTime: string | undefined;
107 /** RFC3339 expiry. */
108 expireTime: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * A user's consent in a consent store.
116 *
117 * Consent ids are server-assigned. `userId`, policies, consent artifact,
118 * and metadata update by committing a new revision. Ownership is stamped
119 * into `metadata`.
120 *
121 * ### Creating a Consent
122 * **Example:** Draft consent
123 * ```typescript
124 * const consent = yield* GCP.Healthcare.DatasetsConsentStoresConsent(
125 * "Grant",
126 * {
127 * consentStore: store.name,
128 * userId: "user-123",
129 * consentArtifact: artifact.name,
130 * state: "DRAFT",
131 * metadata: { source: "app" },
132 * },
133 * );
134 * ```
135 *
136 * @resource
137 * @category Healthcare
138 */
139export const DatasetsConsentStoresConsent =
140 Resource<DatasetsConsentStoresConsent>(
141 "GCP.Healthcare.DatasetsConsentStoresConsent",
142 );
143
144export class DatasetsConsentStoresConsentNotResolved extends Data.TaggedError(
145 "GCP.Healthcare.DatasetsConsentStoresConsentNotResolved",
146)<{
147 name: string;
148}> {}
149
150const DEFAULT_STATE = "DRAFT";
151
152const storeOf = (
153 consentStore: string,
154 project: string,
155 location: string,
156 dataset: string | undefined,
157) => {
158 if (consentStore.includes("/")) return consentStore.replace(/\/+$/, "");
159 if (dataset === undefined) {
160 return expandParent(consentStore, project, location, "consentStores");
161 }
162 const datasetName = expandParent(dataset, project, location, "datasets");
163 return `${datasetName}/consentStores/${consentStore}`;
164};
165
166const toAttrs = (
167 consent: healthcare.Consent,
168 project: string,
169 region: string,
170) => {
171 const name = consent.name ?? "";
172 const parsed = parseResourceName(name, "consents", region);
173 return {
174 name,
175 consentId: parsed.id.split("@")[0] ?? parsed.id,
176 consentStore: parsed.parent,
177 project: parsed.project || project,
178 location: parsed.location,
179 userId: consent.userId,
180 consentArtifact: consent.consentArtifact,
181 state: consent.state,
182 metadata: userLabels(consent.metadata),
183 revisionId: consent.revisionId,
184 revisionCreateTime: consent.revisionCreateTime,
185 expireTime: consent.expireTime,
186 };
187};
188
189const getByName = (name: string) =>
190 name.length === 0
191 ? Effect.succeed(undefined)
192 : healthcare
193 .getProjectsLocationsDatasetsConsentStoresConsents({ name })
194 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
195
196export const DatasetsConsentStoresConsentProvider = () =>
197 Provider.succeed(DatasetsConsentStoresConsent, {
198 stables: ["name", "consentId", "consentStore", "project", "location"],
199
200 diff: Effect.fn(function* ({ news, olds, output }) {
201 if (!isResolved(news)) return undefined;
202 const env = yield* GcpEnvironment.current;
203 const previousParent = olds?.consentStore ?? output?.consentStore;
204 const nextParent = storeOf(
205 news.consentStore,
206 env.project,
207 normalizeLocation(news.location ?? output?.location, env.region),
208 news.dataset,
209 );
210 if (previousParent !== undefined && previousParent !== nextParent) {
211 return { action: "replace" as const, deleteFirst: false };
212 }
213 return undefined;
214 }),
215
216 read: Effect.fn(function* ({ id, output }) {
217 const env = yield* GcpEnvironment.current;
218 const existing = yield* getByName(output?.name ?? "");
219 if (existing === undefined) return undefined;
220 const attrs = toAttrs(existing, env.project, env.region);
221 return (yield* hasAlchemyLabels(id, tagRecord(existing.metadata)))
222 ? attrs
223 : Unowned(attrs);
224 }),
225
226 list: () =>
227 Effect.gen(function* () {
228 const env = yield* GcpEnvironment.current;
229 const stores = yield* listAlchemyConsentStores(env.project, env.region);
230 const consents = yield* Effect.forEach(
231 stores,
232 (store) =>
233 collectPages(
234 healthcare.listProjectsLocationsDatasetsConsentStoresConsents.pages(
235 {
236 parent: store.name ?? "",
237 pageSize: 1000,
238 },
239 ),
240 (page) => page.consents,
241 ),
242 { concurrency: 4 },
243 );
244 return consents
245 .flat()
246 .filter((consent) => hasAlchemyLabelMap(consent.metadata))
247 .map((consent) => toAttrs(consent, env.project, env.region));
248 }),
249
250 reconcile: Effect.fn(function* ({ id, news, output }) {
251 const env = yield* GcpEnvironment.current;
252 const location = normalizeLocation(
253 news.location ?? output?.location,
254 env.region,
255 );
256 const consentStore = storeOf(
257 news.consentStore,
258 env.project,
259 location,
260 news.dataset,
261 );
262 const ownership = yield* createInternalLabels(id);
263 const metadata = withOwnershipMetadata(news.metadata, ownership);
264 const state = news.state ?? DEFAULT_STATE;
265
266 let current = yield* getByName(output?.name ?? "");
267
268 if (current === undefined) {
269 current = yield* retryTransient(
270 healthcare.createProjectsLocationsDatasetsConsentStoresConsents({
271 parent: consentStore,
272 body: {
273 userId: news.userId,
274 consentArtifact: news.consentArtifact,
275 state,
276 metadata,
277 policies: news.policies,
278 ttl: news.ttl,
279 },
280 }),
281 );
282 }
283
284 if (current === undefined) {
285 return yield* new DatasetsConsentStoresConsentNotResolved({
286 name: consentStore,
287 });
288 }
289
290 const currentName = current.name ?? output?.name ?? "";
291 const userChanged = !sameText(current.userId, news.userId);
292 const artifactChanged = !sameText(
293 current.consentArtifact,
294 news.consentArtifact,
295 );
296 const policiesChanged = !sameJson(current.policies, news.policies);
297 const metadataChanged =
298 !sameJson(userLabels(current.metadata), userLabels(news.metadata)) ||
299 !hasAlchemyLabelMap(current.metadata);
300
301 if (
302 userChanged ||
303 artifactChanged ||
304 policiesChanged ||
305 metadataChanged
306 ) {
307 current = yield* retryTransient(
308 healthcare.patchProjectsLocationsDatasetsConsentStoresConsents({
309 name: currentName,
310 updateMask: updateMaskOf(
311 userChanged ? "userId" : undefined,
312 artifactChanged ? "consentArtifact" : undefined,
313 policiesChanged ? "policies" : undefined,
314 metadataChanged ? "metadata" : undefined,
315 ),
316 body: {
317 userId: news.userId,
318 consentArtifact: news.consentArtifact,
319 policies: news.policies,
320 metadata,
321 },
322 }),
323 );
324 }
325
326 const currentState = current.state ?? DEFAULT_STATE;
327 if (state === "ACTIVE" && currentState === "DRAFT") {
328 current = yield* retryTransient(
329 healthcare.activateProjectsLocationsDatasetsConsentStoresConsents({
330 name: current.name ?? currentName,
331 body: {
332 consentArtifact: news.consentArtifact,
333 },
334 }),
335 );
336 } else if (state === "REVOKED" && currentState === "ACTIVE") {
337 current = yield* retryTransient(
338 healthcare.revokeProjectsLocationsDatasetsConsentStoresConsents({
339 name: current.name ?? currentName,
340 }),
341 );
342 }
343
344 return toAttrs(current, env.project, env.region);
345 }),
346
347 delete: Effect.fn(function* ({ output }) {
348 if (!output.name) return;
349 yield* retryTransient(
350 healthcare.deleteProjectsLocationsDatasetsConsentStoresConsents({
351 name: output.name,
352 }),
353 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
354 yield* waitUntilGone(getByName(output.name), output.name);
355 }),
356 });