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

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,131 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { Firestore } from 'firebase-admin/firestore';
18
+ /**
19
+ * A SITE'S POSTS WRITTEN ON A MEMBER'S BEHALF (AGL-3676).
20
+ *
21
+ * A blog is a core content collection — `hosts/{hostId}/collections/{id}`
22
+ * with `kind: 'content'` and a slug the tenant serves its listing at — and
23
+ * its posts are the collection's entries. The console makes both through two
24
+ * routes: `/api/hosts/collections` claims the slug inside a transaction and
25
+ * holds the per-site collection cap, and `/api/hosts/resources` creates an
26
+ * entry from its allow-list, under the per-collection cap, born a DRAFT.
27
+ *
28
+ * A server job that writes a site's first posts (the guided start's) needs
29
+ * the same writes without a browser's token, so they live here, in the lib
30
+ * both runtimes already share, under the same rules:
31
+ *
32
+ * - THE ROLE is the member's on the site: one who may write its content
33
+ * creates; one who may publish publishes. Refused in the routes' words.
34
+ * - THE SLUG of a collection is claimed per host and per kind in the
35
+ * transaction that creates it, and the collection cap is counted there.
36
+ * - AN ENTRY keeps only the fields the resources route's allow-list keeps,
37
+ * is counted against `ENTRIES_MAX_PER_COLLECTION`, and is stamped
38
+ * `status: 'draft'` with its title's search keys — never sent published.
39
+ * - PUBLISHING is a separate act, as it is in the console: it needs the
40
+ * publish role and a byline (`entryHasByline`), and sets `publishedAt`.
41
+ *
42
+ * Every write is idempotent on the id its caller chose: asked again, it
43
+ * reports what it already wrote.
44
+ */
45
+ /** The fields an entry keeps: the resources route's `entry` allow-list. */
46
+ export declare const CONTENT_ENTRY_DRAFT_FIELDS: readonly ["title", "slug", "excerpt", "body", "coverImage", "coverImageAlt", "coverVideo", "coverVideoDuration", "seoTitle", "seoDescription", "authorName", "categoryId", "category", "tags", "authorId"];
47
+ /** The routes' refusal for a member who may not write the site's content. */
48
+ export declare const CONTENT_DRAFT_ROLE_REFUSAL = "Editing requires the editor role";
49
+ /** The refusal for a member who may not publish it. */
50
+ export declare const CONTENT_PUBLISH_ROLE_REFUSAL = "Publishing requires the publisher role";
51
+ /** The collections route's refusal at the per-site cap. */
52
+ export declare const CONTENT_COLLECTIONS_FULL_REFUSAL = "This site holds the maximum of 100 collections \u2014 delete one to make room";
53
+ /** The resources route's refusal at the per-collection cap. */
54
+ export declare const CONTENT_ENTRIES_FULL_REFUSAL = "A collection holds at most 10000 entries \u2014 delete some to make room";
55
+ /** The longest entry slug this writer mints from a title. */
56
+ export declare const CONTENT_ENTRY_SLUG_MAX = 80;
57
+ /** A title as an entry slug: lowercase words joined by dashes. */
58
+ export declare function contentEntrySlug(title: string): string;
59
+ export type ContentDraftRefusal = {
60
+ ok: false;
61
+ status: 400 | 403 | 404 | 409;
62
+ error: string;
63
+ };
64
+ export type ContentCollectionWrite = {
65
+ ok: true;
66
+ id: string;
67
+ slug: string;
68
+ displayName: string;
69
+ replayed: boolean;
70
+ } | ContentDraftRefusal;
71
+ export type ContentEntryWrite = {
72
+ ok: true;
73
+ id: string;
74
+ slug: string;
75
+ title: string;
76
+ replayed: boolean;
77
+ } | ContentDraftRefusal;
78
+ /**
79
+ * The site's content collection under `id`, created when it does not exist:
80
+ * the first of `slugs` no other content collection of the site serves, under
81
+ * the collection cap. Refused for a member who may not write the site, or
82
+ * when every slug is taken.
83
+ */
84
+ export declare function writeContentCollection(firestore: Firestore, request: {
85
+ hostId: string;
86
+ uid: string;
87
+ id: string;
88
+ displayName: string;
89
+ slugs: readonly string[];
90
+ now: Date;
91
+ }): Promise<ContentCollectionWrite>;
92
+ /**
93
+ * One draft entry of a content collection under `id`: the allow-listed
94
+ * fields of `content`, a slug no other entry of the collection holds (from
95
+ * the title when none is given), `status: 'draft'`, its title's search keys
96
+ * and its author. Never published here.
97
+ */
98
+ export declare function writeContentEntryDraft(firestore: Firestore, request: {
99
+ hostId: string;
100
+ collectionId: string;
101
+ uid: string;
102
+ id: string;
103
+ content: Readonly<Record<string, unknown>>;
104
+ now: Date;
105
+ }): Promise<ContentEntryWrite>;
106
+ /** What publishing a set of entries came to. */
107
+ export interface ContentEntriesPublish {
108
+ published: string[];
109
+ /** Entries left as they were, each with the plain sentence for why. */
110
+ kept: Array<{
111
+ id: string;
112
+ reason: string;
113
+ }>;
114
+ }
115
+ /** The console's refusal for an entry with no byline (`ENTRY_BYLINE_REQUIRED_MESSAGE`'s sense). */
116
+ export declare const CONTENT_PUBLISH_NO_BYLINE = "It has no author yet. Add one in Content and publish it.";
117
+ export declare const CONTENT_PUBLISH_GONE = "It was deleted before it could be published.";
118
+ /**
119
+ * Publishes draft entries of a collection on a member's behalf, as the
120
+ * entry editor's Publish does: `status: 'published'` and `publishedAt`, for a
121
+ * member who may publish the site and an entry that names its byline. An
122
+ * entry already published stays as it is; one that cannot be published is
123
+ * kept a draft and reported. Never throws for an entry.
124
+ */
125
+ export declare function publishContentEntries(firestore: Firestore, request: {
126
+ hostId: string;
127
+ collectionId: string;
128
+ uid: string;
129
+ ids: readonly string[];
130
+ now: Date;
131
+ }): Promise<ContentEntriesPublish | ContentDraftRefusal>;
@@ -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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"}
@@ -172,7 +172,17 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
172
172
  // subject, but an `email.opened` payload may carry none at all — and
