@aglyn/tenant-data-admin 1.0.0-beta.231 → 1.0.0-beta.232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,307 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { hostCollectionKind } from "@aglyn/aglyn/app-utils/collection-kind";
18
+ import { COLLECTIONS_MAX_PER_HOST, ENTRIES_MAX_PER_COLLECTION } from "@aglyn/aglyn/app-utils/collection-entries";
19
+ import { entryHasByline } from "@aglyn/aglyn/app-utils/content-authors";
20
+ import { entryTitleSearchFields } from "@aglyn/aglyn/app-utils/content-query-fields";
21
+ import { hostRoleCanPublish, hostRoleCanWrite } from "@aglyn/aglyn/app-utils/organizations";
22
+ /**
23
+ * A SITE'S POSTS WRITTEN ON A MEMBER'S BEHALF (AGL-3676).
24
+ *
25
+ * A blog is a core content collection — `hosts/{hostId}/collections/{id}`
26
+ * with `kind: 'content'` and a slug the tenant serves its listing at — and
27
+ * its posts are the collection's entries. The console makes both through two
28
+ * routes: `/api/hosts/collections` claims the slug inside a transaction and
29
+ * holds the per-site collection cap, and `/api/hosts/resources` creates an
30
+ * entry from its allow-list, under the per-collection cap, born a DRAFT.
31
+ *
32
+ * A server job that writes a site's first posts (the guided start's) needs
33
+ * the same writes without a browser's token, so they live here, in the lib
34
+ * both runtimes already share, under the same rules:
35
+ *
36
+ * - THE ROLE is the member's on the site: one who may write its content
37
+ * creates; one who may publish publishes. Refused in the routes' words.
38
+ * - THE SLUG of a collection is claimed per host and per kind in the
39
+ * transaction that creates it, and the collection cap is counted there.
40
+ * - AN ENTRY keeps only the fields the resources route's allow-list keeps,
41
+ * is counted against `ENTRIES_MAX_PER_COLLECTION`, and is stamped
42
+ * `status: 'draft'` with its title's search keys — never sent published.
43
+ * - PUBLISHING is a separate act, as it is in the console: it needs the
44
+ * publish role and a byline (`entryHasByline`), and sets `publishedAt`.
45
+ *
46
+ * Every write is idempotent on the id its caller chose: asked again, it
47
+ * reports what it already wrote.
48
+ */ /** The fields an entry keeps: the resources route's `entry` allow-list. */ export const CONTENT_ENTRY_DRAFT_FIELDS = [
49
+ 'title',
50
+ 'slug',
51
+ 'excerpt',
52
+ 'body',
53
+ 'coverImage',
54
+ 'coverImageAlt',
55
+ 'coverVideo',
56
+ 'coverVideoDuration',
57
+ 'seoTitle',
58
+ 'seoDescription',
59
+ 'authorName',
60
+ 'categoryId',
61
+ 'category',
62
+ 'tags',
63
+ 'authorId'
64
+ ];
65
+ /** The routes' refusal for a member who may not write the site's content. */ export const CONTENT_DRAFT_ROLE_REFUSAL = 'Editing requires the editor role';
66
+ /** The refusal for a member who may not publish it. */ export const CONTENT_PUBLISH_ROLE_REFUSAL = 'Publishing requires the publisher role';
67
+ /** The collections route's refusal at the per-site cap. */ export const CONTENT_COLLECTIONS_FULL_REFUSAL = `This site holds the maximum of ${COLLECTIONS_MAX_PER_HOST} collections — delete one to make room`;
68
+ /** The resources route's refusal at the per-collection cap. */ export const CONTENT_ENTRIES_FULL_REFUSAL = `A collection holds at most ${ENTRIES_MAX_PER_COLLECTION} entries — delete some to make room`;
69
+ /** A collection slug the collections route accepts. */ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
70
+ /** The longest entry slug this writer mints from a title. */ export const CONTENT_ENTRY_SLUG_MAX = 80;
71
+ /** A title as an entry slug: lowercase words joined by dashes. */ export function contentEntrySlug(title) {
72
+ const slug = title.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, CONTENT_ENTRY_SLUG_MAX).replace(/-+$/g, '');
73
+ return slug || 'post';
74
+ }
75
+ function roleOf(host, uid) {
76
+ var _host_get;
77
+ return ((_host_get = host.get('memberRoles')) != null ? _host_get : {})[uid];
78
+ }
79
+ /**
80
+ * The site's content collection under `id`, created when it does not exist:
81
+ * the first of `slugs` no other content collection of the site serves, under
82
+ * the collection cap. Refused for a member who may not write the site, or
83
+ * when every slug is taken.
84
+ */ export async function writeContentCollection(firestore, request) {
85
+ const slugs = [
86
+ ...new Set(request.slugs.map((slug)=>slug.trim().toLowerCase()))
87
+ ].filter((slug)=>SLUG.test(slug));
88
+ if (!slugs.length) return {
89
+ ok: false,
90
+ status: 400,
91
+ error: 'Slug must be lowercase letters, numbers, and dashes'
92
+ };
93
+ const hostRef = firestore.collection('hosts').doc(request.hostId);
94
+ const collections = hostRef.collection('collections');
95
+ const ref = collections.doc(request.id);
96
+ return firestore.runTransaction(async (tx)=>{
97
+ // Every read before the write, which Firestore requires.
98
+ const [host, existing] = await Promise.all([
99
+ tx.get(hostRef),
100
+ tx.get(ref)
101
+ ]);
102
+ if (!host.exists) return {
103
+ ok: false,
104
+ status: 404,
105
+ error: 'Unknown site'
106
+ };
107
+ if (existing.exists) {
108
+ var _existing_get, _existing_get1;
109
+ if (hostCollectionKind(existing.data()) !== 'content') {
110
+ return {
111
+ ok: false,
112
+ status: 409,
113
+ error: 'That collection belongs to a different part of the console'
114
+ };
115
+ }
116
+ return {
117
+ ok: true,
118
+ replayed: true,
119
+ id: existing.id,
120
+ slug: String((_existing_get = existing.get('slug')) != null ? _existing_get : ''),
121
+ displayName: String((_existing_get1 = existing.get('displayName')) != null ? _existing_get1 : '')
122
+ };
123
+ }
124
+ if (!hostRoleCanWrite(roleOf(host, request.uid))) return {
125
+ ok: false,
126
+ status: 403,
127
+ error: CONTENT_DRAFT_ROLE_REFUSAL
128
+ };
129
+ const held = await tx.get(collections.where('slug', 'in', slugs.slice(0, 10)));
130
+ const taken = new Set(held.docs.filter((doc)=>hostCollectionKind(doc.data()) === 'content').map((doc)=>String(doc.get('slug'))));
131
+ const slug = slugs.find((candidate)=>!taken.has(candidate));
132
+ if (!slug) return {
133
+ ok: false,
134
+ status: 409,
135
+ error: `Another collection already serves /${slugs[0]}`
136
+ };
137
+ const count = (await tx.get(collections.count())).data().count;
138
+ if (count >= COLLECTIONS_MAX_PER_HOST) return {
139
+ ok: false,
140
+ status: 403,
141
+ error: CONTENT_COLLECTIONS_FULL_REFUSAL
142
+ };
143
+ tx.create(ref, {
144
+ displayName: request.displayName,
145
+ slug,
146
+ kind: 'content',
147
+ createdAt: request.now
148
+ });
149
+ return {
150
+ ok: true,
151
+ replayed: false,
152
+ id: request.id,
153
+ slug,
154
+ displayName: request.displayName
155
+ };
156
+ });
157
+ }
158
+ /**
159
+ * One draft entry of a content collection under `id`: the allow-listed
160
+ * fields of `content`, a slug no other entry of the collection holds (from
161
+ * the title when none is given), `status: 'draft'`, its title's search keys
162
+ * and its author. Never published here.
163
+ */ export async function writeContentEntryDraft(firestore, request) {
164
+ const title = typeof request.content['title'] === 'string' ? request.content['title'].trim() : '';
165
+ if (!title) return {
166
+ ok: false,
167
+ status: 400,
168
+ error: 'A post needs a title'
169
+ };
170
+ const fields = {};
171
+ for (const key of CONTENT_ENTRY_DRAFT_FIELDS){
172
+ const value = request.content[key];
173
+ if (value !== undefined && value !== null) fields[key] = value;
174
+ }
175
+ fields['title'] = title;
176
+ const base = contentEntrySlug(typeof fields['slug'] === 'string' && fields['slug'] ? String(fields['slug']) : title);
177
+ const hostRef = firestore.collection('hosts').doc(request.hostId);
178
+ const parentRef = hostRef.collection('collections').doc(request.collectionId);
179
+ const entries = parentRef.collection('entries');
180
+ const ref = entries.doc(request.id);
181
+ return firestore.runTransaction(async (tx)=>{
182
+ var _candidates_find;
183
+ const [host, parent, existing] = await Promise.all([
184
+ tx.get(hostRef),
185
+ tx.get(parentRef),
186
+ tx.get(ref)
187
+ ]);
188
+ if (!host.exists) return {
189
+ ok: false,
190
+ status: 404,
191
+ error: 'Unknown site'
192
+ };
193
+ if (existing.exists) {
194
+ var _existing_get, _existing_get1;
195
+ return {
196
+ ok: true,
197
+ replayed: true,
198
+ id: existing.id,
199
+ slug: String((_existing_get = existing.get('slug')) != null ? _existing_get : ''),
200
+ title: String((_existing_get1 = existing.get('title')) != null ? _existing_get1 : '')
201
+ };
202
+ }
203
+ if (!parent.exists || hostCollectionKind(parent.data()) !== 'content') {
204
+ return {
205
+ ok: false,
206
+ status: 404,
207
+ error: 'Unknown collections document'
208
+ };
209
+ }
210
+ if (!hostRoleCanWrite(roleOf(host, request.uid))) return {
211
+ ok: false,
212
+ status: 403,
213
+ error: CONTENT_DRAFT_ROLE_REFUSAL
214
+ };
215
+ const candidates = Array.from({
216
+ length: 10
217
+ }, (_, index)=>index === 0 ? base : `${base}-${index + 1}`);
218
+ const held = new Set((await tx.get(entries.where('slug', 'in', candidates))).docs.map((doc)=>String(doc.get('slug'))));
219
+ const slug = (_candidates_find = candidates.find((candidate)=>!held.has(candidate))) != null ? _candidates_find : `${base}-${request.id.slice(0, 8).toLowerCase()}`;
220
+ const count = (await tx.get(entries.count())).data().count;
221
+ if (count >= ENTRIES_MAX_PER_COLLECTION) return {
222
+ ok: false,
223
+ status: 403,
224
+ error: CONTENT_ENTRIES_FULL_REFUSAL
225
+ };
226
+ tx.create(ref, _extends({}, fields, {
227
+ slug,
228
+ // Born a draft, as the resources route stamps it (AGL-2266).
229
+ status: 'draft'
230
+ }, entryTitleSearchFields(title), {
231
+ createdAt: request.now,
232
+ updatedAt: request.now,
233
+ createdBy: request.uid
234
+ }));
235
+ return {
236
+ ok: true,
237
+ replayed: false,
238
+ id: request.id,
239
+ slug,
240
+ title
241
+ };
242
+ });
243
+ }
244
+ /** The console's refusal for an entry with no byline (`ENTRY_BYLINE_REQUIRED_MESSAGE`'s sense). */ export const CONTENT_PUBLISH_NO_BYLINE = 'It has no author yet. Add one in Content and publish it.';
245
+ export const CONTENT_PUBLISH_GONE = 'It was deleted before it could be published.';
246
+ /**
247
+ * Publishes draft entries of a collection on a member's behalf, as the
248
+ * entry editor's Publish does: `status: 'published'` and `publishedAt`, for a
249
+ * member who may publish the site and an entry that names its byline. An
250
+ * entry already published stays as it is; one that cannot be published is
251
+ * kept a draft and reported. Never throws for an entry.
252
+ */ export async function publishContentEntries(firestore, request) {
253
+ const hostRef = firestore.collection('hosts').doc(request.hostId);
254
+ const host = await hostRef.get();
255
+ if (!host.exists) return {
256
+ ok: false,
257
+ status: 404,
258
+ error: 'Unknown site'
259
+ };
260
+ if (!hostRoleCanPublish(roleOf(host, request.uid))) return {
261
+ ok: false,
262
+ status: 403,
263
+ error: CONTENT_PUBLISH_ROLE_REFUSAL
264
+ };
265
+ const entries = hostRef.collection('collections').doc(request.collectionId).collection('entries');
266
+ const result = {
267
+ published: [],
268
+ kept: []
269
+ };
270
+ const batch = firestore.batch();
271
+ for (const id of new Set(request.ids)){
272
+ const entry = await entries.doc(id).get();
273
+ if (!entry.exists || entry.get('deletedAt') != null) {
274
+ result.kept.push({
275
+ id,
276
+ reason: CONTENT_PUBLISH_GONE
277
+ });
278
+ continue;
279
+ }
280
+ if (entry.get('status') === 'published') {
281
+ result.published.push(id);
282
+ continue;
283
+ }
284
+ if (!entryHasByline({
285
+ authorId: entry.get('authorId'),
286
+ authorName: entry.get('authorName')
287
+ })) {
288
+ result.kept.push({
289
+ id,
290
+ reason: CONTENT_PUBLISH_NO_BYLINE
291
+ });
292
+ continue;
293
+ }
294
+ batch.set(entries.doc(id), {
295
+ status: 'published',
296
+ publishedAt: request.now,
297
+ updatedAt: request.now
298
+ }, {
299
+ merge: true
300
+ });
301
+ result.published.push(id);
302
+ }
303
+ await batch.commit();
304
+ return result;
305
+ }
306
+
307
+ //# sourceMappingURL=content-entry-drafts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/content-entry-drafts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { hostCollectionKind } from '@aglyn/aglyn/app-utils/collection-kind'\nimport {\n COLLECTIONS_MAX_PER_HOST,\n ENTRIES_MAX_PER_COLLECTION,\n} from '@aglyn/aglyn/app-utils/collection-entries'\nimport { entryHasByline } from '@aglyn/aglyn/app-utils/content-authors'\nimport { entryTitleSearchFields } from '@aglyn/aglyn/app-utils/content-query-fields'\nimport { hostRoleCanPublish, hostRoleCanWrite } from '@aglyn/aglyn/app-utils/organizations'\nimport type { Firestore } from 'firebase-admin/firestore'\n\n/**\n * A SITE'S POSTS WRITTEN ON A MEMBER'S BEHALF (AGL-3676).\n *\n * A blog is a core content collection — `hosts/{hostId}/collections/{id}`\n * with `kind: 'content'` and a slug the tenant serves its listing at — and\n * its posts are the collection's entries. The console makes both through two\n * routes: `/api/hosts/collections` claims the slug inside a transaction and\n * holds the per-site collection cap, and `/api/hosts/resources` creates an\n * entry from its allow-list, under the per-collection cap, born a DRAFT.\n *\n * A server job that writes a site's first posts (the guided start's) needs\n * the same writes without a browser's token, so they live here, in the lib\n * both runtimes already share, under the same rules:\n *\n * - THE ROLE is the member's on the site: one who may write its content\n * creates; one who may publish publishes. Refused in the routes' words.\n * - THE SLUG of a collection is claimed per host and per kind in the\n * transaction that creates it, and the collection cap is counted there.\n * - AN ENTRY keeps only the fields the resources route's allow-list keeps,\n * is counted against `ENTRIES_MAX_PER_COLLECTION`, and is stamped\n * `status: 'draft'` with its title's search keys — never sent published.\n * - PUBLISHING is a separate act, as it is in the console: it needs the\n * publish role and a byline (`entryHasByline`), and sets `publishedAt`.\n *\n * Every write is idempotent on the id its caller chose: asked again, it\n * reports what it already wrote.\n */\n\n/** The fields an entry keeps: the resources route's `entry` allow-list. */\nexport const CONTENT_ENTRY_DRAFT_FIELDS = [\n 'title',\n 'slug',\n 'excerpt',\n 'body',\n 'coverImage',\n 'coverImageAlt',\n 'coverVideo',\n 'coverVideoDuration',\n 'seoTitle',\n 'seoDescription',\n 'authorName',\n 'categoryId',\n 'category',\n 'tags',\n 'authorId',\n] as const\n\n/** The routes' refusal for a member who may not write the site's content. */\nexport const CONTENT_DRAFT_ROLE_REFUSAL = 'Editing requires the editor role'\n/** The refusal for a member who may not publish it. */\nexport const CONTENT_PUBLISH_ROLE_REFUSAL = 'Publishing requires the publisher role'\n/** The collections route's refusal at the per-site cap. */\nexport const CONTENT_COLLECTIONS_FULL_REFUSAL =\n `This site holds the maximum of ${COLLECTIONS_MAX_PER_HOST} collections — delete one to make room`\n/** The resources route's refusal at the per-collection cap. */\nexport const CONTENT_ENTRIES_FULL_REFUSAL =\n `A collection holds at most ${ENTRIES_MAX_PER_COLLECTION} entries — delete some to make room`\n\n/** A collection slug the collections route accepts. */\nconst SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n\n/** The longest entry slug this writer mints from a title. */\nexport const CONTENT_ENTRY_SLUG_MAX = 80\n\n/** A title as an entry slug: lowercase words joined by dashes. */\nexport function contentEntrySlug(title: string): string {\n const slug = title\n .normalize('NFKD')\n .replace(/[̀-ͯ]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, CONTENT_ENTRY_SLUG_MAX)\n .replace(/-+$/g, '')\n return slug || 'post'\n}\n\nexport type ContentDraftRefusal = { ok: false; status: 400 | 403 | 404 | 409; error: string }\n\nexport type ContentCollectionWrite =\n | { ok: true; id: string; slug: string; displayName: string; replayed: boolean }\n | ContentDraftRefusal\n\nexport type ContentEntryWrite =\n | { ok: true; id: string; slug: string; title: string; replayed: boolean }\n | ContentDraftRefusal\n\nfunction roleOf(host: FirebaseFirestore.DocumentSnapshot, uid: string): unknown {\n return (host.get('memberRoles') ?? {})[uid]\n}\n\n/**\n * The site's content collection under `id`, created when it does not exist:\n * the first of `slugs` no other content collection of the site serves, under\n * the collection cap. Refused for a member who may not write the site, or\n * when every slug is taken.\n */\nexport async function writeContentCollection(\n firestore: Firestore,\n request: { hostId: string; uid: string; id: string; displayName: string; slugs: readonly string[]; now: Date },\n): Promise<ContentCollectionWrite> {\n const slugs = [...new Set(request.slugs.map((slug) => slug.trim().toLowerCase()))].filter((slug) => SLUG.test(slug))\n if (!slugs.length) return { ok: false, status: 400, error: 'Slug must be lowercase letters, numbers, and dashes' }\n const hostRef = firestore.collection('hosts').doc(request.hostId)\n const collections = hostRef.collection('collections')\n const ref = collections.doc(request.id)\n return firestore.runTransaction(async (tx): Promise<ContentCollectionWrite> => {\n // Every read before the write, which Firestore requires.\n const [host, existing] = await Promise.all([tx.get(hostRef), tx.get(ref)])\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (existing.exists) {\n if (hostCollectionKind(existing.data()) !== 'content') {\n return { ok: false, status: 409, error: 'That collection belongs to a different part of the console' }\n }\n return {\n ok: true,\n replayed: true,\n id: existing.id,\n slug: String(existing.get('slug') ?? ''),\n displayName: String(existing.get('displayName') ?? ''),\n }\n }\n if (!hostRoleCanWrite(roleOf(host, request.uid))) return { ok: false, status: 403, error: CONTENT_DRAFT_ROLE_REFUSAL }\n const held = await tx.get(collections.where('slug', 'in', slugs.slice(0, 10)))\n const taken = new Set(\n held.docs.filter((doc) => hostCollectionKind(doc.data()) === 'content').map((doc) => String(doc.get('slug'))),\n )\n const slug = slugs.find((candidate) => !taken.has(candidate))\n if (!slug) return { ok: false, status: 409, error: `Another collection already serves /${slugs[0]}` }\n const count = (await tx.get(collections.count())).data().count\n if (count >= COLLECTIONS_MAX_PER_HOST) return { ok: false, status: 403, error: CONTENT_COLLECTIONS_FULL_REFUSAL }\n tx.create(ref, { displayName: request.displayName, slug, kind: 'content', createdAt: request.now })\n return { ok: true, replayed: false, id: request.id, slug, displayName: request.displayName }\n })\n}\n\n/**\n * One draft entry of a content collection under `id`: the allow-listed\n * fields of `content`, a slug no other entry of the collection holds (from\n * the title when none is given), `status: 'draft'`, its title's search keys\n * and its author. Never published here.\n */\nexport async function writeContentEntryDraft(\n firestore: Firestore,\n request: {\n hostId: string\n collectionId: string\n uid: string\n id: string\n content: Readonly<Record<string, unknown>>\n now: Date\n },\n): Promise<ContentEntryWrite> {\n const title = typeof request.content['title'] === 'string' ? request.content['title'].trim() : ''\n if (!title) return { ok: false, status: 400, error: 'A post needs a title' }\n const fields: Record<string, unknown> = {}\n for (const key of CONTENT_ENTRY_DRAFT_FIELDS) {\n const value = request.content[key]\n if (value !== undefined && value !== null) fields[key] = value\n }\n fields['title'] = title\n const base = contentEntrySlug(typeof fields['slug'] === 'string' && fields['slug'] ? String(fields['slug']) : title)\n const hostRef = firestore.collection('hosts').doc(request.hostId)\n const parentRef = hostRef.collection('collections').doc(request.collectionId)\n const entries = parentRef.collection('entries')\n const ref = entries.doc(request.id)\n return firestore.runTransaction(async (tx): Promise<ContentEntryWrite> => {\n const [host, parent, existing] = await Promise.all([tx.get(hostRef), tx.get(parentRef), tx.get(ref)])\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (existing.exists) {\n return {\n ok: true,\n replayed: true,\n id: existing.id,\n slug: String(existing.get('slug') ?? ''),\n title: String(existing.get('title') ?? ''),\n }\n }\n if (!parent.exists || hostCollectionKind(parent.data()) !== 'content') {\n return { ok: false, status: 404, error: 'Unknown collections document' }\n }\n if (!hostRoleCanWrite(roleOf(host, request.uid))) return { ok: false, status: 403, error: CONTENT_DRAFT_ROLE_REFUSAL }\n const candidates = Array.from({ length: 10 }, (_, index) => (index === 0 ? base : `${base}-${index + 1}`))\n const held = new Set((await tx.get(entries.where('slug', 'in', candidates))).docs.map((doc) => String(doc.get('slug'))))\n const slug = candidates.find((candidate) => !held.has(candidate)) ?? `${base}-${request.id.slice(0, 8).toLowerCase()}`\n const count = (await tx.get(entries.count())).data().count\n if (count >= ENTRIES_MAX_PER_COLLECTION) return { ok: false, status: 403, error: CONTENT_ENTRIES_FULL_REFUSAL }\n tx.create(ref, {\n ...fields,\n slug,\n // Born a draft, as the resources route stamps it (AGL-2266).\n status: 'draft',\n ...entryTitleSearchFields(title),\n createdAt: request.now,\n updatedAt: request.now,\n createdBy: request.uid,\n })\n return { ok: true, replayed: false, id: request.id, slug, title }\n })\n}\n\n/** What publishing a set of entries came to. */\nexport interface ContentEntriesPublish {\n published: string[]\n /** Entries left as they were, each with the plain sentence for why. */\n kept: Array<{ id: string; reason: string }>\n}\n\n/** The console's refusal for an entry with no byline (`ENTRY_BYLINE_REQUIRED_MESSAGE`'s sense). */\nexport const CONTENT_PUBLISH_NO_BYLINE = 'It has no author yet. Add one in Content and publish it.'\nexport const CONTENT_PUBLISH_GONE = 'It was deleted before it could be published.'\n\n/**\n * Publishes draft entries of a collection on a member's behalf, as the\n * entry editor's Publish does: `status: 'published'` and `publishedAt`, for a\n * member who may publish the site and an entry that names its byline. An\n * entry already published stays as it is; one that cannot be published is\n * kept a draft and reported. Never throws for an entry.\n */\nexport async function publishContentEntries(\n firestore: Firestore,\n request: { hostId: string; collectionId: string; uid: string; ids: readonly string[]; now: Date },\n): Promise<ContentEntriesPublish | ContentDraftRefusal> {\n const hostRef = firestore.collection('hosts').doc(request.hostId)\n const host = await hostRef.get()\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (!hostRoleCanPublish(roleOf(host, request.uid))) return { ok: false, status: 403, error: CONTENT_PUBLISH_ROLE_REFUSAL }\n const entries = hostRef.collection('collections').doc(request.collectionId).collection('entries')\n const result: ContentEntriesPublish = { published: [], kept: [] }\n const batch = firestore.batch()\n for (const id of new Set(request.ids)) {\n const entry = await entries.doc(id).get()\n if (!entry.exists || entry.get('deletedAt') != null) {\n result.kept.push({ id, reason: CONTENT_PUBLISH_GONE })\n continue\n }\n if (entry.get('status') === 'published') {\n result.published.push(id)\n continue\n }\n if (!entryHasByline({ authorId: entry.get('authorId'), authorName: entry.get('authorName') })) {\n result.kept.push({ id, reason: CONTENT_PUBLISH_NO_BYLINE })\n continue\n }\n batch.set(entries.doc(id), { status: 'published', publishedAt: request.now, updatedAt: request.now }, { merge: true })\n result.published.push(id)\n }\n await batch.commit()\n return result\n}\n"],"names":["hostCollectionKind","COLLECTIONS_MAX_PER_HOST","ENTRIES_MAX_PER_COLLECTION","entryHasByline","entryTitleSearchFields","hostRoleCanPublish","hostRoleCanWrite","CONTENT_ENTRY_DRAFT_FIELDS","CONTENT_DRAFT_ROLE_REFUSAL","CONTENT_PUBLISH_ROLE_REFUSAL","CONTENT_COLLECTIONS_FULL_REFUSAL","CONTENT_ENTRIES_FULL_REFUSAL","SLUG","CONTENT_ENTRY_SLUG_MAX","contentEntrySlug","title","slug","normalize","replace","toLowerCase","slice","roleOf","host","uid","get","writeContentCollection","firestore","request","slugs","Set","map","trim","filter","test","length","ok","status","error","hostRef","collection","doc","hostId","collections","ref","id","runTransaction","tx","existing","Promise","all","exists","data","replayed","String","displayName","held","where","taken","docs","find","candidate","has","count","create","kind","createdAt","now","writeContentEntryDraft","content","fields","key","value","undefined","base","parentRef","collectionId","entries","candidates","parent","Array","from","_","index","updatedAt","createdBy","CONTENT_PUBLISH_NO_BYLINE","CONTENT_PUBLISH_GONE","publishContentEntries","result","published","kept","batch","ids","entry","push","reason","authorId","authorName","set","publishedAt","merge","commit"],"mappings":"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"}
@@ -256,6 +256,114 @@ const firestoreApp = ()=>firebaseAdmin.app().firestore();
256
256
  exported: true,
