@btst/plugin-affiliate 0.0.0-stage → 1.0.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.
Files changed (79) hide show
  1. package/LICENSE.md +17 -0
  2. package/dist/api/getters.cjs +182 -0
  3. package/dist/api/getters.mjs +166 -0
  4. package/dist/api/index.cjs +49 -0
  5. package/dist/api/index.d.cts +975 -0
  6. package/dist/api/index.d.mts +975 -0
  7. package/dist/api/index.d.ts +975 -0
  8. package/dist/api/index.mjs +7 -0
  9. package/dist/api/mutations.cjs +362 -0
  10. package/dist/api/mutations.mjs +352 -0
  11. package/dist/api/operations.cjs +328 -0
  12. package/dist/api/operations.mjs +326 -0
  13. package/dist/api/plugin.cjs +146 -0
  14. package/dist/api/plugin.mjs +144 -0
  15. package/dist/attribution.cjs +92 -0
  16. package/dist/attribution.d.cts +20 -0
  17. package/dist/attribution.d.mts +20 -0
  18. package/dist/attribution.d.ts +20 -0
  19. package/dist/attribution.mjs +82 -0
  20. package/dist/client/components/index.cjs +12 -0
  21. package/dist/client/components/index.d.cts +13 -0
  22. package/dist/client/components/index.d.mts +13 -0
  23. package/dist/client/components/index.d.ts +13 -0
  24. package/dist/client/components/index.mjs +1 -0
  25. package/dist/client/components/pages/affiliate-apply-page.internal.cjs +59 -0
  26. package/dist/client/components/pages/affiliate-apply-page.internal.mjs +57 -0
  27. package/dist/client/components/pages/affiliate-detail-page.internal.cjs +112 -0
  28. package/dist/client/components/pages/affiliate-detail-page.internal.mjs +110 -0
  29. package/dist/client/components/pages/affiliate-portal-page.internal.cjs +252 -0
  30. package/dist/client/components/pages/affiliate-portal-page.internal.mjs +250 -0
  31. package/dist/client/components/pages/affiliates-page.internal.cjs +160 -0
  32. package/dist/client/components/pages/affiliates-page.internal.mjs +158 -0
  33. package/dist/client/components/pages/commissions-page.internal.cjs +151 -0
  34. package/dist/client/components/pages/commissions-page.internal.mjs +149 -0
  35. package/dist/client/components/pages/payouts-page.internal.cjs +230 -0
  36. package/dist/client/components/pages/payouts-page.internal.mjs +228 -0
  37. package/dist/client/components/pages.cjs +145 -0
  38. package/dist/client/components/pages.mjs +138 -0
  39. package/dist/client/components/shared.cjs +331 -0
  40. package/dist/client/components/shared.mjs +316 -0
  41. package/dist/client/constants.cjs +5 -0
  42. package/dist/client/constants.mjs +3 -0
  43. package/dist/client/hooks/index.cjs +77 -0
  44. package/dist/client/hooks/index.d.cts +39 -0
  45. package/dist/client/hooks/index.d.mts +39 -0
  46. package/dist/client/hooks/index.d.ts +39 -0
  47. package/dist/client/hooks/index.mjs +61 -0
  48. package/dist/client/index.cjs +7 -0
  49. package/dist/client/index.d.cts +110 -0
  50. package/dist/client/index.d.mts +110 -0
  51. package/dist/client/index.d.ts +110 -0
  52. package/dist/client/index.mjs +1 -0
  53. package/dist/client/plugin.cjs +77 -0
  54. package/dist/client/plugin.mjs +75 -0
  55. package/dist/client.css +311 -0
  56. package/dist/commission.cjs +24 -0
  57. package/dist/commission.mjs +22 -0
  58. package/dist/db.cjs +74 -0
  59. package/dist/db.mjs +72 -0
  60. package/dist/permissions.cjs +53 -0
  61. package/dist/permissions.d.cts +78 -0
  62. package/dist/permissions.d.mts +78 -0
  63. package/dist/permissions.d.ts +78 -0
  64. package/dist/permissions.mjs +51 -0
  65. package/dist/query-keys.cjs +152 -0
  66. package/dist/query-keys.d.cts +394 -0
  67. package/dist/query-keys.d.mts +394 -0
  68. package/dist/query-keys.d.ts +394 -0
  69. package/dist/query-keys.mjs +149 -0
  70. package/dist/schemas.cjs +95 -0
  71. package/dist/schemas.mjs +77 -0
  72. package/dist/style.css +313 -0
  73. package/dist/types.cjs +2 -0
  74. package/dist/types.d.cts +176 -0
  75. package/dist/types.d.mts +176 -0
  76. package/dist/types.d.ts +176 -0
  77. package/dist/types.mjs +1 -0
  78. package/package.json +159 -3
  79. package/README.md +0 -3
