← Files WixARCHIVED FILE

skills/wix-headless-templates/faq/seed/seed-faq.mjs

14.8 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// FAQ seed — a BUILD-TIME script, never shipped in the app. Run from the project root (where
// wix.config.json lives) with a plan file:
//
//   node <SKILL_ROOT>/templates/faq/seed/seed-faq.mjs plan.json
//
// It mints its own site token via the Wix CLI, installs the Wix FAQ app if needed, reads what the
// site already holds, creates the missing categories (idempotent by title, with a fresh-install
// verify-retry), creates the missing questions one at a time (idempotent by category + question text;
// the API has no bulk create), and verifies by re-querying. Prints a JSON result to stdout.
//
// Plan shape (see SEED.md):
//   { "categories": [{ "title", "questions": [{ "question", "answer", "labels"? }] }] }
//   answer: a string (plain text) | [blocks] (Ricos richContent) | { nodes: [...] } (a pre-built Ricos document, verbatim)
//   blocks: { type:"heading", text, level? } | { type:"paragraph", text }
//     | { type:"quote", text } | { type:"bulleted"|"ordered", items:[text,…] }
//
// Seeding is ADDITIVE — never deletes or overwrites existing content. A fresh FAQ install may carry
// Wix's own sample categories/questions; removing them is the owner's call, not this script's.
// Unexpected shapes → read the live API reference; every call below carries a docs: line with its
// reference page.
import { seedSiteId } from "../../shared/seed/site-context.mjs";
import { wixToken } from "../../shared/seed/wix-cli.mjs";
import { readFileSync } from "node:fs";

const API = "https://www.wixapis.com";
const FAQ_APP_ID = "14c92d28-031e-7910-c9a8-a670011e062d";
const D = "https://dev.wix.com/docs/rest/business-management/faq-app/faq";
/** The API's page cap (CursorPaging.limit max 100). */
const PAGE_LIMIT = 100;
/** sortOrder step — increments of 10 leave room to insert between items later (the reference's own tip). */
const SORT_STEP = 10;

export function makeCtx({ cwd = process.cwd() } = {}) {
  // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
  // a seed there unless --allow-parent is passed after the user confirmed).
  const siteId = seedSiteId({ cwd, argv: process.argv });
  const token = wixToken(siteId, cwd);
  return { token, siteId };
}

async function req(ctx, path, { method = "POST", body } = {}) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${ctx.token}`,
      "wix-site-id": ctx.siteId,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 400)}`);
  return json;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// A fresh site's FAQ backend can transiently fail its FIRST calls while provisioning (403, 404 before
// the app instance exists, 5xx). Retry the same body ONCE after ~3s, then fail loud (never loop).
async function reqRetryOnce(ctx, path, opts) {
  try {
    return await req(ctx, path, opts);
  } catch (e) {
    if (/-> (403|404|5\d\d):/.test(String(e.message))) {
      await sleep(3000);
      return req(ctx, path, opts);
    }
    throw e;
  }
}

// ---- Ricos richContent builder (the blog seed's, copied) ------------------------------------------
// Rules baked in: TEXT is always a leaf inside a container; BLOCKQUOTE / LIST_ITEM wrap a PARAGRAPH;
// BULLETED_LIST / ORDERED_LIST wrap LIST_ITEM -> PARAGRAPH -> TEXT; every container node gets a unique
// id, TEXT leaves use id "". For node types not covered here pass a pre-built `answer: { nodes }`.
const mkText = (text) => ({ type: "TEXT", id: "", nodes: [], textData: { text: text || "", decorations: [] } });
const mkParagraph = (id, text) => ({ type: "PARAGRAPH", id, nodes: [mkText(text)], paragraphData: {} });

export function mkRichContent(blocks = [], idx = 0) {
  let n = 0;
  const id = () => `q${idx}-n${n++}`;
  const nodes = [];
  for (const b of blocks) {
    switch (b.type) {
      case "heading":
        nodes.push({ type: "HEADING", id: id(), nodes: [mkText(b.text)], headingData: { level: b.level ?? 2 } });
        break;
      case "quote":
        nodes.push({ type: "BLOCKQUOTE", id: id(), nodes: [mkParagraph(id(), b.text)], blockquoteData: { indentation: 1 } });
        break;
      case "bulleted":
      case "ordered": {
        const listType = b.type === "bulleted" ? "BULLETED_LIST" : "ORDERED_LIST";
        nodes.push({
          type: listType, id: id(),
          nodes: (b.items ?? []).map((item) => ({ type: "LIST_ITEM", id: id(), nodes: [mkParagraph(id(), item)] })),
        });
        break;
      }
      case "paragraph":
      default:
        nodes.push(mkParagraph(id(), b.text));
    }
  }
  return { nodes };
}

// A plan answer → the entity's answer field. The answer is a oneof: exactly one of plainText |
// richContent | draftjs is sent.
function answerField(answer, idx) {
  if (typeof answer === "string") return { plainText: answer };
  if (Array.isArray(answer)) return { richContent: mkRichContent(answer, idx) };
  if (answer && typeof answer === "object" && Array.isArray(answer.nodes)) return { richContent: answer };
  throw new Error(`question ${idx}: "answer" must be a string, an array of blocks, or a Ricos { nodes } object`);
}

