@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 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
@@ -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
+ }