profile-lifecycle.ts
import { readFileSync } from "node:fs";
import {
deleteProfile,
getProfile,
listProfiles,
putProfile,
reconcile,
sendBatch,
type Outcome,
type Profile,
type SyncClient,
type UpsertItem,
} from "./profile-sync.ts";
const PREFIX = "example-";
function secretKey(): string {
const file = process.env["MESSENGER_SECRET_KEY_FILE"];
const key =
file !== undefined && file !== ""
? readFileSync(file, "utf8").trim()
: (process.env["MESSENGER_SECRET_KEY"] ?? "").trim();
if (!key.startsWith("sk_")) {
throw new Error(
"Set MESSENGER_SECRET_KEY_FILE to a file that holds the site's secret key (sk_...), or MESSENGER_SECRET_KEY to the key.",
);
}
if (!key.startsWith("sk_test_")) {
throw new Error(
"This walk-through runs only against a sandbox site. Use its secret key, which starts with sk_test_.",
);
}
return key;
}
function client(): SyncClient {
const apiUrl = process.env["MESSENGER_API_URL"];
if (apiUrl === undefined || apiUrl === "") {
throw new Error(
"Set MESSENGER_API_URL to the platform's API address, such as https://api.messenger.example.",
);
}
return { apiUrl, secretKey: secretKey() };
}
class StepFailed extends Error {}
let steps = 0;
function step(
title: string,
request: string,
outcome: Outcome | string,
expected: string,
): void {
steps += 1;
const got =
typeof outcome === "string"
? outcome
: outcome.outcome === "rejected"
? `rejected, ${outcome.reason}${outcome.field === undefined ? "" : ` (field ${outcome.field})`}`
: outcome.outcome;
console.log(`${String(steps)}. ${title}\n ${request}\n => ${got}`);
if (got !== expected)
throw new StepFailed(
`step ${String(steps)} expected "${expected}" but got "${got}".`,
);
}
function lena(
version: number,
changes: Partial<Record<string, Profile["fields"][string]>> = {},
): Profile {
const fields: Record<string, Profile["fields"][string]> = {
name: { value: "Lena, yoga", visibility: "public" },
assistant_name: { value: "Lena", visibility: "public" },
headline: { value: "Hatha and yin yoga, Alfama", visibility: "public" },
classes: {
value: "Hatha yoga 60 minutes, yin yoga 75 minutes, beginners on Mondays",
visibility: "ai_only",
},
prices: {
value: "One class 14 EUR, ten classes 120 EUR",
visibility: "ai_only",
},
editor_note: {
value: "Featured in the spring newsletter",
visibility: "ai_only",
},
};
for (const [name, field] of Object.entries(changes))
if (field !== undefined) fields[name] = field;
return { version, owner: "owner-lena", fields };
}
function hugo(version: number, owner: string): UpsertItem {
return {
action: "upsert",
external_id: "example-hugo",
version,
owner,
fields: {
name: { value: "Hugo, physiotherapy", visibility: "public" },
assistant_name: { value: "Hugo", visibility: "public" },
headline: {
value: "Sports injuries and back pain, Campo de Ourique",
visibility: "public",
},
prices: {
value: "First visit 50 EUR, follow-up 40 EUR",
visibility: "ai_only",
},
},
};
}
function sofia(version: number, hours: string): UpsertItem {
return {
action: "upsert",
external_id: "example-sofia",
version,
owner: "owner-sofia",
fields: {
name: { value: "Sofia, hair studio", visibility: "public" },
assistant_name: { value: "Sofia", visibility: "public" },
headline: { value: "Cuts and colour, Graça", visibility: "public" },
opening_hours: { value: hours, visibility: "ai_only" },
},
};
}
async function walkThrough(sync: SyncClient): Promise<void> {
const v = Date.now();
step(
"A new profile",
`PUT /v1/profiles/example-lena, version ${String(v + 1)}`,
await putProfile(sync, "example-lena", lena(v + 1)),
"applied",
);
step(
"The same request again, as after a timeout",
`PUT /v1/profiles/example-lena, version ${String(v + 1)}, same content`,
await putProfile(sync, "example-lena", lena(v + 1)),
"unchanged",
);
const newPrices = {
value: "One class 15 EUR, ten classes 130 EUR",
visibility: "ai_only",
} as const;
step(
"A field changes: the prices",
`PUT /v1/profiles/example-lena, version ${String(v + 2)}`,
await putProfile(sync, "example-lena", lena(v + 2, { prices: newPrices })),
"applied",
);
step(
"An older change arrives late",
`PUT /v1/profiles/example-lena, version ${String(v + 1)}`,
await putProfile(sync, "example-lena", lena(v + 1)),
"stale",
);
const privateNote = {
value: "Featured in the spring newsletter",
visibility: "private",
} as const;
const current = lena(v + 3, {
prices: newPrices,
editor_note: privateNote,
});
step(
"A field turns private",
`PUT /v1/profiles/example-lena, version ${String(v + 3)}`,
await putProfile(sync, "example-lena", current),
"applied",
);
const copy = await getProfile(sync, "example-lena");
const kept =
copy === undefined
? "not found"
: `public: ${Object.keys(copy.fields)
.filter((name) => copy.fields[name]?.visibility === "public")
.join(", ")}; ` +
`AI-only: ${Object.keys(copy.fields)
.filter((name) => copy.fields[name]?.visibility === "ai_only")
.join(", ")}; ` +
`private, name only: ${copy.private_fields.join(", ")}`;
step(
"What the platform keeps",
"GET /v1/profiles/example-lena",
kept,
"public: assistant_name, headline, name; AI-only: classes, prices; private, name only: editor_note",
);
const reused = lena(v + 3, {
prices: newPrices,
editor_note: privateNote,
headline: { value: "Yoga for every body, Alfama", visibility: "public" },
});
step(
"A version sent again with other content",
`PUT /v1/profiles/example-lena, version ${String(v + 3)}, another headline`,
await putProfile(sync, "example-lena", reused),
"rejected, version-reused",
);
const broken = lena(v + 4, {
prices: newPrices,
editor_note: privateNote,
assistant_name: { value: "Lena\nYoga", visibility: "public" },
});
step(
"A field that breaks a rule",
`PUT /v1/profiles/example-lena, version ${String(v + 4)}, assistant_name on two lines`,
await putProfile(sync, "example-lena", broken),
"rejected, invalid-field (field assistant_name)",
);
const batch = await sendBatch(sync, [
hugo(v + 5, "owner-hugo"),
sofia(v + 5, "Tuesday to Saturday, 10:00 to 19:00"),
]);
step(
"Two new profiles in one batch",
"POST /v1/profiles/batch with example-hugo and example-sofia",
batch.map((result) => `${result.external_id} ${result.outcome}`).join(", "),
"example-hugo applied, example-sofia applied",
);
const handedOver = hugo(v + 6, "owner-joana");
step(
"A change of owner: Joana now answers for Hugo's profile",
`PUT /v1/profiles/example-hugo, version ${String(v + 6)}, owner owner-joana`,
await putProfile(sync, "example-hugo", {
version: handedOver.version,
owner: handedOver.owner,
fields: handedOver.fields,
}),
"applied",
);
const site: readonly UpsertItem[] = [
{ action: "upsert", external_id: "example-lena", ...current },
handedOver,
sofia(v + 7, "Tuesday to Sunday, 10:00 to 19:00"),
];
const held = (await listProfiles(sync)).filter((profile) =>
profile.external_id.startsWith(PREFIX),
);
const { behind, gone } = reconcile(site, held);
const resent = behind.length === 0 ? [] : await sendBatch(sync, behind);
step(
"The daily reconciliation",
`GET /v1/profiles (every page), then POST /v1/profiles/batch with what differs`,
`${String(behind.length)} behind, ${String(gone.length)} gone: ${resent.map((result) => `${result.external_id} ${result.outcome}`).join(", ")}`,
"1 behind, 0 gone: example-sofia applied",
);
step(
"A delete",
`DELETE /v1/profiles/example-sofia?version=${String(v + 8)}`,
await deleteProfile(sync, "example-sofia", v + 8),
"applied",
);
const deleted = await getProfile(sync, "example-sofia");
step(
"What a delete leaves",
"GET /v1/profiles/example-sofia",
deleted === undefined
? "not found"
: `deleted ${String(deleted.deleted)}, ${String(Object.keys(deleted.fields).length)} fields`,
"deleted true, 0 fields",
);
console.log(
"\nDone. example-lena and example-hugo stay on your site: see them on the partner console's Profiles page, with the two rejected changes. Run with --remove to delete them.",
);
}
async function remove(sync: SyncClient): Promise<void> {
const version = Date.now();
const listed = (await listProfiles(sync)).filter(
(profile) => profile.external_id.startsWith(PREFIX) && !profile.deleted,
);
const results =
listed.length === 0
? []
: await sendBatch(
sync,
listed.map((profile) => ({
action: "delete",
external_id: profile.external_id,
version,
})),
);
console.log(
`Deleted ${String(results.filter((result) => result.outcome === "applied").length)} example profiles.`,
);
}
async function main(): Promise<void> {
const sync = client();
await (process.argv.includes("--remove") ? remove(sync) : walkThrough(sync));
}
main().catch((error: unknown) => {
console.error(
`profile-lifecycle: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
});