All docs pages

profile-lifecycle.ts

// Walks through the whole life of a profile against your sandbox site, one request at a time,
// and checks each answer (sdk/docs/02-profile-lifecycle.md tells the same story). It uses three
// invented profiles, `example-lena`, `example-hugo` and `example-sofia`, and prints what each
// step sent and what the platform answered: never a field value, and never the secret key. It
// runs only with a sandbox site's secret key, sk_test_..., so it never touches a live site.
//
// Run it (Node 22.18 or later, nothing to install):
//
//   MESSENGER_API_URL=https://api.messenger.example \
//   MESSENGER_SECRET_KEY_FILE=/path/to/secret-key \
//   node profile-lifecycle.ts
//
// Running it again is safe: each run takes versions from the clock, so they are higher than the
// last run's. `node profile-lifecycle.ts --remove` deletes the example profiles it leaves behind.
import { readFileSync } from "node:fs";
import {
  deleteProfile,
  getProfile,
  listProfiles,
  putProfile,
  reconcile,
  sendBatch,
  type Outcome,
  type Profile,
  type SyncClient,
  type UpsertItem,
} from "./profile-sync.ts";

/** The ids this walk-through uses, so its reconciliation and --remove touch no other profile of your site. */
const PREFIX = "example-";

/** The site's secret key: from the file MESSENGER_SECRET_KEY_FILE names, or else MESSENGER_SECRET_KEY. */
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.",
    );
  }
  // It writes and deletes example profiles: never on a live site.
  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;

/** Prints one step, and stops the walk-through when the platform did not answer as expected. */
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}".`,
    );
}

/** Lena's profile as your site has it, at `version`. */
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",
    },
    // Sent AI-only by mistake. What the AI assistant has read stays in its reply records, so only
    // a harmless field like this one can show the flag changing.
    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> {
  // Versions from the clock: every change gets a higher one than the change before it.
  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",
  );

  // The editors' note was AI-only, so the AI assistant could read it. Now private: the platform
  // drops the value, from the next reply on.
  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",
  );

  // Your site's own list. Sofia's opening hours changed at version v + 7, but that request was lost.
  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),
  );
  // Compare as sets, by external id: send again what the platform lacks or holds at a lower
  // version, and delete what it holds that your site no longer lists. Here, nothing is gone.
  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.",
  );
}

/** Deletes every example profile still listed, in one batch, with a version from the clock. */
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;
});