173
173
  // overwriting a known subject with null is how a staff row loses the
174
174
  // only thing that identifies it.
175
- if (!snapshot.exists) update.firstSeenAtMs = event.at;
175
+ if (!snapshot.exists) {
176
+ update.firstSeenAtMs = event.at;
177
+ /*
178
+ * Every message carries the fields the staff Emails sent table sorts
179
+ * by (AGL-3680) — an `orderBy` drops a document that lacks one. A
180
+ * subject not known yet is null until an event brings it; the counts
181
+ * start at zero and the increments below land on top.
182
+ */ update.subject = event.subject || null;
183
+ update.openCount = 0;
184
+ update.clickCount = 0;
185
+ }
176
186
  if (event.subject && !existing.subject) update.subject = event.subject;
177
187
  if (event.context && !existing.context) update.context = event.context;
178
188
  if (((_event_tags = event.tags) == null ? void 0 : _event_tags.hostId) && !existing.hostId) update.hostId = event.tags.hostId;
@@ -262,7 +272,13 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
262
272
  importedAtMs: Date.now(),
263
273
  updatedAt: FieldValue.serverTimestamp()
264
274
  };
265
- if (!stored.exists) update.firstSeenAtMs = snapshot.sentAt;
275
+ if (!stored.exists) {
276
+ update.firstSeenAtMs = snapshot.sentAt;
277
+ // The sorted fields on every message, as the event writer (AGL-3680).
278
+ update.subject = snapshot.subject || null;
279
+ update.openCount = 0;
280
+ update.clickCount = 0;
281
+ }
266
282
  if (snapshot.subject && !existing.subject) update.subject = snapshot.subject;
267
283
  // Only when the event feed has not already dated the send itself. An
268
284
  // imported `created_at` is the provider's, and so is the webhook's, but