@aglyn/plugins-commerce 1.0.0-beta.244 → 1.0.0-beta.247
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/README.md +4 -1
- package/package.json +13 -13
- package/src/lib/components/commerce-console-page.js +10 -0
- package/src/lib/components/commerce-console-page.js.map +1 -1
- package/src/lib/components/console/local-delivery-settings-card.component.js +10 -13
- package/src/lib/components/console/local-delivery-settings-card.component.js.map +1 -1
- package/src/lib/components/console/locations-card.component.js +5 -9
- package/src/lib/components/console/locations-card.component.js.map +1 -1
- package/src/lib/components/console/product-zones.d.ts +7 -1
- package/src/lib/components/console/product-zones.js.map +1 -1
- package/src/lib/components/console/products-hub-zone.component.d.ts +3 -2
- package/src/lib/components/console/products-hub-zone.component.js +9 -1
- package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
- package/src/lib/components/console/review-settings-card.component.d.ts +14 -0
- package/src/lib/components/console/review-settings-card.component.js +168 -0
- package/src/lib/components/console/review-settings-card.component.js.map +1 -0
- package/src/lib/components/console/reviews-moderation-card.component.d.ts +14 -0
- package/src/lib/components/console/reviews-moderation-card.component.js +85 -11
- package/src/lib/components/console/reviews-moderation-card.component.js.map +1 -1
- package/src/lib/components/product-reviews.d.ts +29 -1
- package/src/lib/components/product-reviews.js +97 -12
- package/src/lib/components/product-reviews.js.map +1 -1
- package/src/lib/model/commerce.d.ts +11 -2
- package/src/lib/model/commerce.js +14 -2
- package/src/lib/model/commerce.js.map +1 -1
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/product-reviews.d.ts +124 -0
- package/src/lib/model/product-reviews.js +133 -0
- package/src/lib/model/product-reviews.js.map +1 -0
- package/src/lib/model/store-setup-content.d.ts +145 -0
- package/src/lib/model/store-setup-content.js +813 -0
- package/src/lib/model/store-setup-content.js.map +1 -0
- package/src/lib/server/membership-reset.js +8 -2
- package/src/lib/server/membership-reset.js.map +1 -1
- package/src/lib/server/order-event-triggers.d.ts +2 -0
- package/src/lib/server/order-event-triggers.js +18 -1
- package/src/lib/server/order-event-triggers.js.map +1 -1
- package/src/lib/server/review-requests.d.ts +102 -0
- package/src/lib/server/review-requests.js +346 -0
- package/src/lib/server/review-requests.js.map +1 -0
- package/src/lib/server/review-tokens.d.ts +82 -0
- package/src/lib/server/review-tokens.js +119 -0
- package/src/lib/server/review-tokens.js.map +1 -0
- package/src/lib/server/reviews.d.ts +36 -3
- package/src/lib/server/reviews.js +310 -52
- package/src/lib/server/reviews.js.map +1 -1
- package/src/lib/server/store-setup-ai-capability.d.ts +61 -0
- package/src/lib/server/store-setup-ai-capability.js +343 -0
- package/src/lib/server/store-setup-ai-capability.js.map +1 -0
- package/src/lib/server/store-setup-ai-writer.console.d.ts +27 -0
- package/src/lib/server/store-setup-ai-writer.console.js +44 -0
- package/src/lib/server/store-setup-ai-writer.console.js.map +1 -0
- package/src/lib/server/store-setup-ai-writer.d.ts +37 -0
- package/src/lib/server/store-setup-ai-writer.js +174 -0
- package/src/lib/server/store-setup-ai-writer.js.map +1 -0
- package/src/lib/server.js +27 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.js +58 -0
- package/src/lib/tenant-emails.js.map +1 -1
|
@@ -0,0 +1,346 @@
|
|
|
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 * as Aglyn from "@aglyn/aglyn/server";
|
|
18
|
+
import { resolveHostToken } from "@aglyn/aglyn/app-utils/host-tokens";
|
|
19
|
+
import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail } from "@aglyn/tenant-data-admin";
|
|
20
|
+
import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
|
|
21
|
+
import { isDeferrableSendResult, sendEmail, sendFailureReason } from "@aglyn/shared-util-email";
|
|
22
|
+
import * as CommerceModel from "../model/index.js";
|
|
23
|
+
import { stageReviewTokens } from "./review-tokens.js";
|
|
24
|
+
/**
|
|
25
|
+
* The review-request email (AGL-3736).
|
|
26
|
+
*
|
|
27
|
+
* ## When
|
|
28
|
+
*
|
|
29
|
+
* N days (3–30, default 7, per store) after an order is fulfilled. Every door
|
|
30
|
+
* that ships or delivers an order already raises `order.fulfilled` or
|
|
31
|
+
* `order.delivered` (AGL-3611), and an order with nothing to ship raises
|
|
32
|
+
* `order.paid`, so commerce subscribes to its own events and SCHEDULES the
|
|
33
|
+
* request by stamping the order: `reviewRequestDueAtMs` at the earliest
|
|
34
|
+
* possible moment (fulfilment + 3 days) and `reviewRequest.state:
|
|
35
|
+
* 'scheduled'`. The hourly sweep reads the store's CURRENT delay when the
|
|
36
|
+
* stamp comes due and pushes it out if the store asked for longer, so a
|
|
37
|
+
* setting changed after fulfilment still applies.
|
|
38
|
+
*
|
|
39
|
+
* ## Once
|
|
40
|
+
*
|
|
41
|
+
* `reviewRequestDueAtMs` exists only while a request is owed — it is the
|
|
42
|
+
* field the collection-group sweep queries, so a sent or skipped order costs
|
|
43
|
+
* the sweep nothing. Scheduling refuses an order that already has a
|
|
44
|
+
* `reviewRequest`, and the send CLAIMS the order in a transaction that deletes
|
|
45
|
+
* the due field before anything is mailed: two overlapping sweeps cannot both
|
|
46
|
+
* send, and a redelivered event cannot schedule twice.
|
|
47
|
+
*
|
|
48
|
+
* ## What it respects
|
|
49
|
+
*
|
|
50
|
+
* Sent through the marketing seam (no stream), so the suppression lists, the
|
|
51
|
+
* site's unsubscribe link and header, and the per-person frequency ceiling
|
|
52
|
+
* all apply; a frequency or hourly refusal is retried an hour later. A line
|
|
53
|
+
* refunded by name, and a refunded or cancelled order, are never asked about.
|
|
54
|
+
* Register sales and marketplace orders are never asked. The store turns it
|
|
55
|
+
* off on its review settings, and words it under Emails (`review-request`).
|
|
56
|
+
*/ export const REVIEW_REQUEST_EMAIL_KEY = 'review-request';
|
|
57
|
+
/** How far a deferred send is pushed. */ const RETRY_AFTER_MS = 60 * 60 * 1000;
|
|
58
|
+
function ordersRef(firestore, hostId) {
|
|
59
|
+
return firestore.collection('hosts').doc(hostId).collection('orders');
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Schedules one order's request, once. Returns whether this call scheduled it.
|
|
63
|
+
*/ export async function scheduleReviewRequest(input, firestore = firebaseAdmin.app().firestore()) {
|
|
64
|
+
if (!input.hostId || !input.orderId) return false;
|
|
65
|
+
const orderRef = ordersRef(firestore, input.hostId).doc(input.orderId);
|
|
66
|
+
return firestore.runTransaction(async (transaction)=>{
|
|
67
|
+
var _snapshot_data, _order_lineItems;
|
|
68
|
+
const snapshot = await transaction.get(orderRef);
|
|
69
|
+
if (!snapshot.exists) return false;
|
|
70
|
+
const data = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
71
|
+
if (data['reviewRequest'] || data['reviewRequestDueAtMs']) return false;
|
|
72
|
+
const order = CommerceModel.liftLegacyOrder(data);
|
|
73
|
+
if (!CommerceModel.orderTakesReviewRequest(order)) return false;
|
|
74
|
+
// `order.paid` schedules only an order with nothing to ship; a shipped
|
|
75
|
+
// order waits for its shipment.
|
|
76
|
+
if (input.onlyIfNothingShips && ((_order_lineItems = order.lineItems) != null ? _order_lineItems : []).some((line)=>CommerceModel.lineRequiresShipping(line))) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const request = {
|
|
80
|
+
state: 'scheduled',
|
|
81
|
+
fulfilledAtMs: input.fulfilledAtMs
|
|
82
|
+
};
|
|
83
|
+
transaction.update(orderRef, {
|
|
84
|
+
reviewRequest: request,
|
|
85
|
+
reviewRequestDueAtMs: CommerceModel.reviewRequestEarliestDueAtMs(input.fulfilledAtMs)
|
|
86
|
+
});
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** The `order.fulfilled` / `order.delivered` / `order.paid` subscriber. */ export async function scheduleReviewRequestFromEvent(envelope, firestore) {
|
|
91
|
+
var _envelope_payload, _ref;
|
|
92
|
+
const order = ((_envelope_payload = envelope.payload) != null ? _envelope_payload : {}).order;
|
|
93
|
+
const orderId = String((_ref = order == null ? void 0 : order.id) != null ? _ref : '');
|
|
94
|
+
if (!orderId) return;
|
|
95
|
+
await scheduleReviewRequest({
|
|
96
|
+
hostId: envelope.hostId,
|
|
97
|
+
orderId,
|
|
98
|
+
fulfilledAtMs: Number(envelope.occurredAtMs) || Date.now(),
|
|
99
|
+
onlyIfNothingShips: envelope.event === 'order.paid'
|
|
100
|
+
}, firestore);
|
|
101
|
+
}
|
|
102
|
+
/** The email's words. Pure. */ export function composeReviewRequest(input) {
|
|
103
|
+
var _ref, _ref1;
|
|
104
|
+
const store = input.businessName || 'the store';
|
|
105
|
+
const list = input.lines.map((line)=>`${line.name}: ${line.url}`).join('\n');
|
|
106
|
+
const first = input.lines[0];
|
|
107
|
+
return {
|
|
108
|
+
subject: input.lines.length === 1 && first ? `How is your ${first.name}?` : `How was your order from ${store}?`,
|
|
109
|
+
text: `Thanks again for your order ${input.orderNumber} from ${store}. ` + 'Would you tell other shoppers what you think? Each link opens the ' + `product page with a review form, and works once, for 60 days.\n\n${list}`,
|
|
110
|
+
tokens: {
|
|
111
|
+
'order.number': input.orderNumber,
|
|
112
|
+
'review.products': list,
|
|
113
|
+
'review.url': (_ref = first == null ? void 0 : first.url) != null ? _ref : '',
|
|
114
|
+
'review.productName': (_ref1 = first == null ? void 0 : first.name) != null ? _ref1 : ''
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* One pass over the requests that have come due. Bounded (100) and
|
|
120
|
+
* idempotent; registered on the hourly plugin-job beat in `server.ts`.
|
|
121
|
+
*/ export async function scanReviewRequests(gate, options = {}) {
|
|
122
|
+
var _options_firestore, _options_nowMs;
|
|
123
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
124
|
+
const now = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
125
|
+
const due = await firestore.collectionGroup('orders').where('reviewRequestDueAtMs', '<=', now).orderBy('reviewRequestDueAtMs', 'asc').limit(100).get();
|
|
126
|
+
const result = {
|
|
127
|
+
scanned: due.size,
|
|
128
|
+
sent: 0,
|
|
129
|
+
skipped: 0,
|
|
130
|
+
deferred: 0,
|
|
131
|
+
skippedLocked: 0
|
|
132
|
+
};
|
|
133
|
+
const hosts = new Map();
|
|
134
|
+
const identityByHost = new Map();
|
|
135
|
+
for (const docSnapshot of due.docs){
|
|
136
|
+
var _hosts_get, _docSnapshot_data, _data_reviewRequest, _order_customerEmail, _ref;
|
|
137
|
+
var _docSnapshot_ref_parent_parent;
|
|
138
|
+
const hostId = (_docSnapshot_ref_parent_parent = docSnapshot.ref.parent.parent) == null ? void 0 : _docSnapshot_ref_parent_parent.id;
|
|
139
|
+
if (!hostId) continue;
|
|
140
|
+
// Lockdown first: everything below writes for this host. Skipped, not
|
|
141
|
+
// dropped — the due stamp stays, so the first beat after the lift sends.
|
|
142
|
+
if (await gate.isLocked(hostId)) {
|
|
143
|
+
result.skippedLocked += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const host = (_hosts_get = hosts.get(hostId)) != null ? _hosts_get : await readHostContext(firestore, hostId);
|
|
147
|
+
hosts.set(hostId, host);
|
|
148
|
+
const data = (_docSnapshot_data = docSnapshot.data()) != null ? _docSnapshot_data : {};
|
|
149
|
+
const order = CommerceModel.liftLegacyOrder(data);
|
|
150
|
+
const request = (_data_reviewRequest = data['reviewRequest']) != null ? _data_reviewRequest : {};
|
|
151
|
+
const fulfilledAtMs = Number(request.fulfilledAtMs) || Number(data['reviewRequestDueAtMs']) || now;
|
|
152
|
+
const finish = async (state, reason)=>{
|
|
153
|
+
await docSnapshot.ref.update({
|
|
154
|
+
reviewRequestDueAtMs: firebaseAdmin.firestore.FieldValue.delete(),
|
|
155
|
+
reviewRequest: _extends({}, request, {
|
|
156
|
+
fulfilledAtMs,
|
|
157
|
+
state,
|
|
158
|
+
reason,
|
|
159
|
+
atMs: now
|
|
160
|
+
})
|
|
161
|
+
}).catch(()=>undefined);
|
|
162
|
+
result.skipped += 1;
|
|
163
|
+
};
|
|
164
|
+
if (!host.entitled) {
|
|
165
|
+
await finish('skipped', 'not_entitled');
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!host.settings.requestEmail) {
|
|
169
|
+
await finish('skipped', 'disabled');
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const dueAtMs = CommerceModel.reviewRequestDueAtMs(fulfilledAtMs, host.settings.requestDelayDays);
|
|
173
|
+
if (dueAtMs > now) {
|
|
174
|
+
// The store asks for more days than the earliest stamp: wait.
|
|
175
|
+
await docSnapshot.ref.update({
|
|
176
|
+
reviewRequestDueAtMs: dueAtMs
|
|
177
|
+
}).catch(()=>undefined);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (!CommerceModel.orderTakesReviewRequest(order)) {
|
|
181
|
+
await finish('skipped', 'not_applicable');
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const email = String((_order_customerEmail = order.customerEmail) != null ? _order_customerEmail : '').trim().toLowerCase();
|
|
185
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
186
|
+
await finish('skipped', 'no_email');
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!host.hasProductPage || !host.origin) {
|
|
190
|
+
await finish('skipped', 'no_product_page');
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const products = await reviewableProducts(firestore, hostId, order);
|
|
194
|
+
if (!products.length) {
|
|
195
|
+
await finish('skipped', 'nothing_to_review');
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// CLAIM: whoever deletes the due stamp sends.
|
|
199
|
+
const claimed = await firestore.runTransaction(async (transaction)=>{
|
|
200
|
+
const fresh = await transaction.get(docSnapshot.ref);
|
|
201
|
+
if (!fresh.exists || !fresh.get('reviewRequestDueAtMs')) return false;
|
|
202
|
+
transaction.update(docSnapshot.ref, {
|
|
203
|
+
reviewRequestDueAtMs: firebaseAdmin.firestore.FieldValue.delete(),
|
|
204
|
+
reviewRequest: _extends({}, request, {
|
|
205
|
+
fulfilledAtMs,
|
|
206
|
+
state: 'sending',
|
|
207
|
+
atMs: now
|
|
208
|
+
})
|
|
209
|
+
});
|
|
210
|
+
return true;
|
|
211
|
+
}).catch(()=>false);
|
|
212
|
+
if (!claimed) continue;
|
|
213
|
+
const batch = firestore.batch();
|
|
214
|
+
const tokens = stageReviewTokens(firestore, batch, {
|
|
215
|
+
hostId,
|
|
216
|
+
orderId: docSnapshot.id,
|
|
217
|
+
email,
|
|
218
|
+
productIds: products.map((product)=>product.id),
|
|
219
|
+
nowMs: now
|
|
220
|
+
});
|
|
221
|
+
await batch.commit();
|
|
222
|
+
const lines = products.map((product)=>({
|
|
223
|
+
productId: product.id,
|
|
224
|
+
name: product.name,
|
|
225
|
+
url: `${host.origin}/products/${encodeURIComponent(product.slug)}?review=${tokens[product.id]}`
|
|
226
|
+
}));
|
|
227
|
+
const message = composeReviewRequest({
|
|
228
|
+
businessName: host.businessName,
|
|
229
|
+
orderNumber: CommerceModel.formatOrderNumber(order, docSnapshot.id),
|
|
230
|
+
lines
|
|
231
|
+
});
|
|
232
|
+
if (host.template === undefined) {
|
|
233
|
+
host.template = await loadHostEmailWithTokens(firestore, hostId, REVIEW_REQUEST_EMAIL_KEY).catch(()=>null);
|
|
234
|
+
}
|
|
235
|
+
const designed = host.template ? renderLoadedHostEmailWithTokens(host.template, message.tokens) : null;
|
|
236
|
+
const sent = await sendEmail(_extends({
|
|
237
|
+
to: email,
|
|
238
|
+
subject: (_ref = designed == null ? void 0 : designed.subject) != null ? _ref : message.subject,
|
|
239
|
+
text: (designed == null ? void 0 : designed.text) || message.text
|
|
240
|
+
}, (designed == null ? void 0 : designed.html) ? {
|
|
241
|
+
html: designed.html
|
|
242
|
+
} : {}, {
|
|
243
|
+
fromName: host.fromName,
|
|
244
|
+
sendingIdentity: await hostSendingIdentity(hostId, identityByHost),
|
|
245
|
+
audience: 'tenant',
|
|
246
|
+
context: 'review request',
|
|
247
|
+
priority: 'bulk',
|
|
248
|
+
// Through the marketing seam with NO stream: the suppression lists,
|
|
249
|
+
// the unsubscribe link and header and the frequency ceiling apply, and
|
|
250
|
+
// leaving "Promotions and offers" does not stop it.
|
|
251
|
+
marketing: {
|
|
252
|
+
hostId,
|
|
253
|
+
siteBase: host.origin,
|
|
254
|
+
consentHostIds: host.consentGroup.hostIds,
|
|
255
|
+
consentAwaitsConfirmation: host.consentGroup.awaitsConfirmation
|
|
256
|
+
}
|
|
257
|
+
})).catch((error)=>{
|
|
258
|
+
console.error('[review-requests] send failed', hostId, docSnapshot.id, error);
|
|
259
|
+
return null;
|
|
260
|
+
});
|
|
261
|
+
if (!sent || isDeferrableSendResult(sent)) {
|
|
262
|
+
// Not this hour: put the stamp back. The minted links are unspent and
|
|
263
|
+
// expire on their own.
|
|
264
|
+
await docSnapshot.ref.update({
|
|
265
|
+
reviewRequestDueAtMs: now + RETRY_AFTER_MS,
|
|
266
|
+
reviewRequest: _extends({}, request, {
|
|
267
|
+
fulfilledAtMs,
|
|
268
|
+
state: 'scheduled'
|
|
269
|
+
})
|
|
270
|
+
}).catch(()=>undefined);
|
|
271
|
+
result.deferred += 1;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (sent.sent) {
|
|
275
|
+
await meterHostEmail(hostId);
|
|
276
|
+
await docSnapshot.ref.update({
|
|
277
|
+
reviewRequest: _extends({}, request, {
|
|
278
|
+
fulfilledAtMs,
|
|
279
|
+
state: 'sent',
|
|
280
|
+
atMs: now,
|
|
281
|
+
productIds: products.map((product)=>product.id)
|
|
282
|
+
}),
|
|
283
|
+
timeline: CommerceModel.appendOrderEvent(order, 'buyer-notified', 'Review request sent by email', now)
|
|
284
|
+
}).catch(()=>undefined);
|
|
285
|
+
result.sent += 1;
|
|
286
|
+
} else {
|
|
287
|
+
var _sendFailureReason;
|
|
288
|
+
await docSnapshot.ref.update({
|
|
289
|
+
reviewRequest: _extends({}, request, {
|
|
290
|
+
fulfilledAtMs,
|
|
291
|
+
state: 'skipped',
|
|
292
|
+
reason: (_sendFailureReason = sendFailureReason(sent)) != null ? _sendFailureReason : 'refused',
|
|
293
|
+
atMs: now
|
|
294
|
+
})
|
|
295
|
+
}).catch(()=>undefined);
|
|
296
|
+
result.skipped += 1;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
async function readHostContext(firestore, hostId) {
|
|
302
|
+
var _ref, _ref1, _ref2, _Aglyn_hostPublicOrigin, _resolveHostToken;
|
|
303
|
+
var _hostSnapshot_data, _settingsSnapshot_data;
|
|
304
|
+
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
305
|
+
const [org, hostSnapshot, settingsSnapshot] = await Promise.all([
|
|
306
|
+
getOrgForHost(hostId).catch(()=>null),
|
|
307
|
+
hostRef.get().catch(()=>null),
|
|
308
|
+
hostRef.collection('settings').doc('store').get().catch(()=>null)
|
|
309
|
+
]);
|
|
310
|
+
const host = (_ref = hostSnapshot == null ? void 0 : (_hostSnapshot_data = hostSnapshot.data) == null ? void 0 : _hostSnapshot_data.call(hostSnapshot)) != null ? _ref : {};
|
|
311
|
+
const settings = (_ref1 = settingsSnapshot == null ? void 0 : (_settingsSnapshot_data = settingsSnapshot.data) == null ? void 0 : _settingsSnapshot_data.call(settingsSnapshot)) != null ? _ref1 : {};
|
|
312
|
+
const orgDoc = (_ref2 = org == null ? void 0 : org.org) != null ? _ref2 : null;
|
|
313
|
+
return {
|
|
314
|
+
entitled: Aglyn.checkEntitlement(org == null ? void 0 : org.org, 'productReviews'),
|
|
315
|
+
settings: CommerceModel.reviewSettingsFrom(settings),
|
|
316
|
+
hasProductPage: Boolean(settings['pdpScreenId']),
|
|
317
|
+
origin: (_Aglyn_hostPublicOrigin = Aglyn.hostPublicOrigin({
|
|
318
|
+
cname: typeof host['cname'] === 'string' ? host['cname'] : null,
|
|
319
|
+
subdomain: typeof host['subdomain'] === 'string' ? host['subdomain'] : null
|
|
320
|
+
})) != null ? _Aglyn_hostPublicOrigin : '',
|
|
321
|
+
businessName: String((_resolveHostToken = resolveHostToken('businessName', host)) != null ? _resolveHostToken : ''),
|
|
322
|
+
fromName: Aglyn.resolveBrandingProfile(org == null ? void 0 : org.org).fromName,
|
|
323
|
+
consentGroup: Aglyn.consentGroupForHost(orgDoc, hostId),
|
|
324
|
+
template: undefined
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/** The order's reviewable products that are still on sale, with their slugs. */ async function reviewableProducts(firestore, hostId, order) {
|
|
328
|
+
const ids = CommerceModel.reviewableProductIds(order).slice(0, 10);
|
|
329
|
+
if (!ids.length) return [];
|
|
330
|
+
const productsRef = firestore.collection('hosts').doc(hostId).collection('products');
|
|
331
|
+
const snapshots = await firestore.getAll(...ids.map((id)=>productsRef.doc(id)));
|
|
332
|
+
const products = [];
|
|
333
|
+
for (const snapshot of snapshots){
|
|
334
|
+
var _data_name;
|
|
335
|
+
const data = snapshot.exists ? snapshot.data() : null;
|
|
336
|
+
if (!data || data['deletedAt'] || data['status'] !== 'active' || !data['slug']) continue;
|
|
337
|
+
products.push({
|
|
338
|
+
id: snapshot.id,
|
|
339
|
+
name: String((_data_name = data['name']) != null ? _data_name : 'Your item'),
|
|
340
|
+
slug: String(data['slug'])
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return products;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
//# sourceMappingURL=review-requests.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/review-requests.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 * as Aglyn from '@aglyn/aglyn/server'\nimport type { PluginJobHostGate } from '@aglyn/aglyn/server'\nimport type { PluginDomainEventEnvelope } from '@aglyn/aglyn/plugin-manager/plugin-domain-events'\nimport {\n resolveHostToken,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport {\n isDeferrableSendResult,\n sendEmail,\n sendFailureReason,\n} from '@aglyn/shared-util-email'\nimport * as CommerceModel from '../model'\nimport { stageReviewTokens } from './review-tokens'\n\n/**\n * The review-request email (AGL-3736).\n *\n * ## When\n *\n * N days (3–30, default 7, per store) after an order is fulfilled. Every door\n * that ships or delivers an order already raises `order.fulfilled` or\n * `order.delivered` (AGL-3611), and an order with nothing to ship raises\n * `order.paid`, so commerce subscribes to its own events and SCHEDULES the\n * request by stamping the order: `reviewRequestDueAtMs` at the earliest\n * possible moment (fulfilment + 3 days) and `reviewRequest.state:\n * 'scheduled'`. The hourly sweep reads the store's CURRENT delay when the\n * stamp comes due and pushes it out if the store asked for longer, so a\n * setting changed after fulfilment still applies.\n *\n * ## Once\n *\n * `reviewRequestDueAtMs` exists only while a request is owed — it is the\n * field the collection-group sweep queries, so a sent or skipped order costs\n * the sweep nothing. Scheduling refuses an order that already has a\n * `reviewRequest`, and the send CLAIMS the order in a transaction that deletes\n * the due field before anything is mailed: two overlapping sweeps cannot both\n * send, and a redelivered event cannot schedule twice.\n *\n * ## What it respects\n *\n * Sent through the marketing seam (no stream), so the suppression lists, the\n * site's unsubscribe link and header, and the per-person frequency ceiling\n * all apply; a frequency or hourly refusal is retried an hour later. A line\n * refunded by name, and a refunded or cancelled order, are never asked about.\n * Register sales and marketplace orders are never asked. The store turns it\n * off on its review settings, and words it under Emails (`review-request`).\n */\n\nexport const REVIEW_REQUEST_EMAIL_KEY = 'review-request'\n\n/** How far a deferred send is pushed. */\nconst RETRY_AFTER_MS = 60 * 60 * 1000\n\n/** What a stamped order carries. */\nexport interface OrderReviewRequest {\n state: 'scheduled' | 'sending' | 'sent' | 'skipped'\n fulfilledAtMs: number\n atMs?: number\n reason?: string\n productIds?: string[]\n}\n\nfunction ordersRef(firestore: FirebaseFirestore.Firestore, hostId: string) {\n return firestore.collection('hosts').doc(hostId).collection('orders')\n}\n\n/**\n * Schedules one order's request, once. Returns whether this call scheduled it.\n */\nexport async function scheduleReviewRequest(\n input: { hostId: string; orderId: string; fulfilledAtMs: number; onlyIfNothingShips?: boolean },\n firestore: FirebaseFirestore.Firestore = firebaseAdmin.app().firestore(),\n): Promise<boolean> {\n if (!input.hostId || !input.orderId) return false\n const orderRef = ordersRef(firestore, input.hostId).doc(input.orderId)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(orderRef)\n if (!snapshot.exists) return false\n const data = snapshot.data() ?? {}\n if (data['reviewRequest'] || data['reviewRequestDueAtMs']) return false\n const order = CommerceModel.liftLegacyOrder(data as never)\n if (!CommerceModel.orderTakesReviewRequest(order)) return false\n // `order.paid` schedules only an order with nothing to ship; a shipped\n // order waits for its shipment.\n if (\n input.onlyIfNothingShips &&\n (order.lineItems ?? []).some((line) => CommerceModel.lineRequiresShipping(line))\n ) {\n return false\n }\n const request: OrderReviewRequest = {\n state: 'scheduled',\n fulfilledAtMs: input.fulfilledAtMs,\n }\n transaction.update(orderRef, {\n reviewRequest: request,\n reviewRequestDueAtMs: CommerceModel.reviewRequestEarliestDueAtMs(input.fulfilledAtMs),\n })\n return true\n })\n}\n\n/** The `order.fulfilled` / `order.delivered` / `order.paid` subscriber. */\nexport async function scheduleReviewRequestFromEvent(\n envelope: Pick<PluginDomainEventEnvelope, 'event' | 'hostId' | 'occurredAtMs' | 'payload'>,\n firestore?: FirebaseFirestore.Firestore,\n): Promise<void> {\n const order = ((envelope.payload ?? {}) as { order?: { id?: string } }).order\n const orderId = String(order?.id ?? '')\n if (!orderId) return\n await scheduleReviewRequest(\n {\n hostId: envelope.hostId,\n orderId,\n fulfilledAtMs: Number(envelope.occurredAtMs) || Date.now(),\n onlyIfNothingShips: envelope.event === 'order.paid',\n },\n firestore,\n )\n}\n\n/** One reviewable product as the email lists it. */\nexport interface ReviewRequestLine {\n productId: string\n name: string\n url: string\n}\n\n/** The email's words. Pure. */\nexport function composeReviewRequest(input: {\n businessName: string\n orderNumber: string\n lines: ReviewRequestLine[]\n}): { subject: string; text: string; tokens: Record<string, string> } {\n const store = input.businessName || 'the store'\n const list = input.lines.map((line) => `${line.name}: ${line.url}`).join('\\n')\n const first = input.lines[0]\n return {\n subject:\n input.lines.length === 1 && first\n ? `How is your ${first.name}?`\n : `How was your order from ${store}?`,\n text:\n `Thanks again for your order ${input.orderNumber} from ${store}. ` +\n 'Would you tell other shoppers what you think? Each link opens the ' +\n `product page with a review form, and works once, for 60 days.\\n\\n${list}`,\n tokens: {\n 'order.number': input.orderNumber,\n 'review.products': list,\n 'review.url': first?.url ?? '',\n 'review.productName': first?.name ?? '',\n },\n }\n}\n\nexport interface ReviewRequestScanResult {\n scanned: number\n sent: number\n skipped: number\n deferred: number\n skippedLocked: number\n}\n\ninterface HostContext {\n entitled: boolean\n settings: CommerceModel.ReviewSettings\n hasProductPage: boolean\n origin: string\n businessName: string\n fromName?: string\n consentGroup: Aglyn.ConsentGroup\n template: LoadedHostEmailWithTokens | null | undefined\n}\n\n/**\n * One pass over the requests that have come due. Bounded (100) and\n * idempotent; registered on the hourly plugin-job beat in `server.ts`.\n */\nexport async function scanReviewRequests(\n gate: PluginJobHostGate,\n options: { firestore?: FirebaseFirestore.Firestore; nowMs?: number } = {},\n): Promise<ReviewRequestScanResult> {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const now = options.nowMs ?? Date.now()\n const due = await firestore\n .collectionGroup('orders')\n .where('reviewRequestDueAtMs', '<=', now)\n .orderBy('reviewRequestDueAtMs', 'asc')\n .limit(100)\n .get()\n const result: ReviewRequestScanResult = {\n scanned: due.size,\n sent: 0,\n skipped: 0,\n deferred: 0,\n skippedLocked: 0,\n }\n const hosts = new Map<string, HostContext>()\n const identityByHost = new Map<string, Awaited<ReturnType<typeof hostSendingIdentity>>>()\n\n for (const docSnapshot of due.docs) {\n const hostId = docSnapshot.ref.parent.parent?.id\n if (!hostId) continue\n // Lockdown first: everything below writes for this host. Skipped, not\n // dropped — the due stamp stays, so the first beat after the lift sends.\n if (await gate.isLocked(hostId)) {\n result.skippedLocked += 1\n continue\n }\n const host = hosts.get(hostId) ?? (await readHostContext(firestore, hostId))\n hosts.set(hostId, host)\n const data = docSnapshot.data() ?? {}\n const order = CommerceModel.liftLegacyOrder(data as never)\n const request = (data['reviewRequest'] ?? {}) as Partial<OrderReviewRequest>\n const fulfilledAtMs = Number(request.fulfilledAtMs) || Number(data['reviewRequestDueAtMs']) || now\n\n const finish = async (state: 'skipped', reason: string) => {\n await docSnapshot.ref\n .update({\n reviewRequestDueAtMs: firebaseAdmin.firestore.FieldValue.delete(),\n reviewRequest: { ...request, fulfilledAtMs, state, reason, atMs: now },\n })\n .catch(() => undefined)\n result.skipped += 1\n }\n\n if (!host.entitled) {\n await finish('skipped', 'not_entitled')\n continue\n }\n if (!host.settings.requestEmail) {\n await finish('skipped', 'disabled')\n continue\n }\n const dueAtMs = CommerceModel.reviewRequestDueAtMs(fulfilledAtMs, host.settings.requestDelayDays)\n if (dueAtMs > now) {\n // The store asks for more days than the earliest stamp: wait.\n await docSnapshot.ref.update({ reviewRequestDueAtMs: dueAtMs }).catch(() => undefined)\n continue\n }\n if (!CommerceModel.orderTakesReviewRequest(order)) {\n await finish('skipped', 'not_applicable')\n continue\n }\n const email = String(order.customerEmail ?? '').trim().toLowerCase()\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {\n await finish('skipped', 'no_email')\n continue\n }\n if (!host.hasProductPage || !host.origin) {\n await finish('skipped', 'no_product_page')\n continue\n }\n const products = await reviewableProducts(firestore, hostId, order)\n if (!products.length) {\n await finish('skipped', 'nothing_to_review')\n continue\n }\n\n // CLAIM: whoever deletes the due stamp sends.\n const claimed = await firestore\n .runTransaction(async (transaction) => {\n const fresh = await transaction.get(docSnapshot.ref)\n if (!fresh.exists || !fresh.get('reviewRequestDueAtMs')) return false\n transaction.update(docSnapshot.ref, {\n reviewRequestDueAtMs: firebaseAdmin.firestore.FieldValue.delete(),\n reviewRequest: { ...request, fulfilledAtMs, state: 'sending', atMs: now },\n })\n return true\n })\n .catch(() => false)\n if (!claimed) continue\n\n const batch = firestore.batch()\n const tokens = stageReviewTokens(firestore, batch, {\n hostId,\n orderId: docSnapshot.id,\n email,\n productIds: products.map((product) => product.id),\n nowMs: now,\n })\n await batch.commit()\n const lines: ReviewRequestLine[] = products.map((product) => ({\n productId: product.id,\n name: product.name,\n url: `${host.origin}/products/${encodeURIComponent(product.slug)}?review=${tokens[product.id]}`,\n }))\n const message = composeReviewRequest({\n businessName: host.businessName,\n orderNumber: CommerceModel.formatOrderNumber(order, docSnapshot.id),\n lines,\n })\n if (host.template === undefined) {\n host.template = await loadHostEmailWithTokens(firestore, hostId, REVIEW_REQUEST_EMAIL_KEY).catch(\n () => null,\n )\n }\n const designed = host.template\n ? renderLoadedHostEmailWithTokens(host.template, message.tokens)\n : null\n const sent = await sendEmail({\n to: email,\n subject: designed?.subject ?? message.subject,\n text: designed?.text || message.text,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: host.fromName,\n sendingIdentity: await hostSendingIdentity(hostId, identityByHost),\n audience: 'tenant',\n context: 'review request',\n priority: 'bulk',\n // Through the marketing seam with NO stream: the suppression lists,\n // the unsubscribe link and header and the frequency ceiling apply, and\n // leaving \"Promotions and offers\" does not stop it.\n marketing: {\n hostId,\n siteBase: host.origin,\n consentHostIds: host.consentGroup.hostIds,\n consentAwaitsConfirmation: host.consentGroup.awaitsConfirmation,\n },\n }).catch((error) => {\n console.error('[review-requests] send failed', hostId, docSnapshot.id, error)\n return null\n })\n\n if (!sent || isDeferrableSendResult(sent)) {\n // Not this hour: put the stamp back. The minted links are unspent and\n // expire on their own.\n await docSnapshot.ref\n .update({\n reviewRequestDueAtMs: now + RETRY_AFTER_MS,\n reviewRequest: { ...request, fulfilledAtMs, state: 'scheduled' },\n })\n .catch(() => undefined)\n result.deferred += 1\n continue\n }\n if (sent.sent) {\n await meterHostEmail(hostId)\n await docSnapshot.ref\n .update({\n reviewRequest: {\n ...request,\n fulfilledAtMs,\n state: 'sent',\n atMs: now,\n productIds: products.map((product) => product.id),\n },\n timeline: CommerceModel.appendOrderEvent(order, 'buyer-notified', 'Review request sent by email', now),\n })\n .catch(() => undefined)\n result.sent += 1\n } else {\n await docSnapshot.ref\n .update({\n reviewRequest: {\n ...request,\n fulfilledAtMs,\n state: 'skipped',\n reason: sendFailureReason(sent) ?? 'refused',\n atMs: now,\n },\n })\n .catch(() => undefined)\n result.skipped += 1\n }\n }\n return result\n}\n\nasync function readHostContext(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<HostContext> {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [org, hostSnapshot, settingsSnapshot] = await Promise.all([\n getOrgForHost(hostId).catch(() => null),\n hostRef.get().catch(() => null),\n hostRef.collection('settings').doc('store').get().catch(() => null),\n ])\n const host = (hostSnapshot?.data?.() ?? {}) as Record<string, unknown>\n const settings = (settingsSnapshot?.data?.() ?? {}) as Record<string, unknown>\n const orgDoc = (org?.org as Record<string, unknown> | undefined) ?? null\n return {\n entitled: Aglyn.checkEntitlement(org?.org as never, 'productReviews'),\n settings: CommerceModel.reviewSettingsFrom(settings),\n hasProductPage: Boolean(settings['pdpScreenId']),\n origin:\n Aglyn.hostPublicOrigin({\n cname: typeof host['cname'] === 'string' ? host['cname'] : null,\n subdomain: typeof host['subdomain'] === 'string' ? host['subdomain'] : null,\n }) ?? '',\n businessName: String(resolveHostToken('businessName', host as HostTokenSource) ?? ''),\n fromName: Aglyn.resolveBrandingProfile(org?.org as never).fromName,\n consentGroup: Aglyn.consentGroupForHost(orgDoc, hostId),\n template: undefined,\n }\n}\n\n/** The order's reviewable products that are still on sale, with their slugs. */\nasync function reviewableProducts(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n order: CommerceModel.HostOrder,\n): Promise<Array<{ id: string; name: string; slug: string }>> {\n const ids = CommerceModel.reviewableProductIds(order).slice(0, 10)\n if (!ids.length) return []\n const productsRef = firestore.collection('hosts').doc(hostId).collection('products')\n const snapshots = await firestore.getAll(...ids.map((id) => productsRef.doc(id)))\n const products: Array<{ id: string; name: string; slug: string }> = []\n for (const snapshot of snapshots) {\n const data = snapshot.exists ? (snapshot.data() as Record<string, unknown>) : null\n if (!data || data['deletedAt'] || data['status'] !== 'active' || !data['slug']) continue\n products.push({ id: snapshot.id, name: String(data['name'] ?? 'Your item'), slug: String(data['slug']) })\n }\n return products\n}\n"],"names":["Aglyn","resolveHostToken","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","isDeferrableSendResult","sendEmail","sendFailureReason","CommerceModel","stageReviewTokens","REVIEW_REQUEST_EMAIL_KEY","RETRY_AFTER_MS","ordersRef","firestore","hostId","collection","doc","scheduleReviewRequest","input","app","orderId","orderRef","runTransaction","transaction","snapshot","order","get","exists","data","liftLegacyOrder","orderTakesReviewRequest","onlyIfNothingShips","lineItems","some","line","lineRequiresShipping","request","state","fulfilledAtMs","update","reviewRequest","reviewRequestDueAtMs","reviewRequestEarliestDueAtMs","scheduleReviewRequestFromEvent","envelope","payload","String","id","Number","occurredAtMs","Date","now","event","composeReviewRequest","store","businessName","list","lines","map","name","url","join","first","subject","length","text","orderNumber","tokens","scanReviewRequests","gate","options","nowMs","due","collectionGroup","where","orderBy","limit","result","scanned","size","sent","skipped","deferred","skippedLocked","hosts","Map","identityByHost","docSnapshot","docs","ref","parent","isLocked","host","readHostContext","set","finish","reason","FieldValue","delete","atMs","catch","undefined","entitled","settings","requestEmail","dueAtMs","requestDelayDays","email","customerEmail","trim","toLowerCase","test","hasProductPage","origin","products","reviewableProducts","claimed","fresh","batch","productIds","product","commit","productId","encodeURIComponent","slug","message","formatOrderNumber","template","designed","to","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","consentHostIds","consentGroup","hostIds","consentAwaitsConfirmation","awaitsConfirmation","error","console","timeline","appendOrderEvent","hostSnapshot","settingsSnapshot","hostRef","org","Promise","all","orgDoc","checkEntitlement","reviewSettingsFrom","Boolean","hostPublicOrigin","cname","subdomain","resolveBrandingProfile","consentGroupForHost","ids","reviewableProductIds","slice","productsRef","snapshots","getAll","push"],"mappings":"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"}
|
|
@@ -0,0 +1,82 @@
|
|
|
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 * as CommerceModel from '../model';
|
|
18
|
+
/**
|
|
19
|
+
* One-time review links (AGL-3736).
|
|
20
|
+
*
|
|
21
|
+
* The review-request email carries, per product, a link to the product page
|
|
22
|
+
* with `?review=<token>`. The token is 24 random bytes; only its SHA-256 is
|
|
23
|
+
* stored, as the document id of `hosts/{hostId}/reviewTokens/{hash}`, so a
|
|
24
|
+
* leaked database row cannot be replayed as a link. The row binds the token
|
|
25
|
+
* to one order, one product and the buyer's email, works once, and expires
|
|
26
|
+
* 60 days after it was minted (a TTL policy on `expiresAt` clears old rows).
|
|
27
|
+
*
|
|
28
|
+
* The collection is closed to every client in the Firestore rules: a site
|
|
29
|
+
* editor who could write a row could mint a "verified" review for any
|
|
30
|
+
* product.
|
|
31
|
+
*/
|
|
32
|
+
export declare const REVIEW_TOKENS_COLLECTION = "reviewTokens";
|
|
33
|
+
export interface ReviewTokenRecord {
|
|
34
|
+
orderId: string;
|
|
35
|
+
productId: string;
|
|
36
|
+
email: string;
|
|
37
|
+
createdAtMs: number;
|
|
38
|
+
expiresAtMs: number;
|
|
39
|
+
usedAtMs: number | null;
|
|
40
|
+
reviewId?: string;
|
|
41
|
+
}
|
|
42
|
+
/** The stored id for a presented token. */
|
|
43
|
+
export declare function hashReviewToken(token: string): string;
|
|
44
|
+
/** A fresh token: URL-safe, 32 characters. */
|
|
45
|
+
export declare function newReviewToken(): string;
|
|
46
|
+
/** A presented token's shape, before any read. */
|
|
47
|
+
export declare function plausibleReviewToken(token: unknown): token is string;
|
|
48
|
+
/**
|
|
49
|
+
* Stages one token per product in the caller's batch and returns the raw
|
|
50
|
+
* tokens (to put in the email), by product id.
|
|
51
|
+
*/
|
|
52
|
+
export declare function stageReviewTokens(firestore: FirebaseFirestore.Firestore, batch: FirebaseFirestore.WriteBatch, input: {
|
|
53
|
+
hostId: string;
|
|
54
|
+
orderId: string;
|
|
55
|
+
email: string;
|
|
56
|
+
productIds: string[];
|
|
57
|
+
nowMs: number;
|
|
58
|
+
}): Record<string, string>;
|
|
59
|
+
export type ReviewTokenCheck = {
|
|
60
|
+
ok: true;
|
|
61
|
+
record: ReviewTokenRecord;
|
|
62
|
+
ref: FirebaseFirestore.DocumentReference;
|
|
63
|
+
code?: undefined;
|
|
64
|
+
} | {
|
|
65
|
+
ok: false;
|
|
66
|
+
code: CommerceModel.ReviewErrorCode;
|
|
67
|
+
record?: undefined;
|
|
68
|
+
ref?: undefined;
|
|
69
|
+
};
|
|
70
|
+
/** The verdict on one stored row, for a product. Pure. */
|
|
71
|
+
export declare function reviewTokenVerdict(record: Partial<ReviewTokenRecord> | null | undefined, productId: string, nowMs: number): CommerceModel.ReviewErrorCode | null;
|
|
72
|
+
/**
|
|
73
|
+
* Reads and judges a presented token without spending it (the form's
|
|
74
|
+
* eligibility check). The POST spends it with {@link redeemReviewTokenIn}.
|
|
75
|
+
*/
|
|
76
|
+
export declare function inspectReviewToken(firestore: FirebaseFirestore.Firestore, hostId: string, token: unknown, productId: string, nowMs?: number): Promise<ReviewTokenCheck>;
|
|
77
|
+
/**
|
|
78
|
+
* Spends a token inside the caller's transaction: re-reads it, re-judges it,
|
|
79
|
+
* and marks it used with the review it produced. Two submissions of one link
|
|
80
|
+
* race on the same document, so exactly one wins.
|
|
81
|
+
*/
|
|
82
|
+
export declare function redeemReviewTokenIn(transaction: FirebaseFirestore.Transaction, ref: FirebaseFirestore.DocumentReference, productId: string, reviewId: string, nowMs?: number): Promise<CommerceModel.ReviewErrorCode | null>;
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
*/ import { createHash, randomBytes } from "crypto";
|
|
17
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
18
|
+
import * as CommerceModel from "../model/index.js";
|
|
19
|
+
/**
|
|
20
|
+
* One-time review links (AGL-3736).
|
|
21
|
+
*
|
|
22
|
+
* The review-request email carries, per product, a link to the product page
|
|
23
|
+
* with `?review=<token>`. The token is 24 random bytes; only its SHA-256 is
|
|
24
|
+
* stored, as the document id of `hosts/{hostId}/reviewTokens/{hash}`, so a
|
|
25
|
+
* leaked database row cannot be replayed as a link. The row binds the token
|
|
26
|
+
* to one order, one product and the buyer's email, works once, and expires
|
|
27
|
+
* 60 days after it was minted (a TTL policy on `expiresAt` clears old rows).
|
|
28
|
+
*
|
|
29
|
+
* The collection is closed to every client in the Firestore rules: a site
|
|
30
|
+
* editor who could write a row could mint a "verified" review for any
|
|
31
|
+
* product.
|
|
32
|
+
*/ export const REVIEW_TOKENS_COLLECTION = 'reviewTokens';
|
|
33
|
+
/** The stored id for a presented token. */ export function hashReviewToken(token) {
|
|
34
|
+
return createHash('sha256').update(`review-token:${token}`).digest('hex');
|
|
35
|
+
}
|
|
36
|
+
/** A fresh token: URL-safe, 32 characters. */ export function newReviewToken() {
|
|
37
|
+
return randomBytes(24).toString('base64url');
|
|
38
|
+
}
|
|
39
|
+
/** A presented token's shape, before any read. */ export function plausibleReviewToken(token) {
|
|
40
|
+
return typeof token === 'string' && /^[A-Za-z0-9_-]{32}$/.test(token);
|
|
41
|
+
}
|
|
42
|
+
function tokensRef(firestore, hostId) {
|
|
43
|
+
return firestore.collection('hosts').doc(hostId).collection(REVIEW_TOKENS_COLLECTION);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Stages one token per product in the caller's batch and returns the raw
|
|
47
|
+
* tokens (to put in the email), by product id.
|
|
48
|
+
*/ export function stageReviewTokens(firestore, batch, input) {
|
|
49
|
+
const tokens = {};
|
|
50
|
+
const expiresAtMs = input.nowMs + CommerceModel.REVIEW_TOKEN_TTL_MS;
|
|
51
|
+
for (const productId of input.productIds){
|
|
52
|
+
const token = newReviewToken();
|
|
53
|
+
tokens[productId] = token;
|
|
54
|
+
batch.set(tokensRef(firestore, input.hostId).doc(hashReviewToken(token)), {
|
|
55
|
+
orderId: input.orderId,
|
|
56
|
+
productId,
|
|
57
|
+
email: input.email.toLowerCase(),
|
|
58
|
+
createdAtMs: input.nowMs,
|
|
59
|
+
expiresAtMs,
|
|
60
|
+
// The TTL field (cloud/firebase-firestore.indexes.json).
|
|
61
|
+
expiresAt: firebaseAdmin.firestore.Timestamp.fromMillis(expiresAtMs),
|
|
62
|
+
usedAtMs: null
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return tokens;
|
|
66
|
+
}
|
|
67
|
+
/** The verdict on one stored row, for a product. Pure. */ export function reviewTokenVerdict(record, productId, nowMs) {
|
|
68
|
+
var _record_productId;
|
|
69
|
+
const codes = CommerceModel.REVIEW_ERROR_CODES;
|
|
70
|
+
if (!record) return codes.tokenInvalid;
|
|
71
|
+
// A link for another product is not a link for this one.
|
|
72
|
+
if (String((_record_productId = record.productId) != null ? _record_productId : '') !== productId) return codes.tokenInvalid;
|
|
73
|
+
if (record.usedAtMs) return codes.tokenUsed;
|
|
74
|
+
if (!(Number(record.expiresAtMs) > nowMs)) return codes.tokenExpired;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Reads and judges a presented token without spending it (the form's
|
|
79
|
+
* eligibility check). The POST spends it with {@link redeemReviewTokenIn}.
|
|
80
|
+
*/ export async function inspectReviewToken(firestore, hostId, token, productId, nowMs = Date.now()) {
|
|
81
|
+
if (!plausibleReviewToken(token)) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
code: CommerceModel.REVIEW_ERROR_CODES.tokenInvalid
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const ref = tokensRef(firestore, hostId).doc(hashReviewToken(token));
|
|
88
|
+
const snapshot = await ref.get();
|
|
89
|
+
const record = snapshot.exists ? snapshot.data() : null;
|
|
90
|
+
const problem = reviewTokenVerdict(record, productId, nowMs);
|
|
91
|
+
if (problem || !record) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
code: problem != null ? problem : CommerceModel.REVIEW_ERROR_CODES.tokenInvalid
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
record,
|
|
100
|
+
ref
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Spends a token inside the caller's transaction: re-reads it, re-judges it,
|
|
105
|
+
* and marks it used with the review it produced. Two submissions of one link
|
|
106
|
+
* race on the same document, so exactly one wins.
|
|
107
|
+
*/ export async function redeemReviewTokenIn(transaction, ref, productId, reviewId, nowMs = Date.now()) {
|
|
108
|
+
const snapshot = await transaction.get(ref);
|
|
109
|
+
const record = snapshot.exists ? snapshot.data() : null;
|
|
110
|
+
const problem = reviewTokenVerdict(record, productId, nowMs);
|
|
111
|
+
if (problem) return problem;
|
|
112
|
+
transaction.update(ref, {
|
|
113
|
+
usedAtMs: nowMs,
|
|
114
|
+
reviewId
|
|
115
|
+
});
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
//# sourceMappingURL=review-tokens.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/review-tokens.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 { createHash, randomBytes } from 'crypto'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * One-time review links (AGL-3736).\n *\n * The review-request email carries, per product, a link to the product page\n * with `?review=<token>`. The token is 24 random bytes; only its SHA-256 is\n * stored, as the document id of `hosts/{hostId}/reviewTokens/{hash}`, so a\n * leaked database row cannot be replayed as a link. The row binds the token\n * to one order, one product and the buyer's email, works once, and expires\n * 60 days after it was minted (a TTL policy on `expiresAt` clears old rows).\n *\n * The collection is closed to every client in the Firestore rules: a site\n * editor who could write a row could mint a \"verified\" review for any\n * product.\n */\n\nexport const REVIEW_TOKENS_COLLECTION = 'reviewTokens'\n\nexport interface ReviewTokenRecord {\n orderId: string\n productId: string\n email: string\n createdAtMs: number\n expiresAtMs: number\n usedAtMs: number | null\n reviewId?: string\n}\n\n/** The stored id for a presented token. */\nexport function hashReviewToken(token: string): string {\n return createHash('sha256').update(`review-token:${token}`).digest('hex')\n}\n\n/** A fresh token: URL-safe, 32 characters. */\nexport function newReviewToken(): string {\n return randomBytes(24).toString('base64url')\n}\n\n/** A presented token's shape, before any read. */\nexport function plausibleReviewToken(token: unknown): token is string {\n return typeof token === 'string' && /^[A-Za-z0-9_-]{32}$/.test(token)\n}\n\nfunction tokensRef(firestore: FirebaseFirestore.Firestore, hostId: string) {\n return firestore.collection('hosts').doc(hostId).collection(REVIEW_TOKENS_COLLECTION)\n}\n\n/**\n * Stages one token per product in the caller's batch and returns the raw\n * tokens (to put in the email), by product id.\n */\nexport function stageReviewTokens(\n firestore: FirebaseFirestore.Firestore,\n batch: FirebaseFirestore.WriteBatch,\n input: { hostId: string; orderId: string; email: string; productIds: string[]; nowMs: number },\n): Record<string, string> {\n const tokens: Record<string, string> = {}\n const expiresAtMs = input.nowMs + CommerceModel.REVIEW_TOKEN_TTL_MS\n for (const productId of input.productIds) {\n const token = newReviewToken()\n tokens[productId] = token\n batch.set(tokensRef(firestore, input.hostId).doc(hashReviewToken(token)), {\n orderId: input.orderId,\n productId,\n email: input.email.toLowerCase(),\n createdAtMs: input.nowMs,\n expiresAtMs,\n // The TTL field (cloud/firebase-firestore.indexes.json).\n expiresAt: firebaseAdmin.firestore.Timestamp.fromMillis(expiresAtMs),\n usedAtMs: null,\n })\n }\n return tokens\n}\n\nexport type ReviewTokenCheck =\n | {\n ok: true\n record: ReviewTokenRecord\n ref: FirebaseFirestore.DocumentReference\n code?: undefined\n }\n | { ok: false; code: CommerceModel.ReviewErrorCode; record?: undefined; ref?: undefined }\n\n/** The verdict on one stored row, for a product. Pure. */\nexport function reviewTokenVerdict(\n record: Partial<ReviewTokenRecord> | null | undefined,\n productId: string,\n nowMs: number,\n): CommerceModel.ReviewErrorCode | null {\n const codes = CommerceModel.REVIEW_ERROR_CODES\n if (!record) return codes.tokenInvalid\n // A link for another product is not a link for this one.\n if (String(record.productId ?? '') !== productId) return codes.tokenInvalid\n if (record.usedAtMs) return codes.tokenUsed\n if (!(Number(record.expiresAtMs) > nowMs)) return codes.tokenExpired\n return null\n}\n\n/**\n * Reads and judges a presented token without spending it (the form's\n * eligibility check). The POST spends it with {@link redeemReviewTokenIn}.\n */\nexport async function inspectReviewToken(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n token: unknown,\n productId: string,\n nowMs = Date.now(),\n): Promise<ReviewTokenCheck> {\n if (!plausibleReviewToken(token)) {\n return { ok: false, code: CommerceModel.REVIEW_ERROR_CODES.tokenInvalid }\n }\n const ref = tokensRef(firestore, hostId).doc(hashReviewToken(token))\n const snapshot = await ref.get()\n const record = snapshot.exists ? (snapshot.data() as ReviewTokenRecord) : null\n const problem = reviewTokenVerdict(record, productId, nowMs)\n if (problem || !record) {\n return { ok: false, code: problem ?? CommerceModel.REVIEW_ERROR_CODES.tokenInvalid }\n }\n return { ok: true, record, ref }\n}\n\n/**\n * Spends a token inside the caller's transaction: re-reads it, re-judges it,\n * and marks it used with the review it produced. Two submissions of one link\n * race on the same document, so exactly one wins.\n */\nexport async function redeemReviewTokenIn(\n transaction: FirebaseFirestore.Transaction,\n ref: FirebaseFirestore.DocumentReference,\n productId: string,\n reviewId: string,\n nowMs = Date.now(),\n): Promise<CommerceModel.ReviewErrorCode | null> {\n const snapshot = await transaction.get(ref)\n const record = snapshot.exists ? (snapshot.data() as ReviewTokenRecord) : null\n const problem = reviewTokenVerdict(record, productId, nowMs)\n if (problem) return problem\n transaction.update(ref, { usedAtMs: nowMs, reviewId })\n return null\n}\n"],"names":["createHash","randomBytes","firebaseAdmin","CommerceModel","REVIEW_TOKENS_COLLECTION","hashReviewToken","token","update","digest","newReviewToken","toString","plausibleReviewToken","test","tokensRef","firestore","hostId","collection","doc","stageReviewTokens","batch","input","tokens","expiresAtMs","nowMs","REVIEW_TOKEN_TTL_MS","productId","productIds","set","orderId","email","toLowerCase","createdAtMs","expiresAt","Timestamp","fromMillis","usedAtMs","reviewTokenVerdict","record","codes","REVIEW_ERROR_CODES","tokenInvalid","String","tokenUsed","Number","tokenExpired","inspectReviewToken","Date","now","ok","code","ref","snapshot","get","exists","data","problem","redeemReviewTokenIn","transaction","reviewId"],"mappings":"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"}
|