@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
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
import type { PluginApiHandler } from '@aglyn/aglyn/server';
|
|
18
|
+
import * as CommerceModel from '../model';
|
|
18
19
|
export interface PublicProductReview {
|
|
19
20
|
id: string;
|
|
20
21
|
rating: number;
|
|
@@ -78,9 +79,41 @@ export declare function readStoreReviews(hostId: string, limit?: number): Promis
|
|
|
78
79
|
reviews: PublicStoreReview[];
|
|
79
80
|
aggregate: ProductReviewAggregate;
|
|
80
81
|
}>;
|
|
82
|
+
/** Reviews one address may submit per site, per hour (AGL-3736). */
|
|
83
|
+
export declare const REVIEW_POSTS_PER_IP_PER_HOUR = 10;
|
|
84
|
+
/** Reviews one signed-in member may submit per site, per day. */
|
|
85
|
+
export declare const REVIEW_POSTS_PER_MEMBER_PER_DAY = 10;
|
|
81
86
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
87
|
+
* The address a signed-in member has PROVEN they own, or `''`.
|
|
88
|
+
*
|
|
89
|
+
* A site member signs up by typing an email nobody checks, so the address on
|
|
90
|
+
* the member document alone would let anyone register with a buyer's address
|
|
91
|
+
* and collect a "Verified buyer" badge on their purchases. Only a member
|
|
92
|
+
* whose inbox has answered — `emailVerifiedAtMs`, stamped when they complete
|
|
93
|
+
* a password reset from the emailed link or redeem a review link while signed
|
|
94
|
+
* in — counts.
|
|
95
|
+
*/
|
|
96
|
+
export declare function memberProvenEmail(member: FirebaseFirestore.DocumentSnapshot | null | undefined): string;
|
|
97
|
+
/** The first order under `email` that still entitles `productId`, or null. */
|
|
98
|
+
export declare function entitlingOrderId(hostRef: FirebaseFirestore.DocumentReference, email: string, productId: string): Promise<string | null>;
|
|
99
|
+
/**
|
|
100
|
+
* Product reviews (AGL-324, AGL-3736).
|
|
101
|
+
*
|
|
102
|
+
* GET returns approved reviews + aggregate (`scope=store`: store-wide;
|
|
103
|
+
* `mode=eligibility`: what the form may do). POST submits one review:
|
|
104
|
+
*
|
|
105
|
+
* - VERIFIED when the reviewer presents an unspent, unexpired review link
|
|
106
|
+
* for this product, or is a signed-in member whose proven email has an
|
|
107
|
+
* order that still entitles it. Published at once (`approved`): the FTC's
|
|
108
|
+
* 2024 rule bars suppressing honest negative reviews; the merchant may
|
|
109
|
+
* still hide or reject one for a content reason.
|
|
110
|
+
* - Otherwise, under the store's `verified` policy (the default), 403
|
|
111
|
+
* `verified_purchase_required`; under `anyone`, saved `pending` with the
|
|
112
|
+
* name and email given — the email is never shown and verifies nothing.
|
|
113
|
+
*
|
|
114
|
+
* Rate-limited per address and per member, durably. Plan-gated
|
|
115
|
+
* (`productReviews`).
|
|
85
116
|
*/
|
|
86
117
|
export declare const reviewsHandler: PluginApiHandler;
|
|
118
|
+
/** The words for a review link that does not work. */
|
|
119
|
+
export declare function tokenProblemMessage(code: CommerceModel.ReviewErrorCode): string;
|
|
@@ -17,8 +17,12 @@
|
|
|
17
17
|
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
18
18
|
import * as Aglyn from "@aglyn/aglyn/server";
|
|
19
19
|
import * as CommerceModel from "../model/index.js";
|
|
20
|
-
import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
20
|
+
import { consumeRateLimit, firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
21
|
+
import { readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
|
|
22
|
+
import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
|
|
21
23
|
import { AggregateField } from "firebase-admin/firestore";
|
|
24
|
+
import { MEMBER_SUSPENDED_ERROR, readActiveMemberSession } from "./membership.js";
|
|
25
|
+
import { inspectReviewToken, redeemReviewTokenIn } from "./review-tokens.js";
|
|
22
26
|
/**
|
|
23
27
|
* Approved reviews for a product, newest first, plus their aggregate.
|
|
24
28
|
*
|
|
@@ -137,10 +141,130 @@ import { AggregateField } from "firebase-admin/firestore";
|
|
|
137
141
|
}
|
|
138
142
|
};
|
|
139
143
|
}
|
|
144
|
+
/** Reviews one address may submit per site, per hour (AGL-3736). */ export const REVIEW_POSTS_PER_IP_PER_HOUR = 10;
|
|
145
|
+
/** Reviews one signed-in member may submit per site, per day. */ export const REVIEW_POSTS_PER_MEMBER_PER_DAY = 10;
|
|
146
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
140
147
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
148
|
+
* The address a signed-in member has PROVEN they own, or `''`.
|
|
149
|
+
*
|
|
150
|
+
* A site member signs up by typing an email nobody checks, so the address on
|
|
151
|
+
* the member document alone would let anyone register with a buyer's address
|
|
152
|
+
* and collect a "Verified buyer" badge on their purchases. Only a member
|
|
153
|
+
* whose inbox has answered — `emailVerifiedAtMs`, stamped when they complete
|
|
154
|
+
* a password reset from the emailed link or redeem a review link while signed
|
|
155
|
+
* in — counts.
|
|
156
|
+
*/ export function memberProvenEmail(member) {
|
|
157
|
+
var _member_get, _member_get1;
|
|
158
|
+
if (!(member == null ? void 0 : member.exists)) return '';
|
|
159
|
+
if (!Number((_member_get = member.get('emailVerifiedAtMs')) != null ? _member_get : 0)) return '';
|
|
160
|
+
return String((_member_get1 = member.get('email')) != null ? _member_get1 : '').trim().toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
/** The first order under `email` that still entitles `productId`, or null. */ export async function entitlingOrderId(hostRef, email, productId) {
|
|
163
|
+
if (!email) return null;
|
|
164
|
+
// By the member's email, as the account page matches order history.
|
|
165
|
+
const orders = await hostRef.collection('orders').where('customerEmail', '==', email).limit(25).get();
|
|
166
|
+
for (const docSnapshot of orders.docs){
|
|
167
|
+
const order = CommerceModel.liftLegacyOrder(docSnapshot.data());
|
|
168
|
+
// The shared entitlement test (AGL-2454): a line refunded by name is
|
|
169
|
+
// goods the buyer no longer has.
|
|
170
|
+
if (CommerceModel.orderEntitlesProduct(order, productId)) return docSnapshot.id;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/** A store's review settings, from `settings/store`. */ async function readReviewSettings(hostRef) {
|
|
175
|
+
var _ref;
|
|
176
|
+
var _snapshot_data;
|
|
177
|
+
const snapshot = await hostRef.collection('settings').doc('store').get().catch(()=>null);
|
|
178
|
+
return CommerceModel.reviewSettingsFrom((_ref = snapshot == null ? void 0 : (_snapshot_data = snapshot.data) == null ? void 0 : _snapshot_data.call(snapshot)) != null ? _ref : null);
|
|
179
|
+
}
|
|
180
|
+
function clientIp(req) {
|
|
181
|
+
var _readClientIp;
|
|
182
|
+
var _req_socket;
|
|
183
|
+
return (_readClientIp = readClientIp(req.headers, {
|
|
184
|
+
remoteAddress: (_req_socket = req.socket) == null ? void 0 : _req_socket.remoteAddress
|
|
185
|
+
})) != null ? _readClientIp : 'no-address';
|
|
186
|
+
}
|
|
187
|
+
const ANONYMOUS_REVIEWER = {
|
|
188
|
+
signedIn: false,
|
|
189
|
+
suspended: false,
|
|
190
|
+
memberId: null,
|
|
191
|
+
memberEmail: '',
|
|
192
|
+
memberName: '',
|
|
193
|
+
memberOrderId: null
|
|
194
|
+
};
|
|
195
|
+
async function identifyReviewer(req, hostRef, hostId, productId) {
|
|
196
|
+
var _session_member_get, _session_member_get1;
|
|
197
|
+
const session = await readActiveMemberSession(req, hostId).catch(()=>({
|
|
198
|
+
status: 'anonymous'
|
|
199
|
+
}));
|
|
200
|
+
if (session.status === 'suspended') {
|
|
201
|
+
return _extends({}, ANONYMOUS_REVIEWER, {
|
|
202
|
+
signedIn: true,
|
|
203
|
+
suspended: true,
|
|
204
|
+
memberId: session.memberId
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (session.status !== 'active') return ANONYMOUS_REVIEWER;
|
|
208
|
+
const proven = memberProvenEmail(session.member);
|
|
209
|
+
return {
|
|
210
|
+
signedIn: true,
|
|
211
|
+
suspended: false,
|
|
212
|
+
memberId: session.memberId,
|
|
213
|
+
memberEmail: String((_session_member_get = session.member.get('email')) != null ? _session_member_get : '').trim().toLowerCase(),
|
|
214
|
+
memberName: String((_session_member_get1 = session.member.get('displayName')) != null ? _session_member_get1 : '').trim(),
|
|
215
|
+
memberOrderId: proven ? await entitlingOrderId(hostRef, proven, productId) : null
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The storefront form's question (AGL-3736): may this visitor review this
|
|
220
|
+
* product, and would the review be verified? `GET ?mode=eligibility`, never
|
|
221
|
+
* cached — the answer depends on the visitor's cookie and link.
|
|
222
|
+
*/ async function eligibilityHandler(req, res, hostId, productId) {
|
|
223
|
+
res.setHeader('Cache-Control', 'private, no-store');
|
|
224
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
225
|
+
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
226
|
+
const org = await getOrgForHost(hostId);
|
|
227
|
+
const enabled = Aglyn.checkEntitlement(org == null ? void 0 : org.org, 'productReviews');
|
|
228
|
+
const settings = await readReviewSettings(hostRef);
|
|
229
|
+
const reviewer = await identifyReviewer(req, hostRef, hostId, productId);
|
|
230
|
+
const presented = req.query.review;
|
|
231
|
+
let tokenVerified = false;
|
|
232
|
+
let tokenProblem;
|
|
233
|
+
if (presented) {
|
|
234
|
+
var _check_code;
|
|
235
|
+
const check = await inspectReviewToken(firestore, hostId, presented, productId);
|
|
236
|
+
if (check.ok) tokenVerified = true;
|
|
237
|
+
else tokenProblem = (_check_code = check.code) != null ? _check_code : CommerceModel.REVIEW_ERROR_CODES.tokenInvalid;
|
|
238
|
+
}
|
|
239
|
+
const eligibility = CommerceModel.reviewEligibility({
|
|
240
|
+
policy: settings.policy,
|
|
241
|
+
signedIn: reviewer.signedIn && !reviewer.suspended,
|
|
242
|
+
memberVerified: Boolean(reviewer.memberOrderId),
|
|
243
|
+
tokenVerified,
|
|
244
|
+
tokenProblem
|
|
245
|
+
});
|
|
246
|
+
return res.status(200).json(_extends({}, eligibility, {
|
|
247
|
+
enabled,
|
|
248
|
+
canReview: enabled && !reviewer.suspended && eligibility.canReview
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Product reviews (AGL-324, AGL-3736).
|
|
253
|
+
*
|
|
254
|
+
* GET returns approved reviews + aggregate (`scope=store`: store-wide;
|
|
255
|
+
* `mode=eligibility`: what the form may do). POST submits one review:
|
|
256
|
+
*
|
|
257
|
+
* - VERIFIED when the reviewer presents an unspent, unexpired review link
|
|
258
|
+
* for this product, or is a signed-in member whose proven email has an
|
|
259
|
+
* order that still entitles it. Published at once (`approved`): the FTC's
|
|
260
|
+
* 2024 rule bars suppressing honest negative reviews; the merchant may
|
|
261
|
+
* still hide or reject one for a content reason.
|
|
262
|
+
* - Otherwise, under the store's `verified` policy (the default), 403
|
|
263
|
+
* `verified_purchase_required`; under `anyone`, saved `pending` with the
|
|
264
|
+
* name and email given — the email is never shown and verifies nothing.
|
|
265
|
+
*
|
|
266
|
+
* Rate-limited per address and per member, durably. Plan-gated
|
|
267
|
+
* (`productReviews`).
|
|
144
268
|
*/ export const reviewsHandler = async (req, res)=>{
|
|
145
269
|
var _ref, _ref1;
|
|
146
270
|
var _req_body, _req_body1;
|
|
@@ -185,54 +309,9 @@ import { AggregateField } from "firebase-admin/firestore";
|
|
|
185
309
|
});
|
|
186
310
|
}
|
|
187
311
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
var _ref2, _ref3, _ref4, _ref5;
|
|
192
|
-
var _req_body2, _req_body3, _req_body4, _req_body5;
|
|
193
|
-
const org = await getOrgForHost(hostId);
|
|
194
|
-
if (!Aglyn.checkEntitlement(org == null ? void 0 : org.org, 'productReviews')) {
|
|
195
|
-
return res.status(403).json({
|
|
196
|
-
error: 'Reviews are not enabled'
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
const rating = Math.min(5, Math.max(1, Math.round(Number((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.rating) != null ? _ref2 : 0))));
|
|
200
|
-
const body = String((_ref3 = (_req_body3 = req.body) == null ? void 0 : _req_body3.body) != null ? _ref3 : '').trim().slice(0, 2000);
|
|
201
|
-
const authorName = String((_ref4 = (_req_body4 = req.body) == null ? void 0 : _req_body4.authorName) != null ? _ref4 : '').trim().slice(0, 80);
|
|
202
|
-
const authorEmail = String((_ref5 = (_req_body5 = req.body) == null ? void 0 : _req_body5.authorEmail) != null ? _ref5 : '').trim().toLowerCase().slice(0, 120);
|
|
203
|
-
if (!rating || !body || !authorEmail) {
|
|
204
|
-
return res.status(400).json({
|
|
205
|
-
error: 'Rating, review, and email required'
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
// Verified buyer: a paid order with the product under this email.
|
|
209
|
-
let verified = false;
|
|
210
|
-
const orders = await hostRef.collection('orders').where('customerEmail', '==', authorEmail).limit(25).get();
|
|
211
|
-
for (const docSnapshot of orders.docs){
|
|
212
|
-
const order = CommerceModel.liftLegacyOrder(docSnapshot.data());
|
|
213
|
-
// Through the shared entitlement test (AGL-2454). "Verified buyer" on a
|
|
214
|
-
// purchase that was refunded line by line is a badge for goods the
|
|
215
|
-
// buyer no longer has — and the inline status literal this replaced
|
|
216
|
-
// only moved on a FULL refund.
|
|
217
|
-
if (CommerceModel.orderEntitlesProduct(order, productId)) {
|
|
218
|
-
verified = true;
|
|
219
|
-
break;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
await hostRef.collection('reviews').add({
|
|
223
|
-
productId,
|
|
224
|
-
rating,
|
|
225
|
-
body,
|
|
226
|
-
authorName: authorName || 'Anonymous',
|
|
227
|
-
authorEmail,
|
|
228
|
-
verified,
|
|
229
|
-
status: 'pending',
|
|
230
|
-
createdAtMs: Date.now()
|
|
231
|
-
});
|
|
232
|
-
return res.status(200).json({
|
|
233
|
-
ok: true,
|
|
234
|
-
pending: true
|
|
235
|
-
});
|
|
312
|
+
if (isPost) return await submitReview(req, res, hostId, productId);
|
|
313
|
+
if (req.query.mode === 'eligibility') {
|
|
314
|
+
return await eligibilityHandler(req, res, hostId, productId);
|
|
236
315
|
}
|
|
237
316
|
const { reviews, aggregate } = await readProductReviews(hostId, productId);
|
|
238
317
|
res.setHeader('Cache-Control', 'public, s-maxage=120, stale-while-revalidate=300');
|
|
@@ -247,5 +326,184 @@ import { AggregateField } from "firebase-admin/firestore";
|
|
|
247
326
|
});
|
|
248
327
|
}
|
|
249
328
|
};
|
|
329
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
330
|
+
async function submitReview(req, res, hostId, productId) {
|
|
331
|
+
var _ref, _ref1, _ref2, _ref3;
|
|
332
|
+
var _req_body, _req_body1, _req_body2, _req_body3, _req_body4;
|
|
333
|
+
const codes = CommerceModel.REVIEW_ERROR_CODES;
|
|
334
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
335
|
+
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
336
|
+
const org = await getOrgForHost(hostId);
|
|
337
|
+
if (!Aglyn.checkEntitlement(org == null ? void 0 : org.org, 'productReviews')) {
|
|
338
|
+
return res.status(403).json({
|
|
339
|
+
error: 'Reviews are not enabled',
|
|
340
|
+
code: codes.notEnabled
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const refuseRate = (resetMs)=>{
|
|
344
|
+
res.setHeader('Retry-After', String(Math.max(1, Math.ceil((resetMs - Date.now()) / 1000))));
|
|
345
|
+
return res.status(429).json({
|
|
346
|
+
error: 'Too many reviews from here. Try again later.',
|
|
347
|
+
code: codes.rateLimited
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
// Per address, before anything else is read.
|
|
351
|
+
const perIp = await consumeRateLimit(`commerce-review:ip:${hostId}:${clientIp(req)}`, {
|
|
352
|
+
limit: REVIEW_POSTS_PER_IP_PER_HOUR,
|
|
353
|
+
windowMs: HOUR_MS
|
|
354
|
+
});
|
|
355
|
+
if (!perIp.allowed) return refuseRate(perIp.resetMs);
|
|
356
|
+
const rating = Math.min(5, Math.max(1, Math.round(Number((_ref = (_req_body = req.body) == null ? void 0 : _req_body.rating) != null ? _ref : 0))));
|
|
357
|
+
const body = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.body) != null ? _ref1 : '').trim().slice(0, 2000);
|
|
358
|
+
const typedName = String((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.authorName) != null ? _ref2 : '').trim().slice(0, 80);
|
|
359
|
+
if (!rating || !body) {
|
|
360
|
+
return res.status(400).json({
|
|
361
|
+
error: 'A rating and a review are required'
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const settings = await readReviewSettings(hostRef);
|
|
365
|
+
const reviewer = await identifyReviewer(req, hostRef, hostId, productId);
|
|
366
|
+
if (reviewer.suspended) {
|
|
367
|
+
return res.status(403).json({
|
|
368
|
+
error: MEMBER_SUSPENDED_ERROR,
|
|
369
|
+
code: codes.memberSuspended
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (reviewer.memberId) {
|
|
373
|
+
const perMember = await consumeRateLimit(`commerce-review:member:${hostId}:${reviewer.memberId}`, {
|
|
374
|
+
limit: REVIEW_POSTS_PER_MEMBER_PER_DAY,
|
|
375
|
+
windowMs: 24 * HOUR_MS
|
|
376
|
+
});
|
|
377
|
+
if (!perMember.allowed) return refuseRate(perMember.resetMs);
|
|
378
|
+
}
|
|
379
|
+
// 1. A review link from the email. A link that does not work is an error,
|
|
380
|
+
// never a silent fall back to an unverified review.
|
|
381
|
+
const presented = (_req_body3 = req.body) == null ? void 0 : _req_body3.reviewToken;
|
|
382
|
+
if (presented) {
|
|
383
|
+
const check = await inspectReviewToken(firestore, hostId, presented, productId);
|
|
384
|
+
if (!check.ok || !check.record || !check.ref) {
|
|
385
|
+
var _check_code;
|
|
386
|
+
const code = (_check_code = check.code) != null ? _check_code : codes.tokenInvalid;
|
|
387
|
+
return res.status(403).json({
|
|
388
|
+
error: tokenProblemMessage(code),
|
|
389
|
+
code
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const reviewRef = hostRef.collection('reviews').doc(createResourceUid());
|
|
393
|
+
const nowMs = Date.now();
|
|
394
|
+
const problem = await firestore.runTransaction(async (transaction)=>{
|
|
395
|
+
const spent = await redeemReviewTokenIn(transaction, check.ref, productId, reviewRef.id, nowMs);
|
|
396
|
+
if (spent) return spent;
|
|
397
|
+
transaction.create(reviewRef, _extends({
|
|
398
|
+
productId,
|
|
399
|
+
rating,
|
|
400
|
+
body,
|
|
401
|
+
authorName: typedName || reviewer.memberName || 'Anonymous',
|
|
402
|
+
authorEmail: check.record.email
|
|
403
|
+
}, reviewer.memberId ? {
|
|
404
|
+
memberId: reviewer.memberId
|
|
405
|
+
} : {}, {
|
|
406
|
+
verified: true,
|
|
407
|
+
verifiedVia: 'token',
|
|
408
|
+
orderId: check.record.orderId,
|
|
409
|
+
status: CommerceModel.initialReviewStatus(true),
|
|
410
|
+
createdAtMs: nowMs
|
|
411
|
+
}));
|
|
412
|
+
return null;
|
|
413
|
+
});
|
|
414
|
+
if (problem) {
|
|
415
|
+
return res.status(403).json({
|
|
416
|
+
error: tokenProblemMessage(problem),
|
|
417
|
+
code: problem
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// The link came through the inbox, so a member signed in with the same
|
|
421
|
+
// address has now proven it.
|
|
422
|
+
if (reviewer.memberId && reviewer.memberEmail === check.record.email) {
|
|
423
|
+
await hostRef.collection('siteMembers').doc(reviewer.memberId).set({
|
|
424
|
+
emailVerifiedAtMs: nowMs
|
|
425
|
+
}, {
|
|
426
|
+
merge: true
|
|
427
|
+
}).catch(()=>undefined);
|
|
428
|
+
}
|
|
429
|
+
return res.status(200).json({
|
|
430
|
+
ok: true,
|
|
431
|
+
pending: false,
|
|
432
|
+
verified: true
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
// 2. A signed-in member whose proven email bought it. One verified review
|
|
436
|
+
// per member per product.
|
|
437
|
+
if (reviewer.memberOrderId) {
|
|
438
|
+
const already = await hostRef.collection('reviews').where('productId', '==', productId).where('memberId', '==', reviewer.memberId).limit(1).get();
|
|
439
|
+
if (!already.empty) {
|
|
440
|
+
return res.status(409).json({
|
|
441
|
+
error: 'You have already reviewed this product.',
|
|
442
|
+
code: codes.alreadyReviewed
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
await hostRef.collection('reviews').doc(createResourceUid()).set({
|
|
446
|
+
productId,
|
|
447
|
+
rating,
|
|
448
|
+
body,
|
|
449
|
+
authorName: typedName || reviewer.memberName || 'Anonymous',
|
|
450
|
+
authorEmail: reviewer.memberEmail,
|
|
451
|
+
memberId: reviewer.memberId,
|
|
452
|
+
verified: true,
|
|
453
|
+
verifiedVia: 'member',
|
|
454
|
+
orderId: reviewer.memberOrderId,
|
|
455
|
+
status: CommerceModel.initialReviewStatus(true),
|
|
456
|
+
createdAtMs: Date.now()
|
|
457
|
+
});
|
|
458
|
+
return res.status(200).json({
|
|
459
|
+
ok: true,
|
|
460
|
+
pending: false,
|
|
461
|
+
verified: true
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
// 3. Nobody the store can verify.
|
|
465
|
+
if (settings.policy !== 'anyone') {
|
|
466
|
+
return res.status(403).json({
|
|
467
|
+
error: 'Only customers who bought this can review it.',
|
|
468
|
+
code: codes.verifiedPurchaseRequired
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const authorEmail = reviewer.signedIn ? reviewer.memberEmail : String((_ref3 = (_req_body4 = req.body) == null ? void 0 : _req_body4.authorEmail) != null ? _ref3 : '').trim().toLowerCase().slice(0, 120);
|
|
472
|
+
const authorName = typedName || reviewer.memberName;
|
|
473
|
+
if (!authorName || !EMAIL_PATTERN.test(authorEmail)) {
|
|
474
|
+
return res.status(400).json({
|
|
475
|
+
error: 'Your name and email are required'
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
await hostRef.collection('reviews').doc(createResourceUid()).set(_extends({
|
|
479
|
+
productId,
|
|
480
|
+
rating,
|
|
481
|
+
body,
|
|
482
|
+
authorName,
|
|
483
|
+
// Kept for the merchant; never shown, and it verifies nothing.
|
|
484
|
+
authorEmail
|
|
485
|
+
}, reviewer.memberId ? {
|
|
486
|
+
memberId: reviewer.memberId
|
|
487
|
+
} : {}, {
|
|
488
|
+
verified: false,
|
|
489
|
+
status: CommerceModel.initialReviewStatus(false),
|
|
490
|
+
createdAtMs: Date.now()
|
|
491
|
+
}));
|
|
492
|
+
return res.status(200).json({
|
|
493
|
+
ok: true,
|
|
494
|
+
pending: true,
|
|
495
|
+
verified: false
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
/** The words for a review link that does not work. */ export function tokenProblemMessage(code) {
|
|
499
|
+
switch(code){
|
|
500
|
+
case CommerceModel.REVIEW_ERROR_CODES.tokenUsed:
|
|
501
|
+
return 'This review link has already been used.';
|
|
502
|
+
case CommerceModel.REVIEW_ERROR_CODES.tokenExpired:
|
|
503
|
+
return 'This review link has expired.';
|
|
504
|
+
default:
|
|
505
|
+
return 'This review link is not valid for this product.';
|
|
506
|
+
}
|
|
507
|
+
}
|
|
250
508
|
|
|
251
509
|
//# sourceMappingURL=reviews.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reviews.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 type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { AggregateField } from 'firebase-admin/firestore'\n\nexport interface PublicProductReview {\n id: string\n rating: number\n body: string\n authorName: string\n verified: boolean\n reply: string | null\n createdAtMs: number\n}\n\nexport interface ProductReviewAggregate {\n count: number\n /** Mean rating, one decimal. */\n average: number\n}\n\n/**\n * Approved reviews for a product, newest first, plus their aggregate.\n *\n * Extracted from the handler (AGL-686) so the site-page resolver can nest\n * `aggregateRating` inside the server-rendered `Product` node. Schema.org\n * wants it as a PROPERTY of the product; the reviews block was emitting a\n * free-standing `AggregateRating` node alongside it, which is orphaned and\n * ignored. One reader means the rating in the structured data and the rating\n * on the page cannot disagree.\n */\nexport async function readProductReviews(\n hostId: string,\n productId: string,\n): Promise<{\n reviews: PublicProductReview[]\n aggregate: ProductReviewAggregate\n}> {\n const hostRef = firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n const reviewsSnapshot = await hostRef\n .collection('reviews')\n .where('productId', '==', productId)\n .where('status', '==', 'approved')\n .limit(100)\n .get()\n const reviews = reviewsSnapshot.docs\n .map((docSnapshot) => ({\n id: docSnapshot.id,\n rating: Number(docSnapshot.get('rating') ?? 0),\n body: String(docSnapshot.get('body') ?? ''),\n authorName: String(docSnapshot.get('authorName') ?? 'Anonymous'),\n verified: Boolean(docSnapshot.get('verified')),\n reply: (docSnapshot.get('reply') as string | undefined) ?? null,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n }))\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n const count = reviews.length\n const average = count\n ? reviews.reduce((sum, review) => sum + review.rating, 0) / count\n : 0\n return {\n reviews,\n aggregate: { count, average: Math.round(average * 10) / 10 },\n }\n}\n\n/** One approved review as the store-wide list shows it. */\nexport interface PublicStoreReview {\n id: string\n rating: number\n body: string\n /** First name and last initial only (\"Jane D.\"), never the full name. */\n authorName: string\n verified: boolean\n createdAtMs: number\n /** The reviewed product, when it is still on sale. */\n productName?: string\n productSlug?: string\n}\n\n/** The most reviews the store-wide list returns. */\nexport const STORE_REVIEWS_MAX = 12\n/** What the store-wide list returns when the caller names no limit. */\nexport const STORE_REVIEWS_DEFAULT = 6\n\n/** \"Jane Doe\" → \"Jane D.\"; one word stays as it is. */\nexport function reviewerShortName(name: string): string {\n const words = String(name ?? '').trim().split(/\\s+/).filter(Boolean)\n if (!words.length) return 'Anonymous'\n const [first, ...others] = words\n const last = others[others.length - 1]\n return last ? `${first} ${last[0].toUpperCase()}.` : first\n}\n\n/**\n * The store's latest APPROVED reviews across every product, newest first, with\n * the aggregate over all of them — the store-wide reviews band a storefront\n * home carries. Approved only: a pending or rejected review never reaches a\n * page, and nothing here is ever sample text (a published page shows real\n * reviews or none).\n *\n * The list is `status == approved` ordered by `createdAtMs desc`, served by\n * the COLLECTION composite `reviews (status ASC, createdAtMs DESC)` that the\n * moderation queue already declares; the aggregate is a count and an average\n * over the same equality, which the automatic single-field index serves.\n */\nexport async function readStoreReviews(\n hostId: string,\n limit: number = STORE_REVIEWS_DEFAULT,\n): Promise<{ reviews: PublicStoreReview[]; aggregate: ProductReviewAggregate }> {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const approved = hostRef.collection('reviews').where('status', '==', 'approved')\n const max = Math.min(STORE_REVIEWS_MAX, Math.max(1, Math.floor(Number(limit)) || STORE_REVIEWS_DEFAULT))\n const [listSnapshot, aggregateSnapshot] = await Promise.all([\n approved.orderBy('createdAtMs', 'desc').limit(max).get(),\n approved\n .aggregate({ count: AggregateField.count(), average: AggregateField.average('rating') })\n .get(),\n ])\n const rows = listSnapshot.docs.map((docSnapshot) => ({\n id: docSnapshot.id,\n productId: String(docSnapshot.get('productId') ?? ''),\n rating: Number(docSnapshot.get('rating') ?? 0),\n body: String(docSnapshot.get('body') ?? ''),\n authorName: reviewerShortName(String(docSnapshot.get('authorName') ?? '')),\n verified: Boolean(docSnapshot.get('verified')),\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n }))\n // The reviewed products' names, in one batched read of at most `max` docs.\n const productIds = [...new Set(rows.map((row) => row.productId).filter(Boolean))]\n const products = new Map<string, { name: string; slug: string }>()\n if (productIds.length) {\n const snapshots = await firestore.getAll(\n ...productIds.map((id) => hostRef.collection('products').doc(id)),\n )\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') continue\n products.set(snapshot.id, { name: String(data['name'] ?? ''), slug: String(data['slug'] ?? '') })\n }\n }\n const reviews = rows.map(({ productId, ...row }) => {\n const product = products.get(productId)\n return {\n ...row,\n ...(product?.name ? { productName: product.name } : {}),\n ...(product?.slug ? { productSlug: product.slug } : {}),\n }\n })\n const data = aggregateSnapshot.data() as { count?: number; average?: number | null }\n const count = Number(data.count ?? 0)\n const average = count ? Number(data.average ?? 0) : 0\n return { reviews, aggregate: { count, average: Math.round(average * 10) / 10 } }\n}\n\n/**\n * Product reviews (AGL-324). GET returns approved reviews + aggregate;\n * POST submits into the moderation queue, marking `verified` when the\n * email has a paid order containing the product. Pro-plan gated.\n */\nexport const reviewsHandler: PluginApiHandler = async (req, res) => {\n const isPost = req.method === 'POST'\n const hostId = String((isPost ? req.body?.hostId : req.query.hostId) ?? '')\n const productId = String(\n (isPost ? req.body?.productId : req.query.productId) ?? '',\n )\n // Store-wide (`scope=store`, no productId): the latest approved reviews\n // across the store, for the storefront's reviews band. Read only, and only\n // where the store's plan has reviews — a store without the feature shows\n // no band rather than reviews it can no longer collect.\n if (!isPost && !productId && req.query.scope === 'store') {\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n try {\n const org = await getOrgForHost(hostId)\n if (!Aglyn.checkEntitlement(org?.org as any, 'productReviews')) {\n return res.status(200).json({ reviews: [], aggregate: { count: 0, average: 0 } })\n }\n const { reviews, aggregate } = await readStoreReviews(hostId, Number(req.query.limit))\n res.setHeader('Cache-Control', 'public, s-maxage=120, stale-while-revalidate=300')\n return res.status(200).json({ reviews, aggregate })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Reviews unavailable' })\n }\n }\n if (!hostId || !productId) {\n return res.status(400).json({ error: 'Missing hostId or productId' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n\n if (isPost) {\n const org = await getOrgForHost(hostId)\n if (!Aglyn.checkEntitlement(org?.org as any, 'productReviews')) {\n return res.status(403).json({ error: 'Reviews are not enabled' })\n }\n const rating = Math.min(5, Math.max(1, Math.round(Number(req.body?.rating ?? 0))))\n const body = String(req.body?.body ?? '').trim().slice(0, 2000)\n const authorName = String(req.body?.authorName ?? '').trim().slice(0, 80)\n const authorEmail = String(req.body?.authorEmail ?? '')\n .trim()\n .toLowerCase()\n .slice(0, 120)\n if (!rating || !body || !authorEmail) {\n return res.status(400).json({ error: 'Rating, review, and email required' })\n }\n // Verified buyer: a paid order with the product under this email.\n let verified = false\n const orders = await hostRef\n .collection('orders')\n .where('customerEmail', '==', authorEmail)\n .limit(25)\n .get()\n for (const docSnapshot of orders.docs) {\n const order = CommerceModel.liftLegacyOrder(docSnapshot.data() as any)\n // Through the shared entitlement test (AGL-2454). \"Verified buyer\" on a\n // purchase that was refunded line by line is a badge for goods the\n // buyer no longer has — and the inline status literal this replaced\n // only moved on a FULL refund.\n if (CommerceModel.orderEntitlesProduct(order, productId)) {\n verified = true\n break\n }\n }\n await hostRef.collection('reviews').add({\n productId,\n rating,\n body,\n authorName: authorName || 'Anonymous',\n authorEmail,\n verified,\n status: 'pending',\n createdAtMs: Date.now(),\n })\n return res.status(200).json({ ok: true, pending: true })\n }\n\n const { reviews, aggregate } = await readProductReviews(hostId, productId)\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=120, stale-while-revalidate=300',\n )\n return res.status(200).json({ reviews, aggregate })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Reviews unavailable' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","AggregateField","readProductReviews","hostId","productId","hostRef","app","firestore","collection","doc","reviewsSnapshot","where","limit","get","reviews","docs","map","docSnapshot","id","rating","Number","body","String","authorName","verified","Boolean","reply","createdAtMs","sort","a","b","count","length","average","reduce","sum","review","aggregate","Math","round","STORE_REVIEWS_MAX","STORE_REVIEWS_DEFAULT","reviewerShortName","name","words","trim","split","filter","first","others","last","toUpperCase","readStoreReviews","data","approved","max","min","floor","listSnapshot","aggregateSnapshot","Promise","all","orderBy","rows","productIds","Set","row","products","Map","snapshots","getAll","snapshot","exists","set","slug","product","productName","productSlug","reviewsHandler","req","res","isPost","method","query","scope","status","json","error","org","checkEntitlement","setHeader","console","slice","authorEmail","toLowerCase","orders","order","liftLegacyOrder","orderEntitlesProduct","add","Date","now","ok","pending"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reviews.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 type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { consumeRateLimit, firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { AggregateField } from 'firebase-admin/firestore'\nimport {\n MEMBER_SUSPENDED_ERROR,\n readActiveMemberSession,\n type MemberSessionCheck,\n} from './membership'\nimport { inspectReviewToken, redeemReviewTokenIn } from './review-tokens'\n\nexport interface PublicProductReview {\n id: string\n rating: number\n body: string\n authorName: string\n verified: boolean\n reply: string | null\n createdAtMs: number\n}\n\nexport interface ProductReviewAggregate {\n count: number\n /** Mean rating, one decimal. */\n average: number\n}\n\n/**\n * Approved reviews for a product, newest first, plus their aggregate.\n *\n * Extracted from the handler (AGL-686) so the site-page resolver can nest\n * `aggregateRating` inside the server-rendered `Product` node. Schema.org\n * wants it as a PROPERTY of the product; the reviews block was emitting a\n * free-standing `AggregateRating` node alongside it, which is orphaned and\n * ignored. One reader means the rating in the structured data and the rating\n * on the page cannot disagree.\n */\nexport async function readProductReviews(\n hostId: string,\n productId: string,\n): Promise<{\n reviews: PublicProductReview[]\n aggregate: ProductReviewAggregate\n}> {\n const hostRef = firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n const reviewsSnapshot = await hostRef\n .collection('reviews')\n .where('productId', '==', productId)\n .where('status', '==', 'approved')\n .limit(100)\n .get()\n const reviews = reviewsSnapshot.docs\n .map((docSnapshot) => ({\n id: docSnapshot.id,\n rating: Number(docSnapshot.get('rating') ?? 0),\n body: String(docSnapshot.get('body') ?? ''),\n authorName: String(docSnapshot.get('authorName') ?? 'Anonymous'),\n verified: Boolean(docSnapshot.get('verified')),\n reply: (docSnapshot.get('reply') as string | undefined) ?? null,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n }))\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n const count = reviews.length\n const average = count\n ? reviews.reduce((sum, review) => sum + review.rating, 0) / count\n : 0\n return {\n reviews,\n aggregate: { count, average: Math.round(average * 10) / 10 },\n }\n}\n\n/** One approved review as the store-wide list shows it. */\nexport interface PublicStoreReview {\n id: string\n rating: number\n body: string\n /** First name and last initial only (\"Jane D.\"), never the full name. */\n authorName: string\n verified: boolean\n createdAtMs: number\n /** The reviewed product, when it is still on sale. */\n productName?: string\n productSlug?: string\n}\n\n/** The most reviews the store-wide list returns. */\nexport const STORE_REVIEWS_MAX = 12\n/** What the store-wide list returns when the caller names no limit. */\nexport const STORE_REVIEWS_DEFAULT = 6\n\n/** \"Jane Doe\" → \"Jane D.\"; one word stays as it is. */\nexport function reviewerShortName(name: string): string {\n const words = String(name ?? '').trim().split(/\\s+/).filter(Boolean)\n if (!words.length) return 'Anonymous'\n const [first, ...others] = words\n const last = others[others.length - 1]\n return last ? `${first} ${last[0].toUpperCase()}.` : first\n}\n\n/**\n * The store's latest APPROVED reviews across every product, newest first, with\n * the aggregate over all of them — the store-wide reviews band a storefront\n * home carries. Approved only: a pending or rejected review never reaches a\n * page, and nothing here is ever sample text (a published page shows real\n * reviews or none).\n *\n * The list is `status == approved` ordered by `createdAtMs desc`, served by\n * the COLLECTION composite `reviews (status ASC, createdAtMs DESC)` that the\n * moderation queue already declares; the aggregate is a count and an average\n * over the same equality, which the automatic single-field index serves.\n */\nexport async function readStoreReviews(\n hostId: string,\n limit: number = STORE_REVIEWS_DEFAULT,\n): Promise<{ reviews: PublicStoreReview[]; aggregate: ProductReviewAggregate }> {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const approved = hostRef.collection('reviews').where('status', '==', 'approved')\n const max = Math.min(STORE_REVIEWS_MAX, Math.max(1, Math.floor(Number(limit)) || STORE_REVIEWS_DEFAULT))\n const [listSnapshot, aggregateSnapshot] = await Promise.all([\n approved.orderBy('createdAtMs', 'desc').limit(max).get(),\n approved\n .aggregate({ count: AggregateField.count(), average: AggregateField.average('rating') })\n .get(),\n ])\n const rows = listSnapshot.docs.map((docSnapshot) => ({\n id: docSnapshot.id,\n productId: String(docSnapshot.get('productId') ?? ''),\n rating: Number(docSnapshot.get('rating') ?? 0),\n body: String(docSnapshot.get('body') ?? ''),\n authorName: reviewerShortName(String(docSnapshot.get('authorName') ?? '')),\n verified: Boolean(docSnapshot.get('verified')),\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n }))\n // The reviewed products' names, in one batched read of at most `max` docs.\n const productIds = [...new Set(rows.map((row) => row.productId).filter(Boolean))]\n const products = new Map<string, { name: string; slug: string }>()\n if (productIds.length) {\n const snapshots = await firestore.getAll(\n ...productIds.map((id) => hostRef.collection('products').doc(id)),\n )\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') continue\n products.set(snapshot.id, { name: String(data['name'] ?? ''), slug: String(data['slug'] ?? '') })\n }\n }\n const reviews = rows.map(({ productId, ...row }) => {\n const product = products.get(productId)\n return {\n ...row,\n ...(product?.name ? { productName: product.name } : {}),\n ...(product?.slug ? { productSlug: product.slug } : {}),\n }\n })\n const data = aggregateSnapshot.data() as { count?: number; average?: number | null }\n const count = Number(data.count ?? 0)\n const average = count ? Number(data.average ?? 0) : 0\n return { reviews, aggregate: { count, average: Math.round(average * 10) / 10 } }\n}\n\n/** Reviews one address may submit per site, per hour (AGL-3736). */\nexport const REVIEW_POSTS_PER_IP_PER_HOUR = 10\n/** Reviews one signed-in member may submit per site, per day. */\nexport const REVIEW_POSTS_PER_MEMBER_PER_DAY = 10\nconst HOUR_MS = 60 * 60 * 1000\n\n/**\n * The address a signed-in member has PROVEN they own, or `''`.\n *\n * A site member signs up by typing an email nobody checks, so the address on\n * the member document alone would let anyone register with a buyer's address\n * and collect a \"Verified buyer\" badge on their purchases. Only a member\n * whose inbox has answered — `emailVerifiedAtMs`, stamped when they complete\n * a password reset from the emailed link or redeem a review link while signed\n * in — counts.\n */\nexport function memberProvenEmail(\n member: FirebaseFirestore.DocumentSnapshot | null | undefined,\n): string {\n if (!member?.exists) return ''\n if (!Number(member.get('emailVerifiedAtMs') ?? 0)) return ''\n return String(member.get('email') ?? '').trim().toLowerCase()\n}\n\n/** The first order under `email` that still entitles `productId`, or null. */\nexport async function entitlingOrderId(\n hostRef: FirebaseFirestore.DocumentReference,\n email: string,\n productId: string,\n): Promise<string | null> {\n if (!email) return null\n // By the member's email, as the account page matches order history.\n const orders = await hostRef\n .collection('orders')\n .where('customerEmail', '==', email)\n .limit(25)\n .get()\n for (const docSnapshot of orders.docs) {\n const order = CommerceModel.liftLegacyOrder(docSnapshot.data() as any)\n // The shared entitlement test (AGL-2454): a line refunded by name is\n // goods the buyer no longer has.\n if (CommerceModel.orderEntitlesProduct(order, productId)) return docSnapshot.id\n }\n return null\n}\n\n/** A store's review settings, from `settings/store`. */\nasync function readReviewSettings(\n hostRef: FirebaseFirestore.DocumentReference,\n): Promise<CommerceModel.ReviewSettings> {\n const snapshot = await hostRef\n .collection('settings')\n .doc('store')\n .get()\n .catch(() => null)\n return CommerceModel.reviewSettingsFrom(snapshot?.data?.() ?? null)\n}\n\ntype ReviewsRequest = Parameters<PluginApiHandler>[0]\ntype ReviewsResponse = Parameters<PluginApiHandler>[1]\n\nfunction clientIp(req: ReviewsRequest): string {\n return (\n readClientIp(req.headers, { remoteAddress: req.socket?.remoteAddress }) ??\n 'no-address'\n )\n}\n\n/** What the server established about who is reviewing. */\ninterface ReviewerIdentity {\n signedIn: boolean\n suspended: boolean\n memberId: string | null\n memberEmail: string\n memberName: string\n /** The member's proven email holds an order that entitles the product. */\n memberOrderId: string | null\n}\n\nconst ANONYMOUS_REVIEWER: ReviewerIdentity = {\n signedIn: false,\n suspended: false,\n memberId: null,\n memberEmail: '',\n memberName: '',\n memberOrderId: null,\n}\n\nasync function identifyReviewer(\n req: ReviewsRequest,\n hostRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n productId: string,\n): Promise<ReviewerIdentity> {\n const session: MemberSessionCheck = await readActiveMemberSession(req, hostId).catch(\n () => ({ status: 'anonymous' }) as MemberSessionCheck,\n )\n if (session.status === 'suspended') {\n return { ...ANONYMOUS_REVIEWER, signedIn: true, suspended: true, memberId: session.memberId }\n }\n if (session.status !== 'active') return ANONYMOUS_REVIEWER\n const proven = memberProvenEmail(session.member)\n return {\n signedIn: true,\n suspended: false,\n memberId: session.memberId,\n memberEmail: String(session.member.get('email') ?? '').trim().toLowerCase(),\n memberName: String(session.member.get('displayName') ?? '').trim(),\n memberOrderId: proven ? await entitlingOrderId(hostRef, proven, productId) : null,\n }\n}\n\n/**\n * The storefront form's question (AGL-3736): may this visitor review this\n * product, and would the review be verified? `GET ?mode=eligibility`, never\n * cached — the answer depends on the visitor's cookie and link.\n */\nasync function eligibilityHandler(\n req: ReviewsRequest,\n res: ReviewsResponse,\n hostId: string,\n productId: string,\n) {\n res.setHeader('Cache-Control', 'private, no-store')\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const org = await getOrgForHost(hostId)\n const enabled = Aglyn.checkEntitlement(org?.org as any, 'productReviews')\n const settings = await readReviewSettings(hostRef)\n const reviewer = await identifyReviewer(req, hostRef, hostId, productId)\n const presented = req.query.review\n let tokenVerified = false\n let tokenProblem: CommerceModel.ReviewErrorCode | undefined\n if (presented) {\n const check = await inspectReviewToken(firestore, hostId, presented, productId)\n if (check.ok) tokenVerified = true\n else tokenProblem = check.code ?? CommerceModel.REVIEW_ERROR_CODES.tokenInvalid\n }\n const eligibility = CommerceModel.reviewEligibility({\n policy: settings.policy,\n signedIn: reviewer.signedIn && !reviewer.suspended,\n memberVerified: Boolean(reviewer.memberOrderId),\n tokenVerified,\n tokenProblem,\n })\n return res.status(200).json({\n ...eligibility,\n enabled,\n canReview: enabled && !reviewer.suspended && eligibility.canReview,\n })\n}\n\n/**\n * Product reviews (AGL-324, AGL-3736).\n *\n * GET returns approved reviews + aggregate (`scope=store`: store-wide;\n * `mode=eligibility`: what the form may do). POST submits one review:\n *\n * - VERIFIED when the reviewer presents an unspent, unexpired review link\n * for this product, or is a signed-in member whose proven email has an\n * order that still entitles it. Published at once (`approved`): the FTC's\n * 2024 rule bars suppressing honest negative reviews; the merchant may\n * still hide or reject one for a content reason.\n * - Otherwise, under the store's `verified` policy (the default), 403\n * `verified_purchase_required`; under `anyone`, saved `pending` with the\n * name and email given — the email is never shown and verifies nothing.\n *\n * Rate-limited per address and per member, durably. Plan-gated\n * (`productReviews`).\n */\nexport const reviewsHandler: PluginApiHandler = async (req, res) => {\n const isPost = req.method === 'POST'\n const hostId = String((isPost ? req.body?.hostId : req.query.hostId) ?? '')\n const productId = String(\n (isPost ? req.body?.productId : req.query.productId) ?? '',\n )\n // Store-wide (`scope=store`, no productId): the latest approved reviews\n // across the store, for the storefront's reviews band. Read only, and only\n // where the store's plan has reviews — a store without the feature shows\n // no band rather than reviews it can no longer collect.\n if (!isPost && !productId && req.query.scope === 'store') {\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n try {\n const org = await getOrgForHost(hostId)\n if (!Aglyn.checkEntitlement(org?.org as any, 'productReviews')) {\n return res.status(200).json({ reviews: [], aggregate: { count: 0, average: 0 } })\n }\n const { reviews, aggregate } = await readStoreReviews(hostId, Number(req.query.limit))\n res.setHeader('Cache-Control', 'public, s-maxage=120, stale-while-revalidate=300')\n return res.status(200).json({ reviews, aggregate })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Reviews unavailable' })\n }\n }\n if (!hostId || !productId) {\n return res.status(400).json({ error: 'Missing hostId or productId' })\n }\n try {\n if (isPost) return await submitReview(req, res, hostId, productId)\n if (req.query.mode === 'eligibility') {\n return await eligibilityHandler(req, res, hostId, productId)\n }\n const { reviews, aggregate } = await readProductReviews(hostId, productId)\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=120, stale-while-revalidate=300',\n )\n return res.status(200).json({ reviews, aggregate })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Reviews unavailable' })\n }\n}\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nasync function submitReview(\n req: ReviewsRequest,\n res: ReviewsResponse,\n hostId: string,\n productId: string,\n) {\n const codes = CommerceModel.REVIEW_ERROR_CODES\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const org = await getOrgForHost(hostId)\n if (!Aglyn.checkEntitlement(org?.org as any, 'productReviews')) {\n return res.status(403).json({ error: 'Reviews are not enabled', code: codes.notEnabled })\n }\n const refuseRate = (resetMs: number) => {\n res.setHeader(\n 'Retry-After',\n String(Math.max(1, Math.ceil((resetMs - Date.now()) / 1000))),\n )\n return res.status(429).json({\n error: 'Too many reviews from here. Try again later.',\n code: codes.rateLimited,\n })\n }\n // Per address, before anything else is read.\n const perIp = await consumeRateLimit(`commerce-review:ip:${hostId}:${clientIp(req)}`, {\n limit: REVIEW_POSTS_PER_IP_PER_HOUR,\n windowMs: HOUR_MS,\n })\n if (!perIp.allowed) return refuseRate(perIp.resetMs)\n\n const rating = Math.min(5, Math.max(1, Math.round(Number(req.body?.rating ?? 0))))\n const body = String(req.body?.body ?? '').trim().slice(0, 2000)\n const typedName = String(req.body?.authorName ?? '').trim().slice(0, 80)\n if (!rating || !body) {\n return res.status(400).json({ error: 'A rating and a review are required' })\n }\n\n const settings = await readReviewSettings(hostRef)\n const reviewer = await identifyReviewer(req, hostRef, hostId, productId)\n if (reviewer.suspended) {\n return res.status(403).json({ error: MEMBER_SUSPENDED_ERROR, code: codes.memberSuspended })\n }\n if (reviewer.memberId) {\n const perMember = await consumeRateLimit(\n `commerce-review:member:${hostId}:${reviewer.memberId}`,\n { limit: REVIEW_POSTS_PER_MEMBER_PER_DAY, windowMs: 24 * HOUR_MS },\n )\n if (!perMember.allowed) return refuseRate(perMember.resetMs)\n }\n\n // 1. A review link from the email. A link that does not work is an error,\n // never a silent fall back to an unverified review.\n const presented = req.body?.reviewToken\n if (presented) {\n const check = await inspectReviewToken(firestore, hostId, presented, productId)\n if (!check.ok || !check.record || !check.ref) {\n const code = check.code ?? codes.tokenInvalid\n return res.status(403).json({ error: tokenProblemMessage(code), code })\n }\n const reviewRef = hostRef.collection('reviews').doc(createResourceUid())\n const nowMs = Date.now()\n const problem = await firestore.runTransaction(async (transaction) => {\n const spent = await redeemReviewTokenIn(transaction, check.ref, productId, reviewRef.id, nowMs)\n if (spent) return spent\n transaction.create(reviewRef, {\n productId,\n rating,\n body,\n authorName: typedName || reviewer.memberName || 'Anonymous',\n authorEmail: check.record.email,\n ...(reviewer.memberId ? { memberId: reviewer.memberId } : {}),\n verified: true,\n verifiedVia: 'token',\n orderId: check.record.orderId,\n status: CommerceModel.initialReviewStatus(true),\n createdAtMs: nowMs,\n })\n return null\n })\n if (problem) {\n return res.status(403).json({ error: tokenProblemMessage(problem), code: problem })\n }\n // The link came through the inbox, so a member signed in with the same\n // address has now proven it.\n if (reviewer.memberId && reviewer.memberEmail === check.record.email) {\n await hostRef\n .collection('siteMembers')\n .doc(reviewer.memberId)\n .set({ emailVerifiedAtMs: nowMs }, { merge: true })\n .catch(() => undefined)\n }\n return res.status(200).json({ ok: true, pending: false, verified: true })\n }\n\n // 2. A signed-in member whose proven email bought it. One verified review\n // per member per product.\n if (reviewer.memberOrderId) {\n const already = await hostRef\n .collection('reviews')\n .where('productId', '==', productId)\n .where('memberId', '==', reviewer.memberId)\n .limit(1)\n .get()\n if (!already.empty) {\n return res.status(409).json({\n error: 'You have already reviewed this product.',\n code: codes.alreadyReviewed,\n })\n }\n await hostRef.collection('reviews').doc(createResourceUid()).set({\n productId,\n rating,\n body,\n authorName: typedName || reviewer.memberName || 'Anonymous',\n authorEmail: reviewer.memberEmail,\n memberId: reviewer.memberId,\n verified: true,\n verifiedVia: 'member',\n orderId: reviewer.memberOrderId,\n status: CommerceModel.initialReviewStatus(true),\n createdAtMs: Date.now(),\n })\n return res.status(200).json({ ok: true, pending: false, verified: true })\n }\n\n // 3. Nobody the store can verify.\n if (settings.policy !== 'anyone') {\n return res.status(403).json({\n error: 'Only customers who bought this can review it.',\n code: codes.verifiedPurchaseRequired,\n })\n }\n const authorEmail = reviewer.signedIn\n ? reviewer.memberEmail\n : String(req.body?.authorEmail ?? '').trim().toLowerCase().slice(0, 120)\n const authorName = typedName || reviewer.memberName\n if (!authorName || !EMAIL_PATTERN.test(authorEmail)) {\n return res.status(400).json({ error: 'Your name and email are required' })\n }\n await hostRef.collection('reviews').doc(createResourceUid()).set({\n productId,\n rating,\n body,\n authorName,\n // Kept for the merchant; never shown, and it verifies nothing.\n authorEmail,\n ...(reviewer.memberId ? { memberId: reviewer.memberId } : {}),\n verified: false,\n status: CommerceModel.initialReviewStatus(false),\n createdAtMs: Date.now(),\n })\n return res.status(200).json({ ok: true, pending: true, verified: false })\n}\n\n/** The words for a review link that does not work. */\nexport function tokenProblemMessage(code: CommerceModel.ReviewErrorCode): string {\n switch (code) {\n case CommerceModel.REVIEW_ERROR_CODES.tokenUsed:\n return 'This review link has already been used.'\n case CommerceModel.REVIEW_ERROR_CODES.tokenExpired:\n return 'This review link has expired.'\n default:\n return 'This review link is not valid for this product.'\n }\n}\n"],"names":["Aglyn","CommerceModel","consumeRateLimit","firebaseAdmin","getOrgForHost","readClientIp","createResourceUid","AggregateField","MEMBER_SUSPENDED_ERROR","readActiveMemberSession","inspectReviewToken","redeemReviewTokenIn","readProductReviews","hostId","productId","hostRef","app","firestore","collection","doc","reviewsSnapshot","where","limit","get","reviews","docs","map","docSnapshot","id","rating","Number","body","String","authorName","verified","Boolean","reply","createdAtMs","sort","a","b","count","length","average","reduce","sum","review","aggregate","Math","round","STORE_REVIEWS_MAX","STORE_REVIEWS_DEFAULT","reviewerShortName","name","words","trim","split","filter","first","others","last","toUpperCase","readStoreReviews","data","approved","max","min","floor","listSnapshot","aggregateSnapshot","Promise","all","orderBy","rows","productIds","Set","row","products","Map","snapshots","getAll","snapshot","exists","set","slug","product","productName","productSlug","REVIEW_POSTS_PER_IP_PER_HOUR","REVIEW_POSTS_PER_MEMBER_PER_DAY","HOUR_MS","memberProvenEmail","member","toLowerCase","entitlingOrderId","email","orders","order","liftLegacyOrder","orderEntitlesProduct","readReviewSettings","catch","reviewSettingsFrom","clientIp","req","headers","remoteAddress","socket","ANONYMOUS_REVIEWER","signedIn","suspended","memberId","memberEmail","memberName","memberOrderId","identifyReviewer","session","status","proven","eligibilityHandler","res","setHeader","org","enabled","checkEntitlement","settings","reviewer","presented","query","tokenVerified","tokenProblem","check","ok","code","REVIEW_ERROR_CODES","tokenInvalid","eligibility","reviewEligibility","policy","memberVerified","json","canReview","reviewsHandler","isPost","method","scope","error","console","submitReview","mode","EMAIL_PATTERN","codes","notEnabled","refuseRate","resetMs","ceil","Date","now","rateLimited","perIp","windowMs","allowed","slice","typedName","memberSuspended","perMember","reviewToken","record","ref","tokenProblemMessage","reviewRef","nowMs","problem","runTransaction","transaction","spent","create","authorEmail","verifiedVia","orderId","initialReviewStatus","emailVerifiedAtMs","merge","undefined","pending","already","empty","alreadyReviewed","verifiedPurchaseRequired","test","tokenUsed","tokenExpired"],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginAiCapability, type PluginAiCapabilityArgs } from '@aglyn/aglyn/plugin-manager/plugin-ai-capabilities';
|
|
18
|
+
import { type StoreSetupSection } from '../model/store-setup-content';
|
|
19
|
+
/**
|
|
20
|
+
* WHAT AN AI BUILD CAN SET UP IN A STORE (AGL-3749): shipping zones and
|
|
21
|
+
* rates, local pickup, local delivery and sales tax — "free shipping over $50,
|
|
22
|
+
* otherwise $5 flat in the US", "offer local pickup at our Austin shop",
|
|
23
|
+
* "charge 8.25% Texas sales tax".
|
|
24
|
+
*
|
|
25
|
+
* Four operations the AI plugin's build planner offers, all written by this
|
|
26
|
+
* plugin's `store-settings` writer (`store-setup-ai-writer.ts`), so every
|
|
27
|
+
* change is the console cards' own: the role that may save them, the plan's
|
|
28
|
+
* `commerce` feature, lockdown, and the cards' normalizers and checks. No
|
|
29
|
+
* model runs: the planner fills the arguments and they ARE the change, so an
|
|
30
|
+
* item costs no credits.
|
|
31
|
+
*
|
|
32
|
+
* NEVER A NUMBER THE PERSON DID NOT GIVE. A blank flat rate charges nothing
|
|
33
|
+
* and a tax rate is the merchant's legal call, so every amount and
|
|
34
|
+
* percentage is "only when the request states it", and the writer refuses a
|
|
35
|
+
* rate without one. A request to "set up tax" that says neither how nor what
|
|
36
|
+
* rate is left out of the plan rather than guessed at.
|
|
37
|
+
*
|
|
38
|
+
* NEVER AN ACCOUNT. Carrier rates, label services and outside tax services
|
|
39
|
+
* need a person to connect an account; none of them is offered here.
|
|
40
|
+
*
|
|
41
|
+
* Settings are live once written: the cards save them live, and a store
|
|
42
|
+
* setting has no draft. `live` (the contract's flag, AGL-3745) tells the
|
|
43
|
+
* build step so, and the job reports the change as live.
|
|
44
|
+
* Free has no commerce, so a Free workspace's build is never offered one.
|
|
45
|
+
*/
|
|
46
|
+
export declare const STORE_SHIPPING_AI_OP = "store-shipping";
|
|
47
|
+
export declare const STORE_PICKUP_AI_OP = "store-pickup";
|
|
48
|
+
export declare const STORE_DELIVERY_AI_OP = "store-delivery";
|
|
49
|
+
export declare const STORE_TAX_AI_OP = "store-tax";
|
|
50
|
+
/** The writer's content for an item: its arguments, tagged with the section its op changes. */
|
|
51
|
+
export declare function storeSetupContentFromArgs(section: StoreSetupSection, args: PluginAiCapabilityArgs): Readonly<Record<string, unknown>>;
|
|
52
|
+
export declare const storeShippingAiCapability: PluginAiCapability;
|
|
53
|
+
export declare const storePickupAiCapability: PluginAiCapability;
|
|
54
|
+
export declare const storeDeliveryAiCapability: PluginAiCapability;
|
|
55
|
+
export declare const storeTaxAiCapability: PluginAiCapability;
|
|
56
|
+
export declare const STORE_SETUP_AI_CAPABILITIES: readonly PluginAiCapability[];
|
|
57
|
+
/**
|
|
58
|
+
* Registers the four operations; the console surface calls it beside the
|
|
59
|
+
* writer, since only the console runs AI jobs (AGL-3026). Idempotent.
|
|
60
|
+
*/
|
|
61
|
+
export declare function registerStoreSetupAiCapabilities(): void;
|