@86d-app/reviews 0.0.4 → 0.0.13
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/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +54 -18
- package/README.md +150 -41
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/new-features.test.d.ts +2 -0
- package/dist/__tests__/new-features.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +4 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/review-analytics.d.ts +2 -0
- package/dist/admin/components/review-analytics.d.ts.map +1 -0
- package/dist/admin/components/review-list.d.ts +2 -0
- package/dist/admin/components/review-list.d.ts.map +1 -0
- package/dist/admin/components/review-moderation.d.ts +6 -0
- package/dist/admin/components/review-moderation.d.ts.map +1 -0
- package/dist/admin/endpoints/approve-review.d.ts +16 -0
- package/dist/admin/endpoints/approve-review.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-review.d.ts +16 -0
- package/dist/admin/endpoints/delete-review.d.ts.map +1 -0
- package/dist/admin/endpoints/get-review.d.ts +16 -0
- package/dist/admin/endpoints/get-review.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +167 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/list-reports.d.ts +17 -0
- package/dist/admin/endpoints/list-reports.d.ts.map +1 -0
- package/dist/admin/endpoints/list-review-requests.d.ts +11 -0
- package/dist/admin/endpoints/list-review-requests.d.ts.map +1 -0
- package/dist/admin/endpoints/list-reviews.d.ts +18 -0
- package/dist/admin/endpoints/list-reviews.d.ts.map +1 -0
- package/dist/admin/endpoints/reject-review.d.ts +16 -0
- package/dist/admin/endpoints/reject-review.d.ts.map +1 -0
- package/dist/admin/endpoints/respond-review.d.ts +19 -0
- package/dist/admin/endpoints/respond-review.d.ts.map +1 -0
- package/dist/admin/endpoints/review-analytics.d.ts +6 -0
- package/dist/admin/endpoints/review-analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/review-request-stats.d.ts +6 -0
- package/dist/admin/endpoints/review-request-stats.d.ts.map +1 -0
- package/dist/admin/endpoints/send-review-request.d.ts +23 -0
- package/dist/admin/endpoints/send-review-request.d.ts.map +1 -0
- package/dist/admin/endpoints/update-report.d.ts +22 -0
- package/dist/admin/endpoints/update-report.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/schema.d.ts +136 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +6 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +149 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/store/components/_hooks.d.ts +6 -0
- package/dist/store/components/_hooks.d.ts.map +1 -0
- package/dist/store/components/_utils.d.ts +3 -0
- package/dist/store/components/_utils.d.ts.map +1 -0
- package/dist/store/components/distribution-bars.d.ts +5 -0
- package/dist/store/components/distribution-bars.d.ts.map +1 -0
- package/dist/store/components/index.d.ts +18 -0
- package/dist/store/components/index.d.ts.map +1 -0
- package/dist/store/components/product-reviews.d.ts +5 -0
- package/dist/store/components/product-reviews.d.ts.map +1 -0
- package/dist/store/components/review-card.d.ts +18 -0
- package/dist/store/components/review-card.d.ts.map +1 -0
- package/dist/store/components/review-form.d.ts +5 -0
- package/dist/store/components/review-form.d.ts.map +1 -0
- package/dist/store/components/reviews-summary.d.ts +4 -0
- package/dist/store/components/reviews-summary.d.ts.map +1 -0
- package/dist/store/components/star-display.d.ts +5 -0
- package/dist/store/components/star-display.d.ts.map +1 -0
- package/dist/store/components/star-picker.d.ts +5 -0
- package/dist/store/components/star-picker.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +116 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/list-my-reviews.d.ts +30 -0
- package/dist/store/endpoints/list-my-reviews.d.ts.map +1 -0
- package/dist/store/endpoints/list-product-reviews.d.ts +23 -0
- package/dist/store/endpoints/list-product-reviews.d.ts.map +1 -0
- package/dist/store/endpoints/mark-helpful.d.ts +18 -0
- package/dist/store/endpoints/mark-helpful.d.ts.map +1 -0
- package/dist/store/endpoints/report-review.d.ts +27 -0
- package/dist/store/endpoints/report-review.d.ts.map +1 -0
- package/dist/store/endpoints/submit-review.d.ts +25 -0
- package/dist/store/endpoints/submit-review.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/controllers.test.ts +1074 -0
- package/src/__tests__/endpoint-security.test.ts +559 -0
- package/src/__tests__/new-features.test.ts +618 -0
- package/src/__tests__/service-impl.test.ts +1 -1
- package/src/admin/endpoints/index.ts +4 -0
- package/src/admin/endpoints/list-reports.ts +25 -0
- package/src/admin/endpoints/update-report.ts +22 -0
- package/src/index.ts +7 -1
- package/src/schema.ts +28 -0
- package/src/service-impl.ts +151 -3
- package/src/service.ts +61 -0
- package/src/store/endpoints/index.ts +2 -0
- package/src/store/endpoints/list-my-reviews.ts +1 -1
- package/src/store/endpoints/list-product-reviews.ts +6 -2
- package/src/store/endpoints/mark-helpful.ts +21 -2
- package/src/store/endpoints/report-review.ts +38 -0
- package/src/store/endpoints/submit-review.ts +33 -7
- package/COMPONENTS.md +0 -34
package/src/index.ts
CHANGED
|
@@ -6,12 +6,17 @@ import { storeEndpoints } from "./store/endpoints";
|
|
|
6
6
|
|
|
7
7
|
export type {
|
|
8
8
|
RatingSummary,
|
|
9
|
+
ReportStatus,
|
|
9
10
|
Review,
|
|
10
11
|
ReviewAnalytics,
|
|
11
12
|
ReviewController,
|
|
13
|
+
ReviewImage,
|
|
14
|
+
ReviewReport,
|
|
12
15
|
ReviewRequest,
|
|
13
16
|
ReviewRequestStats,
|
|
17
|
+
ReviewSortBy,
|
|
14
18
|
ReviewStatus,
|
|
19
|
+
ReviewVote,
|
|
15
20
|
} from "./service";
|
|
16
21
|
|
|
17
22
|
export interface ReviewsOptions extends ModuleConfig {
|
|
@@ -22,7 +27,7 @@ export interface ReviewsOptions extends ModuleConfig {
|
|
|
22
27
|
export default function reviews(options?: ReviewsOptions): Module {
|
|
23
28
|
return {
|
|
24
29
|
id: "reviews",
|
|
25
|
-
version: "0.0.
|
|
30
|
+
version: "0.0.2",
|
|
26
31
|
schema: reviewsSchema,
|
|
27
32
|
exports: {
|
|
28
33
|
read: ["productRating", "reviewCount"],
|
|
@@ -34,6 +39,7 @@ export default function reviews(options?: ReviewsOptions): Module {
|
|
|
34
39
|
"review.rejected",
|
|
35
40
|
"review.responded",
|
|
36
41
|
"review.requested",
|
|
42
|
+
"review.reported",
|
|
37
43
|
],
|
|
38
44
|
},
|
|
39
45
|
init: async (ctx: ModuleContext) => {
|
package/src/schema.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const reviewsSchema = {
|
|
|
18
18
|
defaultValue: false,
|
|
19
19
|
},
|
|
20
20
|
helpfulCount: { type: "number", required: true, defaultValue: 0 },
|
|
21
|
+
images: { type: "json", required: false },
|
|
21
22
|
merchantResponse: { type: "string", required: false },
|
|
22
23
|
merchantResponseAt: { type: "date", required: false },
|
|
23
24
|
moderationNote: { type: "string", required: false },
|
|
@@ -34,4 +35,31 @@ export const reviewsSchema = {
|
|
|
34
35
|
},
|
|
35
36
|
},
|
|
36
37
|
},
|
|
38
|
+
reviewVote: {
|
|
39
|
+
fields: {
|
|
40
|
+
id: { type: "string", required: true },
|
|
41
|
+
reviewId: { type: "string", required: true },
|
|
42
|
+
voterId: { type: "string", required: true },
|
|
43
|
+
createdAt: {
|
|
44
|
+
type: "date",
|
|
45
|
+
required: true,
|
|
46
|
+
defaultValue: () => new Date(),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
reviewReport: {
|
|
51
|
+
fields: {
|
|
52
|
+
id: { type: "string", required: true },
|
|
53
|
+
reviewId: { type: "string", required: true },
|
|
54
|
+
reporterId: { type: "string", required: false },
|
|
55
|
+
reason: { type: "string", required: true },
|
|
56
|
+
details: { type: "string", required: false },
|
|
57
|
+
status: { type: "string", required: true, defaultValue: "pending" },
|
|
58
|
+
createdAt: {
|
|
59
|
+
type: "date",
|
|
60
|
+
required: true,
|
|
61
|
+
defaultValue: () => new Date(),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
37
65
|
} satisfies ModuleSchema;
|
package/src/service-impl.ts
CHANGED
|
@@ -2,10 +2,35 @@ import type { ModuleDataService } from "@86d-app/core";
|
|
|
2
2
|
import type {
|
|
3
3
|
Review,
|
|
4
4
|
ReviewController,
|
|
5
|
+
ReviewReport,
|
|
5
6
|
ReviewRequest,
|
|
7
|
+
ReviewSortBy,
|
|
6
8
|
ReviewStatus,
|
|
9
|
+
ReviewVote,
|
|
7
10
|
} from "./service";
|
|
8
11
|
|
|
12
|
+
function sortReviews(reviews: Review[], sortBy: ReviewSortBy): Review[] {
|
|
13
|
+
const sorted = [...reviews];
|
|
14
|
+
switch (sortBy) {
|
|
15
|
+
case "recent":
|
|
16
|
+
return sorted.sort(
|
|
17
|
+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
|
18
|
+
);
|
|
19
|
+
case "oldest":
|
|
20
|
+
return sorted.sort(
|
|
21
|
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
|
22
|
+
);
|
|
23
|
+
case "highest":
|
|
24
|
+
return sorted.sort((a, b) => b.rating - a.rating);
|
|
25
|
+
case "lowest":
|
|
26
|
+
return sorted.sort((a, b) => a.rating - b.rating);
|
|
27
|
+
case "helpful":
|
|
28
|
+
return sorted.sort((a, b) => b.helpfulCount - a.helpfulCount);
|
|
29
|
+
default:
|
|
30
|
+
return sorted;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
export function createReviewController(
|
|
10
35
|
data: ModuleDataService,
|
|
11
36
|
options?: { autoApprove?: boolean },
|
|
@@ -28,6 +53,9 @@ export function createReviewController(
|
|
|
28
53
|
status,
|
|
29
54
|
isVerifiedPurchase: params.isVerifiedPurchase ?? false,
|
|
30
55
|
helpfulCount: 0,
|
|
56
|
+
...(params.images && params.images.length > 0
|
|
57
|
+
? { images: params.images }
|
|
58
|
+
: {}),
|
|
31
59
|
createdAt: now,
|
|
32
60
|
updatedAt: now,
|
|
33
61
|
};
|
|
@@ -49,10 +77,23 @@ export function createReviewController(
|
|
|
49
77
|
|
|
50
78
|
const all = await data.findMany("review", {
|
|
51
79
|
where,
|
|
52
|
-
...(params?.take !== undefined ? { take: params.take } : {}),
|
|
53
|
-
...(params?.skip !== undefined ? { skip: params.skip } : {}),
|
|
54
80
|
});
|
|
55
|
-
|
|
81
|
+
let reviews = all as unknown as Review[];
|
|
82
|
+
|
|
83
|
+
if (params?.sortBy) {
|
|
84
|
+
reviews = sortReviews(reviews, params.sortBy);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const skip = params?.skip ?? 0;
|
|
88
|
+
const take = params?.take;
|
|
89
|
+
if (skip > 0 || take !== undefined) {
|
|
90
|
+
reviews = reviews.slice(
|
|
91
|
+
skip,
|
|
92
|
+
take !== undefined ? skip + take : undefined,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return reviews;
|
|
56
97
|
},
|
|
57
98
|
|
|
58
99
|
async listReviews(params) {
|
|
@@ -120,6 +161,45 @@ export function createReviewController(
|
|
|
120
161
|
return updated;
|
|
121
162
|
},
|
|
122
163
|
|
|
164
|
+
async voteHelpful(reviewId, voterId) {
|
|
165
|
+
const existing = await data.get("review", reviewId);
|
|
166
|
+
if (!existing) return null;
|
|
167
|
+
const review = existing as unknown as Review;
|
|
168
|
+
|
|
169
|
+
// Check if voter already voted
|
|
170
|
+
const existingVotes = await data.findMany("reviewVote", {
|
|
171
|
+
where: { reviewId, voterId },
|
|
172
|
+
take: 1,
|
|
173
|
+
});
|
|
174
|
+
const votes = existingVotes as unknown as ReviewVote[];
|
|
175
|
+
|
|
176
|
+
if (votes.length > 0) {
|
|
177
|
+
return { review, alreadyVoted: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Record the vote
|
|
181
|
+
const voteId = crypto.randomUUID();
|
|
182
|
+
const vote: ReviewVote = {
|
|
183
|
+
id: voteId,
|
|
184
|
+
reviewId,
|
|
185
|
+
voterId,
|
|
186
|
+
createdAt: new Date(),
|
|
187
|
+
};
|
|
188
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
189
|
+
await data.upsert("reviewVote", voteId, vote as Record<string, any>);
|
|
190
|
+
|
|
191
|
+
// Increment helpful count
|
|
192
|
+
const updated: Review = {
|
|
193
|
+
...review,
|
|
194
|
+
helpfulCount: review.helpfulCount + 1,
|
|
195
|
+
updatedAt: new Date(),
|
|
196
|
+
};
|
|
197
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
198
|
+
await data.upsert("review", reviewId, updated as Record<string, any>);
|
|
199
|
+
|
|
200
|
+
return { review: updated, alreadyVoted: false };
|
|
201
|
+
},
|
|
202
|
+
|
|
123
203
|
async getReviewAnalytics() {
|
|
124
204
|
const all = await data.findMany("review", {});
|
|
125
205
|
const reviews = all as unknown as Review[];
|
|
@@ -150,6 +230,13 @@ export function createReviewController(
|
|
|
150
230
|
if (r.merchantResponse) withMerchantResponse++;
|
|
151
231
|
}
|
|
152
232
|
|
|
233
|
+
// Count reported reviews
|
|
234
|
+
const allReports = await data.findMany("reviewReport", {
|
|
235
|
+
where: { status: "pending" },
|
|
236
|
+
});
|
|
237
|
+
const reports = allReports as unknown as ReviewReport[];
|
|
238
|
+
const reportedReviewIds = new Set(reports.map((r) => r.reviewId));
|
|
239
|
+
|
|
153
240
|
return {
|
|
154
241
|
totalReviews: reviews.length,
|
|
155
242
|
pendingCount,
|
|
@@ -161,6 +248,7 @@ export function createReviewController(
|
|
|
161
248
|
: 0,
|
|
162
249
|
ratingsDistribution: distribution,
|
|
163
250
|
withMerchantResponse,
|
|
251
|
+
reportedCount: reportedReviewIds.size,
|
|
164
252
|
};
|
|
165
253
|
},
|
|
166
254
|
|
|
@@ -250,6 +338,66 @@ export function createReviewController(
|
|
|
250
338
|
return { reviews: filtered, total: reviews.length };
|
|
251
339
|
},
|
|
252
340
|
|
|
341
|
+
async hasReviewedProduct(customerId, productId) {
|
|
342
|
+
const all = await data.findMany("review", {
|
|
343
|
+
where: { customerId, productId },
|
|
344
|
+
take: 1,
|
|
345
|
+
});
|
|
346
|
+
const reviews = all as unknown as Review[];
|
|
347
|
+
return reviews.length > 0;
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async reportReview(params) {
|
|
351
|
+
const id = crypto.randomUUID();
|
|
352
|
+
const report: ReviewReport = {
|
|
353
|
+
id,
|
|
354
|
+
reviewId: params.reviewId,
|
|
355
|
+
reporterId: params.reporterId,
|
|
356
|
+
reason: params.reason,
|
|
357
|
+
details: params.details,
|
|
358
|
+
status: "pending",
|
|
359
|
+
createdAt: new Date(),
|
|
360
|
+
};
|
|
361
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
362
|
+
await data.upsert("reviewReport", id, report as Record<string, any>);
|
|
363
|
+
return report;
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
async listReports(params) {
|
|
367
|
+
// biome-ignore lint/suspicious/noExplicitAny: JSONB where filter
|
|
368
|
+
const where: Record<string, any> = {};
|
|
369
|
+
if (params?.status) where.status = params.status;
|
|
370
|
+
if (params?.reviewId) where.reviewId = params.reviewId;
|
|
371
|
+
|
|
372
|
+
const all = await data.findMany("reviewReport", {
|
|
373
|
+
...(Object.keys(where).length > 0 ? { where } : {}),
|
|
374
|
+
...(params?.take !== undefined ? { take: params.take } : {}),
|
|
375
|
+
...(params?.skip !== undefined ? { skip: params.skip } : {}),
|
|
376
|
+
});
|
|
377
|
+
return all as unknown as ReviewReport[];
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
async updateReportStatus(id, status) {
|
|
381
|
+
const existing = await data.get("reviewReport", id);
|
|
382
|
+
if (!existing) return null;
|
|
383
|
+
const report = existing as unknown as ReviewReport;
|
|
384
|
+
const updated: ReviewReport = {
|
|
385
|
+
...report,
|
|
386
|
+
status,
|
|
387
|
+
};
|
|
388
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
389
|
+
await data.upsert("reviewReport", id, updated as Record<string, any>);
|
|
390
|
+
return updated;
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
async getReportCount(reviewId) {
|
|
394
|
+
const all = await data.findMany("reviewReport", {
|
|
395
|
+
where: { reviewId, status: "pending" },
|
|
396
|
+
});
|
|
397
|
+
const reports = all as unknown as ReviewReport[];
|
|
398
|
+
return reports.length;
|
|
399
|
+
},
|
|
400
|
+
|
|
253
401
|
async getReviewRequestStats() {
|
|
254
402
|
const all = await data.findMany("reviewRequest", {});
|
|
255
403
|
const requests = all as unknown as ReviewRequest[];
|
package/src/service.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import type { ModuleController } from "@86d-app/core";
|
|
2
2
|
|
|
3
3
|
export type ReviewStatus = "pending" | "approved" | "rejected";
|
|
4
|
+
export type ReportStatus = "pending" | "resolved" | "dismissed";
|
|
5
|
+
export type ReviewSortBy =
|
|
6
|
+
| "recent"
|
|
7
|
+
| "oldest"
|
|
8
|
+
| "highest"
|
|
9
|
+
| "lowest"
|
|
10
|
+
| "helpful";
|
|
11
|
+
|
|
12
|
+
export interface ReviewImage {
|
|
13
|
+
url: string;
|
|
14
|
+
caption?: string | undefined;
|
|
15
|
+
}
|
|
4
16
|
|
|
5
17
|
export interface Review {
|
|
6
18
|
id: string;
|
|
@@ -14,6 +26,7 @@ export interface Review {
|
|
|
14
26
|
status: ReviewStatus;
|
|
15
27
|
isVerifiedPurchase: boolean;
|
|
16
28
|
helpfulCount: number;
|
|
29
|
+
images?: ReviewImage[] | undefined;
|
|
17
30
|
merchantResponse?: string | undefined;
|
|
18
31
|
merchantResponseAt?: Date | undefined;
|
|
19
32
|
moderationNote?: string | undefined;
|
|
@@ -27,6 +40,23 @@ export interface RatingSummary {
|
|
|
27
40
|
distribution: Record<string, number>;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
export interface ReviewVote {
|
|
44
|
+
id: string;
|
|
45
|
+
reviewId: string;
|
|
46
|
+
voterId: string;
|
|
47
|
+
createdAt: Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ReviewReport {
|
|
51
|
+
id: string;
|
|
52
|
+
reviewId: string;
|
|
53
|
+
reporterId?: string | undefined;
|
|
54
|
+
reason: string;
|
|
55
|
+
details?: string | undefined;
|
|
56
|
+
status: ReportStatus;
|
|
57
|
+
createdAt: Date;
|
|
58
|
+
}
|
|
59
|
+
|
|
30
60
|
export interface ReviewController extends ModuleController {
|
|
31
61
|
createReview(params: {
|
|
32
62
|
productId: string;
|
|
@@ -37,6 +67,7 @@ export interface ReviewController extends ModuleController {
|
|
|
37
67
|
body: string;
|
|
38
68
|
customerId?: string | undefined;
|
|
39
69
|
isVerifiedPurchase?: boolean | undefined;
|
|
70
|
+
images?: ReviewImage[] | undefined;
|
|
40
71
|
}): Promise<Review>;
|
|
41
72
|
|
|
42
73
|
getReview(id: string): Promise<Review | null>;
|
|
@@ -47,6 +78,7 @@ export interface ReviewController extends ModuleController {
|
|
|
47
78
|
approvedOnly?: boolean | undefined;
|
|
48
79
|
take?: number | undefined;
|
|
49
80
|
skip?: number | undefined;
|
|
81
|
+
sortBy?: ReviewSortBy | undefined;
|
|
50
82
|
},
|
|
51
83
|
): Promise<Review[]>;
|
|
52
84
|
|
|
@@ -71,6 +103,11 @@ export interface ReviewController extends ModuleController {
|
|
|
71
103
|
|
|
72
104
|
markHelpful(id: string): Promise<Review | null>;
|
|
73
105
|
|
|
106
|
+
voteHelpful(
|
|
107
|
+
reviewId: string,
|
|
108
|
+
voterId: string,
|
|
109
|
+
): Promise<{ review: Review; alreadyVoted: boolean } | null>;
|
|
110
|
+
|
|
74
111
|
getReviewAnalytics(): Promise<ReviewAnalytics>;
|
|
75
112
|
|
|
76
113
|
createReviewRequest(params: {
|
|
@@ -98,6 +135,29 @@ export interface ReviewController extends ModuleController {
|
|
|
98
135
|
skip?: number | undefined;
|
|
99
136
|
},
|
|
100
137
|
): Promise<{ reviews: Review[]; total: number }>;
|
|
138
|
+
|
|
139
|
+
hasReviewedProduct(customerId: string, productId: string): Promise<boolean>;
|
|
140
|
+
|
|
141
|
+
reportReview(params: {
|
|
142
|
+
reviewId: string;
|
|
143
|
+
reporterId?: string | undefined;
|
|
144
|
+
reason: string;
|
|
145
|
+
details?: string | undefined;
|
|
146
|
+
}): Promise<ReviewReport>;
|
|
147
|
+
|
|
148
|
+
listReports(params?: {
|
|
149
|
+
status?: ReportStatus | undefined;
|
|
150
|
+
reviewId?: string | undefined;
|
|
151
|
+
take?: number | undefined;
|
|
152
|
+
skip?: number | undefined;
|
|
153
|
+
}): Promise<ReviewReport[]>;
|
|
154
|
+
|
|
155
|
+
updateReportStatus(
|
|
156
|
+
id: string,
|
|
157
|
+
status: ReportStatus,
|
|
158
|
+
): Promise<ReviewReport | null>;
|
|
159
|
+
|
|
160
|
+
getReportCount(reviewId: string): Promise<number>;
|
|
101
161
|
}
|
|
102
162
|
|
|
103
163
|
export interface ReviewAnalytics {
|
|
@@ -108,6 +168,7 @@ export interface ReviewAnalytics {
|
|
|
108
168
|
averageRating: number;
|
|
109
169
|
ratingsDistribution: Record<string, number>;
|
|
110
170
|
withMerchantResponse: number;
|
|
171
|
+
reportedCount: number;
|
|
111
172
|
}
|
|
112
173
|
|
|
113
174
|
export interface ReviewRequest {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { listMyReviews } from "./list-my-reviews";
|
|
2
2
|
import { listProductReviews } from "./list-product-reviews";
|
|
3
3
|
import { markHelpful } from "./mark-helpful";
|
|
4
|
+
import { reportReview } from "./report-review";
|
|
4
5
|
import { submitReview } from "./submit-review";
|
|
5
6
|
|
|
6
7
|
export const storeEndpoints = {
|
|
@@ -8,4 +9,5 @@ export const storeEndpoints = {
|
|
|
8
9
|
"/reviews/me": listMyReviews,
|
|
9
10
|
"/reviews/products/:productId": listProductReviews,
|
|
10
11
|
"/reviews/:id/helpful": markHelpful,
|
|
12
|
+
"/reviews/:id/report": reportReview,
|
|
11
13
|
};
|
|
@@ -20,7 +20,7 @@ export const listMyReviews = createStoreEndpoint(
|
|
|
20
20
|
const { page, limit, status } = ctx.query;
|
|
21
21
|
const skip = (page - 1) * limit;
|
|
22
22
|
|
|
23
|
-
const controller = ctx.context.controllers.
|
|
23
|
+
const controller = ctx.context.controllers.reviews as ReviewController;
|
|
24
24
|
const { reviews, total } = await controller.listReviewsByCustomer(userId, {
|
|
25
25
|
status: status as ReviewStatus | undefined,
|
|
26
26
|
take: limit,
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { createStoreEndpoint, z } from "@86d-app/core";
|
|
2
|
-
import type { ReviewController } from "../../service";
|
|
2
|
+
import type { ReviewController, ReviewSortBy } from "../../service";
|
|
3
3
|
|
|
4
4
|
export const listProductReviews = createStoreEndpoint(
|
|
5
5
|
"/reviews/products/:productId",
|
|
6
6
|
{
|
|
7
7
|
method: "GET",
|
|
8
|
-
params: z.object({ productId: z.string() }),
|
|
8
|
+
params: z.object({ productId: z.string().max(200) }),
|
|
9
9
|
query: z.object({
|
|
10
10
|
take: z.coerce.number().int().min(1).max(100).optional(),
|
|
11
11
|
skip: z.coerce.number().int().min(0).optional(),
|
|
12
|
+
sortBy: z
|
|
13
|
+
.enum(["recent", "oldest", "highest", "lowest", "helpful"])
|
|
14
|
+
.optional(),
|
|
12
15
|
}),
|
|
13
16
|
},
|
|
14
17
|
async (ctx) => {
|
|
@@ -18,6 +21,7 @@ export const listProductReviews = createStoreEndpoint(
|
|
|
18
21
|
approvedOnly: true,
|
|
19
22
|
take: ctx.query.take ?? 20,
|
|
20
23
|
skip: ctx.query.skip ?? 0,
|
|
24
|
+
sortBy: (ctx.query.sortBy as ReviewSortBy | undefined) ?? "recent",
|
|
21
25
|
}),
|
|
22
26
|
controller.getProductRatingSummary(ctx.params.productId),
|
|
23
27
|
]);
|
|
@@ -5,12 +5,31 @@ export const markHelpful = createStoreEndpoint(
|
|
|
5
5
|
"/reviews/:id/helpful",
|
|
6
6
|
{
|
|
7
7
|
method: "POST",
|
|
8
|
-
params: z.object({ id: z.string() }),
|
|
8
|
+
params: z.object({ id: z.string().max(128) }),
|
|
9
9
|
},
|
|
10
10
|
async (ctx) => {
|
|
11
11
|
const controller = ctx.context.controllers.reviews as ReviewController;
|
|
12
|
+
const voterId = ctx.context.session?.user.id;
|
|
13
|
+
|
|
14
|
+
// Authenticated users get vote deduplication
|
|
15
|
+
if (voterId) {
|
|
16
|
+
const result = await controller.voteHelpful(ctx.params.id, voterId);
|
|
17
|
+
if (!result) return { error: "Review not found", status: 404 };
|
|
18
|
+
if (result.alreadyVoted) {
|
|
19
|
+
return {
|
|
20
|
+
helpfulCount: result.review.helpfulCount,
|
|
21
|
+
alreadyVoted: true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
helpfulCount: result.review.helpfulCount,
|
|
26
|
+
alreadyVoted: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Anonymous users: simple increment (no dedup possible)
|
|
12
31
|
const review = await controller.markHelpful(ctx.params.id);
|
|
13
32
|
if (!review) return { error: "Review not found", status: 404 };
|
|
14
|
-
return { helpfulCount: review.helpfulCount };
|
|
33
|
+
return { helpfulCount: review.helpfulCount, alreadyVoted: false };
|
|
15
34
|
},
|
|
16
35
|
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
|
|
2
|
+
import type { ReviewController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const reportReview = createStoreEndpoint(
|
|
5
|
+
"/reviews/:id/report",
|
|
6
|
+
{
|
|
7
|
+
method: "POST",
|
|
8
|
+
params: z.object({ id: z.string().max(128) }),
|
|
9
|
+
body: z.object({
|
|
10
|
+
reason: z.enum([
|
|
11
|
+
"spam",
|
|
12
|
+
"offensive",
|
|
13
|
+
"fake",
|
|
14
|
+
"irrelevant",
|
|
15
|
+
"harassment",
|
|
16
|
+
"other",
|
|
17
|
+
]),
|
|
18
|
+
details: z.string().max(1000).transform(sanitizeText).optional(),
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
async (ctx) => {
|
|
22
|
+
const controller = ctx.context.controllers.reviews as ReviewController;
|
|
23
|
+
|
|
24
|
+
// Verify review exists
|
|
25
|
+
const review = await controller.getReview(ctx.params.id);
|
|
26
|
+
if (!review) return { error: "Review not found", status: 404 };
|
|
27
|
+
|
|
28
|
+
const reporterId = ctx.context.session?.user.id;
|
|
29
|
+
|
|
30
|
+
const report = await controller.reportReview({
|
|
31
|
+
reviewId: ctx.params.id,
|
|
32
|
+
reporterId,
|
|
33
|
+
reason: ctx.body.reason,
|
|
34
|
+
details: ctx.body.details,
|
|
35
|
+
});
|
|
36
|
+
return { report };
|
|
37
|
+
},
|
|
38
|
+
);
|
|
@@ -1,32 +1,58 @@
|
|
|
1
1
|
import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
|
|
2
2
|
import type { ReviewController } from "../../service";
|
|
3
3
|
|
|
4
|
+
const imageSchema = z.object({
|
|
5
|
+
url: z.string().url().max(2000),
|
|
6
|
+
caption: z.string().max(500).transform(sanitizeText).optional(),
|
|
7
|
+
});
|
|
8
|
+
|
|
4
9
|
export const submitReview = createStoreEndpoint(
|
|
5
10
|
"/reviews",
|
|
6
11
|
{
|
|
7
12
|
method: "POST",
|
|
8
13
|
body: z.object({
|
|
9
|
-
productId: z.string(),
|
|
14
|
+
productId: z.string().max(200),
|
|
10
15
|
authorName: z.string().max(200).transform(sanitizeText),
|
|
11
|
-
authorEmail: z.string().email(),
|
|
16
|
+
authorEmail: z.string().email().max(320),
|
|
12
17
|
rating: z.number().int().min(1).max(5),
|
|
13
18
|
title: z.string().max(500).transform(sanitizeText).optional(),
|
|
14
19
|
body: z.string().max(10000).transform(sanitizeText),
|
|
15
|
-
|
|
16
|
-
isVerifiedPurchase: z.boolean().optional(),
|
|
20
|
+
images: z.array(imageSchema).max(5).optional(),
|
|
17
21
|
}),
|
|
18
22
|
},
|
|
19
23
|
async (ctx) => {
|
|
20
24
|
const controller = ctx.context.controllers.reviews as ReviewController;
|
|
25
|
+
const customerId = ctx.context.session?.user.id;
|
|
26
|
+
|
|
27
|
+
// Prevent duplicate reviews from authenticated customers
|
|
28
|
+
if (customerId) {
|
|
29
|
+
const alreadyReviewed = await controller.hasReviewedProduct(
|
|
30
|
+
customerId,
|
|
31
|
+
ctx.body.productId,
|
|
32
|
+
);
|
|
33
|
+
if (alreadyReviewed) {
|
|
34
|
+
return {
|
|
35
|
+
error: "You have already reviewed this product",
|
|
36
|
+
status: 409,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Use session email when authenticated to prevent spoofing
|
|
42
|
+
const authorEmail = customerId
|
|
43
|
+
? (ctx.context.session?.user.email ?? ctx.body.authorEmail)
|
|
44
|
+
: ctx.body.authorEmail;
|
|
45
|
+
|
|
21
46
|
const review = await controller.createReview({
|
|
22
47
|
productId: ctx.body.productId,
|
|
23
48
|
authorName: ctx.body.authorName,
|
|
24
|
-
authorEmail
|
|
49
|
+
authorEmail,
|
|
25
50
|
rating: ctx.body.rating,
|
|
26
51
|
title: ctx.body.title,
|
|
27
52
|
body: ctx.body.body,
|
|
28
|
-
customerId
|
|
29
|
-
isVerifiedPurchase:
|
|
53
|
+
customerId,
|
|
54
|
+
isVerifiedPurchase: false,
|
|
55
|
+
images: ctx.body.images,
|
|
30
56
|
});
|
|
31
57
|
return { review };
|
|
32
58
|
},
|
package/COMPONENTS.md
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# Reviews Module — Store Components
|
|
2
|
-
|
|
3
|
-
## ReviewsSummary
|
|
4
|
-
|
|
5
|
-
Compact star rating and count for product cards.
|
|
6
|
-
|
|
7
|
-
### Props
|
|
8
|
-
|
|
9
|
-
| Prop | Type | Description |
|
|
10
|
-
|------|------|-------------|
|
|
11
|
-
| `productId` | `string` | Product ID to fetch review summary for |
|
|
12
|
-
|
|
13
|
-
### Usage in MDX
|
|
14
|
-
|
|
15
|
-
```mdx
|
|
16
|
-
<ReviewsSummary productId={product.id} />
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## ProductReviews
|
|
20
|
-
|
|
21
|
-
Full reviews section with summary, distribution bars, review list, and submit form.
|
|
22
|
-
|
|
23
|
-
### Props
|
|
24
|
-
|
|
25
|
-
| Prop | Type | Default | Description |
|
|
26
|
-
|------|------|---------|-------------|
|
|
27
|
-
| `productId` | `string` | — | Product ID |
|
|
28
|
-
| `title` | `string` | `"Customer Reviews"` | Section heading |
|
|
29
|
-
|
|
30
|
-
### Usage in MDX
|
|
31
|
-
|
|
32
|
-
```mdx
|
|
33
|
-
<ProductReviews productId={product.id} />
|
|
34
|
-
```
|