@aglyn/tenant-data-admin 1.0.0-beta.230 → 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.
- package/package.json +7 -7
- package/src/lib/server/account-acquisition.d.ts +30 -0
- package/src/lib/server/account-acquisition.js +37 -2
- package/src/lib/server/account-acquisition.js.map +1 -1
- package/src/lib/server/api-keys.d.ts +14 -1
- package/src/lib/server/api-keys.js +28 -0
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/content-entry-drafts.d.ts +131 -0
- package/src/lib/server/content-entry-drafts.js +307 -0
- package/src/lib/server/content-entry-drafts.js.map +1 -0
- package/src/lib/server/erase.js +14 -0
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-released-names.d.ts +22 -0
- package/src/lib/server/host-released-names.js +31 -0
- package/src/lib/server/host-released-names.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +1 -1
- package/src/lib/server/host-visitor-records.js +1 -1
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/mobile-push-switch.d.ts +46 -0
- package/src/lib/server/mobile-push-switch.js +45 -0
- package/src/lib/server/mobile-push-switch.js.map +1 -0
- package/src/lib/server/native-push.d.ts +17 -0
- package/src/lib/server/native-push.js +35 -0
- package/src/lib/server/native-push.js.map +1 -0
- package/src/lib/server/notifications.js +23 -0
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/personal-data-export.js +230 -0
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/plugin-event-outbox.d.ts +138 -0
- package/src/lib/server/plugin-event-outbox.js +229 -0
- package/src/lib/server/plugin-event-outbox.js.map +1 -0
- package/src/lib/server/push-apns.d.ts +73 -0
- package/src/lib/server/push-apns.js +225 -0
- package/src/lib/server/push-apns.js.map +1 -0
- package/src/lib/server/push-delivery.d.ts +78 -0
- package/src/lib/server/push-delivery.js +176 -0
- package/src/lib/server/push-delivery.js.map +1 -0
- package/src/lib/server/push-fcm.d.ts +29 -0
- package/src/lib/server/push-fcm.js +83 -0
- package/src/lib/server/push-fcm.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +6 -0
- package/src/lib/server/serve-media-cdn.js +20 -3
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-marketing-sync.d.ts +146 -0
- package/src/lib/server/site-marketing-sync.js +315 -0
- package/src/lib/server/site-marketing-sync.js.map +1 -0
- package/src/lib/server/upsert-contact.js +6 -5
- package/src/lib/server/upsert-contact.js.map +1 -1
|
@@ -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":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,yCAAwC;AAC3E,SACEC,wBAAwB,EACxBC,0BAA0B,QACrB,4CAA2C;AAClD,SAASC,cAAc,QAAQ,yCAAwC;AACvE,SAASC,sBAAsB,QAAQ,8CAA6C;AACpF,SAASC,kBAAkB,EAAEC,gBAAgB,QAAQ,uCAAsC;AAG3F;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,yEAAyE,GACzE,OAAO,MAAMC,6BAA6B;IACxC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAS;AAEV,2EAA2E,GAC3E,OAAO,MAAMC,6BAA6B,mCAAkC;AAC5E,qDAAqD,GACrD,OAAO,MAAMC,+BAA+B,yCAAwC;AACpF,yDAAyD,GACzD,OAAO,MAAMC,mCACX,CAAC,+BAA+B,EAAET,yBAAyB,sCAAsC,CAAC,CAAA;AACpG,6DAA6D,GAC7D,OAAO,MAAMU,+BACX,CAAC,2BAA2B,EAAET,2BAA2B,mCAAmC,CAAC,CAAA;AAE/F,qDAAqD,GACrD,MAAMU,OAAO;AAEb,2DAA2D,GAC3D,OAAO,MAAMC,yBAAyB,GAAE;AAExC,gEAAgE,GAChE,OAAO,SAASC,iBAAiBC,KAAa;IAC5C,MAAMC,OAAOD,MACVE,SAAS,CAAC,QACVC,OAAO,CAAC,UAAU,IAClBC,WAAW,GACXD,OAAO,CAAC,eAAe,KACvBA,OAAO,CAAC,YAAY,IACpBE,KAAK,CAAC,GAAGP,wBACTK,OAAO,CAAC,QAAQ;IACnB,OAAOF,QAAQ;AACjB;AAYA,SAASK,OAAOC,IAAwC,EAAEC,GAAW;QAC3DD;IAAR,OAAO,EAACA,YAAAA,KAAKE,GAAG,CAAC,0BAATF,YAA2B,CAAC,EAAE,CAACC,IAAI;AAC7C;AAEA;;;;;CAKC,GACD,OAAO,eAAeE,uBACpBC,SAAoB,EACpBC,OAA8G;IAE9G,MAAMC,QAAQ;WAAI,IAAIC,IAAIF,QAAQC,KAAK,CAACE,GAAG,CAAC,CAACd,OAASA,KAAKe,IAAI,GAAGZ,WAAW;KAAK,CAACa,MAAM,CAAC,CAAChB,OAASJ,KAAKqB,IAAI,CAACjB;IAC9G,IAAI,CAACY,MAAMM,MAAM,EAAE,OAAO;QAAEC,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAsD;IACjH,MAAMC,UAAUZ,UAAUa,UAAU,CAAC,SAASC,GAAG,CAACb,QAAQc,MAAM;IAChE,MAAMC,cAAcJ,QAAQC,UAAU,CAAC;IACvC,MAAMI,MAAMD,YAAYF,GAAG,CAACb,QAAQiB,EAAE;IACtC,OAAOlB,UAAUmB,cAAc,CAAC,OAAOC;QACrC,yDAAyD;QACzD,MAAM,CAACxB,MAAMyB,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAACH,GAAGtB,GAAG,CAACc;YAAUQ,GAAGtB,GAAG,CAACmB;SAAK;QACzE,IAAI,CAACrB,KAAK4B,MAAM,EAAE,OAAO;YAAEf,IAAI;YAAOC,QAAQ;YAAKC,OAAO;QAAe;QACzE,IAAIU,SAASG,MAAM,EAAE;gBAQJH,eACOA;YARtB,IAAI/C,mBAAmB+C,SAASI,IAAI,QAAQ,WAAW;gBACrD,OAAO;oBAAEhB,IAAI;oBAAOC,QAAQ;oBAAKC,OAAO;gBAA6D;YACvG;YACA,OAAO;gBACLF,IAAI;gBACJiB,UAAU;gBACVR,IAAIG,SAASH,EAAE;gBACf5B,MAAMqC,QAAON,gBAAAA,SAASvB,GAAG,CAAC,mBAAbuB,gBAAwB;gBACrCO,aAAaD,QAAON,iBAAAA,SAASvB,GAAG,CAAC,0BAAbuB,iBAA+B;YACrD;QACF;QACA,IAAI,CAACzC,iBAAiBe,OAAOC,MAAMK,QAAQJ,GAAG,IAAI,OAAO;YAAEY,IAAI;YAAOC,QAAQ;YAAKC,OAAO7B;QAA2B;QACrH,MAAM+C,OAAO,MAAMT,GAAGtB,GAAG,CAACkB,YAAYc,KAAK,CAAC,QAAQ,MAAM5B,MAAMR,KAAK,CAAC,GAAG;QACzE,MAAMqC,QAAQ,IAAI5B,IAChB0B,KAAKG,IAAI,CAAC1B,MAAM,CAAC,CAACQ,MAAQxC,mBAAmBwC,IAAIW,IAAI,QAAQ,WAAWrB,GAAG,CAAC,CAACU,MAAQa,OAAOb,IAAIhB,GAAG,CAAC;QAEtG,MAAMR,OAAOY,MAAM+B,IAAI,CAAC,CAACC,YAAc,CAACH,MAAMI,GAAG,CAACD;QAClD,IAAI,CAAC5C,MAAM,OAAO;YAAEmB,IAAI;YAAOC,QAAQ;YAAKC,OAAO,CAAC,mCAAmC,EAAET,KAAK,CAAC,EAAE,EAAE;QAAC;QACpG,MAAMkC,QAAQ,AAAC,CAAA,MAAMhB,GAAGtB,GAAG,CAACkB,YAAYoB,KAAK,GAAE,EAAGX,IAAI,GAAGW,KAAK;QAC9D,IAAIA,SAAS7D,0BAA0B,OAAO;YAAEkC,IAAI;YAAOC,QAAQ;YAAKC,OAAO3B;QAAiC;QAChHoC,GAAGiB,MAAM,CAACpB,KAAK;YAAEW,aAAa3B,QAAQ2B,WAAW;YAAEtC;YAAMgD,MAAM;YAAWC,WAAWtC,QAAQuC,GAAG;QAAC;QACjG,OAAO;YAAE/B,IAAI;YAAMiB,UAAU;YAAOR,IAAIjB,QAAQiB,EAAE;YAAE5B;YAAMsC,aAAa3B,QAAQ2B,WAAW;QAAC;IAC7F;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAea,uBACpBzC,SAAoB,EACpBC,OAOC;IAED,MAAMZ,QAAQ,OAAOY,QAAQyC,OAAO,CAAC,QAAQ,KAAK,WAAWzC,QAAQyC,OAAO,CAAC,QAAQ,CAACrC,IAAI,KAAK;IAC/F,IAAI,CAAChB,OAAO,OAAO;QAAEoB,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAuB;IAC3E,MAAMgC,SAAkC,CAAC;IACzC,KAAK,MAAMC,OAAO/D,2BAA4B;QAC5C,MAAMgE,QAAQ5C,QAAQyC,OAAO,CAACE,IAAI;QAClC,IAAIC,UAAUC,aAAaD,UAAU,MAAMF,MAAM,CAACC,IAAI,GAAGC;IAC3D;IACAF,MAAM,CAAC,QAAQ,GAAGtD;IAClB,MAAM0D,OAAO3D,iBAAiB,OAAOuD,MAAM,CAAC,OAAO,KAAK,YAAYA,MAAM,CAAC,OAAO,GAAGhB,OAAOgB,MAAM,CAAC,OAAO,IAAItD;IAC9G,MAAMuB,UAAUZ,UAAUa,UAAU,CAAC,SAASC,GAAG,CAACb,QAAQc,MAAM;IAChE,MAAMiC,YAAYpC,QAAQC,UAAU,CAAC,eAAeC,GAAG,CAACb,QAAQgD,YAAY;IAC5E,MAAMC,UAAUF,UAAUnC,UAAU,CAAC;IACrC,MAAMI,MAAMiC,QAAQpC,GAAG,CAACb,QAAQiB,EAAE;IAClC,OAAOlB,UAAUmB,cAAc,CAAC,OAAOC;YAkBxB+B;QAjBb,MAAM,CAACvD,MAAMwD,QAAQ/B,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAACH,GAAGtB,GAAG,CAACc;YAAUQ,GAAGtB,GAAG,CAACkD;YAAY5B,GAAGtB,GAAG,CAACmB;SAAK;QACpG,IAAI,CAACrB,KAAK4B,MAAM,EAAE,OAAO;YAAEf,IAAI;YAAOC,QAAQ;YAAKC,OAAO;QAAe;QACzE,IAAIU,SAASG,MAAM,EAAE;gBAKJH,eACCA;YALhB,OAAO;gBACLZ,IAAI;gBACJiB,UAAU;gBACVR,IAAIG,SAASH,EAAE;gBACf5B,MAAMqC,QAAON,gBAAAA,SAASvB,GAAG,CAAC,mBAAbuB,gBAAwB;gBACrChC,OAAOsC,QAAON,iBAAAA,SAASvB,GAAG,CAAC,oBAAbuB,iBAAyB;YACzC;QACF;QACA,IAAI,CAAC+B,OAAO5B,MAAM,IAAIlD,mBAAmB8E,OAAO3B,IAAI,QAAQ,WAAW;YACrE,OAAO;gBAAEhB,IAAI;gBAAOC,QAAQ;gBAAKC,OAAO;YAA+B;QACzE;QACA,IAAI,CAAC/B,iBAAiBe,OAAOC,MAAMK,QAAQJ,GAAG,IAAI,OAAO;YAAEY,IAAI;YAAOC,QAAQ;YAAKC,OAAO7B;QAA2B;QACrH,MAAMqE,aAAaE,MAAMC,IAAI,CAAC;YAAE9C,QAAQ;QAAG,GAAG,CAAC+C,GAAGC,QAAWA,UAAU,IAAIT,OAAO,GAAGA,KAAK,CAAC,EAAES,QAAQ,GAAG;QACxG,MAAM3B,OAAO,IAAI1B,IAAI,AAAC,CAAA,MAAMiB,GAAGtB,GAAG,CAACoD,QAAQpB,KAAK,CAAC,QAAQ,MAAMqB,YAAW,EAAGnB,IAAI,CAAC5B,GAAG,CAAC,CAACU,MAAQa,OAAOb,IAAIhB,GAAG,CAAC;QAC9G,MAAMR,QAAO6D,mBAAAA,WAAWlB,IAAI,CAAC,CAACC,YAAc,CAACL,KAAKM,GAAG,CAACD,uBAAzCiB,mBAAwD,GAAGJ,KAAK,CAAC,EAAE9C,QAAQiB,EAAE,CAACxB,KAAK,CAAC,GAAG,GAAGD,WAAW,IAAI;QACtH,MAAM2C,QAAQ,AAAC,CAAA,MAAMhB,GAAGtB,GAAG,CAACoD,QAAQd,KAAK,GAAE,EAAGX,IAAI,GAAGW,KAAK;QAC1D,IAAIA,SAAS5D,4BAA4B,OAAO;YAAEiC,IAAI;YAAOC,QAAQ;YAAKC,OAAO1B;QAA6B;QAC9GmC,GAAGiB,MAAM,CAACpB,KAAK,aACV0B;YACHrD;YACA,6DAA6D;YAC7DoB,QAAQ;WACLhC,uBAAuBW;YAC1BkD,WAAWtC,QAAQuC,GAAG;YACtBiB,WAAWxD,QAAQuC,GAAG;YACtBkB,WAAWzD,QAAQJ,GAAG;;QAExB,OAAO;YAAEY,IAAI;YAAMiB,UAAU;YAAOR,IAAIjB,QAAQiB,EAAE;YAAE5B;YAAMD;QAAM;IAClE;AACF;AASA,iGAAiG,GACjG,OAAO,MAAMsE,4BAA4B,2DAA0D;AACnG,OAAO,MAAMC,uBAAuB,+CAA8C;AAElF;;;;;;CAMC,GACD,OAAO,eAAeC,sBACpB7D,SAAoB,EACpBC,OAAiG;IAEjG,MAAMW,UAAUZ,UAAUa,UAAU,CAAC,SAASC,GAAG,CAACb,QAAQc,MAAM;IAChE,MAAMnB,OAAO,MAAMgB,QAAQd,GAAG;IAC9B,IAAI,CAACF,KAAK4B,MAAM,EAAE,OAAO;QAAEf,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAe;IACzE,IAAI,CAAChC,mBAAmBgB,OAAOC,MAAMK,QAAQJ,GAAG,IAAI,OAAO;QAAEY,IAAI;QAAOC,QAAQ;QAAKC,OAAO5B;IAA6B;IACzH,MAAMmE,UAAUtC,QAAQC,UAAU,CAAC,eAAeC,GAAG,CAACb,QAAQgD,YAAY,EAAEpC,UAAU,CAAC;IACvF,MAAMiD,SAAgC;QAAEC,WAAW,EAAE;QAAEC,MAAM,EAAE;IAAC;IAChE,MAAMC,QAAQjE,UAAUiE,KAAK;IAC7B,KAAK,MAAM/C,MAAM,IAAIf,IAAIF,QAAQiE,GAAG,EAAG;QACrC,MAAMC,QAAQ,MAAMjB,QAAQpC,GAAG,CAACI,IAAIpB,GAAG;QACvC,IAAI,CAACqE,MAAM3C,MAAM,IAAI2C,MAAMrE,GAAG,CAAC,gBAAgB,MAAM;YACnDgE,OAAOE,IAAI,CAACI,IAAI,CAAC;gBAAElD;gBAAImD,QAAQT;YAAqB;YACpD;QACF;QACA,IAAIO,MAAMrE,GAAG,CAAC,cAAc,aAAa;YACvCgE,OAAOC,SAAS,CAACK,IAAI,CAAClD;YACtB;QACF;QACA,IAAI,CAACzC,eAAe;YAAE6F,UAAUH,MAAMrE,GAAG,CAAC;YAAayE,YAAYJ,MAAMrE,GAAG,CAAC;QAAc,IAAI;YAC7FgE,OAAOE,IAAI,CAACI,IAAI,CAAC;gBAAElD;gBAAImD,QAAQV;YAA0B;YACzD;QACF;QACAM,MAAMO,GAAG,CAACtB,QAAQpC,GAAG,CAACI,KAAK;YAAER,QAAQ;YAAa+D,aAAaxE,QAAQuC,GAAG;YAAEiB,WAAWxD,QAAQuC,GAAG;QAAC,GAAG;YAAEkC,OAAO;QAAK;QACpHZ,OAAOC,SAAS,CAACK,IAAI,CAAClD;IACxB;IACA,MAAM+C,MAAMU,MAAM;IAClB,OAAOb;AACT"}
|
package/src/lib/server/erase.js
CHANGED
|
@@ -32,6 +32,8 @@ import { isBillingSubscription } from "@aglyn/aglyn/server";
|
|
|
32
32
|
// barrel, and which sites a consent group still needs is the rule itself.
|
|
33
33
|
import { consentGroupSiteHold } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
34
34
|
import { listPluginOrgCollections } from "@aglyn/aglyn/plugin-manager/plugin-host-collections";
|
|
35
|
+
import { runPluginEventHandlers } from "@aglyn/aglyn/plugin-manager/plugin-events";
|
|
36
|
+
import { releasedHostNames } from "./host-released-names.js";
|
|
35
37
|
import { readOrgBilling } from "./org-billing.js";
|
|
36
38
|
import { disposeHostSendingDomain, readSendingDomainTeardownByLabel } from "./sending-domain-debt.js";
|
|
37
39
|
import { addAdminAudit } from "./admin-audit-write.js";
|
|
@@ -242,6 +244,18 @@ export async function eraseHost(hostId, options = {}) {
|
|
|
242
244
|
}
|
|
243
245
|
// The host document tree (screens/layouts/versions/counters/products/…).
|
|
244
246
|
await firestore.recursiveDelete(hostRef);
|
|
247
|
+
// The site's names no longer serve it (AGL-3629): plugins undo what they
|
|
248
|
+
// registered for each — commerce's Stripe payment method domains. Raised
|
|
249
|
+
// after the delete, so a plugin checking whether ANOTHER site still serves
|
|
250
|
+
// the name cannot find this one.
|
|
251
|
+
for (const domain of releasedHostNames(hostSnapshot.data())){
|
|
252
|
+
await runPluginEventHandlers('host.domain.released', {
|
|
253
|
+
orgId: orgId != null ? orgId : null,
|
|
254
|
+
hostId,
|
|
255
|
+
domain,
|
|
256
|
+
reason: 'site-deleted'
|
|
257
|
+
});
|
|
258
|
+
}
|
|
245
259
|
// Never throws, and never leaves the slot unaccounted for: whatever the
|
|
246
260
|
// vendors refuse is recorded on the label claim for the reaper to finish.
|
|
247
261
|
return {
|