const rawId = (x) => x?.id ?? x?._id ?? null;
const norm = (s) => String(s ?? "").trim().toLowerCase().replace(/\s+/g, " ");

// ---- operations ----------------------------------------------------------------------------------

// Idempotent — re-installing an installed app is fine.
// docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
export async function installFaqApp(ctx) {
  try {
    await req(ctx, "/apps-installer-service/v1/app-instance/install", { body: {
      tenant: { tenantType: "SITE", id: ctx.siteId },
      appInstance: { appDefId: FAQ_APP_ID, enabled: true },
    } });
  } catch {
    /* already installed is fine */
  }
}

// Existing categories, straight from the query (the source of truth for what persisted).
// docs: https://dev.wix.com/docs/rest/business-management/faq-app/faq/category-v2/query-categories.md
export async function readCategories(ctx) {
  const r = await reqRetryOnce(ctx, "/faq/v2/categories/query", { body: { query: { cursorPaging: { limit: PAGE_LIMIT } } } });
  return (r.categories ?? []).map((c) => ({ id: rawId(c), title: c.title ?? "", sortOrder: typeof c.sortOrder === "number" ? c.sortOrder : null }));
}

// Every existing question (paged by cursor; PLAIN_TEXT keeps the read light — only the text is compared).
// docs: https://dev.wix.com/docs/rest/business-management/faq-app/faq/question-entry-v2/query-question-entries.md
export async function readQuestions(ctx) {
  const out = [];
  let cursor = null;
  do {
    const query = cursor ? { cursorPaging: { limit: PAGE_LIMIT, cursor } } : { cursorPaging: { limit: PAGE_LIMIT } };
    // No contentFormat: the default read. Asking for PLAIN_TEXT made entries stored as rich content
    // drop out of the page while the platform converted them, so a re-run did not see them and created
    // them again (run 153). Only `question`, `categoryId` and `sortOrder` are read here.
    const r = await reqRetryOnce(ctx, "/faq/v2/question-entries/query", { body: { query } });
    for (const q of r.questionEntries ?? []) out.push({ id: rawId(q), question: q.question ?? "", categoryId: q.categoryId ?? "", sortOrder: q.sortOrder ?? null });
    cursor = r.pagingMetadata?.hasNext ? (r.pagingMetadata?.cursors?.next ?? null) : null;
  } while (cursor);
  return out;
}

// Create the missing categories resiliently. TWO hazards this absorbs:
//  1. Fresh-install provisioning window — a create can answer 200 with an id that does not persist.
//     So the create response is never trusted — re-query, treat the query as truth, re-create what's
//     still missing until it sticks (8 attempts, 1.5 s apart).
//  2. Idempotency — an already-present title (a partial-failure re-run, Wix's sample content) is kept.
// New categories append after the existing ones: sortOrder = max(existing) + 10, +20, …
// Body is NESTED: { category: { title, sortOrder } } → { category: { id, … } }.
// docs: https://dev.wix.com/docs/rest/business-management/faq-app/faq/category-v2/create-category.md
export async function ensureCategories(ctx, titles) {
  let existing = await readCategories(ctx);
  const byTitle = () => new Map(existing.map((c) => [norm(c.title), c]));
  const created = new Set();
  for (let attempt = 0; attempt < 8; attempt++) {
    const map = byTitle();
    const missing = titles.filter((t) => !map.has(norm(t)));
    if (!missing.length) break;
    if (attempt) await sleep(1500); // backoff only between retries — the happy path pays nothing
    // Never 0: a zero sortOrder is dropped on the wire (the proto default) and the category then sorts as unnumbered.
    const base = Math.max(0, ...existing.map((c) => c.sortOrder ?? 0));
    for (const [i, title] of missing.entries()) {
      try {
        await req(ctx, "/faq/v2/categories", { body: { category: { title, sortOrder: base + SORT_STEP * (i + 1) } } });
        created.add(norm(title));
      } catch (e) {
        if (!String(e.message).includes("-> 409")) throw e; // 409 = raced, already there
      }
    }
    existing = await readCategories(ctx);
  }
  const map = byTitle();
  return titles.map((title) => {
    const c = map.get(norm(title));
    if (!c?.id) throw new Error(`category "${title}" did not persist after 8 attempts — retry the seed`);
    return { id: c.id, title, created: created.has(norm(title)) };
  });
}

// One question, one call (the SDK exports no bulk create). Body is NESTED: { questionEntry: { … } } →
// { questionEntry: { id, slug, … } }. `question` and `categoryId` are required; the answer is a oneof.
// docs: https://dev.wix.com/docs/rest/business-management/faq-app/faq/question-entry-v2/create-question-entry.md
export async function createQuestion(ctx, { question, categoryId, sortOrder, labels, answer }, idx) {
  const body = {
    questionEntry: {
      question,
      categoryId,
      sortOrder,
      ...(labels?.length ? { labels: labels.map((title, i) => ({ title, sortOrder: i })) } : {}),
      ...answerField(answer, idx),
    },
  };
  const r = await req(ctx, "/faq/v2/question-entries", { body });
  return { id: rawId(r.questionEntry), slug: r.questionEntry?.slug ?? null };
}