257
257
  note: 'Each paid order’s hand-off to a fulfillment network (AGL-3634): the order number, the lines sent with SKU and quantity, the network’s reference, the parcels read back with carrier, tracking number and status, and why anything was not sent. The merchant’s own fulfillment record of a SHOPPER’s order, so disclosed to the ORG only.'
258
258
  },
259
+ {
260
+ collection: 'marketplaceConnections',
261
+ keyedBy: 'field',
262
+ subjects: [
263
+ 'org'
264
+ ],
265
+ exported: true,
266
+ note: 'EXISTENCE ONLY for the grant — each site’s connection to the merchant’s own Amazon, eBay, Etsy, TikTok Shop, Walmart or Faire seller account (AGL-3638): marketplace, status, account name, the marketplace sites the account sells on, the listing and order settings, the order cursor and the last sync’s counts. The sealed access and refresh tokens and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets); the account’s marketplace ids are disclosed as stored. The document id is the site id and the marketplace. Its `log` and `listingState` subcollections (what each listing was last sent) are not read by this export.'
267
+ },
268
+ {
269
+ collection: 'marketplaceOrders',
270
+ keyedBy: 'field',
271
+ subjects: [
272
+ 'org'
273
+ ],
274
+ exported: true,
275
+ note: 'Each marketplace order the store imported (AGL-3638): the marketplace and its order id, the store order it became, which order line each marketplace line became, the marketplace’s fees, and each shipment confirmed back with carrier and tracking number. The merchant’s own record of a marketplace BUYER’s order, so disclosed to the ORG only; the buyer’s name and address are on the store’s order, not here.'
276
+ },
277
+ {
278
+ collection: 'inventorySyncConnections',
279
+ keyedBy: 'field',
280
+ subjects: [
281
+ 'org'
282
+ ],
283
+ exported: true,
284
+ note: 'EXISTENCE ONLY for the keys — each site’s connection to the merchant’s own Cin7 Core, inFlow or Brightpearl account (AGL-3642): system, status, account name, the stock, product and order settings (location, the customer orders are recorded under, the tax rule), the last count by SKU, the last syncs’ counts, cursors and totals. The sealed API keys or grant and the id of the key that sealed them carry `credential` in their names and are redacted (see redactSecrets); Brightpearl’s account code and datacenter are disclosed as stored. The document id is the site id. Its `log` subcollection is not read by this export.'
285
+ },
286
+ {
287
+ collection: 'inventorySyncOrders',
288
+ keyedBy: 'field',
289
+ subjects: [
290
+ 'org'
291
+ ],
292
+ exported: true,
293
+ note: 'Each paid order’s hand-off to the connected inventory system (AGL-3642): the order number and our reference, the lines sent with SKU, quantity and price, the buyer’s name, email address and shipping address as sent, the totals, the system’s id and number for it, attempts and why it was not sent. The merchant’s own record of a SHOPPER’s order, so disclosed to the ORG only.'
294
+ },
295
+ {
296
+ collection: 'inventorySyncProducts',
297
+ keyedBy: 'field',
298
+ subjects: [
299
+ 'org'
300
+ ],
301
+ exported: true,
302
+ note: 'Each product linked between the store and the connected inventory system (AGL-3642): the system’s product id, the SKU, the version last synced, the store’s product and variant ids, and why it was not synced. Catalog data, no person’s.'
303
+ },
304
+ {
305
+ collection: 'podConnections',
306
+ keyedBy: 'field',
307
+ subjects: [
308
+ 'org'
309
+ ],
310
+ exported: true,
311
+ note: 'EXISTENCE ONLY for the tokens — each site’s connection to the merchant’s own Printful store or Printify shop (AGL-3641): service, store id and name, currency, whether orders are sent automatically or held for review, whether prices sync, how shipments are heard about, and the last error. The sealed API token and webhook token and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets).'
312
+ },
313
+ {
314
+ collection: 'podProductLinks',
315
+ keyedBy: 'field',
316
+ subjects: [
317
+ 'org'
318
+ ],
319
+ exported: true,
320
+ note: 'Each product imported from a print-on-demand service (AGL-3641): the service’s product and variant ids, the store product and variants it became, names, SKUs, what the service charges for each variant and the store’s price. Catalog data, no person’s.'
321
+ },
322
+ {
323
+ collection: 'podOrders',
324
+ keyedBy: 'field',
325
+ subjects: [
326
+ 'org'
327
+ ],
328
+ exported: true,
329
+ note: 'The part of each paid order a print-on-demand service makes and ships (AGL-3641): the store order and its number, the service’s order id, the lines sent with quantity and cost, its status, costs, attempts and errors, and each parcel’s carrier and tracking number. No buyer name, address or email is kept here; they are on the store’s order. The merchant’s own record of a SHOPPER’s order, so disclosed to the ORG only.'
330
+ },
331
+ {
332
+ collection: 'deliveryAppStores',
333
+ keyedBy: 'field',
334
+ subjects: [
335
+ 'org'
336
+ ],
337
+ exported: true,
338
+ note: 'Each site’s link to the merchant’s own DoorDash, Uber Eats or Grubhub store (AGL-3644): the service, the store id it shows the merchant, the settings (accept automatically, prep minutes), the items matched to products and those waiting, and the menu’s last send. No credential is stored: every call is signed with the deployment’s partner credentials.'
339
+ },
340
+ {
341
+ collection: 'deliveryAppOrders',
342
+ keyedBy: 'field',
343
+ subjects: [
344
+ 'org'
345
+ ],
346
+ exported: true,
347
+ note: 'Each order a delivery service sent a site’s register (AGL-3644): the service and its order id and short code, the items with their options and instructions, the totals and refunds, where it stood at the counter, and the store order it became. The buyer appears by first name and last initial only. The merchant’s own record of a delivery BUYER’s order, so disclosed to the ORG only.'
348
+ },
349
+ {
350
+ collection: 'zapierHooks',
351
+ keyedBy: 'field',
352
+ subjects: [
353
+ 'org'
354
+ ],
355
+ exported: true,
356
+ note: 'Each Zapier REST hook a site’s API key subscribed (AGL-3643): the site, the events, the API key’s public id and name, delivery counts and dates. EXISTENCE ONLY for the URL — `targetUrl` is the capability Zapier minted for the Zap, a bearer URL whose secret is in its path, so it is redacted by name (see redactSecrets).'
357
+ },
358
+ {
359
+ collection: 'zapierHookDeliveries',
360
+ keyedBy: 'field',
361
+ subjects: [
362
+ 'org'
363
+ ],
364
+ exported: true,
365
+ note: 'Markers that one event reached one Zapier hook, so a retry does not post it twice (AGL-3643): the hook, the status and the time. The document id is a SHA-256 of the event and hook, an identifier rather than a verifier of any credential; no customer data, and a TTL policy removes each row.'
366
+ },
259
367
  {
260
368
  collection: 'ssoDomains',
261
369
  keyedBy: 'field',
@@ -496,6 +604,10 @@ const firestoreApp = ()=>firebaseAdmin.app().firestore();
496
604
  // A Stripe payment link is a live bearer URL: whoever holds it can pay.
497
605
  // Named in the AGL-1443 inventory of what the old dump actually carried.
498
606
  'payment link',
607
+ // A Zapier REST hook's `targetUrl` (AGL-3643) is the same kind of bearer
608
+ // URL: whoever holds it can post into the Zap, and its secret is in the
609
+ // path, which the URL scrub does not read.
610
+ 'target url',
499
611
  'auth code',
500
612
  'recovery code',
501
613
  'backup code'
@@ -939,7 +1051,17 @@ function sourcesFor(subject) {
939
1051
  'marketingPlatformConnections',
940
1052
  'marketingPlatformEvents',
941
1053
  'fulfillmentNetworkConnections',
942
- 'fulfillmentNetworkOrders'
1054
+ 'fulfillmentNetworkOrders',
1055
+ 'marketplaceConnections',
1056
+ 'marketplaceOrders',
1057
+ 'inventorySyncConnections',
1058
+ 'inventorySyncOrders',
1059
+ 'inventorySyncProducts',
1060
+ 'podConnections',
1061
+ 'podProductLinks',
1062
+ 'podOrders',
1063
+ 'deliveryAppStores',
1064
+ 'deliveryAppOrders'
943
1065
  ]){
944
1066
  data[collection] = await readByField(db, collection, 'orgId', orgId);
945
1067
  }