1import * as b2bi from "@distilled.cloud/aws/b2bi";
2import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import { readB2biTags, syncB2biTags, toWireTags } from "./internal.ts";
13
14export interface ProfileProps {
15 /**
16 * The name of the profile.
17 */
18 name: string;
19 /**
20 * Name for the business associated with this profile.
21 */
22 businessName: string;
23 /**
24 * The phone number associated with the business, in E.164 format
25 * (e.g. `+1234567890`).
26 */
27 phone: string;
28 /**
29 * The email address associated with this customer profile.
30 */
31 email?: string;
32 /**
33 * Whether Amazon Web Services logs each event in an Amazon CloudWatch log
34 * group for the profile. Immutable after creation — changing it replaces
35 * the profile.
36 * @default "ENABLED"
37 */
38 logging?: "ENABLED" | "DISABLED";
39 /**
40 * User-defined tags for the profile.
41 */
42 tags?: Record<string, string>;
43}
44
45export interface Profile extends Resource<
46 "AWS.B2BI.Profile",
47 ProfileProps,
48 {
49 /**
50 * Service-assigned unique ID of the profile.
51 */
52 profileId: string;
53 /**
54 * ARN of the profile.
55 */
56 profileArn: string;
57 /**
58 * Name of the profile.
59 */
60 name: string;
61 /**
62 * Business name associated with the profile.
63 */
64 businessName: string;
65 /**
66 * CloudWatch log group created for the profile when logging is enabled.
67 */
68 logGroupName: string | undefined;
69 },
70 never,
71 Providers
72> {}
73
74/**
75 * An AWS B2B Data Interchange (B2BI) customer profile. A profile is the
76 * mechanism used to model a distinct private network; you can have up to
77 * five profiles per account. Profiles are credential-free and fully
78 * self-service, so their lifecycle is directly testable.
79 * ### Creating a Profile
80 * **Example:** Basic Profile
81 * ```typescript
82 * const profile = yield* B2BI.Profile("Acme", {
83 * name: "Acme Trading",
84 * businessName: "Acme Corp",
85 * phone: "+15555550100",
86 * email: "edi@acme.example",
87 * });
88 * ```
89 *
90 * ### Disabling CloudWatch Logging
91 * **Example:** Logging Disabled
92 * ```typescript
93 * const profile = yield* B2BI.Profile("Acme", {
94 * name: "Acme Trading",
95 * businessName: "Acme Corp",
96 * phone: "+15555550100",
97 * logging: "DISABLED",
98 * });
99 * ```
100 *
101 * @resource
102 */
103export const Profile = Resource<Profile>("AWS.B2BI.Profile");
104
105const toAttrs = (r: b2bi.GetProfileResponse | b2bi.CreateProfileResponse) => ({
106 profileId: r.profileId,
107 profileArn: r.profileArn,
108 name: r.name,
109 businessName: r.businessName,
110 logGroupName: r.logGroupName,
111});
112
113export const ProfileProvider = () =>
114 Provider.effect(
115 Profile,
116 Effect.gen(function* () {
117 /** Find a profile by its user-facing name across all pages. */
118 const findByName = (name: string) =>
119 b2bi.listProfiles.items({}).pipe(
120 Stream.filter((s) => s.name === name),
121 Stream.runHead,
122 Effect.flatMap((head) =>
123 head._tag === "Some"
124 ? b2bi
125 .getProfile({ profileId: head.value.profileId })
126 .pipe(
127 Effect.catchTag("ResourceNotFoundException", () =>
128 Effect.succeed(undefined),
129 ),
130 )
131 : Effect.succeed(undefined),
132 ),
133 );
134
135 return {
136 stables: ["profileId", "profileArn"],
137
138 diff: Effect.fn(function* ({ olds = {}, news }) {
139 if (!isResolved(news)) return undefined;
140 // logging has no member on updateProfile — it is immutable after
141 // create, so a change forces a replacement. Delete-first: profiles
142 // are recovered by name (findByName) and capped at 5 per account.
143 if ((olds.logging ?? "ENABLED") !== (news.logging ?? "ENABLED")) {
144 return { action: "replace", deleteFirst: true } as const;
145 }
146 }),
147
148 read: Effect.fn(function* ({ id, olds, output }) {
149 const found = output?.profileId
150 ? yield* b2bi
151 .getProfile({ profileId: output.profileId })
152 .pipe(
153 Effect.catchTag("ResourceNotFoundException", () =>
154 Effect.succeed(undefined),
155 ),
156 )
157 : yield* findByName(olds?.name ?? "");
158 if (found === undefined) return undefined;
159 const attrs = toAttrs(found);
160 const tags = yield* readB2biTags(attrs.profileArn);
161 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
162 }),
163
164 reconcile: Effect.fn(function* ({ id, news, output, session }) {
165 const internalTags = yield* createInternalTags(id);
166 const desiredTags = { ...internalTags, ...news.tags };
167
168 // 1. Observe — cloud state is authoritative; output caches the id.
169 let live = output?.profileId
170 ? yield* b2bi
171 .getProfile({ profileId: output.profileId })
172 .pipe(
173 Effect.catchTag("ResourceNotFoundException", () =>
174 Effect.succeed(undefined),
175 ),
176 )
177 : yield* findByName(news.name);
178
179 // 2. Ensure — create if missing.
180 if (live === undefined) {
181 const created = yield* b2bi.createProfile({
182 name: news.name,
183 businessName: news.businessName,
184 phone: news.phone,
185 email: news.email,
186 logging: news.logging ?? "ENABLED",
187 tags: toWireTags(desiredTags),
188 });
189 live = yield* b2bi.getProfile({ profileId: created.profileId });
190 } else {
191 // 3. Sync — converge mutable settings (name/businessName/phone/
192 // email). logging is immutable after create (diff replaces).
193 // phone/email are sensitive members that may decode as Redacted.
194 const unwrap = (
195 v: string | Redacted.Redacted<string> | undefined,
196 ) => (Redacted.isRedacted(v) ? Redacted.value(v) : v);
197 const nameDrift = live.name !== news.name;
198 const bizDrift = live.businessName !== news.businessName;
199 const phoneDrift = unwrap(live.phone) !== news.phone;
200 const emailDrift =
201 news.email !== undefined && unwrap(live.email) !== news.email;
202 if (nameDrift || bizDrift || phoneDrift || emailDrift) {
203 yield* b2bi.updateProfile({
204 profileId: live.profileId,
205 name: news.name,
206 businessName: news.businessName,
207 phone: news.phone,
208 email: news.email,
209 });
210 live = yield* b2bi.getProfile({ profileId: live.profileId });
211 }
212 }
213
214 // 3b. Sync tags against observed cloud tags.
215 yield* syncB2biTags(live.profileArn, desiredTags);
216
217 yield* session.note(live.profileId);
218 return toAttrs(live);
219 }),
220
221 delete: Effect.fn(function* ({ output }) {
222 yield* b2bi
223 .deleteProfile({ profileId: output.profileId })
224 .pipe(
225 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
226 );
227 // B2BI auto-creates /aws/vendedlogs/b2bi/profile/{profileId} when
228 // logging is ENABLED and deleteProfile does NOT remove it — every
229 // deleted profile would leak an orphaned log group. Reap it
230 // (idempotently) using the service-reported name when available.
231 const logGroupName =
232 output.logGroupName ??
233 `/aws/vendedlogs/b2bi/profile/${output.profileId}`;
234 yield* logs
235 .deleteLogGroup({ logGroupName })
236 .pipe(
237 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
238 );
239 // NOTE: profile creation also auto-creates the shared account-level
240 // /aws/vendedlogs/b2bi/default log group. That group is a
241 // service-managed singleton and MUST NOT be reaped here: B2BI's
242 // internal log-delivery bookkeeping references it, and deleting it
243 // opens a ~60-90s window where subsequent B2BI creates in the
244 // account fail with "Unable to perform CreateLogDelivery"
245 // (verified live with /aws/vendedlogs/b2bi/transformers).
246 }),
247
248 list: () =>
249 b2bi.listProfiles.items({}).pipe(
250 Stream.mapEffect((s) =>
251 b2bi.getProfile({ profileId: s.profileId }).pipe(
252 Effect.map(toAttrs),
253 Effect.catchTag("ResourceNotFoundException", () =>
254 Effect.succeed(undefined),
255 ),
256 ),
257 ),
258 Stream.filter((item) => item !== undefined),
259 Stream.runCollect,
260 Effect.map((chunk) => Array.from(chunk)),
261 ),
262 };
263 }),
264 );