/**
 * ONE-CALL seed: install → read existing → categories (idempotent by title) → questions (idempotent
 * by category + question text, one at a time, in plan order) → verify by re-query. The default path.
 */
export async function setupFaq(ctx, { categories = [] } = {}) {
  if (!categories.length) throw new Error("plan.categories is empty — nothing to seed");
  await installFaqApp(ctx);
  await sleep(3000); // let a fresh FAQ install settle so the first writes stick (ensureCategories verifies anyway)

  // What the site held BEFORE this seed and the plan does not name: on a fresh install that is Wix's
  // sample content ("General", "Setting up FAQs", …), which the live page shows above the owner's
  // questions. Reported, never touched: this seed deletes nothing on a site, ever. The owner removes
  // what they do not want in the dashboard; the closing message tells them it is there and where.
  const planTitles = new Set(categories.map((c) => norm(c.title)));
  const preexisting = (await readCategories(ctx)).filter((c) => !planTitles.has(norm(c.title)));
  const preexistingQuestions = preexisting.length ? (await readQuestions(ctx)).filter((q) => preexisting.some((c) => c.id === q.categoryId)) : [];

  const cats = await ensureCategories(ctx, categories.map((c) => c.title));
  const catId = new Map(cats.map((c) => [norm(c.title), c.id]));

  const existing = await readQuestions(ctx);
  const seen = new Set(existing.map((q) => `${q.categoryId}\n${norm(q.question)}`));
  const maxSort = new Map();
  for (const q of existing) if (typeof q.sortOrder === "number") maxSort.set(q.categoryId, Math.max(maxSort.get(q.categoryId) ?? 0, q.sortOrder));

  const created = [];
  const skipped = [];
  const failed = [];
  let idx = 0;
  for (const c of categories) {
    const categoryId = catId.get(norm(c.title));
    let next = Math.max(0, maxSort.get(categoryId) ?? 0) + SORT_STEP; // never 0 (dropped on the wire)
    for (const q of c.questions ?? []) {
      idx++;
      const key = `${categoryId}\n${norm(q.question)}`;
      if (seen.has(key)) { skipped.push({ category: c.title, question: q.question }); continue; }
      try {
        const r = await createQuestion(ctx, { question: q.question, categoryId, sortOrder: next, labels: q.labels, answer: q.answer }, idx);
        created.push({ category: c.title, question: q.question, id: r.id, slug: r.slug });
        seen.add(key);
        next += SORT_STEP;
      } catch (e) {
        if (String(e.message).includes("-> 409")) { skipped.push({ category: c.title, question: q.question }); continue; }
        failed.push({ category: c.title, question: q.question, error: String(e.message).slice(0, 300) });
      }
    }
  }

  // A 200 on create does NOT prove persistence — re-query and count per category.
  const after = await readQuestions(ctx);
  const counts = new Map();
  for (const q of after) counts.set(q.categoryId, (counts.get(q.categoryId) ?? 0) + 1);
  return {
    categories: cats.map((c) => ({ id: c.id, title: c.title, created: c.created, questions: counts.get(c.id) ?? 0 })),
    questionsCreated: created.length,
    questionsSkipped: skipped.length,
    questionsFailed: failed.length,
    created,
    skipped,
    failed,
    questionsOnSite: after.length,
    // Content the plan did not name (Wix's install samples on a fresh site, or the owner's own on an
    // existing one): the closing message names what is on the page and where the owner removes it.
    preexisting: preexisting.map((c) => ({ id: c.id, title: c.title, questions: preexistingQuestions.filter((q) => q.categoryId === c.id).length })),
    dashboardUrl: `https://manage.wix.com/dashboard/${ctx.siteId}/app/${FAQ_APP_ID}`,
    docs: [`${D}/category-v2/create-category`, `${D}/question-entry-v2/create-question-entry`, `${D}/question-entry-v2/query-question-entries`],
  };
}

// ---- CLI entry ----------------------------------------------------------------------------------

const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop());
if (invokedDirectly) {
  const planPath = process.argv[2];
  if (!planPath) {
    console.error("usage: node seed-faq.mjs <plan.json>   (run from the project root)");
    process.exit(1);
  }
  const plan = JSON.parse(readFileSync(planPath, "utf8"));
  const ctx = makeCtx();
  setupFaq(ctx, plan)
    .then((result) => {
      console.log(JSON.stringify(result, null, 2));
      if (result.questionsFailed) process.exit(1);
    })
    .catch((e) => {
      console.error(e.message);
      process.exit(1);
    });
}

SHA-256: 994073532a5a9cc95a96ecfb3d2d0cf891190bf679e5eeb1b986c398918b70c5