@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.
Files changed (104) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/AGENTS.md +54 -18
  3. package/README.md +150 -41
  4. package/dist/__tests__/controllers.test.d.ts +2 -0
  5. package/dist/__tests__/controllers.test.d.ts.map +1 -0
  6. package/dist/__tests__/endpoint-security.test.d.ts +2 -0
  7. package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
  8. package/dist/__tests__/new-features.test.d.ts +2 -0
  9. package/dist/__tests__/new-features.test.d.ts.map +1 -0
  10. package/dist/__tests__/service-impl.test.d.ts +2 -0
  11. package/dist/__tests__/service-impl.test.d.ts.map +1 -0
  12. package/dist/admin/components/index.d.ts +4 -0
  13. package/dist/admin/components/index.d.ts.map +1 -0
  14. package/dist/admin/components/review-analytics.d.ts +2 -0
  15. package/dist/admin/components/review-analytics.d.ts.map +1 -0
  16. package/dist/admin/components/review-list.d.ts +2 -0
  17. package/dist/admin/components/review-list.d.ts.map +1 -0
  18. package/dist/admin/components/review-moderation.d.ts +6 -0
  19. package/dist/admin/components/review-moderation.d.ts.map +1 -0
  20. package/dist/admin/endpoints/approve-review.d.ts +16 -0
  21. package/dist/admin/endpoints/approve-review.d.ts.map +1 -0
  22. package/dist/admin/endpoints/delete-review.d.ts +16 -0
  23. package/dist/admin/endpoints/delete-review.d.ts.map +1 -0
  24. package/dist/admin/endpoints/get-review.d.ts +16 -0
  25. package/dist/admin/endpoints/get-review.d.ts.map +1 -0
  26. package/dist/admin/endpoints/index.d.ts +167 -0
  27. package/dist/admin/endpoints/index.d.ts.map +1 -0
  28. package/dist/admin/endpoints/list-reports.d.ts +17 -0
  29. package/dist/admin/endpoints/list-reports.d.ts.map +1 -0
  30. package/dist/admin/endpoints/list-review-requests.d.ts +11 -0
  31. package/dist/admin/endpoints/list-review-requests.d.ts.map +1 -0
  32. package/dist/admin/endpoints/list-reviews.d.ts +18 -0
  33. package/dist/admin/endpoints/list-reviews.d.ts.map +1 -0
  34. package/dist/admin/endpoints/reject-review.d.ts +16 -0
  35. package/dist/admin/endpoints/reject-review.d.ts.map +1 -0
  36. package/dist/admin/endpoints/respond-review.d.ts +19 -0
  37. package/dist/admin/endpoints/respond-review.d.ts.map +1 -0
  38. package/dist/admin/endpoints/review-analytics.d.ts +6 -0
  39. package/dist/admin/endpoints/review-analytics.d.ts.map +1 -0
  40. package/dist/admin/endpoints/review-request-stats.d.ts +6 -0
  41. package/dist/admin/endpoints/review-request-stats.d.ts.map +1 -0
  42. package/dist/admin/endpoints/send-review-request.d.ts +23 -0
  43. package/dist/admin/endpoints/send-review-request.d.ts.map +1 -0
  44. package/dist/admin/endpoints/update-report.d.ts +22 -0
  45. package/dist/admin/endpoints/update-report.d.ts.map +1 -0
  46. package/dist/index.d.ts +8 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/schema.d.ts +136 -0
  49. package/dist/schema.d.ts.map +1 -0
  50. package/dist/service-impl.d.ts +6 -0
  51. package/dist/service-impl.d.ts.map +1 -0
  52. package/dist/service.d.ts +149 -0
  53. package/dist/service.d.ts.map +1 -0
  54. package/dist/store/components/_hooks.d.ts +6 -0
  55. package/dist/store/components/_hooks.d.ts.map +1 -0
  56. package/dist/store/components/_utils.d.ts +3 -0
  57. package/dist/store/components/_utils.d.ts.map +1 -0
  58. package/dist/store/components/distribution-bars.d.ts +5 -0
  59. package/dist/store/components/distribution-bars.d.ts.map +1 -0
  60. package/dist/store/components/index.d.ts +18 -0
  61. package/dist/store/components/index.d.ts.map +1 -0
  62. package/dist/store/components/product-reviews.d.ts +5 -0
  63. package/dist/store/components/product-reviews.d.ts.map +1 -0
  64. package/dist/store/components/review-card.d.ts +18 -0
  65. package/dist/store/components/review-card.d.ts.map +1 -0
  66. package/dist/store/components/review-form.d.ts +5 -0
  67. package/dist/store/components/review-form.d.ts.map +1 -0
  68. package/dist/store/components/reviews-summary.d.ts +4 -0
  69. package/dist/store/components/reviews-summary.d.ts.map +1 -0
  70. package/dist/store/components/star-display.d.ts +5 -0
  71. package/dist/store/components/star-display.d.ts.map +1 -0
  72. package/dist/store/components/star-picker.d.ts +5 -0
  73. package/dist/store/components/star-picker.d.ts.map +1 -0
  74. package/dist/store/endpoints/index.d.ts +116 -0
  75. package/dist/store/endpoints/index.d.ts.map +1 -0
  76. package/dist/store/endpoints/list-my-reviews.d.ts +30 -0
  77. package/dist/store/endpoints/list-my-reviews.d.ts.map +1 -0
  78. package/dist/store/endpoints/list-product-reviews.d.ts +23 -0
  79. package/dist/store/endpoints/list-product-reviews.d.ts.map +1 -0
  80. package/dist/store/endpoints/mark-helpful.d.ts +18 -0
  81. package/dist/store/endpoints/mark-helpful.d.ts.map +1 -0
  82. package/dist/store/endpoints/report-review.d.ts +27 -0
  83. package/dist/store/endpoints/report-review.d.ts.map +1 -0
  84. package/dist/store/endpoints/submit-review.d.ts +25 -0
  85. package/dist/store/endpoints/submit-review.d.ts.map +1 -0
  86. package/package.json +3 -3
  87. package/src/__tests__/controllers.test.ts +1074 -0
  88. package/src/__tests__/endpoint-security.test.ts +559 -0
  89. package/src/__tests__/new-features.test.ts +618 -0
  90. package/src/__tests__/service-impl.test.ts +1 -1
  91. package/src/admin/endpoints/index.ts +4 -0
  92. package/src/admin/endpoints/list-reports.ts +25 -0
  93. package/src/admin/endpoints/update-report.ts +22 -0
  94. package/src/index.ts +7 -1
  95. package/src/schema.ts +28 -0
  96. package/src/service-impl.ts +151 -3
  97. package/src/service.ts +61 -0
  98. package/src/store/endpoints/index.ts +2 -0
  99. package/src/store/endpoints/list-my-reviews.ts +1 -1
  100. package/src/store/endpoints/list-product-reviews.ts +6 -2
  101. package/src/store/endpoints/mark-helpful.ts +21 -2
  102. package/src/store/endpoints/report-review.ts +38 -0
  103. package/src/store/endpoints/submit-review.ts +33 -7
  104. 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.1",
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;
@@ -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
- return all as unknown as Review[];
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.review as ReviewController;
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
- customerId: z.string().optional(),
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: ctx.body.authorEmail,
49
+ authorEmail,
25
50
  rating: ctx.body.rating,
26
51
  title: ctx.body.title,
27
52
  body: ctx.body.body,
28
- customerId: ctx.body.customerId,
29
- isVerifiedPurchase: ctx.body.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
- ```