convex-affiliates 3.0.5 → 3.0.6
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/better-auth/index.d.ts +22 -11
- package/dist/better-auth/index.d.ts.map +1 -1
- package/dist/better-auth/index.js +86 -112
- package/dist/better-auth/index.js.map +1 -1
- package/dist/component/referrals.d.ts +4 -4
- package/dist/component/schema.d.ts +2 -2
- package/package.json +1 -1
- package/src/better-auth/index.ts +84 -141
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Better Auth Server Plugin for Convex Affiliates
|
|
3
3
|
*
|
|
4
4
|
* This plugin automatically handles affiliate attribution when users sign up
|
|
5
|
-
* through Better Auth. It
|
|
6
|
-
*
|
|
5
|
+
* through Better Auth. It uses databaseHooks to intercept user creation events,
|
|
6
|
+
* which fires for ALL auth methods (email, OAuth, magic-link, etc.).
|
|
7
7
|
*
|
|
8
8
|
* @example
|
|
9
9
|
* ```typescript
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* @module
|
|
26
26
|
*/
|
|
27
|
-
import type { BetterAuthPlugin
|
|
27
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
28
28
|
import type { GenericActionCtx, GenericDataModel } from "convex/server";
|
|
29
29
|
import type { ComponentApi } from "../component/_generated/component.js";
|
|
30
30
|
/**
|
|
@@ -73,9 +73,10 @@ export interface AffiliatePluginOptions {
|
|
|
73
73
|
/**
|
|
74
74
|
* Better Auth plugin for automatic affiliate attribution.
|
|
75
75
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* Uses `databaseHooks.user.create.after` to intercept user creation at the
|
|
77
|
+
* database level. This fires for ALL auth methods (email, OAuth, magic-link,
|
|
78
|
+
* passkey, etc.) and receives the actual user record directly — no need
|
|
79
|
+
* for endpoint path matching or response parsing.
|
|
79
80
|
*
|
|
80
81
|
* @param ctx - The Convex context (from createAuth function)
|
|
81
82
|
* @param component - The affiliates component (components.affiliates)
|
|
@@ -108,11 +109,21 @@ export interface AffiliatePluginOptions {
|
|
|
108
109
|
*/
|
|
109
110
|
export declare function affiliatePlugin(ctx: ConvexCtx, component: ComponentApi, options?: AffiliatePluginOptions): {
|
|
110
111
|
id: "convex-affiliates";
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
init(): {
|
|
113
|
+
options: {
|
|
114
|
+
databaseHooks: {
|
|
115
|
+
user: {
|
|
116
|
+
create: {
|
|
117
|
+
after: (user: {
|
|
118
|
+
id: string;
|
|
119
|
+
} & Record<string, unknown>, endpointCtx: {
|
|
120
|
+
body?: unknown;
|
|
121
|
+
headers?: Headers;
|
|
122
|
+
} | null) => Promise<void>;
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
};
|
|
116
127
|
};
|
|
117
128
|
};
|
|
118
129
|
export type { BetterAuthPlugin };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/better-auth/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/better-auth/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACxE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAMzE;;;;GAIG;AACH,KAAK,SAAS,GAAG,IAAI,CACnB,gBAAgB,CAAC,gBAAgB,CAAC,EAClC,UAAU,GAAG,aAAa,CAC3B,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,UAAU,CAAC,EAAE;QACX,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB,CAAC;IAEF;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B;;OAEG;IACH,oBAAoB,CAAC,EAAE,CAAC,IAAI,EAAE;QAC5B,MAAM,EAAE,MAAM,CAAC;QACf,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpB;;OAEG;IACH,oBAAoB,CAAC,EAAE,CAAC,IAAI,EAAE;QAC5B,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;KAChB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,SAAS,EACd,SAAS,EAAE,YAAY,EACvB,OAAO,GAAE,sBAA2B;;;;;;;sCAqBd;4BAAE,EAAE,EAAE,MAAM,CAAA;yBAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,eACjC;4BAAE,IAAI,CAAC,EAAE,OAAO,CAAC;4BAAC,OAAO,CAAC,EAAE,OAAO,CAAA;yBAAE,GAAG,IAAI;;;;;;EA8E1E;AA0BD,YAAY,EAAE,gBAAgB,EAAE,CAAC"}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Better Auth Server Plugin for Convex Affiliates
|
|
3
3
|
*
|
|
4
4
|
* This plugin automatically handles affiliate attribution when users sign up
|
|
5
|
-
* through Better Auth. It
|
|
6
|
-
*
|
|
5
|
+
* through Better Auth. It uses databaseHooks to intercept user creation events,
|
|
6
|
+
* which fires for ALL auth methods (email, OAuth, magic-link, etc.).
|
|
7
7
|
*
|
|
8
8
|
* @example
|
|
9
9
|
* ```typescript
|
|
@@ -24,16 +24,16 @@
|
|
|
24
24
|
*
|
|
25
25
|
* @module
|
|
26
26
|
*/
|
|
27
|
-
import { createAuthMiddleware } from "better-auth/plugins";
|
|
28
27
|
// =============================================================================
|
|
29
28
|
// Plugin Implementation
|
|
30
29
|
// =============================================================================
|
|
31
30
|
/**
|
|
32
31
|
* Better Auth plugin for automatic affiliate attribution.
|
|
33
32
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* Uses `databaseHooks.user.create.after` to intercept user creation at the
|
|
34
|
+
* database level. This fires for ALL auth methods (email, OAuth, magic-link,
|
|
35
|
+
* passkey, etc.) and receives the actual user record directly — no need
|
|
36
|
+
* for endpoint path matching or response parsing.
|
|
37
37
|
*
|
|
38
38
|
* @param ctx - The Convex context (from createAuth function)
|
|
39
39
|
* @param component - The affiliates component (components.affiliates)
|
|
@@ -75,110 +75,86 @@ export function affiliatePlugin(ctx, component, options = {}) {
|
|
|
75
75
|
};
|
|
76
76
|
return {
|
|
77
77
|
id: "convex-affiliates",
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
{
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
init() {
|
|
79
|
+
return {
|
|
80
|
+
options: {
|
|
81
|
+
databaseHooks: {
|
|
82
|
+
user: {
|
|
83
|
+
create: {
|
|
84
|
+
after: async (user, endpointCtx) => {
|
|
85
|
+
// Top-level try-catch: NEVER let attribution crash the auth flow
|
|
86
|
+
try {
|
|
87
|
+
if (!endpointCtx)
|
|
88
|
+
return;
|
|
89
|
+
const userId = user.id;
|
|
90
|
+
// Extract referral data from body (email signups)
|
|
91
|
+
const body = endpointCtx.body;
|
|
92
|
+
let referralId = body?.[config.referralIdField];
|
|
93
|
+
let referralCode = body?.[config.referralCodeField];
|
|
94
|
+
// Also check cookies (OAuth signups — cookies forwarded by Next.js proxy)
|
|
95
|
+
const cookieHeader = endpointCtx.headers?.get("cookie");
|
|
96
|
+
if (cookieHeader) {
|
|
97
|
+
const cookies = parseCookies(cookieHeader);
|
|
98
|
+
if (!referralId)
|
|
99
|
+
referralId = cookies[config.referralIdCookieName];
|
|
100
|
+
if (!referralCode)
|
|
101
|
+
referralCode = cookies[config.cookieName];
|
|
102
|
+
}
|
|
103
|
+
if (!referralId && !referralCode) {
|
|
104
|
+
try {
|
|
105
|
+
await config.onAttributionFailure?.({ userId, reason: "No referral data found" });
|
|
106
|
+
}
|
|
107
|
+
catch { /* swallow */ }
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Attribution logic
|
|
111
|
+
let attributed = false;
|
|
112
|
+
let affiliateCode;
|
|
113
|
+
// Try by referral ID first (more accurate)
|
|
114
|
+
if (referralId) {
|
|
115
|
+
const referral = await ctx.runQuery(component.referrals.getByReferralId, { referralId });
|
|
116
|
+
if (referral?.status === "clicked") {
|
|
117
|
+
await ctx.runMutation(component.referrals.attributeSignup, {
|
|
118
|
+
referralId,
|
|
119
|
+
userId,
|
|
120
|
+
});
|
|
121
|
+
attributed = true;
|
|
122
|
+
const affiliate = await ctx.runQuery(component.affiliates.getById, { affiliateId: referral.affiliateId });
|
|
123
|
+
affiliateCode = affiliate?.code;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Try by affiliate code if referral ID didn't work
|
|
127
|
+
if (!attributed && referralCode) {
|
|
128
|
+
const result = await ctx.runMutation(component.referrals.attributeSignupByCode, { userId, affiliateCode: referralCode });
|
|
129
|
+
attributed = result.success;
|
|
130
|
+
affiliateCode = referralCode;
|
|
131
|
+
}
|
|
132
|
+
if (attributed) {
|
|
133
|
+
try {
|
|
134
|
+
await config.onAttributionSuccess?.({ userId, affiliateCode });
|
|
135
|
+
}
|
|
136
|
+
catch { /* swallow */ }
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
try {
|
|
140
|
+
await config.onAttributionFailure?.({ userId, reason: "Attribution failed - referral not found or invalid" });
|
|
141
|
+
}
|
|
142
|
+
catch { /* swallow */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error("[convex-affiliates] Attribution error:", error);
|
|
147
|
+
try {
|
|
148
|
+
await config.onAttributionFailure?.({ userId: user.id, reason: error instanceof Error ? error.message : "Unknown error" });
|
|
149
|
+
}
|
|
150
|
+
catch { /* swallow */ }
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
88
155
|
},
|
|
89
|
-
handler: createAuthMiddleware(async (ctx_hook) => {
|
|
90
|
-
const context = ctx_hook;
|
|
91
|
-
// Check if signup was successful (user was created)
|
|
92
|
-
const returned = context.context?.returned;
|
|
93
|
-
if (!returned || typeof returned !== "object") {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
// Extract user ID from response
|
|
97
|
-
const responseObj = returned;
|
|
98
|
-
const user = responseObj.user;
|
|
99
|
-
if (!user?.id) {
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
const userId = user.id;
|
|
103
|
-
// For OAuth sign-ins, check if this is actually a new user
|
|
104
|
-
// by comparing createdAt to current time (within 10 seconds = new user)
|
|
105
|
-
if (user.createdAt) {
|
|
106
|
-
const createdAt = new Date(user.createdAt).getTime();
|
|
107
|
-
const now = Date.now();
|
|
108
|
-
const isNewUser = now - createdAt < 10000; // 10 seconds
|
|
109
|
-
if (!isNewUser) {
|
|
110
|
-
// Existing user signing in via OAuth, skip attribution
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
// Extract referral data from request body
|
|
115
|
-
const body = context.body ?? context.context?.body;
|
|
116
|
-
let referralId = body?.[config.referralIdField];
|
|
117
|
-
let referralCode = body?.[config.referralCodeField];
|
|
118
|
-
// Also check cookies
|
|
119
|
-
const cookieHeader = context.headers?.get?.("cookie") ??
|
|
120
|
-
context.context?.request?.headers?.get?.("cookie");
|
|
121
|
-
if (cookieHeader) {
|
|
122
|
-
const cookies = parseCookies(cookieHeader);
|
|
123
|
-
if (!referralId && config.referralIdCookieName) {
|
|
124
|
-
referralId = cookies[config.referralIdCookieName];
|
|
125
|
-
}
|
|
126
|
-
if (!referralCode && config.cookieName) {
|
|
127
|
-
referralCode = cookies[config.cookieName];
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
// Skip if no referral data
|
|
131
|
-
if (!referralId && !referralCode) {
|
|
132
|
-
await config.onAttributionFailure?.({
|
|
133
|
-
userId,
|
|
134
|
-
reason: "No referral data found",
|
|
135
|
-
});
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
// Attribute the signup
|
|
139
|
-
try {
|
|
140
|
-
let attributed = false;
|
|
141
|
-
let affiliateCode;
|
|
142
|
-
// Try by referral ID first (more accurate)
|
|
143
|
-
if (referralId) {
|
|
144
|
-
const referral = await ctx.runQuery(component.referrals.getByReferralId, { referralId });
|
|
145
|
-
if (referral && referral.status === "clicked") {
|
|
146
|
-
await ctx.runMutation(component.referrals.attributeSignup, {
|
|
147
|
-
referralId: referralId,
|
|
148
|
-
userId,
|
|
149
|
-
});
|
|
150
|
-
attributed = true;
|
|
151
|
-
// Look up the affiliate to get their code
|
|
152
|
-
const affiliate = await ctx.runQuery(component.affiliates.getById, { affiliateId: referral.affiliateId });
|
|
153
|
-
affiliateCode = affiliate?.code;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
// Try by affiliate code if referral ID didn't work
|
|
157
|
-
if (!attributed && referralCode) {
|
|
158
|
-
const result = await ctx.runMutation(component.referrals.attributeSignupByCode, { userId, affiliateCode: referralCode });
|
|
159
|
-
attributed = result.success;
|
|
160
|
-
affiliateCode = referralCode;
|
|
161
|
-
}
|
|
162
|
-
if (attributed) {
|
|
163
|
-
await config.onAttributionSuccess?.({ userId, affiliateCode });
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
await config.onAttributionFailure?.({
|
|
167
|
-
userId,
|
|
168
|
-
reason: "Attribution failed - referral not found or invalid",
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
catch (error) {
|
|
173
|
-
console.error("[convex-affiliates] Attribution error:", error);
|
|
174
|
-
await config.onAttributionFailure?.({
|
|
175
|
-
userId,
|
|
176
|
-
reason: error instanceof Error ? error.message : "Unknown error",
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
}),
|
|
180
156
|
},
|
|
181
|
-
|
|
157
|
+
};
|
|
182
158
|
},
|
|
183
159
|
};
|
|
184
160
|
}
|
|
@@ -187,20 +163,18 @@ export function affiliatePlugin(ctx, component, options = {}) {
|
|
|
187
163
|
// =============================================================================
|
|
188
164
|
function parseCookies(cookieHeader) {
|
|
189
165
|
const cookies = {};
|
|
190
|
-
cookieHeader.split(";")
|
|
166
|
+
for (const cookie of cookieHeader.split(";")) {
|
|
191
167
|
const [name, ...valueParts] = cookie.trim().split("=");
|
|
192
168
|
if (name) {
|
|
193
169
|
const value = valueParts.join("=");
|
|
194
|
-
// Decode URI-encoded values (handles special characters in affiliate codes)
|
|
195
170
|
try {
|
|
196
171
|
cookies[name] = decodeURIComponent(value);
|
|
197
172
|
}
|
|
198
173
|
catch {
|
|
199
|
-
// If decoding fails, use the raw value
|
|
200
174
|
cookies[name] = value;
|
|
201
175
|
}
|
|
202
176
|
}
|
|
203
|
-
}
|
|
177
|
+
}
|
|
204
178
|
return cookies;
|
|
205
179
|
}
|
|
206
180
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/better-auth/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/better-auth/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -34,8 +34,8 @@ export declare const getByReferralId: import("convex/server").RegisteredQuery<"p
|
|
|
34
34
|
status: "clicked" | "signed_up" | "converted" | "expired";
|
|
35
35
|
referralId: string;
|
|
36
36
|
landingPage: string;
|
|
37
|
-
expiresAt: number;
|
|
38
37
|
clickedAt: number;
|
|
38
|
+
expiresAt: number;
|
|
39
39
|
} | null>>;
|
|
40
40
|
/**
|
|
41
41
|
* Get a referral by the user who was referred.
|
|
@@ -60,8 +60,8 @@ export declare const getByUserId: import("convex/server").RegisteredQuery<"publi
|
|
|
60
60
|
status: "clicked" | "signed_up" | "converted" | "expired";
|
|
61
61
|
referralId: string;
|
|
62
62
|
landingPage: string;
|
|
63
|
-
expiresAt: number;
|
|
64
63
|
clickedAt: number;
|
|
64
|
+
expiresAt: number;
|
|
65
65
|
} | null>>;
|
|
66
66
|
/**
|
|
67
67
|
* Get a referral by Stripe customer ID.
|
|
@@ -86,8 +86,8 @@ export declare const getByStripeCustomer: import("convex/server").RegisteredQuer
|
|
|
86
86
|
status: "clicked" | "signed_up" | "converted" | "expired";
|
|
87
87
|
referralId: string;
|
|
88
88
|
landingPage: string;
|
|
89
|
-
expiresAt: number;
|
|
90
89
|
clickedAt: number;
|
|
90
|
+
expiresAt: number;
|
|
91
91
|
} | null>>;
|
|
92
92
|
/**
|
|
93
93
|
* List referrals for an affiliate.
|
|
@@ -114,8 +114,8 @@ export declare const listByAffiliate: import("convex/server").RegisteredQuery<"p
|
|
|
114
114
|
status: "clicked" | "signed_up" | "converted" | "expired";
|
|
115
115
|
referralId: string;
|
|
116
116
|
landingPage: string;
|
|
117
|
-
expiresAt: number;
|
|
118
117
|
clickedAt: number;
|
|
118
|
+
expiresAt: number;
|
|
119
119
|
}[]>>;
|
|
120
120
|
/**
|
|
121
121
|
* Get the referee discount for a referral.
|
|
@@ -202,8 +202,8 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
202
202
|
status: "clicked" | "signed_up" | "converted" | "expired";
|
|
203
203
|
referralId: string;
|
|
204
204
|
landingPage: string;
|
|
205
|
-
expiresAt: number;
|
|
206
205
|
clickedAt: number;
|
|
206
|
+
expiresAt: number;
|
|
207
207
|
}, {
|
|
208
208
|
affiliateId: import("convex/values").VId<import("convex/values").GenericId<"affiliates">, "required">;
|
|
209
209
|
referralId: import("convex/values").VString<string, "required">;
|
|
@@ -222,7 +222,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
222
222
|
signedUpAt: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
223
223
|
convertedAt: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
224
224
|
expiresAt: import("convex/values").VFloat64<number, "required">;
|
|
225
|
-
}, "required", "affiliateId" | "userId" | "status" | "referralId" | "stripeCustomerId" | "landingPage" | "ipAddress" | "subId" | "
|
|
225
|
+
}, "required", "affiliateId" | "userId" | "status" | "referralId" | "stripeCustomerId" | "landingPage" | "ipAddress" | "subId" | "utmSource" | "utmMedium" | "utmCampaign" | "deviceType" | "country" | "clickedAt" | "signedUpAt" | "convertedAt" | "expiresAt">, {
|
|
226
226
|
by_referralId: ["referralId", "_creationTime"];
|
|
227
227
|
by_affiliate: ["affiliateId", "_creationTime"];
|
|
228
228
|
by_affiliate_status: ["affiliateId", "status", "_creationTime"];
|
package/package.json
CHANGED
package/src/better-auth/index.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Better Auth Server Plugin for Convex Affiliates
|
|
3
3
|
*
|
|
4
4
|
* This plugin automatically handles affiliate attribution when users sign up
|
|
5
|
-
* through Better Auth. It
|
|
6
|
-
*
|
|
5
|
+
* through Better Auth. It uses databaseHooks to intercept user creation events,
|
|
6
|
+
* which fires for ALL auth methods (email, OAuth, magic-link, etc.).
|
|
7
7
|
*
|
|
8
8
|
* @example
|
|
9
9
|
* ```typescript
|
|
@@ -25,8 +25,7 @@
|
|
|
25
25
|
* @module
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import type { BetterAuthPlugin
|
|
29
|
-
import { createAuthMiddleware } from "better-auth/plugins";
|
|
28
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
30
29
|
import type { GenericActionCtx, GenericDataModel } from "convex/server";
|
|
31
30
|
import type { ComponentApi } from "../component/_generated/component.js";
|
|
32
31
|
|
|
@@ -93,9 +92,10 @@ export interface AffiliatePluginOptions {
|
|
|
93
92
|
/**
|
|
94
93
|
* Better Auth plugin for automatic affiliate attribution.
|
|
95
94
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
95
|
+
* Uses `databaseHooks.user.create.after` to intercept user creation at the
|
|
96
|
+
* database level. This fires for ALL auth methods (email, OAuth, magic-link,
|
|
97
|
+
* passkey, etc.) and receives the actual user record directly — no need
|
|
98
|
+
* for endpoint path matching or response parsing.
|
|
99
99
|
*
|
|
100
100
|
* @param ctx - The Convex context (from createAuth function)
|
|
101
101
|
* @param component - The affiliates component (components.affiliates)
|
|
@@ -143,145 +143,90 @@ export function affiliatePlugin(
|
|
|
143
143
|
return {
|
|
144
144
|
id: "convex-affiliates",
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
handler: createAuthMiddleware(async (ctx_hook) => {
|
|
161
|
-
const context = ctx_hook as unknown as {
|
|
162
|
-
context: {
|
|
163
|
-
returned?: unknown;
|
|
164
|
-
body?: Record<string, unknown>;
|
|
165
|
-
request?: Request;
|
|
166
|
-
};
|
|
167
|
-
body?: Record<string, unknown>;
|
|
168
|
-
headers?: Headers;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
// Check if signup was successful (user was created)
|
|
172
|
-
const returned = context.context?.returned;
|
|
173
|
-
if (!returned || typeof returned !== "object") {
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Extract user ID from response
|
|
178
|
-
const responseObj = returned as Record<string, unknown>;
|
|
179
|
-
const user = responseObj.user as {
|
|
180
|
-
id?: string;
|
|
181
|
-
createdAt?: Date | string | number;
|
|
182
|
-
} | undefined;
|
|
183
|
-
if (!user?.id) {
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
146
|
+
init() {
|
|
147
|
+
return {
|
|
148
|
+
options: {
|
|
149
|
+
databaseHooks: {
|
|
150
|
+
user: {
|
|
151
|
+
create: {
|
|
152
|
+
after: async (
|
|
153
|
+
user: { id: string } & Record<string, unknown>,
|
|
154
|
+
endpointCtx: { body?: unknown; headers?: Headers } | null
|
|
155
|
+
) => {
|
|
156
|
+
// Top-level try-catch: NEVER let attribution crash the auth flow
|
|
157
|
+
try {
|
|
158
|
+
if (!endpointCtx) return;
|
|
186
159
|
|
|
187
|
-
|
|
160
|
+
const userId = user.id;
|
|
188
161
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const now = Date.now();
|
|
194
|
-
const isNewUser = now - createdAt < 10000; // 10 seconds
|
|
195
|
-
if (!isNewUser) {
|
|
196
|
-
// Existing user signing in via OAuth, skip attribution
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
162
|
+
// Extract referral data from body (email signups)
|
|
163
|
+
const body = endpointCtx.body as Record<string, unknown> | undefined;
|
|
164
|
+
let referralId = body?.[config.referralIdField] as string | undefined;
|
|
165
|
+
let referralCode = body?.[config.referralCodeField] as string | undefined;
|
|
200
166
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
167
|
+
// Also check cookies (OAuth signups — cookies forwarded by Next.js proxy)
|
|
168
|
+
const cookieHeader = endpointCtx.headers?.get("cookie");
|
|
169
|
+
if (cookieHeader) {
|
|
170
|
+
const cookies = parseCookies(cookieHeader);
|
|
171
|
+
if (!referralId) referralId = cookies[config.referralIdCookieName];
|
|
172
|
+
if (!referralCode) referralCode = cookies[config.cookieName];
|
|
173
|
+
}
|
|
205
174
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
175
|
+
if (!referralId && !referralCode) {
|
|
176
|
+
try { await config.onAttributionFailure?.({ userId, reason: "No referral data found" }); } catch { /* swallow */ }
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
210
179
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
referralId = cookies[config.referralIdCookieName];
|
|
215
|
-
}
|
|
216
|
-
if (!referralCode && config.cookieName) {
|
|
217
|
-
referralCode = cookies[config.cookieName];
|
|
218
|
-
}
|
|
219
|
-
}
|
|
180
|
+
// Attribution logic
|
|
181
|
+
let attributed = false;
|
|
182
|
+
let affiliateCode: string | undefined;
|
|
220
183
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
184
|
+
// Try by referral ID first (more accurate)
|
|
185
|
+
if (referralId) {
|
|
186
|
+
const referral = await ctx.runQuery(
|
|
187
|
+
component.referrals.getByReferralId,
|
|
188
|
+
{ referralId }
|
|
189
|
+
);
|
|
229
190
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
191
|
+
if (referral?.status === "clicked") {
|
|
192
|
+
await ctx.runMutation(component.referrals.attributeSignup, {
|
|
193
|
+
referralId,
|
|
194
|
+
userId,
|
|
195
|
+
});
|
|
196
|
+
attributed = true;
|
|
197
|
+
const affiliate = await ctx.runQuery(
|
|
198
|
+
component.affiliates.getById,
|
|
199
|
+
{ affiliateId: referral.affiliateId }
|
|
200
|
+
);
|
|
201
|
+
affiliateCode = affiliate?.code;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
234
204
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
205
|
+
// Try by affiliate code if referral ID didn't work
|
|
206
|
+
if (!attributed && referralCode) {
|
|
207
|
+
const result = await ctx.runMutation(
|
|
208
|
+
component.referrals.attributeSignupByCode,
|
|
209
|
+
{ userId, affiliateCode: referralCode }
|
|
210
|
+
);
|
|
211
|
+
attributed = result.success;
|
|
212
|
+
affiliateCode = referralCode;
|
|
213
|
+
}
|
|
241
214
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Try by affiliate code if referral ID didn't work
|
|
258
|
-
if (!attributed && referralCode) {
|
|
259
|
-
const result = await ctx.runMutation(
|
|
260
|
-
component.referrals.attributeSignupByCode,
|
|
261
|
-
{ userId, affiliateCode: referralCode }
|
|
262
|
-
);
|
|
263
|
-
attributed = result.success;
|
|
264
|
-
affiliateCode = referralCode;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (attributed) {
|
|
268
|
-
await config.onAttributionSuccess?.({ userId, affiliateCode });
|
|
269
|
-
} else {
|
|
270
|
-
await config.onAttributionFailure?.({
|
|
271
|
-
userId,
|
|
272
|
-
reason: "Attribution failed - referral not found or invalid",
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
} catch (error) {
|
|
276
|
-
console.error("[convex-affiliates] Attribution error:", error);
|
|
277
|
-
await config.onAttributionFailure?.({
|
|
278
|
-
userId,
|
|
279
|
-
reason: error instanceof Error ? error.message : "Unknown error",
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
}),
|
|
215
|
+
if (attributed) {
|
|
216
|
+
try { await config.onAttributionSuccess?.({ userId, affiliateCode }); } catch { /* swallow */ }
|
|
217
|
+
} else {
|
|
218
|
+
try { await config.onAttributionFailure?.({ userId, reason: "Attribution failed - referral not found or invalid" }); } catch { /* swallow */ }
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error("[convex-affiliates] Attribution error:", error);
|
|
222
|
+
try { await config.onAttributionFailure?.({ userId: user.id, reason: error instanceof Error ? error.message : "Unknown error" }); } catch { /* swallow */ }
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
283
228
|
},
|
|
284
|
-
|
|
229
|
+
};
|
|
285
230
|
},
|
|
286
231
|
} satisfies BetterAuthPlugin;
|
|
287
232
|
}
|
|
@@ -292,19 +237,17 @@ export function affiliatePlugin(
|
|
|
292
237
|
|
|
293
238
|
function parseCookies(cookieHeader: string): Record<string, string> {
|
|
294
239
|
const cookies: Record<string, string> = {};
|
|
295
|
-
cookieHeader.split(";")
|
|
240
|
+
for (const cookie of cookieHeader.split(";")) {
|
|
296
241
|
const [name, ...valueParts] = cookie.trim().split("=");
|
|
297
242
|
if (name) {
|
|
298
243
|
const value = valueParts.join("=");
|
|
299
|
-
// Decode URI-encoded values (handles special characters in affiliate codes)
|
|
300
244
|
try {
|
|
301
245
|
cookies[name] = decodeURIComponent(value);
|
|
302
246
|
} catch {
|
|
303
|
-
// If decoding fails, use the raw value
|
|
304
247
|
cookies[name] = value;
|
|
305
248
|
}
|
|
306
249
|
}
|
|
307
|
-
}
|
|
250
|
+
}
|
|
308
251
|
return cookies;
|
|
309
252
|
}
|
|
310
253
|
|
|
@@ -312,4 +255,4 @@ function parseCookies(cookieHeader: string): Record<string, string> {
|
|
|
312
255
|
// Exports
|
|
313
256
|
// =============================================================================
|
|
314
257
|
|
|
315
|
-
export type { BetterAuthPlugin };
|
|
258
|
+
export type { BetterAuthPlugin };
|