@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 +54 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/routes/reviews.d.ts +9 -0
- package/dist/routes/reviews.d.ts.map +1 -0
- package/dist/routes/reviews.js +72 -0
- package/dist/schema.d.ts +280 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +25 -0
- package/dist/services/review-service.d.ts +40 -0
- package/dist/services/review-service.d.ts.map +1 -0
- package/dist/services/review-service.js +110 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +58 -0
- package/src/index.ts +35 -0
- package/src/routes/reviews.ts +82 -0
- package/src/schema.ts +26 -0
- package/src/services/review-service.ts +144 -0
- package/src/types.ts +5 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|