Setting the file. One moment.
Seed Faq · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page export async function installFaqApp
— line 125
This file
Number 16.383
Position 383 of 828
Type JavaScript
Size 15 KB
Lines 303 faq/seed/ seed-faq.mjs
JavaScript · 303 lines · 15 KB
14 // blocks: { type:"heading", text, level? } | { type:"paragraph", text }
15 // | { type:"quote", text } | { type:"bulleted"|"ordered", items:[text,…] }
16 //
17 // Seeding is ADDITIVE — never deletes or overwrites existing content. A fresh FAQ install may carry
18 // Wix's own sample categories/questions; removing them is the owner's call, not this script's.
19 // Unexpected shapes → read the live API reference; every call below carries a docs: line with its
20 // reference page.
21 import { seedSiteId } from "../../shared/seed/site-context.mjs" ;
22 import { wixToken } from "../../shared/seed/wix-cli.mjs" ;
23 import { isMain } from "../../shared/seed/main.mjs" ;
24 import { readFileSync } from "node:fs" ;
25
26 const API = "https://www.wixapis.com" ;
27 const FAQ_APP_ID = "14c92d28-031e-7910-c9a8-a670011e062d" ;
28 const D = "https://dev.wix.com/docs/api-reference/business-management/faq-app" ;
29 /** The API's page cap (CursorPaging.limit max 100). */
30 const PAGE_LIMIT = 100 ;
31 /** sortOrder step — increments of 10 leave room to insert between items later (the reference's own tip). */
32 const SORT_STEP = 10 ;
33
34 export function makeCtx ({ cwd = process. cwd () } = {}) {
35 // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
36 // a seed there unless --allow-parent is passed after the user confirmed).
37 const siteId = seedSiteId ({ cwd, argv: process.argv });
38 const token = wixToken (siteId, cwd);
39 return { token, siteId };
40 }
41
42 async function req ( ctx , path , { method = "POST" , body } = {}) {
43 const res = await fetch ( API + path, {
44 method,
45 headers: {
46 Authorization: `Bearer ${ ctx . token }` ,
47 "wix-site-id" : ctx.siteId,
48 "Content-Type" : "application/json" ,
49 },
50 body: body ? JSON . stringify (body) : undefined ,
51 });
52 const json = await res. json (). catch (() => ({}));
53 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
54 return json;
55 }
56
57 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
58
59 // A fresh site's FAQ backend can transiently fail its FIRST calls while provisioning (403, 404 before
60 // the app instance exists, 5xx). Retry the same body ONCE after ~3s, then fail loud (never loop).
61 async function reqRetryOnce ( ctx , path , opts ) {
62 try {
63 return await req (ctx, path, opts);
64 } catch (e) {
65 if ( /-> (403 | 404 | 5 \d\d ):/ . test ( String (e.message))) {
66 await sleep ( 3000 );
67 return req (ctx, path, opts);
68 }
69 throw e;
70 }
71 }
72
73 // ---- Ricos richContent builder (the blog seed's, copied) ------------------------------------------
74 // Rules baked in: TEXT is always a leaf inside a container; BLOCKQUOTE / LIST_ITEM wrap a PARAGRAPH;
75 // BULLETED_LIST / ORDERED_LIST wrap LIST_ITEM -> PARAGRAPH -> TEXT; every container node gets a unique
76 // id, TEXT leaves use id "". For node types not covered here pass a pre-built `answer: { nodes }`.
77 const mkText = ( text ) => ({ type: "TEXT" , id: "" , nodes: [], textData: { text: text || "" , decorations: [] } });
78 const mkParagraph = ( id , text ) => ({ type: "PARAGRAPH" , id, nodes: [ mkText (text)], paragraphData: {} });
79
80 export function mkRichContent ( blocks = [], idx = 0 ) {
81 let n = 0 ;
82 const id = () => `q${ idx }-n${ n ++ }` ;
83 const nodes = [];
84 for ( const b of blocks) {
85 switch (b.type) {
86 case "heading" :
87 nodes. push ({ type: "HEADING" , id: id (), nodes: [ mkText (b.text)], headingData: { level: b.level ?? 2 } });
88 break ;
89 case "quote" :
90 nodes. push ({ type: "BLOCKQUOTE" , id: id (), nodes: [ mkParagraph ( id (), b.text)], blockquoteData: { indentation: 1 } });
91 break ;
92 case "bulleted" :
93 case "ordered" : {
94 const listType = b.type === "bulleted" ? "BULLETED_LIST" : "ORDERED_LIST" ;
95 nodes. push ({
96 type: listType, id: id (),
97 nodes: (b.items ?? []). map (( item ) => ({ type: "LIST_ITEM" , id: id (), nodes: [ mkParagraph ( id (), item)] })),
98 });
99 break ;
100 }
101 case "paragraph" :
102 default :
103 nodes. push ( mkParagraph ( id (), b.text));
104 }
105 }
106 return { nodes };
107 }
108
109 // A plan answer → the entity's answer field. The answer is a oneof: exactly one of plainText |
110 // richContent | draftjs is sent.
111 function answerField ( answer , idx ) {
112 if ( typeof answer === "string" ) return { plainText: answer };
113 if (Array. isArray (answer)) return { richContent: mkRichContent (answer, idx) };
114 if (answer && typeof answer === "object" && Array. isArray (answer.nodes)) return { richContent: answer };
115 throw new Error ( `question ${ idx }: "answer" must be a string, an array of blocks, or a Ricos { nodes } object` );
116 }
117
118 const rawId = ( x ) => x?.id ?? x?._id ?? null ;
119 const norm = ( s ) => String (s ?? "" ). trim (). toLowerCase (). replace ( / \s + / g , " " );
120
121 // ---- operations ----------------------------------------------------------------------------------
122
123 // Idempotent — re-installing an installed app is fine.
124 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
125 export async function installFaqApp ( ctx ) {
126 try {
127 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
128 tenant: { tenantType: "SITE" , id: ctx.siteId },
129 appInstance: { appDefId: FAQ_APP_ID , enabled: true },
130 } });
131 } catch {
132 /* already installed is fine */
133 }
134 }
135
136 // Existing categories, straight from the query (the source of truth for what persisted).
137 // docs: https://dev.wix.com/docs/api-reference/business-management/faq-app/category-v2/query-categories.md
138 export async function readCategories ( ctx ) {
139 const r = await reqRetryOnce (ctx, "/faq/v2/categories/query" , { body: { query: { cursorPaging: { limit: PAGE_LIMIT } } } });
140 return (r.categories ?? []). map (( c ) => ({ id: rawId (c), title: c.title ?? "" , sortOrder: typeof c.sortOrder === "number" ? c.sortOrder : null }));
141 }
142
143 // Every existing question (paged by cursor; PLAIN_TEXT keeps the read light — only the text is compared).
144 // docs: https://dev.wix.com/docs/api-reference/business-management/faq-app/question-entry-v2/query-question-entries.md
145 export async function readQuestions ( ctx ) {
146 const out = [];
147 let cursor = null ;
148 do {
149 const query = cursor ? { cursorPaging: { limit: PAGE_LIMIT , cursor } } : { cursorPaging: { limit: PAGE_LIMIT } };
150 // No contentFormat: the default read. Asking for PLAIN_TEXT made entries stored as rich content
151 // drop out of the page while the platform converted them, so a re-run did not see them and created
152 // them again (run 153). Only `question`, `categoryId` and `sortOrder` are read here.
153 const r = await reqRetryOnce (ctx, "/faq/v2/question-entries/query" , { body: { query } });
154 for ( const q of r.questionEntries ?? []) out. push ({ id: rawId (q), question: q.question ?? "" , categoryId: q.categoryId ?? "" , sortOrder: q.sortOrder ?? null });
155 cursor = r.pagingMetadata?.hasNext ? (r.pagingMetadata?.cursors?.next ?? null ) : null ;
156 } while (cursor);
157 return out;
158 }
159
160 // Create the missing categories resiliently. TWO hazards this absorbs:
161 // 1. Fresh-install provisioning window — a create can answer 200 with an id that does not persist.
162 // So the create response is never trusted — re-query, treat the query as truth, re-create what's
163 // still missing until it sticks (8 attempts, 1.5 s apart).
164 // 2. Idempotency — an already-present title (a partial-failure re-run, Wix's sample content) is kept.
165 // New categories append after the existing ones: sortOrder = max(existing) + 10, +20, …
166 // Body is NESTED: { category: { title, sortOrder } } → { category: { id, … } }.
167 // docs: https://dev.wix.com/docs/api-reference/business-management/faq-app/category-v2/create-category.md
168 export async function ensureCategories ( ctx , titles ) {
169 let existing = await readCategories (ctx);
170 const byTitle = () => new Map (existing. map (( c ) => [ norm (c.title), c]));
171 const created = new Set ();
172 for ( let attempt = 0 ; attempt < 8 ; attempt ++ ) {
173 const map = byTitle ();
174 const missing = titles. filter (( t ) => ! map. has ( norm (t)));
175 if ( ! missing. length ) break ;
176 if (attempt) await sleep ( 1500 ); // backoff only between retries — the happy path pays nothing
177 // Never 0: a zero sortOrder is dropped on the wire (the proto default) and the category then sorts as unnumbered.
178 const base = Math. max ( 0 , ... existing. map (( c ) => c.sortOrder ?? 0 ));
179 for ( const [ i , title ] of missing. entries ()) {
180 try {
181 await req (ctx, "/faq/v2/categories" , { body: { category: { title, sortOrder: base + SORT_STEP * (i + 1 ) } } });
182 created. add ( norm (title));
183 } catch (e) {
184 if ( ! String (e.message). includes ( "-> 409" )) throw e; // 409 = raced, already there
185 }
186 }
187 existing = await readCategories (ctx);
188 }
189 const map = byTitle ();
190 return titles. map (( title ) => {
191 const c = map. get ( norm (title));
192 if ( ! c?.id) throw new Error ( `category "${ title }" did not persist after 8 attempts — retry the seed` );
193 return { id: c.id, title, created: created. has ( norm (title)) };
194 });
195 }
196
197 // One question, one call (the SDK exports no bulk create). Body is NESTED: { questionEntry: { … } } →
198 // { questionEntry: { id, slug, … } }. `question` and `categoryId` are required; the answer is a oneof.
199 // docs: https://dev.wix.com/docs/api-reference/business-management/faq-app/question-entry-v2/create-question-entry.md
200 export async function createQuestion ( ctx , { question , categoryId , sortOrder , labels , answer }, idx ) {
201 const body = {
202 questionEntry: {
203 question,
204 categoryId,
205 sortOrder,
206 ... (labels?. length ? { labels: labels. map (( title , i ) => ({ title, sortOrder: i })) } : {}),
207 ... answerField (answer, idx),
208 },
209 };
210 const r = await req (ctx, "/faq/v2/question-entries" , { body });
211 return { id: rawId (r.questionEntry), slug: r.questionEntry?.slug ?? null };
212 }
213
214 /**
215 * ONE-CALL seed: install → read existing → categories (idempotent by title) → questions (idempotent
216 * by category + question text, one at a time, in plan order) → verify by re-query. The default path.
217 */
218 export async function setupFaq ( ctx , { categories = [] } = {}) {
219 if ( ! categories. length ) throw new Error ( "plan.categories is empty — nothing to seed" );
220 await installFaqApp (ctx);
221 await sleep ( 3000 ); // let a fresh FAQ install settle so the first writes stick (ensureCategories verifies anyway)
222
223 // What the site held BEFORE this seed and the plan does not name: on a fresh install that is Wix's
224 // sample content ("General", "Setting up FAQs", …), which the live page shows above the owner's
225 // questions. Reported, never touched: this seed deletes nothing on a site, ever. The owner removes
226 // what they do not want in the dashboard; the closing message tells them it is there and where.
227 const planTitles = new Set (categories. map (( c ) => norm (c.title)));
228 const preexisting = ( await readCategories (ctx)). filter (( c ) => ! planTitles. has ( norm (c.title)));
229 const preexistingQuestions = preexisting. length ? ( await readQuestions (ctx)). filter (( q ) => preexisting. some (( c ) => c.id === q.categoryId)) : [];
230
231 const cats = await ensureCategories (ctx, categories. map (( c ) => c.title));
232 const catId = new Map (cats. map (( c ) => [ norm (c.title), c.id]));
233
234 const existing = await readQuestions (ctx);
235 const seen = new Set (existing. map (( q ) => `${ q . categoryId } \n ${ norm ( q . question ) }` ));
236 const maxSort = new Map ();
237 for ( const q of existing) if ( typeof q.sortOrder === "number" ) maxSort. set (q.categoryId, Math. max (maxSort. get (q.categoryId) ?? 0 , q.sortOrder));
238
239 const created = [];
240 const skipped = [];
241 const failed = [];
242 let idx = 0 ;
243 for ( const c of categories) {
244 const categoryId = catId. get ( norm (c.title));
245 let next = Math. max ( 0 , maxSort. get (categoryId) ?? 0 ) + SORT_STEP ; // never 0 (dropped on the wire)
246 for ( const q of c.questions ?? []) {
247 idx ++ ;
248 const key = `${ categoryId } \n ${ norm ( q . question ) }` ;
249 if (seen. has (key)) { skipped. push ({ category: c.title, question: q.question }); continue ; }
250 try {
251 const r = await createQuestion (ctx, { question: q.question, categoryId, sortOrder: next, labels: q.labels, answer: q.answer }, idx);
252 created. push ({ category: c.title, question: q.question, id: r.id, slug: r.slug });
253 seen. add (key);
254 next += SORT_STEP ;
255 } catch (e) {
256 if ( String (e.message). includes ( "-> 409" )) { skipped. push ({ category: c.title, question: q.question }); continue ; }
257 failed. push ({ category: c.title, question: q.question, error: String (e.message). slice ( 0 , 300 ) });
258 }
259 }
260 }
261
262 // A 200 on create does NOT prove persistence — re-query and count per category.
263 const after = await readQuestions (ctx);
264 const counts = new Map ();
265 for ( const q of after) counts. set (q.categoryId, (counts. get (q.categoryId) ?? 0 ) + 1 );
266 return {
267 categories: cats. map (( c ) => ({ id: c.id, title: c.title, created: c.created, questions: counts. get (c.id) ?? 0 })),
268 questionsCreated: created. length ,
269 questionsSkipped: skipped. length ,
270 questionsFailed: failed. length ,
271 created,
272 skipped,
273 failed,
274 questionsOnSite: after. length ,
275 // Content the plan did not name (Wix's install samples on a fresh site, or the owner's own on an
276 // existing one): the closing message names what is on the page and where the owner removes it.
277 preexisting: preexisting. map (( c ) => ({ id: c.id, title: c.title, questions: preexistingQuestions. filter (( q ) => q.categoryId === c.id). length })),
278 dashboardUrl: `https://manage.wix.com/dashboard/${ ctx . siteId }/app/${ FAQ_APP_ID }` ,
279 docs: [ `${ D }/category-v2/create-category` , `${ D }/question-entry-v2/create-question-entry` , `${ D }/question-entry-v2/query-question-entries` ],
280 };
281 }
282
283 // ---- CLI entry ----------------------------------------------------------------------------------
284
285 const invokedDirectly = isMain ( import . meta .url);
286 if (invokedDirectly) {
287 const planPath = process.argv[ 2 ];
288 if ( ! planPath) {
289 console. error ( "usage: node seed-faq.mjs <plan.json> (run from the project root)" );
290 process. exit ( 1 );
291 }
292 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
293 const ctx = makeCtx ();
294 setupFaq (ctx, plan)
295 . then (( result ) => {
296 console. log ( JSON . stringify (result, null , 2 ));
297 if (result.questionsFailed) process. exit ( 1 );
298 })
299 . catch (( e ) => {
300 console. error (e.message);
301 process. exit ( 1 );
302 });
303 }