1import * as jobs from "@distilled.cloud/gcp/jobs_v4";
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 deleteJob,
12 encodeOwnershipLine,
13 findOwnedJob,
14 getJob,
15 jobNameOf,
16 jsonEqual,
17 listOwnedJobs,
18 MAX_DESCRIPTION_LENGTH,
19 MAX_REQUISITION_ID_LENGTH,
20 MAX_TITLE_LENGTH,
21 ownedByAlchemy,
22 ownershipLabels,
23 parseJobName,
24 parseOwnership,
25 replaceOnIdentity,
26 sameStringList,
27 sameText,
28 tenantNameOf,
29 toGeneratedName,
30 updateMaskOf,
31 waitUntilGone,
32} from "./internal.ts";
33
34export type ApplicationInfo = {
35 /** Email addresses that receive applications (max 255 chars each). */
36 emails?: string[];
37 /** URIs for an online application form (max 2000 chars each). */
38 uris?: string[];
39 /** Free-text instructions a candidate can follow (max 3000 chars). */
40 instruction?: string;
41};
42
43export type CompensationRange = {
44 /** Inclusive range maximum. */
45 maxCompensation?: jobs.Money;
46 /** Inclusive range minimum. */
47 minCompensation?: jobs.Money;
48};
49
50export type CompensationEntry = {
51 /** Compensation description, for example equity terms. */
52 description?: string;
53 /** Compensation range when amount is not a single value. */
54 range?: CompensationRange;
55 /** Expected units paid each year. */
56 expectedUnitsPerYear?: number;
57 /** Compensation amount. */
58 amount?: jobs.Money;
59 /** Compensation type (`BASE`, `BONUS`, …). */
60 type?: jobs.CompensationEntryTypeEnum | (string & {});
61 /** Frequency (`HOURLY`, `ANNUAL`, …). */
62 unit?: jobs.CompensationEntryUnitEnum | (string & {});
63};
64
65export type CompensationInfo = {
66 /** Compensation entries. At most one entry may have type `BASE`. */
67 entries?: CompensationEntry[];
68};
69
70export type ProcessingOptions = {
71 /** HTML sanitization applied to description and related fields. */
72 htmlSanitization?: jobs.ProcessingOptionsHtmlSanitizationEnum | (string & {});
73 /** Skip resolving a more precise street address. */
74 disableStreetAddressResolution?: boolean;
75};
76
77export type TenantsJobProps = {
78 /**
79 * Parent tenant resource name `projects/{project}/tenants/{tenant}`
80 * or the tenant id. Immutable — changing it replaces the job.
81 */
82 parent: string;
83 /**
84 * Company resource name
85 * `projects/{project}/tenants/{tenant}/companies/{company}`. Immutable
86 * — changing it replaces the job.
87 */
88 company: string;
89 /**
90 * Server-assigned job id. Leave blank on create. Immutable — changing
91 * it replaces the job.
92 */
93 jobId?: string;
94 /**
95 * Job title, for example `"Software Engineer"` (max 500 characters).
96 */
97 title?: string;
98 /**
99 * Job description (max 100,000 characters), shown to job seekers as
100 * given. Defaults to the title.
101 */
102 description?: string;
103 /**
104 * Client requisition / posting id (max 255 characters). Unique per
105 * company and language. Ownership is also stamped here.
106 */
107 requisitionId?: string;
108 /**
109 * Hiring addresses. Full street addresses improve commute search.
110 */
111 addresses?: string[];
112 /**
113 * Posting language as a BCP-47 code, for example `"en-US"`.
114 */
115 languageCode?: string;
116 /**
117 * Department or functional area (max 255 characters).
118 */
119 department?: string;
120 /**
121 * Experience level, for example `"ENTRY_LEVEL"`.
122 */
123 jobLevel?: jobs.JobJobLevelEnum | (string & {});
124 /**
125 * Employment types, for example `["FULL_TIME"]`.
126 */
127 employmentTypes?: jobs.JobEmploymentTypesItemEnumList;
128 /**
129 * Region throughout which the job is available.
130 */
131 postingRegion?: jobs.JobPostingRegionEnum | (string & {});
132 /**
133 * Qualifications required to perform the job (max 10,000 characters).
134 */
135 qualifications?: string;
136 /**
137 * Job responsibilities (max 10,000 characters).
138 */
139 responsibilities?: string;
140 /**
141 * Bonus, commission, and other incentives (max 10,000 characters).
142 */
143 incentives?: string;
144 /**
145 * Benefits included with the job.
146 */
147 jobBenefits?: jobs.JobJobBenefitsItemEnumList;
148 /**
149 * Desired education degrees.
150 */
151 degreeTypes?: jobs.JobDegreeTypesItemEnumList;
152 /**
153 * Promotion value used to sort featured jobs.
154 */
155 promotionValue?: number;
156 /**
157 * Job application emails, URIs, or instructions.
158 */
159 applicationInfo?: ApplicationInfo;
160 /**
161 * Compensation / pay rate.
162 */
163 compensationInfo?: CompensationInfo;
164 /**
165 * Expiration timestamp. Expired jobs drop out of search.
166 */
167 postingExpireTime?: string;
168 /**
169 * Job start timestamp (UTC).
170 */
171 jobStartTime?: string;
172 /**
173 * Job end timestamp (UTC).
174 */
175 jobEndTime?: string;
176 /**
177 * Filterable and unfilterable custom attributes.
178 */
179 customAttributes?: jobs.CustomAttributeMap;
180 /**
181 * HTML sanitization and address-resolution options.
182 */
183 processingOptions?: ProcessingOptions;
184 /**
185 * Deprecated visibility. Defaults to account-only.
186 */
187 visibility?: jobs.JobVisibilityEnum | (string & {});
188};
189
190export type TenantsJob = Resource<
191 "GCP.Jobs.TenantsJob",
192 TenantsJobProps,
193 {
194 /**
195 * Full resource name
196 * `projects/{project}/tenants/{tenant}/jobs/{job}`.
197 */
198 name: string;
199 /** Server-assigned job id. */
200 jobId: string;
201 /** Parent tenant resource name. */
202 parent: string;
203 /** Company resource name. */
204 company: string;
205 /** Company display name. */
206 companyDisplayName: string | undefined;
207 /** Project id. */
208 project: string;
209 /** Job title. */
210 title: string | undefined;
211 /** Description with the Alchemy ownership prefix stripped. */
212 description: string | undefined;
213 /** Requisition id with the Alchemy prefix stripped. */
214 requisitionId: string | undefined;
215 /** Hiring addresses. */
216 addresses: string[] | undefined;
217 /** Posting language. */
218 languageCode: string | undefined;
219 /** Department. */
220 department: string | undefined;
221 /** Experience level. */
222 jobLevel: string | undefined;
223 /** Employment types. */
224 employmentTypes: string[] | undefined;
225 /** Posting region. */
226 postingRegion: string | undefined;
227 /** Qualifications. */
228 qualifications: string | undefined;
229 /** Responsibilities. */
230 responsibilities: string | undefined;
231 /** Incentives. */
232 incentives: string | undefined;
233 /** Benefits. */
234 jobBenefits: string[] | undefined;
235 /** Degree types. */
236 degreeTypes: string[] | undefined;
237 /** Promotion value. */
238 promotionValue: number | undefined;
239 /** Application info. */
240 applicationInfo: ApplicationInfo | undefined;
241 /** Compensation info including annualized ranges. */
242 compensationInfo: jobs.CompensationInfo | undefined;
243 /** Expiration timestamp. */
244 postingExpireTime: string | undefined;
245 /** Job start timestamp. */
246 jobStartTime: string | undefined;
247 /** Job end timestamp. */
248 jobEndTime: string | undefined;
249 /** Custom attributes. */
250 customAttributes: jobs.CustomAttributeMap | undefined;
251 /** Derived job locations and categories. */
252 derivedInfo: jobs.JobDerivedInfo | undefined;
253 /** RFC3339 creation timestamp. */
254 postingCreateTime: string | undefined;
255 /** RFC3339 last-update timestamp. */
256 postingUpdateTime: string | undefined;
257 },
258 never,
259 Providers
260>;
261
262/**
263 * A Cloud Talent Solution job posting.
264 *
265 * Jobs have no labels field, so Alchemy stamps ownership into the
266 * client-side `requisitionId` for `list` / nuke (never into the
267 * seeker-visible title or description). Parent tenant,
268 * company, and job id are identity — changing any of them replaces the
269 * job. Title, description, addresses, and related fields update in
270 * place. A company cannot be deleted while it still has jobs.
271 *
272 * ### Creating a Job
273 * **Example:** Software engineer posting
274 * ```typescript
275 * const job = yield* GCP.Jobs.TenantsJob("Engineer", {
276 * parent: tenant.name,
277 * company: company.name,
278 * title: "Software Engineer",
279 * description: "Build Cloud Talent integrations.",
280 * addresses: ["Mountain View, CA"],
281 * applicationInfo: { uris: ["https://www.example.com/apply"] },
282 * });
283 * ```
284 *
285 * ### Updating a Job
286 * Re-declare the same logical id with changed props; the engine keeps the
287 * physical resource and updates it in place.
288 *
289 * **Example:** Promote the posting
290 * ```typescript
291 * const job = yield* GCP.Jobs.TenantsJob("Engineer", {
292 * parent: tenant.name,
293 * company: company.name,
294 * title: "Staff Software Engineer",
295 * description: "Build Cloud Talent integrations.",
296 * promotionValue: 1,
297 * });
298 * ```
299 *
300 * @resource
301 * @category Jobs
302 */
303export const TenantsJob = Resource<TenantsJob>("GCP.Jobs.TenantsJob");
304
305export class TenantsJobNotResolved extends Data.TaggedError(
306 "GCP.Jobs.TenantsJobNotResolved",
307)<{
308 name: string;
309}> {}
310
311const toParent = (project: string, parent: string) =>
312 tenantNameOf(project, parent);
313
314const applicationOf = (
315 info: jobs.ApplicationInfo | undefined,
316): ApplicationInfo | undefined => {
317 if (info === undefined) return undefined;
318 return {
319 emails: info.emails,
320 uris: info.uris,
321 instruction: info.instruction,
322 };
323};
324
325const toAttrs = (job: jobs.Job, project: string) => {
326 const name = job.name ?? "";
327 const parsed = parseJobName(name, project);
328 return {
329 name,
330 jobId: parsed.jobId,
331 parent: parsed.parent,
332 company: job.company ?? "",
333 companyDisplayName: job.companyDisplayName,
334 project: parsed.project || project,
335 title: job.title,
336 description: parseOwnership(job.description).text,
337 requisitionId: parseOwnership(job.requisitionId).text,
338 addresses: job.addresses,
339 languageCode: job.languageCode,
340 department: job.department,
341 jobLevel: job.jobLevel,
342 employmentTypes: job.employmentTypes,
343 postingRegion: job.postingRegion,
344 qualifications: job.qualifications,
345 responsibilities: job.responsibilities,
346 incentives: job.incentives,
347 jobBenefits: job.jobBenefits,
348 degreeTypes: job.degreeTypes,
349 promotionValue: job.promotionValue,
350 applicationInfo: applicationOf(job.applicationInfo),
351 compensationInfo: job.compensationInfo,
352 postingExpireTime: job.postingExpireTime,
353 jobStartTime: job.jobStartTime,
354 jobEndTime: job.jobEndTime,
355 customAttributes: job.customAttributes,
356 derivedInfo: job.derivedInfo,
357 postingCreateTime: job.postingCreateTime,
358 postingUpdateTime: job.postingUpdateTime,
359 };
360};
361
362const toBody = (
363 news: TenantsJobProps,
364 description: string,
365 requisitionId: string,
366 title: string,
367): jobs.Job => ({
368 title,
369 description,
370 requisitionId,
371 company: news.company,
372 addresses: news.addresses,
373 languageCode: news.languageCode,
374 department: news.department,
375 jobLevel: news.jobLevel,
376 employmentTypes: news.employmentTypes,
377 postingRegion: news.postingRegion,
378 qualifications: news.qualifications,
379 responsibilities: news.responsibilities,
380 incentives: news.incentives,
381 jobBenefits: news.jobBenefits,
382 degreeTypes: news.degreeTypes,
383 promotionValue: news.promotionValue,
384 applicationInfo: news.applicationInfo,
385 compensationInfo: news.compensationInfo,
386 postingExpireTime: news.postingExpireTime,
387 jobStartTime: news.jobStartTime,
388 jobEndTime: news.jobEndTime,
389 customAttributes: news.customAttributes,
390 processingOptions: news.processingOptions,
391 visibility: news.visibility,
392});
393
394export const TenantsJobProvider = () =>
395 Provider.succeed(TenantsJob, {
396 stables: ["name", "jobId", "parent", "project"],
397
398 diff: Effect.fn(function* ({ news, olds, output }) {
399 if (!isResolved(news)) return undefined;
400 const env = yield* GcpEnvironment.current;
401 return replaceOnIdentity({
402 previousId: olds?.jobId ?? output?.jobId,
403 nextId: news.jobId,
404 previousParent: olds?.parent ?? output?.parent,
405 nextParent: toParent(env.project, news.parent),
406 extra:
407 (olds?.company ?? output?.company) !== undefined &&
408 news.company !== (olds?.company ?? output?.company),
409 });
410 }),
411
412 read: Effect.fn(function* ({ id, olds, output }) {
413 const env = yield* GcpEnvironment.current;
414 const parent = toParent(
415 env.project,
416 olds?.parent ?? output?.parent ?? "",
417 );
418 const name =
419 output?.name ?? jobNameOf(parent, olds?.jobId ?? output?.jobId ?? "");
420 let existing = yield* getJob(name);
421 if (existing === undefined) {
422 existing = yield* findOwnedJob(parent, id);
423 }
424 if (existing === undefined) return undefined;
425 const attrs = toAttrs(existing, env.project);
426 const owned =
427 (yield* ownedByAlchemy(id, existing.description)) ||
428 (yield* ownedByAlchemy(id, existing.requisitionId));
429 return owned ? attrs : Unowned(attrs);
430 }),
431
432 list: () =>
433 Effect.gen(function* () {
434 const env = yield* GcpEnvironment.current;
435 const listed = yield* listOwnedJobs(env.project);
436 return listed.map((job) => toAttrs(job, env.project));
437 }),
438
439 reconcile: Effect.fn(function* ({ id, news, output }) {
440 const env = yield* GcpEnvironment.current;
441 const parent = toParent(env.project, news.parent);
442 const ownership = yield* ownershipLabels(id);
443 const title = yield* toGeneratedName(
444 id,
445 news.title,
446 output?.title,
447 MAX_TITLE_LENGTH,
448 );
449 const requisition = yield* toGeneratedName(
450 id,
451 news.requisitionId,
452 output?.requisitionId,
453 );
454 const requisitionId = encodeOwnershipLine(
455 ownership,
456 requisition,
457 MAX_REQUISITION_ID_LENGTH,
458 );
459 // Shown to job seekers, so no ownership marker (requisitionId has it).
460 const description = (
461 news.description ??
462 output?.description ??
463 title
464 ).slice(0, MAX_DESCRIPTION_LENGTH);
465 const name = output?.name ?? jobNameOf(parent, news.jobId ?? "");
466
467 let current = yield* getJob(name);
468 if (current === undefined) {
469 current = yield* findOwnedJob(parent, id);
470 }
471
472 if (current === undefined) {
473 const created = yield* jobs
474 .createProjectsTenantsJobs({
475 parent,
476 body: toBody(news, description, requisitionId, title),
477 })
478 .pipe(Effect.catchTag("Conflict", () => findOwnedJob(parent, id)));
479 current = created ?? undefined;
480 }
481
482 if (current === undefined) {
483 return yield* new TenantsJobNotResolved({
484 name: name || `${parent}/jobs/${requisition}`,
485 });
486 }
487
488 const currentName = current.name ?? name;
489 const optionalText = (
490 currentValue: string | undefined,
491 next: string | undefined,
492 field: string,
493 ) =>
494 next === undefined || sameText(currentValue, next) ? undefined : field;
495 const optionalJson = (
496 currentValue: unknown,
497 next: unknown,
498 field: string,
499 ) =>
500 next === undefined || jsonEqual(currentValue, next) ? undefined : field;
501 const updateMask = updateMaskOf(
502 sameText(current.title, title) ? undefined : "title",
503 sameText(current.description, description) ? undefined : "description",
504 sameText(current.requisitionId, requisitionId)
505 ? undefined
506 : "requisitionId",
507 sameText(current.company, news.company) ? undefined : "company",
508 optionalJson(current.addresses, news.addresses, "addresses"),
509 optionalText(current.languageCode, news.languageCode, "languageCode"),
510 optionalText(current.department, news.department, "department"),
511 optionalText(current.jobLevel, news.jobLevel, "jobLevel"),
512 optionalJson(
513 current.employmentTypes,
514 news.employmentTypes,
515 "employmentTypes",
516 ),
517 optionalText(
518 current.postingRegion,
519 news.postingRegion,
520 "postingRegion",
521 ),
522 optionalText(
523 current.qualifications,
524 news.qualifications,
525 "qualifications",
526 ),
527 optionalText(
528 current.responsibilities,
529 news.responsibilities,
530 "responsibilities",
531 ),
532 optionalText(current.incentives, news.incentives, "incentives"),
533 optionalJson(current.jobBenefits, news.jobBenefits, "jobBenefits"),
534 optionalJson(current.degreeTypes, news.degreeTypes, "degreeTypes"),
535 news.promotionValue !== undefined &&
536 (current.promotionValue ?? 0) !== news.promotionValue
537 ? "promotionValue"
538 : undefined,
539 optionalJson(
540 applicationOf(current.applicationInfo),
541 news.applicationInfo,
542 "applicationInfo",
543 ),
544 optionalJson(
545 current.compensationInfo?.entries,
546 news.compensationInfo?.entries,
547 "compensationInfo",
548 ),
549 optionalText(
550 current.postingExpireTime,
551 news.postingExpireTime,
552 "postingExpireTime",
553 ),
554 optionalText(current.jobStartTime, news.jobStartTime, "jobStartTime"),
555 optionalText(current.jobEndTime, news.jobEndTime, "jobEndTime"),
556 optionalJson(
557 current.customAttributes,
558 news.customAttributes,
559 "customAttributes",
560 ),
561 optionalJson(
562 current.processingOptions,
563 news.processingOptions,
564 "processingOptions",
565 ),
566 optionalText(current.visibility, news.visibility, "visibility"),
567 );
568
569 if (updateMask.length > 0) {
570 current = yield* jobs.patchProjectsTenantsJobs({
571 name: currentName,
572 updateMask,
573 body: {
574 name: currentName,
575 ...toBody(news, description, requisitionId, title),
576 },
577 });
578 }
579
580 return toAttrs(current, env.project);
581 }),
582
583 delete: Effect.fn(function* ({ output }) {
584 if (!output.name) return;
585 yield* deleteJob(output.name);
586 yield* waitUntilGone(getJob(output.name));
587 }),
588 });