@porulle/plugin-reviews 0.1.0

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 ADDED
@@ -0,0 +1,54 @@
1
+ # @porulle/plugin-reviews
2
+
3
+ Customer reviews on catalog entities with moderation, replies, and aggregate summaries.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @porulle/plugin-reviews
9
+ ```
10
+
11
+ Add to `commerce.config.ts`:
12
+
13
+ ```typescript
14
+ import { reviewsPlugin } from "@porulle/plugin-reviews";
15
+
16
+ export default defineConfig({
17
+ plugins: [reviewsPlugin()],
18
+ });
19
+ ```
20
+
21
+ Add to `drizzle.config.ts`:
22
+
23
+ ```typescript
24
+ schema: [
25
+ "./node_modules/@porulle/plugin-reviews/src/schema.ts",
26
+ // ...
27
+ ],
28
+ ```
29
+
30
+ ## What it does
31
+
32
+ Stores per-entity reviews with optional verified order linkage, publishes after approval, supports merchant replies, and exposes summaries for storefront display.
33
+
34
+ ## Routes exposed
35
+
36
+ **`/reviews`** — `POST /`, `GET /entity/{entityId}`, `GET /entity/{entityId}/summary`, `PATCH /{id}/approve`, `PATCH /{id}/reject`, `POST /{id}/reply`, `GET /mine`
37
+
38
+ ## Hooks
39
+
40
+ **Emitted:** none.
41
+
42
+ **Consumed:** none.
43
+
44
+ ## MCP tools
45
+
46
+ **`reviews`** — `list`, `summary`, `submit`, `approve`
47
+
48
+ ## Configuration options
49
+
50
+ None (`reviewsPlugin()` takes no options).
51
+
52
+ ## License
53
+
54
+ MIT
@@ -0,0 +1,4 @@
1
+ export type { Db } from "./types.js";
2
+ export { ReviewService } from "./services/review-service.js";
3
+ export declare function reviewsPlugin(): import("@porulle/core").CommercePlugin;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAE7D,wBAAgB,aAAa,2CA2B5B"}
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ import { defineCommercePlugin } from "@porulle/core";
2
+ import { customerReviews } from "./schema.js";
3
+ import { ReviewService } from "./services/review-service.js";
4
+ import { buildReviewRoutes } from "./routes/reviews.js";
5
+ export { ReviewService } from "./services/review-service.js";
6
+ export function reviewsPlugin() {
7
+ return defineCommercePlugin({
8
+ id: "reviews",
9
+ version: "1.0.0",
10
+ permissions: [
11
+ { scope: "reviews:admin", description: "Approve, reject, and reply to reviews." },
12
+ { scope: "reviews:write", description: "Submit reviews." },
13
+ { scope: "reviews:read", description: "View reviews and summaries." },
14
+ ],
15
+ schema: () => ({ customerReviews }),
16
+ hooks: () => [],
17
+ routes: (ctx) => {
18
+ const db = ctx.database.db;
19
+ if (!db)
20
+ return [];
21
+ const customers = ctx.services?.customers;
22
+ return buildReviewRoutes(new ReviewService(db, customers ? { customers } : undefined), ctx);
23
+ },
24
+ });
25
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReviewService } from "../services/review-service.js";
2
+ import type { PluginRouteRegistration } from "@porulle/core";
3
+ export declare function buildReviewRoutes(service: ReviewService, ctx: {
4
+ services?: Record<string, unknown>;
5
+ database?: {
6
+ db: unknown;
7
+ };
8
+ }): PluginRouteRegistration[];
9
+ //# sourceMappingURL=reviews.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reviews.d.ts","sourceRoot":"","sources":["../../src/routes/reviews.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AACnE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GACtE,uBAAuB,EAAE,CAwE3B"}
@@ -0,0 +1,72 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ export function buildReviewRoutes(service, ctx) {
4
+ const r = router("Reviews", "/reviews", ctx);
5
+ r.post("/").summary("Submit review").permission("reviews:write")
6
+ .input(z.object({
7
+ customerId: z.string().uuid().optional(),
8
+ entityId: z.string().uuid(),
9
+ orderId: z.string().uuid().optional(),
10
+ rating: z.number().int().min(1).max(5),
11
+ title: z.string().optional(),
12
+ body: z.string().optional(),
13
+ }))
14
+ .handler(async ({ input, orgId, actor }) => {
15
+ const body = input;
16
+ const result = await service.submit(orgId, body, (actor ?? null));
17
+ if (!result.ok)
18
+ throw new Error(result.error);
19
+ return result.value;
20
+ });
21
+ r.get("/entity/{entityId}").summary("List reviews for entity").permission("reviews:read")
22
+ .handler(async ({ params, orgId }) => {
23
+ const result = await service.listForEntity(orgId, params.entityId);
24
+ if (!result.ok)
25
+ throw new Error(result.error);
26
+ return result.value;
27
+ });
28
+ r.get("/entity/{entityId}/summary").summary("Review summary for entity").permission("reviews:read")
29
+ .handler(async ({ params, orgId }) => {
30
+ const result = await service.getSummary(orgId, params.entityId);
31
+ if (!result.ok)
32
+ throw new Error(result.error);
33
+ return result.value;
34
+ });
35
+ r.patch("/{id}/approve").summary("Approve review").permission("reviews:admin")
36
+ .handler(async ({ params, orgId }) => {
37
+ const result = await service.approve(orgId, params.id);
38
+ if (!result.ok)
39
+ throw new Error(result.error);
40
+ return result.value;
41
+ });
42
+ r.patch("/{id}/reject").summary("Reject review").permission("reviews:admin")
43
+ .handler(async ({ params, orgId }) => {
44
+ const result = await service.reject(orgId, params.id);
45
+ if (!result.ok)
46
+ throw new Error(result.error);
47
+ return result.value;
48
+ });
49
+ r.post("/{id}/reply").summary("Reply to review").permission("reviews:admin")
50
+ .input(z.object({
51
+ response: z.string().min(1),
52
+ responseBy: z.string().min(1),
53
+ }))
54
+ .handler(async ({ params, input, orgId }) => {
55
+ const body = input;
56
+ const result = await service.reply(orgId, params.id, body.response, body.responseBy);
57
+ if (!result.ok)
58
+ throw new Error(result.error);
59
+ return result.value;
60
+ });
61
+ r.get("/mine").summary("My reviews").permission("reviews:write")
62
+ .handler(async ({ actor, orgId }) => {
63
+ // Use the authenticated actor's userId — never accept customerId from query
64
+ if (!actor?.userId)
65
+ throw new Error("Authentication required");
66
+ const result = await service.listByCustomer(orgId, actor.userId);
67
+ if (!result.ok)
68
+ throw new Error(result.error);
69
+ return result.value;
70
+ });
71
+ return r.routes();
72
+ }
@@ -0,0 +1,280 @@
1
+ export declare const customerReviews: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
2
+ name: "customer_reviews";
3
+ schema: undefined;
4
+ columns: {
5
+ id: import("@porulle/core/drizzle").PgColumn<{
6
+ name: "id";
7
+ tableName: "customer_reviews";
8
+ dataType: "string";
9
+ columnType: "PgUUID";
10
+ data: string;
11
+ driverParam: string;
12
+ notNull: true;
13
+ hasDefault: true;
14
+ isPrimaryKey: true;
15
+ isAutoincrement: false;
16
+ hasRuntimeDefault: false;
17
+ enumValues: undefined;
18
+ baseColumn: never;
19
+ identity: undefined;
20
+ generated: undefined;
21
+ }, {}, {}>;
22
+ organizationId: import("@porulle/core/drizzle").PgColumn<{
23
+ name: "organization_id";
24
+ tableName: "customer_reviews";
25
+ dataType: "string";
26
+ columnType: "PgText";
27
+ data: string;
28
+ driverParam: string;
29
+ notNull: true;
30
+ hasDefault: false;
31
+ isPrimaryKey: false;
32
+ isAutoincrement: false;
33
+ hasRuntimeDefault: false;
34
+ enumValues: [string, ...string[]];
35
+ baseColumn: never;
36
+ identity: undefined;
37
+ generated: undefined;
38
+ }, {}, {}>;
39
+ customerId: import("@porulle/core/drizzle").PgColumn<{
40
+ name: "customer_id";
41
+ tableName: "customer_reviews";
42
+ dataType: "string";
43
+ columnType: "PgUUID";
44
+ data: string;
45
+ driverParam: string;
46
+ notNull: false;
47
+ hasDefault: false;
48
+ isPrimaryKey: false;
49
+ isAutoincrement: false;
50
+ hasRuntimeDefault: false;
51
+ enumValues: undefined;
52
+ baseColumn: never;
53
+ identity: undefined;
54
+ generated: undefined;
55
+ }, {}, {}>;
56
+ entityId: import("@porulle/core/drizzle").PgColumn<{
57
+ name: "entity_id";
58
+ tableName: "customer_reviews";
59
+ dataType: "string";
60
+ columnType: "PgUUID";
61
+ data: string;
62
+ driverParam: string;
63
+ notNull: true;
64
+ hasDefault: false;
65
+ isPrimaryKey: false;
66
+ isAutoincrement: false;
67
+ hasRuntimeDefault: false;
68
+ enumValues: undefined;
69
+ baseColumn: never;
70
+ identity: undefined;
71
+ generated: undefined;
72
+ }, {}, {}>;
73
+ orderId: import("@porulle/core/drizzle").PgColumn<{
74
+ name: "order_id";
75
+ tableName: "customer_reviews";
76
+ dataType: "string";
77
+ columnType: "PgUUID";
78
+ data: string;
79
+ driverParam: string;
80
+ notNull: false;
81
+ hasDefault: false;
82
+ isPrimaryKey: false;
83
+ isAutoincrement: false;
84
+ hasRuntimeDefault: false;
85
+ enumValues: undefined;
86
+ baseColumn: never;
87
+ identity: undefined;
88
+ generated: undefined;
89
+ }, {}, {}>;
90
+ rating: import("@porulle/core/drizzle").PgColumn<{
91
+ name: "rating";
92
+ tableName: "customer_reviews";
93
+ dataType: "number";
94
+ columnType: "PgInteger";
95
+ data: number;
96
+ driverParam: string | number;
97
+ notNull: true;
98
+ hasDefault: false;
99
+ isPrimaryKey: false;
100
+ isAutoincrement: false;
101
+ hasRuntimeDefault: false;
102
+ enumValues: undefined;
103
+ baseColumn: never;
104
+ identity: undefined;
105
+ generated: undefined;
106
+ }, {}, {}>;
107
+ title: import("@porulle/core/drizzle").PgColumn<{
108
+ name: "title";
109
+ tableName: "customer_reviews";
110
+ dataType: "string";
111
+ columnType: "PgText";
112
+ data: string;
113
+ driverParam: string;
114
+ notNull: false;
115
+ hasDefault: false;
116
+ isPrimaryKey: false;
117
+ isAutoincrement: false;
118
+ hasRuntimeDefault: false;
119
+ enumValues: [string, ...string[]];
120
+ baseColumn: never;
121
+ identity: undefined;
122
+ generated: undefined;
123
+ }, {}, {}>;
124
+ body: import("@porulle/core/drizzle").PgColumn<{
125
+ name: "body";
126
+ tableName: "customer_reviews";
127
+ dataType: "string";
128
+ columnType: "PgText";
129
+ data: string;
130
+ driverParam: string;
131
+ notNull: false;
132
+ hasDefault: false;
133
+ isPrimaryKey: false;
134
+ isAutoincrement: false;
135
+ hasRuntimeDefault: false;
136
+ enumValues: [string, ...string[]];
137
+ baseColumn: never;
138
+ identity: undefined;
139
+ generated: undefined;
140
+ }, {}, {}>;
141
+ status: import("@porulle/core/drizzle").PgColumn<{
142
+ name: "status";
143
+ tableName: "customer_reviews";
144
+ dataType: "string";
145
+ columnType: "PgText";
146
+ data: "pending" | "approved" | "rejected";
147
+ driverParam: string;
148
+ notNull: true;
149
+ hasDefault: true;
150
+ isPrimaryKey: false;
151
+ isAutoincrement: false;
152
+ hasRuntimeDefault: false;
153
+ enumValues: ["pending", "approved", "rejected"];
154
+ baseColumn: never;
155
+ identity: undefined;
156
+ generated: undefined;
157
+ }, {}, {}>;
158
+ isVerified: import("@porulle/core/drizzle").PgColumn<{
159
+ name: "is_verified";
160
+ tableName: "customer_reviews";
161
+ dataType: "boolean";
162
+ columnType: "PgBoolean";
163
+ data: boolean;
164
+ driverParam: boolean;
165
+ notNull: true;
166
+ hasDefault: true;
167
+ isPrimaryKey: false;
168
+ isAutoincrement: false;
169
+ hasRuntimeDefault: false;
170
+ enumValues: undefined;
171
+ baseColumn: never;
172
+ identity: undefined;
173
+ generated: undefined;
174
+ }, {}, {}>;
175
+ isPublished: import("@porulle/core/drizzle").PgColumn<{
176
+ name: "is_published";
177
+ tableName: "customer_reviews";
178
+ dataType: "boolean";
179
+ columnType: "PgBoolean";
180
+ data: boolean;
181
+ driverParam: boolean;
182
+ notNull: true;
183
+ hasDefault: true;
184
+ isPrimaryKey: false;
185
+ isAutoincrement: false;
186
+ hasRuntimeDefault: false;
187
+ enumValues: undefined;
188
+ baseColumn: never;
189
+ identity: undefined;
190
+ generated: undefined;
191
+ }, {}, {}>;
192
+ response: import("@porulle/core/drizzle").PgColumn<{
193
+ name: "response";
194
+ tableName: "customer_reviews";
195
+ dataType: "string";
196
+ columnType: "PgText";
197
+ data: string;
198
+ driverParam: string;
199
+ notNull: false;
200
+ hasDefault: false;
201
+ isPrimaryKey: false;
202
+ isAutoincrement: false;
203
+ hasRuntimeDefault: false;
204
+ enumValues: [string, ...string[]];
205
+ baseColumn: never;
206
+ identity: undefined;
207
+ generated: undefined;
208
+ }, {}, {}>;
209
+ responseBy: import("@porulle/core/drizzle").PgColumn<{
210
+ name: "response_by";
211
+ tableName: "customer_reviews";
212
+ dataType: "string";
213
+ columnType: "PgText";
214
+ data: string;
215
+ driverParam: string;
216
+ notNull: false;
217
+ hasDefault: false;
218
+ isPrimaryKey: false;
219
+ isAutoincrement: false;
220
+ hasRuntimeDefault: false;
221
+ enumValues: [string, ...string[]];
222
+ baseColumn: never;
223
+ identity: undefined;
224
+ generated: undefined;
225
+ }, {}, {}>;
226
+ responseAt: import("@porulle/core/drizzle").PgColumn<{
227
+ name: "response_at";
228
+ tableName: "customer_reviews";
229
+ dataType: "date";
230
+ columnType: "PgTimestamp";
231
+ data: Date;
232
+ driverParam: string;
233
+ notNull: false;
234
+ hasDefault: false;
235
+ isPrimaryKey: false;
236
+ isAutoincrement: false;
237
+ hasRuntimeDefault: false;
238
+ enumValues: undefined;
239
+ baseColumn: never;
240
+ identity: undefined;
241
+ generated: undefined;
242
+ }, {}, {}>;
243
+ createdAt: import("@porulle/core/drizzle").PgColumn<{
244
+ name: "created_at";
245
+ tableName: "customer_reviews";
246
+ dataType: "date";
247
+ columnType: "PgTimestamp";
248
+ data: Date;
249
+ driverParam: string;
250
+ notNull: true;
251
+ hasDefault: true;
252
+ isPrimaryKey: false;
253
+ isAutoincrement: false;
254
+ hasRuntimeDefault: false;
255
+ enumValues: undefined;
256
+ baseColumn: never;
257
+ identity: undefined;
258
+ generated: undefined;
259
+ }, {}, {}>;
260
+ updatedAt: import("@porulle/core/drizzle").PgColumn<{
261
+ name: "updated_at";
262
+ tableName: "customer_reviews";
263
+ dataType: "date";
264
+ columnType: "PgTimestamp";
265
+ data: Date;
266
+ driverParam: string;
267
+ notNull: true;
268
+ hasDefault: true;
269
+ isPrimaryKey: false;
270
+ isAutoincrement: false;
271
+ hasRuntimeDefault: false;
272
+ enumValues: undefined;
273
+ baseColumn: never;
274
+ identity: undefined;
275
+ generated: undefined;
276
+ }, {}, {}>;
277
+ };
278
+ dialect: "pg";
279
+ }>;
280
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAuBzB,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,25 @@
1
+ import { pgTable, uuid, text, integer, boolean, timestamp, index } from "@porulle/core/drizzle";
2
+ export const customerReviews = pgTable("customer_reviews", {
3
+ id: uuid("id").defaultRandom().primaryKey(),
4
+ organizationId: text("organization_id").notNull(),
5
+ customerId: uuid("customer_id"),
6
+ entityId: uuid("entity_id").notNull(),
7
+ orderId: uuid("order_id"),
8
+ rating: integer("rating").notNull(),
9
+ title: text("title"),
10
+ body: text("body"),
11
+ status: text("status", { enum: ["pending", "approved", "rejected"] })
12
+ .notNull()
13
+ .default("pending"),
14
+ isVerified: boolean("is_verified").notNull().default(false),
15
+ isPublished: boolean("is_published").notNull().default(false),
16
+ response: text("response"),
17
+ responseBy: text("response_by"),
18
+ responseAt: timestamp("response_at", { withTimezone: true }),
19
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
20
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
21
+ }, (table) => ({
22
+ orgIdx: index("idx_customer_reviews_org").on(table.organizationId),
23
+ entityIdx: index("idx_customer_reviews_entity").on(table.entityId),
24
+ statusIdx: index("idx_customer_reviews_status").on(table.status),
25
+ }));
@@ -0,0 +1,40 @@
1
+ import type { PluginResult } from "@porulle/core";
2
+ import type { Actor } from "@porulle/core";
3
+ import type { Db, Review } from "../types.js";
4
+ export interface ReviewSummary {
5
+ averageRating: number;
6
+ totalCount: number;
7
+ distribution: Record<number, number>;
8
+ }
9
+ export declare class ReviewService {
10
+ private db;
11
+ private services?;
12
+ constructor(db: Db, services?: {
13
+ customers?: {
14
+ getByUserId(userId: string, actor?: Actor | null): Promise<{
15
+ ok: true;
16
+ value: {
17
+ id: string;
18
+ };
19
+ } | {
20
+ ok: false;
21
+ error: unknown;
22
+ }>;
23
+ };
24
+ } | undefined);
25
+ submit(orgId: string, input: {
26
+ customerId?: string;
27
+ entityId: string;
28
+ orderId?: string;
29
+ rating: number;
30
+ title?: string;
31
+ body?: string;
32
+ }, actor: Actor | null): Promise<PluginResult<Review>>;
33
+ listForEntity(orgId: string, entityId: string, publishedOnly?: boolean): Promise<PluginResult<Review[]>>;
34
+ getSummary(orgId: string, entityId: string): Promise<PluginResult<ReviewSummary>>;
35
+ approve(orgId: string, id: string): Promise<PluginResult<Review>>;
36
+ reject(orgId: string, id: string): Promise<PluginResult<Review>>;
37
+ reply(orgId: string, id: string, response: string, responseBy: string): Promise<PluginResult<Review>>;
38
+ listByCustomer(orgId: string, customerId: string): Promise<PluginResult<Review[]>>;
39
+ }
40
+ //# sourceMappingURL=review-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"review-service.d.ts","sourceRoot":"","sources":["../../src/services/review-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAE3C,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE9C,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAED,qBAAa,aAAa;IAEtB,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,QAAQ,CAAC;gBADT,EAAE,EAAE,EAAE,EACN,QAAQ,CAAC,EAAE;QACjB,SAAS,CAAC,EAAE;YACV,WAAW,CACT,MAAM,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,KAAK,GAAG,IAAI,GACnB,OAAO,CAAC;gBAAE,EAAE,EAAE,IAAI,CAAC;gBAAC,KAAK,EAAE;oBAAE,EAAE,EAAE,MAAM,CAAA;iBAAE,CAAA;aAAE,GAAG;gBAAE,EAAE,EAAE,KAAK,CAAC;gBAAC,KAAK,EAAE,OAAO,CAAA;aAAE,CAAC,CAAC;SACjF,CAAC;KACH,YAAA;IAGG,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACjC,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI,GAAG,OAAO,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IAuChD,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC;IAYxG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;IAwBjF,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IASjE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IAShE,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IASrG,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC;CAQzF"}
@@ -0,0 +1,110 @@
1
+ import { eq, and, sql } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import { customerReviews } from "../schema.js";
4
+ export class ReviewService {
5
+ db;
6
+ services;
7
+ constructor(db, services) {
8
+ this.db = db;
9
+ this.services = services;
10
+ }
11
+ async submit(orgId, input, actor) {
12
+ if (input.rating < 1 || input.rating > 5 || !Number.isInteger(input.rating)) {
13
+ return Err("Rating must be an integer between 1 and 5");
14
+ }
15
+ if (!actor?.userId) {
16
+ return Err("Authentication required");
17
+ }
18
+ const staffRoles = new Set(["staff", "admin", "owner", "ai_agent", "service"]);
19
+ const isStaff = typeof actor.role === "string" && staffRoles.has(actor.role);
20
+ let resolvedCustomerId = null;
21
+ if (isStaff) {
22
+ resolvedCustomerId = input.customerId ?? null;
23
+ }
24
+ else {
25
+ const customers = this.services?.customers;
26
+ if (!customers?.getByUserId) {
27
+ return Err("Customer resolution is not configured");
28
+ }
29
+ const profile = await customers.getByUserId(actor.userId, actor);
30
+ if (!profile.ok) {
31
+ return Err("Customer profile not found");
32
+ }
33
+ resolvedCustomerId = profile.value.id;
34
+ }
35
+ const isVerified = input.orderId != null;
36
+ const rows = await this.db.insert(customerReviews).values({
37
+ organizationId: orgId,
38
+ customerId: resolvedCustomerId,
39
+ entityId: input.entityId,
40
+ orderId: input.orderId ?? null,
41
+ rating: input.rating,
42
+ title: input.title ?? null,
43
+ body: input.body ?? null,
44
+ isVerified,
45
+ }).returning();
46
+ return Ok(rows[0]);
47
+ }
48
+ async listForEntity(orgId, entityId, publishedOnly) {
49
+ const conditions = [
50
+ eq(customerReviews.organizationId, orgId),
51
+ eq(customerReviews.entityId, entityId),
52
+ ];
53
+ if (publishedOnly) {
54
+ conditions.push(eq(customerReviews.isPublished, true));
55
+ }
56
+ const rows = await this.db.select().from(customerReviews).where(and(...conditions));
57
+ return Ok(rows);
58
+ }
59
+ async getSummary(orgId, entityId) {
60
+ const rows = await this.db.select({
61
+ rating: customerReviews.rating,
62
+ count: sql `count(*)::int`,
63
+ })
64
+ .from(customerReviews)
65
+ .where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.entityId, entityId)))
66
+ .groupBy(customerReviews.rating);
67
+ const distribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
68
+ let totalCount = 0;
69
+ let totalSum = 0;
70
+ for (const row of rows) {
71
+ distribution[row.rating] = row.count;
72
+ totalCount += row.count;
73
+ totalSum += row.rating * row.count;
74
+ }
75
+ const averageRating = totalCount > 0 ? Math.round((totalSum / totalCount) * 100) / 100 : 0;
76
+ return Ok({ averageRating, totalCount, distribution });
77
+ }
78
+ async approve(orgId, id) {
79
+ const rows = await this.db.update(customerReviews)
80
+ .set({ status: "approved", isPublished: true, updatedAt: new Date() })
81
+ .where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.id, id)))
82
+ .returning();
83
+ if (rows.length === 0)
84
+ return Err("Review not found");
85
+ return Ok(rows[0]);
86
+ }
87
+ async reject(orgId, id) {
88
+ const rows = await this.db.update(customerReviews)
89
+ .set({ status: "rejected", isPublished: false, updatedAt: new Date() })
90
+ .where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.id, id)))
91
+ .returning();
92
+ if (rows.length === 0)
93
+ return Err("Review not found");
94
+ return Ok(rows[0]);
95
+ }
96
+ async reply(orgId, id, response, responseBy) {
97
+ const rows = await this.db.update(customerReviews)
98
+ .set({ response, responseBy, responseAt: new Date(), updatedAt: new Date() })
99
+ .where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.id, id)))
100
+ .returning();
101
+ if (rows.length === 0)
102
+ return Err("Review not found");
103
+ return Ok(rows[0]);
104
+ }
105
+ async listByCustomer(orgId, customerId) {
106
+ const rows = await this.db.select().from(customerReviews)
107
+ .where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.customerId, customerId)));
108
+ return Ok(rows);
109
+ }
110
+ }
@@ -0,0 +1,5 @@
1
+ import type { customerReviews } from "./schema.js";
2
+ export type { PluginDb as Db } from "@porulle/core";
3
+ export type Review = typeof customerReviews.$inferSelect;
4
+ export type ReviewInsert = typeof customerReviews.$inferInsert;
5
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,eAAe,CAAC;AACpD,MAAM,MAAM,MAAM,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AACzD,MAAM,MAAM,YAAY,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@porulle/plugin-reviews",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "bun": "./src/index.ts",
9
+ "import": "./dist/index.js",
10
+ "types": "./src/index.ts"
11
+ },
12
+ "./schema": {
13
+ "bun": "./src/schema.ts",
14
+ "import": "./dist/schema.js",
15
+ "require": "./dist/schema.js",
16
+ "types": "./src/schema.ts"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "rm -rf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
21
+ "check-types": "tsc --noEmit",
22
+ "test": "vitest run"
23
+ },
24
+ "dependencies": {
25
+ "@hono/zod-openapi": "^1.2.2",
26
+ "@porulle/core": "workspace:*",
27
+ "hono": "^4.12.5"
28
+ },
29
+ "devDependencies": {
30
+ "@repo/eslint-config": "*",
31
+ "@repo/typescript-config": "*",
32
+ "@types/node": "^24.5.2",
33
+ "typescript": "5.9.2",
34
+ "vitest": "^3.2.4"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "files": [
40
+ "src",
41
+ "dist",
42
+ "README.md"
43
+ ],
44
+ "peerDependencies": {
45
+ "zod": ">=4.0.0"
46
+ },
47
+ "description": "Customer reviews on catalog entities with moderation, replies, and aggregate summaries.",
48
+ "homepage": "https://porulle-docs.vercel.app",
49
+ "bugs": {
50
+ "url": "https://github.com/asyncdotengineering/porulle/issues"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/asyncdotengineering/porulle.git",
55
+ "directory": "packages/plugins/plugin-reviews"
56
+ },
57
+ "author": "Porulle contributors"
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { defineCommercePlugin } from "@porulle/core";
2
+ import { customerReviews } from "./schema.js";
3
+ import { ReviewService } from "./services/review-service.js";
4
+ import { buildReviewRoutes } from "./routes/reviews.js";
5
+ export type { Db } from "./types.js";
6
+ export { ReviewService } from "./services/review-service.js";
7
+
8
+ export function reviewsPlugin() {
9
+ return defineCommercePlugin({
10
+ id: "reviews",
11
+ version: "1.0.0",
12
+ permissions: [
13
+ { scope: "reviews:admin", description: "Approve, reject, and reply to reviews." },
14
+ { scope: "reviews:write", description: "Submit reviews." },
15
+ { scope: "reviews:read", description: "View reviews and summaries." },
16
+ ],
17
+ schema: () => ({ customerReviews }),
18
+ hooks: () => [],
19
+
20
+ routes: (ctx) => {
21
+ const db = ctx.database.db;
22
+ if (!db) return [];
23
+ const customers = ctx.services?.customers as {
24
+ getByUserId(
25
+ userId: string,
26
+ actor?: import("@porulle/core").Actor | null,
27
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: unknown }>;
28
+ } | undefined;
29
+ return buildReviewRoutes(
30
+ new ReviewService(db, customers ? { customers } : undefined),
31
+ ctx,
32
+ );
33
+ },
34
+ });
35
+ }
@@ -0,0 +1,82 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { Actor } from "@porulle/core";
4
+ import type { ReviewService } from "../services/review-service.js";
5
+ import type { PluginRouteRegistration } from "@porulle/core";
6
+
7
+ export function buildReviewRoutes(
8
+ service: ReviewService,
9
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
10
+ ): PluginRouteRegistration[] {
11
+ const r = router("Reviews", "/reviews", ctx);
12
+
13
+ r.post("/").summary("Submit review").permission("reviews:write")
14
+ .input(z.object({
15
+ customerId: z.string().uuid().optional(),
16
+ entityId: z.string().uuid(),
17
+ orderId: z.string().uuid().optional(),
18
+ rating: z.number().int().min(1).max(5),
19
+ title: z.string().optional(),
20
+ body: z.string().optional(),
21
+ }))
22
+ .handler(async ({ input, orgId, actor }) => {
23
+ const body = input as {
24
+ customerId?: string; entityId: string; orderId?: string;
25
+ rating: number; title?: string; body?: string;
26
+ };
27
+ const result = await service.submit(orgId, body, (actor ?? null) as Actor | null);
28
+ if (!result.ok) throw new Error(result.error);
29
+ return result.value;
30
+ });
31
+
32
+ r.get("/entity/{entityId}").summary("List reviews for entity").permission("reviews:read")
33
+ .handler(async ({ params, orgId }) => {
34
+ const result = await service.listForEntity(orgId, params.entityId!);
35
+ if (!result.ok) throw new Error(result.error);
36
+ return result.value;
37
+ });
38
+
39
+ r.get("/entity/{entityId}/summary").summary("Review summary for entity").permission("reviews:read")
40
+ .handler(async ({ params, orgId }) => {
41
+ const result = await service.getSummary(orgId, params.entityId!);
42
+ if (!result.ok) throw new Error(result.error);
43
+ return result.value;
44
+ });
45
+
46
+ r.patch("/{id}/approve").summary("Approve review").permission("reviews:admin")
47
+ .handler(async ({ params, orgId }) => {
48
+ const result = await service.approve(orgId, params.id!);
49
+ if (!result.ok) throw new Error(result.error);
50
+ return result.value;
51
+ });
52
+
53
+ r.patch("/{id}/reject").summary("Reject review").permission("reviews:admin")
54
+ .handler(async ({ params, orgId }) => {
55
+ const result = await service.reject(orgId, params.id!);
56
+ if (!result.ok) throw new Error(result.error);
57
+ return result.value;
58
+ });
59
+
60
+ r.post("/{id}/reply").summary("Reply to review").permission("reviews:admin")
61
+ .input(z.object({
62
+ response: z.string().min(1),
63
+ responseBy: z.string().min(1),
64
+ }))
65
+ .handler(async ({ params, input, orgId }) => {
66
+ const body = input as { response: string; responseBy: string };
67
+ const result = await service.reply(orgId, params.id!, body.response, body.responseBy);
68
+ if (!result.ok) throw new Error(result.error);
69
+ return result.value;
70
+ });
71
+
72
+ r.get("/mine").summary("My reviews").permission("reviews:write")
73
+ .handler(async ({ actor, orgId }) => {
74
+ // Use the authenticated actor's userId — never accept customerId from query
75
+ if (!actor?.userId) throw new Error("Authentication required");
76
+ const result = await service.listByCustomer(orgId, actor.userId);
77
+ if (!result.ok) throw new Error(result.error);
78
+ return result.value;
79
+ });
80
+
81
+ return r.routes();
82
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { pgTable, uuid, text, integer, boolean, timestamp, index } from "@porulle/core/drizzle";
2
+
3
+ export const customerReviews = pgTable("customer_reviews", {
4
+ id: uuid("id").defaultRandom().primaryKey(),
5
+ organizationId: text("organization_id").notNull(),
6
+ customerId: uuid("customer_id"),
7
+ entityId: uuid("entity_id").notNull(),
8
+ orderId: uuid("order_id"),
9
+ rating: integer("rating").notNull(),
10
+ title: text("title"),
11
+ body: text("body"),
12
+ status: text("status", { enum: ["pending", "approved", "rejected"] })
13
+ .notNull()
14
+ .default("pending"),
15
+ isVerified: boolean("is_verified").notNull().default(false),
16
+ isPublished: boolean("is_published").notNull().default(false),
17
+ response: text("response"),
18
+ responseBy: text("response_by"),
19
+ responseAt: timestamp("response_at", { withTimezone: true }),
20
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
21
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
22
+ }, (table) => ({
23
+ orgIdx: index("idx_customer_reviews_org").on(table.organizationId),
24
+ entityIdx: index("idx_customer_reviews_entity").on(table.entityId),
25
+ statusIdx: index("idx_customer_reviews_status").on(table.status),
26
+ }));
@@ -0,0 +1,144 @@
1
+ import { eq, and, sql } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import type { PluginResult } from "@porulle/core";
4
+ import type { Actor } from "@porulle/core";
5
+ import { customerReviews } from "../schema.js";
6
+ import type { Db, Review } from "../types.js";
7
+
8
+ export interface ReviewSummary {
9
+ averageRating: number;
10
+ totalCount: number;
11
+ distribution: Record<number, number>;
12
+ }
13
+
14
+ export class ReviewService {
15
+ constructor(
16
+ private db: Db,
17
+ private services?: {
18
+ customers?: {
19
+ getByUserId(
20
+ userId: string,
21
+ actor?: Actor | null,
22
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: unknown }>;
23
+ };
24
+ },
25
+ ) {}
26
+
27
+ async submit(orgId: string, input: {
28
+ customerId?: string;
29
+ entityId: string;
30
+ orderId?: string;
31
+ rating: number;
32
+ title?: string;
33
+ body?: string;
34
+ }, actor: Actor | null): Promise<PluginResult<Review>> {
35
+ if (input.rating < 1 || input.rating > 5 || !Number.isInteger(input.rating)) {
36
+ return Err("Rating must be an integer between 1 and 5");
37
+ }
38
+ if (!actor?.userId) {
39
+ return Err("Authentication required");
40
+ }
41
+ const staffRoles = new Set(["staff", "admin", "owner", "ai_agent", "service"]);
42
+ const isStaff = typeof actor.role === "string" && staffRoles.has(actor.role);
43
+
44
+ let resolvedCustomerId: string | null = null;
45
+ if (isStaff) {
46
+ resolvedCustomerId = input.customerId ?? null;
47
+ } else {
48
+ const customers = this.services?.customers;
49
+ if (!customers?.getByUserId) {
50
+ return Err("Customer resolution is not configured");
51
+ }
52
+ const profile = await customers.getByUserId(actor.userId, actor);
53
+ if (!profile.ok) {
54
+ return Err("Customer profile not found");
55
+ }
56
+ resolvedCustomerId = profile.value.id;
57
+ }
58
+
59
+ const isVerified = input.orderId != null;
60
+ const rows = await this.db.insert(customerReviews).values({
61
+ organizationId: orgId,
62
+ customerId: resolvedCustomerId,
63
+ entityId: input.entityId,
64
+ orderId: input.orderId ?? null,
65
+ rating: input.rating,
66
+ title: input.title ?? null,
67
+ body: input.body ?? null,
68
+ isVerified,
69
+ }).returning();
70
+ return Ok(rows[0]!);
71
+ }
72
+
73
+ async listForEntity(orgId: string, entityId: string, publishedOnly?: boolean): Promise<PluginResult<Review[]>> {
74
+ const conditions = [
75
+ eq(customerReviews.organizationId, orgId),
76
+ eq(customerReviews.entityId, entityId),
77
+ ];
78
+ if (publishedOnly) {
79
+ conditions.push(eq(customerReviews.isPublished, true));
80
+ }
81
+ const rows = await this.db.select().from(customerReviews).where(and(...conditions));
82
+ return Ok(rows);
83
+ }
84
+
85
+ async getSummary(orgId: string, entityId: string): Promise<PluginResult<ReviewSummary>> {
86
+ const rows = await this.db.select({
87
+ rating: customerReviews.rating,
88
+ count: sql<number>`count(*)::int`,
89
+ })
90
+ .from(customerReviews)
91
+ .where(and(
92
+ eq(customerReviews.organizationId, orgId),
93
+ eq(customerReviews.entityId, entityId),
94
+ ))
95
+ .groupBy(customerReviews.rating);
96
+
97
+ const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
98
+ let totalCount = 0;
99
+ let totalSum = 0;
100
+ for (const row of rows) {
101
+ distribution[row.rating] = row.count;
102
+ totalCount += row.count;
103
+ totalSum += row.rating * row.count;
104
+ }
105
+ const averageRating = totalCount > 0 ? Math.round((totalSum / totalCount) * 100) / 100 : 0;
106
+ return Ok({ averageRating, totalCount, distribution });
107
+ }
108
+
109
+ async approve(orgId: string, id: string): Promise<PluginResult<Review>> {
110
+ const rows = await this.db.update(customerReviews)
111
+ .set({ status: "approved", isPublished: true, updatedAt: new Date() })
112
+ .where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.id, id)))
113
+ .returning();
114
+ if (rows.length === 0) return Err("Review not found");
115
+ return Ok(rows[0]!);
116
+ }
117
+
118
+ async reject(orgId: string, id: string): Promise<PluginResult<Review>> {
119
+ const rows = await this.db.update(customerReviews)
120
+ .set({ status: "rejected", isPublished: false, updatedAt: new Date() })
121
+ .where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.id, id)))
122
+ .returning();
123
+ if (rows.length === 0) return Err("Review not found");
124
+ return Ok(rows[0]!);
125
+ }
126
+
127
+ async reply(orgId: string, id: string, response: string, responseBy: string): Promise<PluginResult<Review>> {
128
+ const rows = await this.db.update(customerReviews)
129
+ .set({ response, responseBy, responseAt: new Date(), updatedAt: new Date() })
130
+ .where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.id, id)))
131
+ .returning();
132
+ if (rows.length === 0) return Err("Review not found");
133
+ return Ok(rows[0]!);
134
+ }
135
+
136
+ async listByCustomer(orgId: string, customerId: string): Promise<PluginResult<Review[]>> {
137
+ const rows = await this.db.select().from(customerReviews)
138
+ .where(and(
139
+ eq(customerReviews.organizationId, orgId),
140
+ eq(customerReviews.customerId, customerId),
141
+ ));
142
+ return Ok(rows);
143
+ }
144
+ }
package/src/types.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { customerReviews } from "./schema.js";
2
+
3
+ export type { PluginDb as Db } from "@porulle/core";
4
+ export type Review = typeof customerReviews.$inferSelect;
5
+ export type ReviewInsert = typeof customerReviews.$inferInsert;