Setting the file. One moment.
Blog Author Attribution · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
lib/blog-author-attribution.js
lib/ blog-author-attribution.js
JavaScript · 164 lines · 10 KB
14 //
15 // The sanctioned fix, verified directly against Wix's own documentation -- the internal skill
16 // doc "How to Create Blog Posts" (dev.wix.com/docs/api-reference/business-solutions/blog/skills/
17 // how-to-create-blog-posts, "Part 0: Get an Author/Member ID") -- is to CREATE a member when
18 // there is no existing member to reuse for an author: its `profile.nickname` (derived from
19 // `contact.firstName`) is the name the blog displays as the author, and "If the user named an
20 // author, use their name." The same doc, and wix-writers.js's own VERIFIED notes near
21 // `createMember`, confirm Create Member sends no email and fires no signup-automation trigger --
22 // member creation here is silent, not a notification risk.
23 //
24 // This creates ONE member per distinct SOURCE AUTHOR, never one per post: callers must resolve
25 // through a crosswalk keyed by `sourceAuthorId` (see the `crosswalk` param below) so two posts by
26 // the same WordPress author resolve to the same Wix member, and a resumed/re-run import never
27 // creates a second member for an author it already provisioned. Recording that crosswalk row
28 // after a successful create is the CALLER's responsibility (mirrors `customer-member.js` /
29 // `member-crosswalk.js`'s own ensure-then-persist split for the order-buyer member pass) --
30 // this module only resolves and creates; see rp-import-codegen/SKILL.md's "Blog post author
31 // attribution" section for the generated call site that does both.
32 //
33 // Not every migration source populates author info on every post (`_embedded.author` is a
34 // WordPress `_embed` convenience, not a guarantee across every source platform this toolkit
35 // supports) -- resolution falls back to the existing site-owner attribution when a post simply
36 // has no source author data, rather than making author resolution a hard requirement that blocks
37 // import.
38
39 const w = require ( './wix-writers.js' );
40
41 const OUTCOMES = Object. freeze ([
42 'blog-author-reconciled-existing' , // crosswalk already had a member for this source author
43 'blog-author-created' , // no crosswalk row for this source author; a new member was created
44 'blog-author-owner-fallback' , // post carries no source author info at all; owner attribution
45 ]);
46
47 // RFC 2606 reserves `.invalid` for addresses guaranteed never to resolve or be deliverable --
48 // unlike `example.com`/`example.org`, which some mail systems do attempt to resolve, `.invalid`
49 // is the correct TLD for a placeholder address that must never receive mail. The local part
50 // carries ONLY the opaque source author id (never the author's real name or any other PII) --
51 // the whole point of this fix is that the human's NAME is what is supposed to show up as
52 // "posted by X" on the blog, deliberately, via `profile.nickname`/`contact.firstName`; the
53 // placeholder address that makes the member creatable carries none of it. The domain is scoped
54 // per `siteId` so the SAME source author id across two different projects' migrations can never
55 // collide into the same placeholder address -- loginEmail uniqueness is per-site in Wix Members,
56 // so this costs nothing and removes any cross-project ambiguity from the scheme.
57 //
58 // Pattern: `blog-author-{hash(sourceAuthorId)}@{hash(siteId)}.invalid` (see the collision fix
59 // below for why each component is a hash of the raw value rather than the raw value itself).
60 //
61 // COLLISION FIX: an earlier version of this function
62 // derived the token via a lossy, human-readable sanitization (lowercase, then collapse every run
63 // of non-alphanumeric characters to a single `-`). That is not injective: two different raw
64 // values that differ only in WHICH separator character they use collapse to the identical token
65 // -- e.g. sourceAuthorId `'1-2'` and `'1@2'` both sanitized to `'1-2'`, so two genuinely distinct
66 // WordPress authors would have collided onto the SAME placeholder `loginEmail` and therefore the
67 // SAME Wix member, silently misattributing one author's posts to the other. The fix derives the
68 // token from a hash of the RAW, un-sanitized value instead: distinct inputs can never collide
69 // (short of a SHA-1 collision). This token is never exposed as user-facing content -- it only
70 // ever appears inside the `.invalid` placeholder email's local part; the real, human-readable
71 // author name goes solely into `profile.nickname`/`contact.firstName` (see
72 // `resolveBlogAuthorMemberId` below) -- so there is no readability requirement to trade off here.
73 function sanitizeForEmailToken ( value ) {
74 const raw = value === undefined || value === null ? '' : String (value);
75 return require ( 'node:crypto' ). createHash ( 'sha1' ). update (raw). digest ( 'hex' ). slice ( 0 , 16 );
76 }
77
78 function buildPlaceholderAuthorEmail ({ sourceAuthorId , siteId }) {
79 if (sourceAuthorId === undefined || sourceAuthorId === null || sourceAuthorId === '' ) {
80 throw new Error ( 'buildPlaceholderAuthorEmail requires sourceAuthorId' );
81 }
82 const authorToken = sanitizeForEmailToken (sourceAuthorId);
83 const siteToken = sanitizeForEmailToken (siteId || 'site' );
84 return `blog-author-${ authorToken }@${ siteToken }.invalid` ;
85 }
86
87 // CORRECTED: this module previously paced its own Create
88 // Member write against a module-private `lastAuthorMemberWriteAt` clock, entirely disjoint from
89 // `ensureMember`'s own `lastMemberWriteAt` in wix-writers.js -- the two never saw each other's
90 // writes despite sharing the same `MEMBER_WRITE_MIN_INTERVAL_MS` constant. wix-writers.js's own
91 // comment documents the >=1s Create Member floor as a constraint on the Wix API endpoint
92 // itself, not on whichever function happens to call it, and rp-import-codegen/SKILL.md's
93 // generated importers run passes sequentially in ONE process -- so a migration's member
94 // pass (`ensureMember`) and blog-author pass (`resolveBlogAuthorMemberId`) could issue two
95 // Create Member calls under 1s apart. Fixed by going through wix-writers.js's exported
96 // `pacedCreateMember`, which reads and stamps the SAME clock `ensureMember` uses, so every
97 // Create Member call in the process -- regardless of which pass issues it -- is correctly
98 // spaced against every other one.
99 if ( typeof w.pacedCreateMember !== 'function' ) {
100 throw new Error ( 'blog-author-attribution.js requires wix-writers.js to export pacedCreateMember' );
101 }
102
103 // wix: the Wix API client (wix-writers.js's createWixClient()).
104 // sourceAuthorId / sourceAuthorName: pulled straight from the source post's author reference
105 // (e.g. WordPress `_embedded.author[0].id`/`.name`) -- null/undefined/'' means the source gave
106 // no author for this post at all.
107 // crosswalk: an object exposing async get(sourceAuthorId) -> { memberId, loginEmail } | null,
108 // such as `member-crosswalk.js`'s createProjectMemberCrosswalk({ projectDir, sourceSystem,
109 // sourceEntityType: 'blog-author' }). Required whenever sourceAuthorId is present -- this is
110 // what makes repeat resolution of the same author idempotent across resumed runs.
111 // siteId: folded into the placeholder email's domain (see buildPlaceholderAuthorEmail).
112 // ownerMemberId: the existing single-owner attribution fallback, used only when the source gives
113 // no author info for this post.
114 async function resolveBlogAuthorMemberId ( wix , {
115 sourceAuthorId = null ,
116 sourceAuthorName = null ,
117 crosswalk = null ,
118 siteId = null ,
119 ownerMemberId = null ,
120 sleep = ( ms ) => new Promise (( resolve ) => setTimeout (resolve, ms)),
121 } = {}) {
122 if (sourceAuthorId === null || sourceAuthorId === undefined || sourceAuthorId === '' ) {
123 if ( ! ownerMemberId) {
124 throw new Error ( 'resolveBlogAuthorMemberId: post has no source author and no ownerMemberId fallback was supplied' );
125 }
126 return { outcome: 'blog-author-owner-fallback' , memberId: String (ownerMemberId), created: false };
127 }
128 if ( ! crosswalk || typeof crosswalk.get !== 'function' ) {
129 throw new Error ( 'resolveBlogAuthorMemberId: a post with a source author requires a crosswalk with an async get(sourceAuthorId) lookup' );
130 }
131
132 const existing = await crosswalk. get (sourceAuthorId);
133 if (existing && existing.memberId) {
134 return { outcome: 'blog-author-reconciled-existing' , memberId: String (existing.memberId), created: false };
135 }
136
137 const displayName = (sourceAuthorName && String (sourceAuthorName). trim ()) || `Author ${ sourceAuthorId }` ;
138 const loginEmail = buildPlaceholderAuthorEmail ({ sourceAuthorId, siteId });
139
140 const member = await w. pacedCreateMember (wix, { email: loginEmail, name: displayName }, sleep);
141 // createMember only ever skips on an empty email (buildCreateMemberRequest's own guard);
142 // buildPlaceholderAuthorEmail never produces one, so reaching `skipped` means a caller bypassed
143 // it -- surfaced as an error rather than silently falling through to the owner.
144 if (member && member.skipped) {
145 throw new Error ( `resolveBlogAuthorMemberId: member creation was skipped unexpectedly: ${ member . reason }` );
146 }
147 if ( ! member || ! member.id) {
148 throw new Error ( 'resolveBlogAuthorMemberId: Create Member returned no id' );
149 }
150 return {
151 outcome: 'blog-author-created' ,
152 memberId: String (member.id),
153 created: true ,
154 loginEmail,
155 displayName,
156 };
157 }
158
159 module . exports = {
160 OUTCOMES,
161 sanitizeForEmailToken,
162 buildPlaceholderAuthorEmail,
163 resolveBlogAuthorMemberId,
164 };