@@ -0,0 +1,326 @@
1
+ import { defineOperation, definePassthroughOperation, OperationHttpError } from '@btst/stack/plugins/api';
2
+ import { z } from 'zod';
3
+ import { attributionFromRequest, isClickWithinWindow, safeLandingPath, selectAttributionClick, AFFILIATE_COOKIE_NAME } from '../attribution.mjs';
4
+ import { affiliatePermissions } from '../permissions.mjs';
5
+ import { listSchema, idSchema, updatePayoutSchema, createPayoutSchema, updateCommissionSchema, createConversionSchema, trackClickSchema, updatePortalSchema, applicationSchema, updateAffiliateSchema, createAffiliateSchema, affiliateListSchema } from '../schemas.mjs';
6
+ import { exportPayouts, getPayout, listPayouts, getCommission, listCommissions, getReferralClick, getAffiliate, getAffiliateByUserId, getPortal, getAffiliateStats, listAffiliates } from './getters.mjs';
7
+ import { updatePayout, createPayout, updateCommission, createConversion, trackClick, updateAffiliate, createAffiliate, deleteAffiliate } from './mutations.mjs';
8
+
9
+ function fail(status, message) {
10
+ throw new OperationHttpError(status, message, "AFFILIATE_ERROR");
11
+ }
12
+ async function requireAffiliate(adapter, id) {
13
+ return await getAffiliate(adapter, id) ?? fail(404, "Affiliate not found");
14
+ }
15
+ async function affiliateFacts(adapter, id) {
16
+ const affiliate = await requireAffiliate(adapter, id);
17
+ return {
18
+ affiliateId: affiliate.id,
19
+ userId: affiliate.userId,
20
+ status: affiliate.status
21
+ };
22
+ }
23
+ function requireIdentity(identity) {
24
+ return identity?.id ?? fail(401, "Sign in to manage your affiliate profile");
25
+ }
26
+ function attributionCookie(request) {
27
+ const value = request?.headers.get("cookie")?.split(";").map((entry) => entry.trim()).find((entry) => entry.startsWith(`${AFFILIATE_COOKIE_NAME}=`))?.slice(AFFILIATE_COOKIE_NAME.length + 1);
28
+ return value && /^[a-zA-Z0-9_-]{1,200}$/.test(value) ? value : void 0;
29
+ }
30
+ function createAffiliateOperations(adapter, options) {
31
+ return {
32
+ listAffiliates: defineOperation({
33
+ input: affiliateListSchema,
34
+ permission: affiliatePermissions.affiliate.read,
35
+ facts: () => ({ scope: "collection" }),
36
+ execute: async ({ input }) => {
37
+ const result = await listAffiliates(adapter, input);
38
+ return {
39
+ ...result,
40
+ items: result.items.map((affiliate) => ({ ...affiliate }))
41
+ };
42
+ }
43
+ }),
44
+ getAffiliate: defineOperation({
45
+ input: idSchema,
46
+ permission: affiliatePermissions.affiliate.read,
47
+ facts: ({ input }) => ({
48
+ scope: "record",
49
+ affiliateId: input.id
50
+ }),
51
+ execute: async ({ input }) => ({
52
+ affiliate: { ...await requireAffiliate(adapter, input.id) },
53
+ stats: { ...await getAffiliateStats(adapter, input.id) }
54
+ })
55
+ }),
56
+ createAffiliate: defineOperation({
57
+ input: createAffiliateSchema,
58
+ permission: affiliatePermissions.affiliate.create,
59
+ facts: () => void 0,
60
+ execute: async ({ input }) => ({
61
+ ...await createAffiliate(adapter, input)
62
+ })
63
+ }),
64
+ updateAffiliate: defineOperation({
65
+ input: idSchema.extend({ data: updateAffiliateSchema }),
66
+ permission: affiliatePermissions.affiliate.update,
67
+ facts: ({ input }) => affiliateFacts(adapter, input.id),
68
+ before: async (context) => {
69
+ if (context.input.data.status === "approved")
70
+ await options.hooks?.onBeforeApproveAffiliate?.(
71
+ await requireAffiliate(adapter, context.input.id),
72
+ context
73
+ );
74
+ },
75
+ execute: async ({ input, facts }) => ({
76
+ ...await updateAffiliate(adapter, input.id, input.data, {
77
+ id: facts.affiliateId,
78
+ userId: facts.userId,
79
+ status: facts.status
80
+ })
81
+ })
82
+ }),
83
+ deleteAffiliate: defineOperation({
84
+ input: idSchema,
85
+ permission: affiliatePermissions.affiliate.delete,
86
+ facts: ({ input }) => affiliateFacts(adapter, input.id),
87
+ execute: async ({ input, facts }) => {
88
+ await deleteAffiliate(adapter, input.id, {
89
+ id: facts.affiliateId,
90
+ userId: facts.userId,
91
+ status: facts.status
92
+ });
93
+ return { success: true };
94
+ }
95
+ }),
96
+ apply: defineOperation({
97
+ input: applicationSchema,
98
+ permission: affiliatePermissions.application.create,
99
+ facts: () => void 0,
100
+ execute: async ({ input, identity }) => ({
101
+ ...await createAffiliate(adapter, {
102
+ ...input,
103
+ userId: requireIdentity(identity)
104
+ })
105
+ })
106
+ }),
107
+ getPortal: defineOperation({
108
+ input: z.object({}),
109
+ permission: affiliatePermissions.portal.read,
110
+ facts: () => void 0,
111
+ execute: async ({ identity }) => {
112
+ const portal = await getPortal(
113
+ adapter,
114
+ requireIdentity(identity),
115
+ options.marketingAssets
116
+ );
117
+ return {
118
+ affiliate: portal.affiliate ? { ...portal.affiliate } : null,
119
+ stats: { ...portal.stats },
120
+ commissions: portal.commissions.map((commission) => ({
121
+ ...commission
122
+ })),
123
+ payouts: portal.payouts.map((payout) => ({ ...payout })),
124
+ assets: portal.assets.map((asset) => ({ ...asset }))
125
+ };
126
+ }
127
+ }),
128
+ updatePortal: defineOperation({
129
+ input: updatePortalSchema,
130
+ permission: affiliatePermissions.portal.update,
131
+ facts: () => void 0,
132
+ execute: async ({ input, identity }) => {
133
+ const affiliate = await getAffiliateByUserId(
134
+ adapter,
135
+ requireIdentity(identity)
136
+ ) ?? fail(404, "Apply to the affiliate program first");
137
+ const updated = await updateAffiliate(
138
+ adapter,
139
+ affiliate.id,
140
+ input
141
+ );
142
+ const { notes: _notes, ...profile } = updated;
143
+ return profile;
144
+ }
145
+ }),
146
+ trackClick: definePassthroughOperation({
147
+ input: trackClickSchema,
148
+ permission: affiliatePermissions.tracking.track,
149
+ access: "public",
150
+ facts: () => void 0,
151
+ execute: async ({ input, request }) => {
152
+ let landingPath;
153
+ try {
154
+ landingPath = safeLandingPath(input.landingPath);
155
+ } catch {
156
+ fail(400, "Landing path must be a local absolute path");
157
+ }
158
+ const destination = new URL(
159
+ landingPath,
160
+ options.siteOrigin ?? request?.url ?? "https://affiliate.invalid"
161
+ );
162
+ const click = await trackClick(adapter, {
163
+ ...input,
164
+ landingPath,
165
+ referrer: request?.headers.get("referer")?.slice(0, 2048) ?? input.referrer,
166
+ userAgent: request?.headers.get("user-agent")?.slice(0, 2048) ?? input.userAgent
167
+ });
168
+ const previousId = attributionCookie(request);
169
+ const previous = previousId ? await getReferralClick(adapter, previousId) : null;
170
+ const previousAffiliate = previous ? await getAffiliate(adapter, previous.affiliateId) : null;
171
+ const selected = selectAttributionClick(
172
+ previousAffiliate?.status === "approved" ? previous : null,
173
+ click,
174
+ options
175
+ );
176
+ const headers = new Headers({
177
+ Location: options.siteOrigin ? destination.href : destination.pathname + destination.search + destination.hash,
178
+ "Cache-Control": "no-store, private",
179
+ "Referrer-Policy": "no-referrer"
180
+ });
181
+ if (selected) {
182
+ const maxAge = Math.max(
183
+ 0,
184
+ Math.floor(
185
+ (new Date(selected.createdAt).getTime() + (options.attributionWindowDays ?? 30) * 864e5 - Date.now()) / 1e3
186
+ )
187
+ );
188
+ headers.set(
189
+ "Set-Cookie",
190
+ `${AFFILIATE_COOKIE_NAME}=${selected.id}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${request && new URL(request.url).protocol === "https:" ? "; Secure" : ""}`
191
+ );
192
+ }
193
+ return new Response(null, { status: 302, headers });
194
+ }
195
+ }),
196
+ createConversion: defineOperation({
197
+ input: createConversionSchema,
198
+ permission: affiliatePermissions.conversion.create,
199
+ facts: () => void 0,
200
+ execute: async ({ input, request }) => {
201
+ let requestAttribution = request ? attributionFromRequest(request) : void 0;
202
+ if (!input.attribution) {
203
+ const cookieId = attributionCookie(request);
204
+ const click = cookieId ? await getReferralClick(adapter, cookieId) : null;
205
+ if (click && isClickWithinWindow(click, options.attributionWindowDays) && (await getAffiliate(adapter, click.affiliateId))?.status === "approved")
206
+ requestAttribution = { clickId: click.id };
207
+ }
208
+ if (!input.attribution && !createConversionSchema.shape.attribution.safeParse(
209
+ requestAttribution
210
+ ).success)
211
+ fail(400, "Invalid referral attribution");
212
+ const result = await createConversion(
213
+ adapter,
214
+ {
215
+ ...input,
216
+ attribution: input.attribution ?? requestAttribution
217
+ },
218
+ options
219
+ );
220
+ return result ? {
221
+ conversion: { ...result.conversion },
222
+ commission: { ...result.commission }
223
+ } : null;
224
+ },
225
+ after: async (context) => {
226
+ if (context.result)
227
+ await options.hooks?.onAfterConversion?.(
228
+ context.result.conversion,
229
+ context.result.commission,
230
+ context
231
+ );
232
+ }
233
+ }),
234
+ listCommissions: defineOperation({
235
+ input: listSchema,
236
+ permission: affiliatePermissions.commission.read,
237
+ facts: () => void 0,
238
+ execute: async ({ input }) => {
239
+ const result = await listCommissions(adapter, input);
240
+ return {
241
+ ...result,
242
+ items: result.items.map((commission) => ({ ...commission }))
243
+ };
244
+ }
245
+ }),
246
+ updateCommission: defineOperation({
247
+ input: idSchema.extend({ data: updateCommissionSchema }),
248
+ permission: affiliatePermissions.commission.update,
249
+ facts: async ({ input }) => {
250
+ const commission = await getCommission(adapter, input.id) ?? fail(404, "Commission not found");
251
+ return {
252
+ commissionId: commission.id,
253
+ affiliateId: commission.affiliateId,
254
+ status: commission.status
255
+ };
256
+ },
257
+ execute: async ({ input, facts }) => ({
258
+ ...await updateCommission(adapter, input.id, input.data, {
259
+ id: facts.commissionId,
260
+ affiliateId: facts.affiliateId,
261
+ status: facts.status
262
+ })
263
+ })
264
+ }),
265
+ listPayouts: defineOperation({
266
+ input: listSchema,
267
+ permission: affiliatePermissions.payout.read,
268
+ facts: () => void 0,
269
+ execute: async ({ input }) => {
270
+ const result = await listPayouts(adapter, input);
271
+ return {
272
+ ...result,
273
+ items: result.items.map((payout) => ({ ...payout }))
274
+ };
275
+ }
276
+ }),
277
+ createPayout: defineOperation({
278
+ input: createPayoutSchema,
279
+ permission: affiliatePermissions.payout.create,
280
+ facts: async ({ input }) => {
281
+ await requireAffiliate(adapter, input.affiliateId);
282
+ return { affiliateId: input.affiliateId, currency: input.currency };
283
+ },
284
+ before: (context) => options.hooks?.onBeforePayout?.(context.input, context),
285
+ execute: async ({ input }) => ({
286
+ ...await createPayout(adapter, {
287
+ ...input,
288
+ commissionIds: input.commissionIds ? [...input.commissionIds] : void 0
289
+ })
290
+ })
291
+ }),
292
+ updatePayout: defineOperation({
293
+ input: idSchema.extend({ data: updatePayoutSchema }),
294
+ permission: affiliatePermissions.payout.update,
295
+ facts: async ({ input }) => {
296
+ const payout = await getPayout(adapter, input.id) ?? fail(404, "Payout not found");
297
+ return {
298
+ payoutId: payout.id,
299
+ affiliateId: payout.affiliateId,
300
+ status: payout.status
301
+ };
302
+ },
303
+ execute: async ({ input, facts }) => ({
304
+ ...await updatePayout(adapter, input.id, input.data, {
305
+ id: facts.payoutId,
306
+ affiliateId: facts.affiliateId,
307
+ status: facts.status
308
+ })
309
+ }),
310
+ after: async (context) => {
311
+ if (context.result.status === "sent")
312
+ await options.hooks?.onAfterPayout?.(context.result, context);
313
+ }
314
+ }),
315
+ exportPayouts: defineOperation({
316
+ input: listSchema,
317
+ permission: affiliatePermissions.payout.export,
318
+ facts: () => void 0,
319
+ execute: async ({ input }) => ({
320
+ csv: await exportPayouts(adapter, input)
321
+ })
322
+ })
323
+ };
324
+ }
325
+
326
+ export { createAffiliateOperations };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ const api = require('@btst/stack/plugins/api');
4
+ const zod = require('zod');
5
+ const commission = require('../commission.cjs');
6
+ const db = require('../db.cjs');
7
+ const schemas = require('../schemas.cjs');
8
+ const operations = require('./operations.cjs');
9
+
10
+ const affiliateBackendPlugin = (options = {}) => {
11
+ const assetURL = zod.z.url({ protocol: /^https?$/ });
12
+ zod.z.object({
13
+ siteOrigin: assetURL.refine((value) => {
14
+ const url = new URL(value);
15
+ return !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash;
16
+ }, "siteOrigin must contain only an HTTP(S) origin").optional(),
17
+ attributionWindowDays: zod.z.number().int().min(1).max(3650).optional(),
18
+ attributionMode: zod.z.enum(["first_touch", "last_touch"]).optional(),
19
+ defaultCommissionType: zod.z.enum(["percent", "flat"]).optional(),
20
+ defaultCommissionValue: zod.z.number().finite().nonnegative().optional(),
21
+ marketingAssets: zod.z.array(
22
+ zod.z.object({
23
+ name: zod.z.string().min(1),
24
+ url: assetURL,
25
+ description: zod.z.string().optional(),
26
+ bannerUrl: assetURL.optional()
27
+ })
28
+ ).optional()
29
+ }).parse(options);
30
+ commission.calculateCommission(
31
+ 0,
32
+ options.defaultCommissionType ?? "percent",
33
+ options.defaultCommissionValue ?? 20
34
+ );
35
+ return api.defineBackendPlugin({
36
+ id: "affiliate",
37
+ dbPlugin: db.affiliateSchema,
38
+ operations: (adapter, context) => {
39
+ if (!context?.auth)
40
+ throw new Error(
41
+ "Affiliate requires a server auth adapter configured with affiliatePermissions"
42
+ );
43
+ return operations.createAffiliateOperations(adapter, options);
44
+ },
45
+ routes: (_adapter, _context, operations) => ({
46
+ listAffiliates: api.createEndpoint(
47
+ "/affiliate/affiliates",
48
+ { method: "GET", query: schemas.affiliateListSchema, requireRequest: true },
49
+ operations.listAffiliates.route((ctx) => ctx.query)
50
+ ),
51
+ getAffiliate: api.createEndpoint(
52
+ "/affiliate/affiliates/:id",
53
+ { method: "GET", requireRequest: true },
54
+ operations.getAffiliate.route((ctx) => ({ id: ctx.params.id }))
55
+ ),
56
+ createAffiliate: api.createEndpoint(
57
+ "/affiliate/affiliates",
58
+ { method: "POST", body: schemas.createAffiliateSchema, requireRequest: true },
59
+ operations.createAffiliate.route((ctx) => ctx.body)
60
+ ),
61
+ updateAffiliate: api.createEndpoint(
62
+ "/affiliate/affiliates/:id",
63
+ { method: "PUT", body: schemas.updateAffiliateSchema, requireRequest: true },
64
+ operations.updateAffiliate.route((ctx) => ({
65
+ id: ctx.params.id,
66
+ data: ctx.body
67
+ }))
68
+ ),
69
+ deleteAffiliate: api.createEndpoint(
70
+ "/affiliate/affiliates/:id",
71
+ { method: "DELETE", requireRequest: true },
72
+ operations.deleteAffiliate.route((ctx) => ({ id: ctx.params.id }))
73
+ ),
74
+ apply: api.createEndpoint(
75
+ "/affiliate/apply",
76
+ { method: "POST", body: schemas.applicationSchema, requireRequest: true },
77
+ operations.apply.route((ctx) => ctx.body)
78
+ ),
79
+ getPortal: api.createEndpoint(
80
+ "/affiliate/portal",
81
+ { method: "GET", requireRequest: true },
82
+ operations.getPortal.route(() => ({}))
83
+ ),
84
+ updatePortal: api.createEndpoint(
85
+ "/affiliate/portal",
86
+ { method: "PUT", body: schemas.updatePortalSchema, requireRequest: true },
87
+ operations.updatePortal.route((ctx) => ctx.body)
88
+ ),
89
+ trackClick: api.createEndpoint(
90
+ "/affiliate/track/:code",
91
+ {
92
+ method: "GET",
93
+ query: zod.z.object({ to: zod.z.string().max(2048).optional() }),
94
+ requireRequest: true
95
+ },
96
+ operations.trackClick.route((ctx) => ({
97
+ code: ctx.params.code,
98
+ landingPath: ctx.query.to
99
+ }))
100
+ ),
101
+ createConversion: api.createEndpoint(
102
+ "/affiliate/conversions",
103
+ { method: "POST", body: schemas.createConversionSchema, requireRequest: true },
104
+ operations.createConversion.route((ctx) => ctx.body)
105
+ ),
106
+ listCommissions: api.createEndpoint(
107
+ "/affiliate/commissions",
108
+ { method: "GET", query: schemas.listSchema, requireRequest: true },
109
+ operations.listCommissions.route((ctx) => ctx.query)
110
+ ),
111
+ updateCommission: api.createEndpoint(
112
+ "/affiliate/commissions/:id",
113
+ { method: "PUT", body: schemas.updateCommissionSchema, requireRequest: true },
114
+ operations.updateCommission.route((ctx) => ({
115
+ id: ctx.params.id,
116
+ data: ctx.body
117
+ }))
118
+ ),
119
+ listPayouts: api.createEndpoint(
120
+ "/affiliate/payouts",
121
+ { method: "GET", query: schemas.listSchema, requireRequest: true },
122
+ operations.listPayouts.route((ctx) => ctx.query)
123
+ ),
124
+ createPayout: api.createEndpoint(
125
+ "/affiliate/payouts",
126
+ { method: "POST", body: schemas.createPayoutSchema, requireRequest: true },
127
+ operations.createPayout.route((ctx) => ctx.body)
128
+ ),
129
+ updatePayout: api.createEndpoint(
130
+ "/affiliate/payouts/:id",
131
+ { method: "PUT", body: schemas.updatePayoutSchema, requireRequest: true },
132
+ operations.updatePayout.route((ctx) => ({
133
+ id: ctx.params.id,
134
+ data: ctx.body
135
+ }))
136
+ ),
137
+ exportPayouts: api.createEndpoint(
138
+ "/affiliate/payouts/export",
139
+ { method: "GET", query: schemas.listSchema, requireRequest: true },
140
+ operations.exportPayouts.route((ctx) => ctx.query)
141
+ )
142
+ })
143
+ });
144
+ };
145
+
146
+ exports.affiliateBackendPlugin = affiliateBackendPlugin;
@@ -0,0 +1,144 @@
1
+ import { defineBackendPlugin, createEndpoint } from '@btst/stack/plugins/api';
2
+ import { z } from 'zod';
3
+ import { calculateCommission } from '../commission.mjs';
4
+ import { affiliateSchema } from '../db.mjs';
5
+ import { listSchema, updatePayoutSchema, createPayoutSchema, updateCommissionSchema, createConversionSchema, updatePortalSchema, applicationSchema, updateAffiliateSchema, createAffiliateSchema, affiliateListSchema } from '../schemas.mjs';
6
+ import { createAffiliateOperations } from './operations.mjs';
7
+
8
+ const affiliateBackendPlugin = (options = {}) => {
9
+ const assetURL = z.url({ protocol: /^https?$/ });
10
+ z.object({
11
+ siteOrigin: assetURL.refine((value) => {
12
+ const url = new URL(value);
13
+ return !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash;
14
+ }, "siteOrigin must contain only an HTTP(S) origin").optional(),
15
+ attributionWindowDays: z.number().int().min(1).max(3650).optional(),
16
+ attributionMode: z.enum(["first_touch", "last_touch"]).optional(),
17
+ defaultCommissionType: z.enum(["percent", "flat"]).optional(),
18
+ defaultCommissionValue: z.number().finite().nonnegative().optional(),
19
+ marketingAssets: z.array(
20
+ z.object({
21
+ name: z.string().min(1),
22
+ url: assetURL,
23
+ description: z.string().optional(),
24
+ bannerUrl: assetURL.optional()
25
+ })
26
+ ).optional()
27
+ }).parse(options);
28
+ calculateCommission(
29
+ 0,
30
+ options.defaultCommissionType ?? "percent",
31
+ options.defaultCommissionValue ?? 20
32
+ );
33
+ return defineBackendPlugin({
34
+ id: "affiliate",
35
+ dbPlugin: affiliateSchema,
36
+ operations: (adapter, context) => {
37
+ if (!context?.auth)
38
+ throw new Error(
39
+ "Affiliate requires a server auth adapter configured with affiliatePermissions"
40
+ );
41
+ return createAffiliateOperations(adapter, options);
42
+ },
43
+ routes: (_adapter, _context, operations) => ({
44
+ listAffiliates: createEndpoint(
45
+ "/affiliate/affiliates",
46
+ { method: "GET", query: affiliateListSchema, requireRequest: true },
47
+ operations.listAffiliates.route((ctx) => ctx.query)
48
+ ),
49
+ getAffiliate: createEndpoint(
50
+ "/affiliate/affiliates/:id",
51
+ { method: "GET", requireRequest: true },
52
+ operations.getAffiliate.route((ctx) => ({ id: ctx.params.id }))
53
+ ),
54
+ createAffiliate: createEndpoint(
55
+ "/affiliate/affiliates",
56
+ { method: "POST", body: createAffiliateSchema, requireRequest: true },
57
+ operations.createAffiliate.route((ctx) => ctx.body)
58
+ ),
59
+ updateAffiliate: createEndpoint(
60
+ "/affiliate/affiliates/:id",
61
+ { method: "PUT", body: updateAffiliateSchema, requireRequest: true },
62
+ operations.updateAffiliate.route((ctx) => ({
63
+ id: ctx.params.id,
64
+ data: ctx.body
65
+ }))
66
+ ),
67
+ deleteAffiliate: createEndpoint(
68
+ "/affiliate/affiliates/:id",
69
+ { method: "DELETE", requireRequest: true },
70
+ operations.deleteAffiliate.route((ctx) => ({ id: ctx.params.id }))
71
+ ),
72
+ apply: createEndpoint(
73
+ "/affiliate/apply",
74
+ { method: "POST", body: applicationSchema, requireRequest: true },
75
+ operations.apply.route((ctx) => ctx.body)
76
+ ),
77
+ getPortal: createEndpoint(
78
+ "/affiliate/portal",
79
+ { method: "GET", requireRequest: true },
80
+ operations.getPortal.route(() => ({}))
81
+ ),
82
+ updatePortal: createEndpoint(
83
+ "/affiliate/portal",
84
+ { method: "PUT", body: updatePortalSchema, requireRequest: true },
85
+ operations.updatePortal.route((ctx) => ctx.body)
86
+ ),
87
+ trackClick: createEndpoint(
88
+ "/affiliate/track/:code",
89
+ {
90
+ method: "GET",
91
+ query: z.object({ to: z.string().max(2048).optional() }),
92
+ requireRequest: true
93
+ },
94
+ operations.trackClick.route((ctx) => ({
95
+ code: ctx.params.code,
96
+ landingPath: ctx.query.to
97
+ }))
98
+ ),
99
+ createConversion: createEndpoint(
100
+ "/affiliate/conversions",
101
+ { method: "POST", body: createConversionSchema, requireRequest: true },
102
+ operations.createConversion.route((ctx) => ctx.body)
103
+ ),
104
+ listCommissions: createEndpoint(
105
+ "/affiliate/commissions",
106
+ { method: "GET", query: listSchema, requireRequest: true },
107
+ operations.listCommissions.route((ctx) => ctx.query)
108
+ ),
109
+ updateCommission: createEndpoint(
110
+ "/affiliate/commissions/:id",
111
+ { method: "PUT", body: updateCommissionSchema, requireRequest: true },
112
+ operations.updateCommission.route((ctx) => ({
113
+ id: ctx.params.id,
114
+ data: ctx.body
115
+ }))
116
+ ),
117
+ listPayouts: createEndpoint(
118
+ "/affiliate/payouts",
119
+ { method: "GET", query: listSchema, requireRequest: true },
120
+ operations.listPayouts.route((ctx) => ctx.query)
121
+ ),
122
+ createPayout: createEndpoint(
123
+ "/affiliate/payouts",
124
+ { method: "POST", body: createPayoutSchema, requireRequest: true },
125
+ operations.createPayout.route((ctx) => ctx.body)
126
+ ),
127
+ updatePayout: createEndpoint(
128
+ "/affiliate/payouts/:id",
129
+ { method: "PUT", body: updatePayoutSchema, requireRequest: true },
130
+ operations.updatePayout.route((ctx) => ({
131
+ id: ctx.params.id,
132
+ data: ctx.body
133
+ }))
134
+ ),
135
+ exportPayouts: createEndpoint(
136
+ "/affiliate/payouts/export",
137
+ { method: "GET", query: listSchema, requireRequest: true },
138
+ operations.exportPayouts.route((ctx) => ctx.query)
139
+ )
140
+ })
141
+ });
142
+ };
143
+
144
+ export { affiliateBackendPlugin };