@unifiedcommerce/plugin-reviews 0.0.1
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/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -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 +26 -0
- package/dist/services/review-service.d.ts.map +1 -0
- package/dist/services/review-service.js +88 -0
- package/dist/tsconfig.tsbuildinfo +1 -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 +37 -0
- package/src/index.ts +27 -0
- package/src/routes/reviews.ts +81 -0
- package/src/schema.ts +26 -0
- package/src/services/review-service.ts +112 -0
- package/src/types.ts +5 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { customerReviews } from "./schema";
|
|
2
|
+
export type { PluginDb as Db } from "@unifiedcommerce/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,UAAU,CAAC;AAEhD,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,uBAAuB,CAAC;AAC5D,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,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unifiedcommerce/plugin-reviews",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.build.json",
|
|
13
|
+
"check-types": "tsc --noEmit",
|
|
14
|
+
"test": "vitest run"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@hono/zod-openapi": "^1.2.2",
|
|
18
|
+
"@unifiedcommerce/core": "*",
|
|
19
|
+
"drizzle-orm": "^0.45.1",
|
|
20
|
+
"hono": "^4.12.5"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@repo/eslint-config": "*",
|
|
24
|
+
"@repo/typescript-config": "*",
|
|
25
|
+
"@types/node": "^24.5.2",
|
|
26
|
+
"typescript": "5.9.2",
|
|
27
|
+
"vitest": "^3.2.4"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"src",
|
|
35
|
+
"README.md"
|
|
36
|
+
]
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineCommercePlugin } from "@unifiedcommerce/core";
|
|
2
|
+
import { customerReviews } from "./schema";
|
|
3
|
+
import { ReviewService } from "./services/review-service";
|
|
4
|
+
import { buildReviewRoutes } from "./routes/reviews";
|
|
5
|
+
import type { Db } from "./types";
|
|
6
|
+
|
|
7
|
+
export type { Db } from "./types";
|
|
8
|
+
export { ReviewService } from "./services/review-service";
|
|
9
|
+
|
|
10
|
+
export function reviewsPlugin() {
|
|
11
|
+
return defineCommercePlugin({
|
|
12
|
+
id: "reviews",
|
|
13
|
+
version: "1.0.0",
|
|
14
|
+
permissions: [
|
|
15
|
+
{ scope: "reviews:admin", description: "Approve, reject, and reply to reviews." },
|
|
16
|
+
{ scope: "reviews:write", description: "Submit reviews." },
|
|
17
|
+
{ scope: "reviews:read", description: "View reviews and summaries." },
|
|
18
|
+
],
|
|
19
|
+
schema: () => ({ customerReviews }),
|
|
20
|
+
hooks: () => [],
|
|
21
|
+
routes: (ctx) => {
|
|
22
|
+
const db = ctx.database.db as unknown as Db;
|
|
23
|
+
if (!db) return [];
|
|
24
|
+
return buildReviewRoutes(new ReviewService(db), ctx);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { router } from "@unifiedcommerce/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import type { ReviewService } from "../services/review-service";
|
|
4
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/core";
|
|
5
|
+
|
|
6
|
+
export function buildReviewRoutes(
|
|
7
|
+
service: ReviewService,
|
|
8
|
+
ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
|
|
9
|
+
): PluginRouteRegistration[] {
|
|
10
|
+
const r = router("Reviews", "/reviews", ctx);
|
|
11
|
+
|
|
12
|
+
r.post("/").summary("Submit review").permission("reviews:write")
|
|
13
|
+
.input(z.object({
|
|
14
|
+
customerId: z.string().uuid().optional(),
|
|
15
|
+
entityId: z.string().uuid(),
|
|
16
|
+
orderId: z.string().uuid().optional(),
|
|
17
|
+
rating: z.number().int().min(1).max(5),
|
|
18
|
+
title: z.string().optional(),
|
|
19
|
+
body: z.string().optional(),
|
|
20
|
+
}))
|
|
21
|
+
.handler(async ({ input, orgId }) => {
|
|
22
|
+
const body = input as {
|
|
23
|
+
customerId?: string; entityId: string; orderId?: string;
|
|
24
|
+
rating: number; title?: string; body?: string;
|
|
25
|
+
};
|
|
26
|
+
const result = await service.submit(orgId, body);
|
|
27
|
+
if (!result.ok) throw new Error(result.error);
|
|
28
|
+
return result.value;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
r.get("/entity/{entityId}").summary("List reviews for entity").permission("reviews:read")
|
|
32
|
+
.handler(async ({ params, orgId }) => {
|
|
33
|
+
const result = await service.listForEntity(orgId, params.entityId!);
|
|
34
|
+
if (!result.ok) throw new Error(result.error);
|
|
35
|
+
return result.value;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
r.get("/entity/{entityId}/summary").summary("Review summary for entity").permission("reviews:read")
|
|
39
|
+
.handler(async ({ params, orgId }) => {
|
|
40
|
+
const result = await service.getSummary(orgId, params.entityId!);
|
|
41
|
+
if (!result.ok) throw new Error(result.error);
|
|
42
|
+
return result.value;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
r.patch("/{id}/approve").summary("Approve review").permission("reviews:admin")
|
|
46
|
+
.handler(async ({ params, orgId }) => {
|
|
47
|
+
const result = await service.approve(orgId, params.id!);
|
|
48
|
+
if (!result.ok) throw new Error(result.error);
|
|
49
|
+
return result.value;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
r.patch("/{id}/reject").summary("Reject review").permission("reviews:admin")
|
|
53
|
+
.handler(async ({ params, orgId }) => {
|
|
54
|
+
const result = await service.reject(orgId, params.id!);
|
|
55
|
+
if (!result.ok) throw new Error(result.error);
|
|
56
|
+
return result.value;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
r.post("/{id}/reply").summary("Reply to review").permission("reviews:admin")
|
|
60
|
+
.input(z.object({
|
|
61
|
+
response: z.string().min(1),
|
|
62
|
+
responseBy: z.string().min(1),
|
|
63
|
+
}))
|
|
64
|
+
.handler(async ({ params, input, orgId }) => {
|
|
65
|
+
const body = input as { response: string; responseBy: string };
|
|
66
|
+
const result = await service.reply(orgId, params.id!, body.response, body.responseBy);
|
|
67
|
+
if (!result.ok) throw new Error(result.error);
|
|
68
|
+
return result.value;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
r.get("/mine").summary("My reviews").permission("reviews:write")
|
|
72
|
+
.handler(async ({ actor, orgId }) => {
|
|
73
|
+
// Use the authenticated actor's userId — never accept customerId from query
|
|
74
|
+
if (!actor?.userId) throw new Error("Authentication required");
|
|
75
|
+
const result = await service.listByCustomer(orgId, actor.userId);
|
|
76
|
+
if (!result.ok) throw new Error(result.error);
|
|
77
|
+
return result.value;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return r.routes();
|
|
81
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { pgTable, uuid, text, integer, boolean, timestamp, index } from "drizzle-orm/pg-core";
|
|
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,112 @@
|
|
|
1
|
+
import { eq, and, sql } from "drizzle-orm";
|
|
2
|
+
import { Ok, Err } from "@unifiedcommerce/core";
|
|
3
|
+
import type { PluginResult } from "@unifiedcommerce/core";
|
|
4
|
+
import { customerReviews } from "../schema";
|
|
5
|
+
import type { Db, Review } from "../types";
|
|
6
|
+
|
|
7
|
+
export interface ReviewSummary {
|
|
8
|
+
averageRating: number;
|
|
9
|
+
totalCount: number;
|
|
10
|
+
distribution: Record<number, number>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ReviewService {
|
|
14
|
+
constructor(private db: Db) {}
|
|
15
|
+
|
|
16
|
+
async submit(orgId: string, input: {
|
|
17
|
+
customerId?: string;
|
|
18
|
+
entityId: string;
|
|
19
|
+
orderId?: string;
|
|
20
|
+
rating: number;
|
|
21
|
+
title?: string;
|
|
22
|
+
body?: string;
|
|
23
|
+
}): Promise<PluginResult<Review>> {
|
|
24
|
+
if (input.rating < 1 || input.rating > 5 || !Number.isInteger(input.rating)) {
|
|
25
|
+
return Err("Rating must be an integer between 1 and 5");
|
|
26
|
+
}
|
|
27
|
+
const isVerified = input.orderId != null;
|
|
28
|
+
const rows = await this.db.insert(customerReviews).values({
|
|
29
|
+
organizationId: orgId,
|
|
30
|
+
customerId: input.customerId ?? null,
|
|
31
|
+
entityId: input.entityId,
|
|
32
|
+
orderId: input.orderId ?? null,
|
|
33
|
+
rating: input.rating,
|
|
34
|
+
title: input.title ?? null,
|
|
35
|
+
body: input.body ?? null,
|
|
36
|
+
isVerified,
|
|
37
|
+
}).returning();
|
|
38
|
+
return Ok(rows[0]!);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async listForEntity(orgId: string, entityId: string, publishedOnly?: boolean): Promise<PluginResult<Review[]>> {
|
|
42
|
+
const conditions = [
|
|
43
|
+
eq(customerReviews.organizationId, orgId),
|
|
44
|
+
eq(customerReviews.entityId, entityId),
|
|
45
|
+
];
|
|
46
|
+
if (publishedOnly) {
|
|
47
|
+
conditions.push(eq(customerReviews.isPublished, true));
|
|
48
|
+
}
|
|
49
|
+
const rows = await this.db.select().from(customerReviews).where(and(...conditions));
|
|
50
|
+
return Ok(rows);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getSummary(orgId: string, entityId: string): Promise<PluginResult<ReviewSummary>> {
|
|
54
|
+
const rows = await this.db.select({
|
|
55
|
+
rating: customerReviews.rating,
|
|
56
|
+
count: sql<number>`count(*)::int`,
|
|
57
|
+
})
|
|
58
|
+
.from(customerReviews)
|
|
59
|
+
.where(and(
|
|
60
|
+
eq(customerReviews.organizationId, orgId),
|
|
61
|
+
eq(customerReviews.entityId, entityId),
|
|
62
|
+
))
|
|
63
|
+
.groupBy(customerReviews.rating);
|
|
64
|
+
|
|
65
|
+
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
|
66
|
+
let totalCount = 0;
|
|
67
|
+
let totalSum = 0;
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
distribution[row.rating] = row.count;
|
|
70
|
+
totalCount += row.count;
|
|
71
|
+
totalSum += row.rating * row.count;
|
|
72
|
+
}
|
|
73
|
+
const averageRating = totalCount > 0 ? Math.round((totalSum / totalCount) * 100) / 100 : 0;
|
|
74
|
+
return Ok({ averageRating, totalCount, distribution });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async approve(orgId: string, id: string): Promise<PluginResult<Review>> {
|
|
78
|
+
const rows = await this.db.update(customerReviews)
|
|
79
|
+
.set({ status: "approved", isPublished: true, updatedAt: new Date() })
|
|
80
|
+
.where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.id, id)))
|
|
81
|
+
.returning();
|
|
82
|
+
if (rows.length === 0) return Err("Review not found");
|
|
83
|
+
return Ok(rows[0]!);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async reject(orgId: string, id: string): Promise<PluginResult<Review>> {
|
|
87
|
+
const rows = await this.db.update(customerReviews)
|
|
88
|
+
.set({ status: "rejected", isPublished: false, updatedAt: new Date() })
|
|
89
|
+
.where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.id, id)))
|
|
90
|
+
.returning();
|
|
91
|
+
if (rows.length === 0) return Err("Review not found");
|
|
92
|
+
return Ok(rows[0]!);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async reply(orgId: string, id: string, response: string, responseBy: string): Promise<PluginResult<Review>> {
|
|
96
|
+
const rows = await this.db.update(customerReviews)
|
|
97
|
+
.set({ response, responseBy, responseAt: new Date(), updatedAt: new Date() })
|
|
98
|
+
.where(and(eq(customerReviews.organizationId, orgId), eq(customerReviews.id, id)))
|
|
99
|
+
.returning();
|
|
100
|
+
if (rows.length === 0) return Err("Review not found");
|
|
101
|
+
return Ok(rows[0]!);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async listByCustomer(orgId: string, customerId: string): Promise<PluginResult<Review[]>> {
|
|
105
|
+
const rows = await this.db.select().from(customerReviews)
|
|
106
|
+
.where(and(
|
|
107
|
+
eq(customerReviews.organizationId, orgId),
|
|
108
|
+
eq(customerReviews.customerId, customerId),
|
|
109
|
+
));
|
|
110
|
+
return Ok(rows);
|
|
111
|
+
}
|
|
112
|
+
}
|