@porulle/plugin-loyalty 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 +55 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/routes/loyalty.d.ts +9 -0
- package/dist/routes/loyalty.d.ts.map +1 -0
- package/dist/routes/loyalty.js +62 -0
- package/dist/schema.d.ts +532 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +51 -0
- package/dist/services/loyalty-service.d.ts +31 -0
- package/dist/services/loyalty-service.d.ts.map +1 -0
- package/dist/services/loyalty-service.js +118 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +58 -0
- package/src/index.ts +50 -0
- package/src/routes/loyalty.ts +69 -0
- package/src/schema.ts +54 -0
- package/src/services/loyalty-service.ts +130 -0
- package/src/types.ts +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @porulle/plugin-loyalty
|
|
2
|
+
|
|
3
|
+
Points, tiers, leaderboard, and redemption offers tied to customers per organization.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @porulle/plugin-loyalty
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Add to `commerce.config.ts`:
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { loyaltyPlugin } from "@porulle/plugin-loyalty";
|
|
15
|
+
|
|
16
|
+
export default defineConfig({
|
|
17
|
+
// ...
|
|
18
|
+
plugins: [loyaltyPlugin()],
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Add to `drizzle.config.ts`:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
schema: [
|
|
26
|
+
"./node_modules/@porulle/plugin-loyalty/src/schema.ts",
|
|
27
|
+
// ...
|
|
28
|
+
],
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What it does
|
|
32
|
+
|
|
33
|
+
Awards points from completed orders (`grandTotal`), maintains tiers from lifetime points, exposes redemption offers, and provides REST + MCP surfaces for balance and admin actions.
|
|
34
|
+
|
|
35
|
+
## Routes exposed
|
|
36
|
+
|
|
37
|
+
**`/loyalty`** — `GET /points/{customerId}`, `GET /leaderboard`, `POST /redeem`, `POST /offers`, `GET /offers`, `POST /offers/{id}/redeem` (permissions vary; some routes require `loyalty:admin` or auth).
|
|
38
|
+
|
|
39
|
+
## Hooks
|
|
40
|
+
|
|
41
|
+
**Emitted:** none.
|
|
42
|
+
|
|
43
|
+
**Consumed:** **`orders.afterCreate`** — earns points from order total when `customerId` is present (`resolveOrgId` for tenant).
|
|
44
|
+
|
|
45
|
+
## MCP tools
|
|
46
|
+
|
|
47
|
+
**`loyalty`** — `balance`, `earn`, `redeem`, `leaderboard`, `list_offers`
|
|
48
|
+
|
|
49
|
+
## Configuration options
|
|
50
|
+
|
|
51
|
+
`LoyaltyPluginOptions`: `pointsPerDollar`, `tierThresholds` (`silver`, `gold`, `platinum` point thresholds).
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { LoyaltyPluginOptions } from "./types.js";
|
|
2
|
+
export type { LoyaltyPluginOptions, Db } from "./types.js";
|
|
3
|
+
export { LoyaltyService } from "./services/loyalty-service.js";
|
|
4
|
+
export declare function loyaltyPlugin(userOptions?: LoyaltyPluginOptions): import("@porulle/core").CommercePlugin;
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAM,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAG3D,YAAY,EAAE,oBAAoB,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D,wBAAgB,aAAa,CAAC,WAAW,GAAE,oBAAyB,0CAsCnE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defineCommercePlugin, resolveOrgId } from "@porulle/core";
|
|
2
|
+
import { loyaltyPoints, loyaltyTransactions, loyaltyRedemptionOffers } from "./schema.js";
|
|
3
|
+
import { LoyaltyService } from "./services/loyalty-service.js";
|
|
4
|
+
import { buildLoyaltyRoutes } from "./routes/loyalty.js";
|
|
5
|
+
import { DEFAULT_LOYALTY_OPTIONS } from "./types.js";
|
|
6
|
+
export { LoyaltyService } from "./services/loyalty-service.js";
|
|
7
|
+
export function loyaltyPlugin(userOptions = {}) {
|
|
8
|
+
const options = { ...DEFAULT_LOYALTY_OPTIONS, ...userOptions };
|
|
9
|
+
return defineCommercePlugin({
|
|
10
|
+
id: "loyalty",
|
|
11
|
+
version: "1.0.0",
|
|
12
|
+
permissions: [
|
|
13
|
+
{ scope: "loyalty:admin", description: "Create/manage redemption offers, view all loyalty data." },
|
|
14
|
+
],
|
|
15
|
+
schema: () => ({ loyaltyPoints, loyaltyTransactions, loyaltyRedemptionOffers }),
|
|
16
|
+
hooks: () => [{
|
|
17
|
+
key: "orders.afterCreate",
|
|
18
|
+
async handler(args) {
|
|
19
|
+
const { result, context } = args;
|
|
20
|
+
if (!result.customerId)
|
|
21
|
+
return;
|
|
22
|
+
const rawDb = context.services.database?.db;
|
|
23
|
+
if (!rawDb)
|
|
24
|
+
return;
|
|
25
|
+
const orgId = resolveOrgId(context.actor);
|
|
26
|
+
const pointsEarned = Math.floor((result.grandTotal / 100) * options.pointsPerDollar);
|
|
27
|
+
if (pointsEarned <= 0)
|
|
28
|
+
return;
|
|
29
|
+
const db = rawDb;
|
|
30
|
+
const service = new LoyaltyService(db, options.tierThresholds);
|
|
31
|
+
await service.earnPoints(orgId, result.customerId, pointsEarned, result.id);
|
|
32
|
+
context.logger.info("loyalty_points_awarded", { customerId: result.customerId, orgId, pointsEarned });
|
|
33
|
+
},
|
|
34
|
+
}],
|
|
35
|
+
routes: (ctx) => {
|
|
36
|
+
const db = ctx.database.db;
|
|
37
|
+
if (!db)
|
|
38
|
+
return [];
|
|
39
|
+
return buildLoyaltyRoutes(new LoyaltyService(db, options.tierThresholds), ctx);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LoyaltyService } from "../services/loyalty-service.js";
|
|
2
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
3
|
+
export declare function buildLoyaltyRoutes(service: LoyaltyService, ctx: {
|
|
4
|
+
services?: Record<string, unknown>;
|
|
5
|
+
database?: {
|
|
6
|
+
db: unknown;
|
|
7
|
+
};
|
|
8
|
+
}): PluginRouteRegistration[];
|
|
9
|
+
//# sourceMappingURL=loyalty.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loyalty.d.ts","sourceRoot":"","sources":["../../src/routes/loyalty.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AACrE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,cAAc,EACvB,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,CA4D3B"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { router } from "@porulle/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
export function buildLoyaltyRoutes(service, ctx) {
|
|
4
|
+
const r = router("Loyalty", "/loyalty", ctx);
|
|
5
|
+
r.get("/points/{customerId}").summary("Get loyalty points (admin)").permission("loyalty:admin")
|
|
6
|
+
.handler(async ({ params, orgId }) => {
|
|
7
|
+
const result = await service.getPoints(orgId, params.customerId);
|
|
8
|
+
if (!result.ok)
|
|
9
|
+
throw new Error("Failed");
|
|
10
|
+
if (!result.value)
|
|
11
|
+
return { customerId: params.customerId, points: 0, tier: "bronze", message: "No points yet" };
|
|
12
|
+
return result.value;
|
|
13
|
+
});
|
|
14
|
+
r.get("/leaderboard").summary("Loyalty leaderboard").auth()
|
|
15
|
+
.handler(async ({ orgId }) => {
|
|
16
|
+
const result = await service.getLeaderboard(orgId);
|
|
17
|
+
if (!result.ok)
|
|
18
|
+
throw new Error(result.error);
|
|
19
|
+
return result.value.map((e, i) => ({ rank: i + 1, customerId: e.customerId, points: e.points, tier: e.tier }));
|
|
20
|
+
});
|
|
21
|
+
r.post("/redeem").summary("Redeem points (admin)").permission("loyalty:admin")
|
|
22
|
+
.input(z.object({ customerId: z.string().min(1), pointsToRedeem: z.number().int().min(1) }))
|
|
23
|
+
.handler(async ({ input, orgId }) => {
|
|
24
|
+
const body = input;
|
|
25
|
+
const result = await service.redeemPoints(orgId, body.customerId, body.pointsToRedeem);
|
|
26
|
+
if (!result.ok)
|
|
27
|
+
throw new Error(result.error);
|
|
28
|
+
return { remainingPoints: result.value.points, tier: result.value.tier };
|
|
29
|
+
});
|
|
30
|
+
// ─── Offers ────────────────────────────────────────────────────
|
|
31
|
+
r.post("/offers").summary("Create redemption offer").permission("loyalty:admin")
|
|
32
|
+
.input(z.object({
|
|
33
|
+
name: z.string().min(1), pointsRequired: z.number().int().positive(),
|
|
34
|
+
rewardType: z.enum(["discount_percentage", "discount_fixed", "free_item", "free_shipping"]),
|
|
35
|
+
rewardValue: z.number().int(), rewardEntityId: z.string().uuid().optional(),
|
|
36
|
+
validFrom: z.string().optional(), validUntil: z.string().optional(),
|
|
37
|
+
maxRedemptions: z.number().int().positive().optional(),
|
|
38
|
+
}))
|
|
39
|
+
.handler(async ({ input, orgId }) => {
|
|
40
|
+
const result = await service.createOffer(orgId, input);
|
|
41
|
+
if (!result.ok)
|
|
42
|
+
throw new Error(result.error);
|
|
43
|
+
return result.value;
|
|
44
|
+
});
|
|
45
|
+
r.get("/offers").summary("List active offers").auth()
|
|
46
|
+
.handler(async ({ orgId }) => {
|
|
47
|
+
const result = await service.listOffers(orgId);
|
|
48
|
+
if (!result.ok)
|
|
49
|
+
throw new Error(result.error);
|
|
50
|
+
return result.value;
|
|
51
|
+
});
|
|
52
|
+
r.post("/offers/{id}/redeem").summary("Redeem an offer (admin)").permission("loyalty:admin")
|
|
53
|
+
.input(z.object({ customerId: z.string().min(1) }))
|
|
54
|
+
.handler(async ({ params, input, orgId }) => {
|
|
55
|
+
const body = input;
|
|
56
|
+
const result = await service.redeemOffer(orgId, body.customerId, params.id);
|
|
57
|
+
if (!result.ok)
|
|
58
|
+
throw new Error(result.error);
|
|
59
|
+
return result.value;
|
|
60
|
+
});
|
|
61
|
+
return r.routes();
|
|
62
|
+
}
|