@windrun-huaiin/backend-core 31.0.0 → 31.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/app/api/user/anonymous/init/fingerprint-only-route.d.ts +8 -0
- package/dist/app/api/user/anonymous/init/fingerprint-only-route.d.ts.map +1 -0
- package/dist/app/api/user/anonymous/init/fingerprint-only-route.js +20 -0
- package/dist/app/api/user/anonymous/init/fingerprint-only-route.mjs +18 -0
- package/dist/app/api/user/anonymous/init/route-shared.d.ts +10 -0
- package/dist/app/api/user/anonymous/init/route-shared.d.ts.map +1 -0
- package/dist/app/api/user/anonymous/init/route-shared.js +557 -0
- package/dist/app/api/user/anonymous/init/route-shared.mjs +555 -0
- package/dist/app/api/user/anonymous/init/route.d.ts +3 -3
- package/dist/app/api/user/anonymous/init/route.d.ts.map +1 -1
- package/dist/app/api/user/anonymous/init/route.js +6 -553
- package/dist/app/api/user/anonymous/init/route.mjs +7 -554
- package/package.json +9 -4
- package/src/app/api/user/anonymous/init/fingerprint-only-route.ts +14 -0
- package/src/app/api/user/anonymous/init/route-shared.ts +710 -0
- package/src/app/api/user/anonymous/init/route.ts +7 -711
|
@@ -1,718 +1,14 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
|
|
3
|
-
// Fix BigInt serialization issue
|
|
4
|
-
(BigInt.prototype as any).toJSON = function () {
|
|
5
|
-
return this.toString();
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
import { anonymousAggregateService } from '@core/aggregate/anonymous.aggregate.service';
|
|
9
1
|
import { getOptionalServerAuthIdentity } from '@core/auth/auth-utils';
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
13
|
-
import {
|
|
14
|
-
fetchLatestUserContextByFingerprintId,
|
|
15
|
-
fetchUserContextByClerkUserId,
|
|
16
|
-
mapCreditToXCredit,
|
|
17
|
-
mapSubscriptionToXSubscription,
|
|
18
|
-
mapUserToXUser,
|
|
19
|
-
type UserContextEntities,
|
|
20
|
-
} from '@core/context/user-context-service';
|
|
21
|
-
import { finalizeUserContext } from '@core/context/user-context-finalizer';
|
|
22
|
-
|
|
23
|
-
import type { CoreJsonObject } from '@core/db/prisma-model-type';
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// ==================== Type Definitions ====================
|
|
27
|
-
|
|
28
|
-
/** Successful response payload */
|
|
29
|
-
interface XUserResponse {
|
|
30
|
-
success: true;
|
|
31
|
-
xUser: XUser;
|
|
32
|
-
xCredit: XCredit | null;
|
|
33
|
-
xSubscription: XSubscription | null;
|
|
34
|
-
isNewUser: boolean;
|
|
35
|
-
totalUsersOnDevice?: number;
|
|
36
|
-
hasAnonymousUser?: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Error response payload */
|
|
40
|
-
interface ErrorResponse {
|
|
41
|
-
error: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ==================== Utilities ====================
|
|
45
|
-
|
|
46
|
-
/** Create a successful response payload */
|
|
47
|
-
function createSuccessResponse(params: {
|
|
48
|
-
entities: UserContextEntities;
|
|
49
|
-
isNewUser: boolean;
|
|
50
|
-
options?: {
|
|
51
|
-
totalUsersOnDevice?: number;
|
|
52
|
-
hasAnonymousUser?: boolean;
|
|
53
|
-
};
|
|
54
|
-
}): XUserResponse {
|
|
55
|
-
const response: XUserResponse = {
|
|
56
|
-
success: true,
|
|
57
|
-
xUser: mapUserToXUser(params.entities.user),
|
|
58
|
-
xCredit: params.entities.credit ? mapCreditToXCredit(params.entities.credit) : null,
|
|
59
|
-
xSubscription: mapSubscriptionToXSubscription(params.entities.subscription),
|
|
60
|
-
isNewUser: params.isNewUser,
|
|
61
|
-
...params.options,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
return finalizeUserContext(response);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Create an error response */
|
|
68
|
-
function createErrorResponse(message: string, status = 400): NextResponse {
|
|
69
|
-
const errorResponse: ErrorResponse = { error: message };
|
|
70
|
-
return NextResponse.json(errorResponse, { status });
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
type SourceRefData = CoreJsonObject & {
|
|
74
|
-
capturedAt?: string;
|
|
75
|
-
landingUrl?: string;
|
|
76
|
-
landingPath?: string;
|
|
77
|
-
landingHost?: string;
|
|
78
|
-
httpRefer?: string;
|
|
79
|
-
refererHost?: string;
|
|
80
|
-
refererPath?: string;
|
|
81
|
-
refererDomain?: string;
|
|
82
|
-
sourceType?: string;
|
|
83
|
-
sourceChannel?: string;
|
|
84
|
-
sourcePlatform?: string;
|
|
85
|
-
isInternalReferer?: boolean;
|
|
86
|
-
utmSource?: string;
|
|
87
|
-
utmMedium?: string;
|
|
88
|
-
utmCampaign?: string;
|
|
89
|
-
utmTerm?: string;
|
|
90
|
-
utmContent?: string;
|
|
91
|
-
utmId?: string;
|
|
92
|
-
ref?: string;
|
|
93
|
-
gclid?: string;
|
|
94
|
-
fbclid?: string;
|
|
95
|
-
msclkid?: string;
|
|
96
|
-
ttclid?: string;
|
|
97
|
-
twclid?: string;
|
|
98
|
-
liFatId?: string;
|
|
99
|
-
userAgent?: string;
|
|
100
|
-
deviceType?: string;
|
|
101
|
-
os?: string;
|
|
102
|
-
browser?: string;
|
|
103
|
-
secChUaMobile?: string;
|
|
104
|
-
secChUaPlatform?: string;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
type SourceRefKey =
|
|
108
|
-
| 'utmSource'
|
|
109
|
-
| 'utmMedium'
|
|
110
|
-
| 'utmCampaign'
|
|
111
|
-
| 'utmTerm'
|
|
112
|
-
| 'utmContent'
|
|
113
|
-
| 'utmId'
|
|
114
|
-
| 'ref'
|
|
115
|
-
| 'gclid'
|
|
116
|
-
| 'fbclid'
|
|
117
|
-
| 'msclkid'
|
|
118
|
-
| 'ttclid'
|
|
119
|
-
| 'twclid'
|
|
120
|
-
| 'liFatId';
|
|
121
|
-
|
|
122
|
-
const SOURCE_REF_MAX_LENGTH = 2048;
|
|
123
|
-
const QUERY_PARAM_MAX_LENGTH = 512;
|
|
124
|
-
const USER_AGENT_MAX_LENGTH = 1024;
|
|
125
|
-
const FIRST_TOUCH_HEADER_MAX_LENGTH = 4096;
|
|
126
|
-
const FIRST_TOUCH_HEADER_NAME = 'x-first-touch';
|
|
127
|
-
|
|
128
|
-
function normalizeSourceRef(ref: string | null): string | null {
|
|
129
|
-
if (!ref) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const trimmed = ref.trim();
|
|
134
|
-
if (!trimmed) {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return trimmed.length > SOURCE_REF_MAX_LENGTH
|
|
139
|
-
? trimmed.slice(0, SOURCE_REF_MAX_LENGTH)
|
|
140
|
-
: trimmed;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function normalizeQueryParam(value: string | null): string | null {
|
|
144
|
-
if (!value) {
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const trimmed = value.trim();
|
|
149
|
-
if (!trimmed) {
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return trimmed.length > QUERY_PARAM_MAX_LENGTH
|
|
154
|
-
? trimmed.slice(0, QUERY_PARAM_MAX_LENGTH)
|
|
155
|
-
: trimmed;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function decodeHeaderValue(value: string): string | null {
|
|
159
|
-
try {
|
|
160
|
-
return decodeURIComponent(value);
|
|
161
|
-
} catch {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function mergeSourceRef(target: SourceRefData, source: SourceRefData | null | undefined) {
|
|
167
|
-
if (!source) {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const entries = Object.entries(source) as Array<[keyof SourceRefData, SourceRefData[keyof SourceRefData]]>;
|
|
172
|
-
for (const [key, value] of entries) {
|
|
173
|
-
if (value === undefined || value === null) {
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (target[key] === undefined) {
|
|
178
|
-
(target as Record<string, unknown>)[key as string] = value;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function applySearchParams(sourceRef: SourceRefData, params: URLSearchParams) {
|
|
184
|
-
const setIfEmpty = (key: SourceRefKey, value: string | null) => {
|
|
185
|
-
if (sourceRef[key] !== undefined) {
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
const normalized = normalizeQueryParam(value);
|
|
189
|
-
if (normalized) {
|
|
190
|
-
sourceRef[key] = normalized;
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
setIfEmpty('utmSource', params.get('utm_source'));
|
|
195
|
-
setIfEmpty('utmMedium', params.get('utm_medium'));
|
|
196
|
-
setIfEmpty('utmCampaign', params.get('utm_campaign'));
|
|
197
|
-
setIfEmpty('utmTerm', params.get('utm_term'));
|
|
198
|
-
setIfEmpty('utmContent', params.get('utm_content'));
|
|
199
|
-
setIfEmpty('utmId', params.get('utm_id'));
|
|
200
|
-
setIfEmpty('ref', params.get('ref'));
|
|
201
|
-
setIfEmpty('gclid', params.get('gclid'));
|
|
202
|
-
setIfEmpty('fbclid', params.get('fbclid'));
|
|
203
|
-
setIfEmpty('msclkid', params.get('msclkid'));
|
|
204
|
-
setIfEmpty('ttclid', params.get('ttclid'));
|
|
205
|
-
setIfEmpty('twclid', params.get('twclid'));
|
|
206
|
-
setIfEmpty('liFatId', params.get('li_fat_id'));
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function normalizeHost(host: string | null | undefined): string | null {
|
|
210
|
-
if (!host) {
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return host.trim().toLowerCase() || null;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function getRootDomain(host: string | null | undefined): string | null {
|
|
218
|
-
const normalizedHost = normalizeHost(host);
|
|
219
|
-
if (!normalizedHost) {
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const hostname = normalizedHost.split(':')[0];
|
|
224
|
-
if (hostname === 'localhost' || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
|
|
225
|
-
return hostname;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const parts = hostname.split('.').filter(Boolean);
|
|
229
|
-
if (parts.length <= 2) {
|
|
230
|
-
return hostname;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return parts.slice(-2).join('.');
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function isInternalReferer(landingHost: string | null | undefined, refererHost: string | null | undefined): boolean {
|
|
237
|
-
const normalizedLandingHost = normalizeHost(landingHost);
|
|
238
|
-
const normalizedRefererHost = normalizeHost(refererHost);
|
|
239
|
-
if (!normalizedLandingHost || !normalizedRefererHost) {
|
|
240
|
-
return false;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (normalizedLandingHost === normalizedRefererHost) {
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return normalizedLandingHost.endsWith(`.${normalizedRefererHost}`)
|
|
248
|
-
|| normalizedRefererHost.endsWith(`.${normalizedLandingHost}`);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function detectPlatform(value: string | null | undefined): string | null {
|
|
252
|
-
const normalized = value?.trim().toLowerCase();
|
|
253
|
-
if (!normalized) {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const matcherList: Array<{ pattern: RegExp; platform: string; channel: string; }> = [
|
|
258
|
-
{ pattern: /chatgpt|chat-openai|openai/, platform: 'openai', channel: 'ai' },
|
|
259
|
-
{ pattern: /claude|anthropic/, platform: 'anthropic', channel: 'ai' },
|
|
260
|
-
{ pattern: /perplexity/, platform: 'perplexity', channel: 'ai' },
|
|
261
|
-
{ pattern: /gemini/, platform: 'gemini', channel: 'ai' },
|
|
262
|
-
{ pattern: /copilot/, platform: 'copilot', channel: 'ai' },
|
|
263
|
-
{ pattern: /google/, platform: 'google', channel: 'search' },
|
|
264
|
-
{ pattern: /bing/, platform: 'bing', channel: 'search' },
|
|
265
|
-
{ pattern: /baidu/, platform: 'baidu', channel: 'search' },
|
|
266
|
-
{ pattern: /yahoo/, platform: 'yahoo', channel: 'search' },
|
|
267
|
-
{ pattern: /duckduckgo/, platform: 'duckduckgo', channel: 'search' },
|
|
268
|
-
{ pattern: /facebook/, platform: 'facebook', channel: 'social' },
|
|
269
|
-
{ pattern: /instagram/, platform: 'instagram', channel: 'social' },
|
|
270
|
-
{ pattern: /x\.com|twitter/, platform: 'x', channel: 'social' },
|
|
271
|
-
{ pattern: /linkedin/, platform: 'linkedin', channel: 'social' },
|
|
272
|
-
{ pattern: /reddit/, platform: 'reddit', channel: 'social' },
|
|
273
|
-
{ pattern: /youtube/, platform: 'youtube', channel: 'social' },
|
|
274
|
-
];
|
|
275
|
-
|
|
276
|
-
const matched = matcherList.find(({ pattern }) => pattern.test(normalized));
|
|
277
|
-
if (!matched) {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return matched.platform;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function detectChannelFromPlatform(platform: string | null | undefined): string | null {
|
|
285
|
-
switch (platform) {
|
|
286
|
-
case 'openai':
|
|
287
|
-
case 'anthropic':
|
|
288
|
-
case 'perplexity':
|
|
289
|
-
case 'gemini':
|
|
290
|
-
case 'copilot':
|
|
291
|
-
return 'ai';
|
|
292
|
-
case 'google':
|
|
293
|
-
case 'bing':
|
|
294
|
-
case 'baidu':
|
|
295
|
-
case 'yahoo':
|
|
296
|
-
case 'duckduckgo':
|
|
297
|
-
return 'search';
|
|
298
|
-
case 'facebook':
|
|
299
|
-
case 'instagram':
|
|
300
|
-
case 'x':
|
|
301
|
-
case 'linkedin':
|
|
302
|
-
case 'reddit':
|
|
303
|
-
case 'youtube':
|
|
304
|
-
return 'social';
|
|
305
|
-
default:
|
|
306
|
-
return null;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function detectChannelFromUtmMedium(value: string | null | undefined): string | null {
|
|
311
|
-
const normalized = value?.trim().toLowerCase();
|
|
312
|
-
if (!normalized) {
|
|
313
|
-
return null;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (/^(cpc|ppc|paid|paid_search|display|banner|affiliate|email|newsletter|push|sms)$/.test(normalized)) {
|
|
317
|
-
return 'campaign';
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (/^(social|social_paid|social-organic|social_organic)$/.test(normalized)) {
|
|
321
|
-
return 'social';
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (/^(organic|seo|search)$/.test(normalized)) {
|
|
325
|
-
return 'search';
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (/^(referral|partner)$/.test(normalized)) {
|
|
329
|
-
return 'referral';
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (/^(ai|llm)$/.test(normalized)) {
|
|
333
|
-
return 'ai';
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return 'campaign';
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function parseUserAgent(request: NextRequest): Pick<SourceRefData, 'userAgent' | 'deviceType' | 'os' | 'browser' | 'secChUaMobile' | 'secChUaPlatform'> {
|
|
340
|
-
const userAgentHeader = request.headers.get('user-agent');
|
|
341
|
-
const secChUaMobile = normalizeQueryParam(request.headers.get('sec-ch-ua-mobile')) ?? undefined;
|
|
342
|
-
const secChUaPlatform = normalizeQueryParam(request.headers.get('sec-ch-ua-platform')) ?? undefined;
|
|
343
|
-
const userAgent = normalizeSourceRef(userAgentHeader)?.slice(0, USER_AGENT_MAX_LENGTH) ?? undefined;
|
|
344
|
-
const ua = userAgent?.toLowerCase() ?? '';
|
|
345
|
-
|
|
346
|
-
let deviceType = 'desktop';
|
|
347
|
-
if (!ua) {
|
|
348
|
-
deviceType = 'unknown';
|
|
349
|
-
} else if (/bot|spider|crawler|curl|wget|headless/.test(ua)) {
|
|
350
|
-
deviceType = 'bot';
|
|
351
|
-
} else if (/ipad|tablet/.test(ua)) {
|
|
352
|
-
deviceType = 'tablet';
|
|
353
|
-
} else if (/mobi|iphone|android/.test(ua) || secChUaMobile === '?1') {
|
|
354
|
-
deviceType = 'mobile';
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
let os = 'Unknown';
|
|
358
|
-
if (/iphone|ipad|ipod/.test(ua)) {
|
|
359
|
-
os = 'iOS';
|
|
360
|
-
} else if (/android/.test(ua)) {
|
|
361
|
-
os = 'Android';
|
|
362
|
-
} else if (/windows nt/.test(ua)) {
|
|
363
|
-
os = 'Windows';
|
|
364
|
-
} else if (/mac os x|macintosh/.test(ua)) {
|
|
365
|
-
os = 'macOS';
|
|
366
|
-
} else if (/cros/.test(ua)) {
|
|
367
|
-
os = 'Chrome OS';
|
|
368
|
-
} else if (/linux/.test(ua)) {
|
|
369
|
-
os = 'Linux';
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (secChUaPlatform) {
|
|
373
|
-
const normalizedPlatform = secChUaPlatform.replaceAll('"', '');
|
|
374
|
-
if (normalizedPlatform && normalizedPlatform !== 'Unknown') {
|
|
375
|
-
os = normalizedPlatform;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
let browser = 'Unknown';
|
|
380
|
-
if (/edg\//.test(ua)) {
|
|
381
|
-
browser = 'Edge';
|
|
382
|
-
} else if (/opr\//.test(ua) || /opera/.test(ua)) {
|
|
383
|
-
browser = 'Opera';
|
|
384
|
-
} else if (/samsungbrowser\//.test(ua)) {
|
|
385
|
-
browser = 'Samsung Internet';
|
|
386
|
-
} else if (/crios\//.test(ua) || /chrome\//.test(ua)) {
|
|
387
|
-
browser = 'Chrome';
|
|
388
|
-
} else if (/firefox\//.test(ua)) {
|
|
389
|
-
browser = 'Firefox';
|
|
390
|
-
} else if (/safari\//.test(ua) && !/chrome\//.test(ua) && !/crios\//.test(ua)) {
|
|
391
|
-
browser = 'Safari';
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return {
|
|
395
|
-
userAgent,
|
|
396
|
-
deviceType,
|
|
397
|
-
os,
|
|
398
|
-
browser,
|
|
399
|
-
secChUaMobile,
|
|
400
|
-
secChUaPlatform,
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function parseFirstTouchHeader(request: NextRequest): SourceRefData | null {
|
|
405
|
-
const rawHeader = request.headers.get(FIRST_TOUCH_HEADER_NAME);
|
|
406
|
-
const normalizedHeader = normalizeSourceRef(rawHeader)?.slice(0, FIRST_TOUCH_HEADER_MAX_LENGTH);
|
|
407
|
-
if (!normalizedHeader) {
|
|
408
|
-
return null;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const decodedHeader = decodeHeaderValue(normalizedHeader);
|
|
412
|
-
if (!decodedHeader) {
|
|
413
|
-
return null;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
try {
|
|
417
|
-
const parsed = JSON.parse(decodedHeader) as Record<string, unknown>;
|
|
418
|
-
const sourceRef: SourceRefData = {};
|
|
419
|
-
|
|
420
|
-
sourceRef.capturedAt = normalizeQueryParam(typeof parsed.capturedAt === 'string' ? parsed.capturedAt : null) ?? undefined;
|
|
421
|
-
sourceRef.landingUrl = normalizeSourceRef(typeof parsed.landingUrl === 'string' ? parsed.landingUrl : null) ?? undefined;
|
|
422
|
-
sourceRef.landingPath = normalizeSourceRef(typeof parsed.landingPath === 'string' ? parsed.landingPath : null) ?? undefined;
|
|
423
|
-
sourceRef.landingHost = normalizeHost(typeof parsed.landingHost === 'string' ? parsed.landingHost : null) ?? undefined;
|
|
424
|
-
sourceRef.ref = normalizeQueryParam(typeof parsed.ref === 'string' ? parsed.ref : null) ?? undefined;
|
|
425
|
-
sourceRef.utmSource = normalizeQueryParam(typeof parsed.utmSource === 'string' ? parsed.utmSource : null) ?? undefined;
|
|
426
|
-
sourceRef.utmMedium = normalizeQueryParam(typeof parsed.utmMedium === 'string' ? parsed.utmMedium : null) ?? undefined;
|
|
427
|
-
sourceRef.utmCampaign = normalizeQueryParam(typeof parsed.utmCampaign === 'string' ? parsed.utmCampaign : null) ?? undefined;
|
|
428
|
-
sourceRef.utmTerm = normalizeQueryParam(typeof parsed.utmTerm === 'string' ? parsed.utmTerm : null) ?? undefined;
|
|
429
|
-
sourceRef.utmContent = normalizeQueryParam(typeof parsed.utmContent === 'string' ? parsed.utmContent : null) ?? undefined;
|
|
430
|
-
sourceRef.utmId = normalizeQueryParam(typeof parsed.utmId === 'string' ? parsed.utmId : null) ?? undefined;
|
|
431
|
-
sourceRef.gclid = normalizeQueryParam(typeof parsed.gclid === 'string' ? parsed.gclid : null) ?? undefined;
|
|
432
|
-
sourceRef.fbclid = normalizeQueryParam(typeof parsed.fbclid === 'string' ? parsed.fbclid : null) ?? undefined;
|
|
433
|
-
sourceRef.msclkid = normalizeQueryParam(typeof parsed.msclkid === 'string' ? parsed.msclkid : null) ?? undefined;
|
|
434
|
-
sourceRef.ttclid = normalizeQueryParam(typeof parsed.ttclid === 'string' ? parsed.ttclid : null) ?? undefined;
|
|
435
|
-
sourceRef.twclid = normalizeQueryParam(typeof parsed.twclid === 'string' ? parsed.twclid : null) ?? undefined;
|
|
436
|
-
sourceRef.liFatId = normalizeQueryParam(typeof parsed.liFatId === 'string' ? parsed.liFatId : null) ?? undefined;
|
|
437
|
-
|
|
438
|
-
const externalReferrer = normalizeSourceRef(typeof parsed.externalReferrer === 'string' ? parsed.externalReferrer : null);
|
|
439
|
-
if (externalReferrer) {
|
|
440
|
-
sourceRef.httpRefer = externalReferrer;
|
|
441
|
-
try {
|
|
442
|
-
const refererUrl = new URL(externalReferrer);
|
|
443
|
-
sourceRef.refererHost = normalizeHost(refererUrl.host) ?? undefined;
|
|
444
|
-
sourceRef.refererPath = normalizeSourceRef(refererUrl.pathname) ?? undefined;
|
|
445
|
-
sourceRef.refererDomain = getRootDomain(refererUrl.host) ?? undefined;
|
|
446
|
-
applySearchParams(sourceRef, refererUrl.searchParams);
|
|
447
|
-
} catch (error) {
|
|
448
|
-
console.warn('Failed to parse first-touch referrer url:', error);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
return Object.keys(sourceRef).length > 0 ? sourceRef : null;
|
|
453
|
-
} catch (error) {
|
|
454
|
-
console.warn('Failed to parse first-touch header:', error);
|
|
455
|
-
return null;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function finalizeAttribution(sourceRef: SourceRefData) {
|
|
460
|
-
const landingHost = normalizeHost(sourceRef.landingHost);
|
|
461
|
-
const refererHost = normalizeHost(sourceRef.refererHost);
|
|
462
|
-
const internal = isInternalReferer(landingHost, refererHost);
|
|
463
|
-
const hasCampaignMarker = Boolean(
|
|
464
|
-
sourceRef.utmSource
|
|
465
|
-
|| sourceRef.utmMedium
|
|
466
|
-
|| sourceRef.utmCampaign
|
|
467
|
-
|| sourceRef.utmTerm
|
|
468
|
-
|| sourceRef.utmContent
|
|
469
|
-
|| sourceRef.utmId
|
|
470
|
-
|| sourceRef.ref
|
|
471
|
-
|| sourceRef.gclid
|
|
472
|
-
|| sourceRef.fbclid
|
|
473
|
-
|| sourceRef.msclkid
|
|
474
|
-
|| sourceRef.ttclid
|
|
475
|
-
|| sourceRef.twclid
|
|
476
|
-
|| sourceRef.liFatId
|
|
477
|
-
);
|
|
478
|
-
if (internal) {
|
|
479
|
-
sourceRef.isInternalReferer = true;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
const utmPlatform = detectPlatform(sourceRef.utmSource) || detectPlatform(sourceRef.ref);
|
|
483
|
-
if (utmPlatform) {
|
|
484
|
-
sourceRef.sourcePlatform = utmPlatform;
|
|
485
|
-
sourceRef.sourceChannel = detectChannelFromPlatform(utmPlatform)
|
|
486
|
-
?? detectChannelFromUtmMedium(sourceRef.utmMedium)
|
|
487
|
-
?? sourceRef.sourceChannel
|
|
488
|
-
?? 'campaign';
|
|
489
|
-
sourceRef.sourceType = 'campaign';
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (sourceRef.gclid) {
|
|
494
|
-
sourceRef.sourcePlatform = 'google';
|
|
495
|
-
sourceRef.sourceChannel = 'search';
|
|
496
|
-
sourceRef.sourceType = 'campaign';
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
if (sourceRef.msclkid) {
|
|
501
|
-
sourceRef.sourcePlatform = 'bing';
|
|
502
|
-
sourceRef.sourceChannel = 'search';
|
|
503
|
-
sourceRef.sourceType = 'campaign';
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
if (sourceRef.fbclid) {
|
|
508
|
-
sourceRef.sourcePlatform = 'facebook';
|
|
509
|
-
sourceRef.sourceChannel = 'social';
|
|
510
|
-
sourceRef.sourceType = 'campaign';
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (sourceRef.ttclid) {
|
|
515
|
-
sourceRef.sourcePlatform = 'tiktok';
|
|
516
|
-
sourceRef.sourceChannel = 'social';
|
|
517
|
-
sourceRef.sourceType = 'campaign';
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
if (sourceRef.twclid) {
|
|
522
|
-
sourceRef.sourcePlatform = 'x';
|
|
523
|
-
sourceRef.sourceChannel = 'social';
|
|
524
|
-
sourceRef.sourceType = 'campaign';
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
if (sourceRef.liFatId) {
|
|
529
|
-
sourceRef.sourcePlatform = 'linkedin';
|
|
530
|
-
sourceRef.sourceChannel = 'social';
|
|
531
|
-
sourceRef.sourceType = 'campaign';
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if (hasCampaignMarker) {
|
|
536
|
-
sourceRef.sourcePlatform = 'other';
|
|
537
|
-
sourceRef.sourceChannel = detectChannelFromUtmMedium(sourceRef.utmMedium) ?? 'campaign';
|
|
538
|
-
sourceRef.sourceType = 'campaign';
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (!internal && refererHost) {
|
|
543
|
-
const refererPlatform = detectPlatform(refererHost) || detectPlatform(sourceRef.httpRefer);
|
|
544
|
-
sourceRef.sourcePlatform = refererPlatform ?? 'other';
|
|
545
|
-
sourceRef.sourceChannel = detectChannelFromPlatform(refererPlatform) ?? 'referral';
|
|
546
|
-
sourceRef.sourceType = 'referer';
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
sourceRef.sourcePlatform = 'direct';
|
|
551
|
-
sourceRef.sourceChannel = 'direct';
|
|
552
|
-
sourceRef.sourceType = 'direct';
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Extract the user's first-touch attribution source.
|
|
556
|
-
function extractSourceRef(request: NextRequest): SourceRefData | null {
|
|
557
|
-
const headerRef = request.headers.get('referer') || request.headers.get('referrer');
|
|
558
|
-
const customRef = request.headers.get('x-source-ref');
|
|
559
|
-
const queryRef = request.nextUrl.searchParams.get('ref');
|
|
560
|
-
const firstTouchRef = parseFirstTouchHeader(request);
|
|
561
|
-
|
|
562
|
-
const sourceRef: SourceRefData = {
|
|
563
|
-
...parseUserAgent(request),
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
mergeSourceRef(sourceRef, firstTouchRef);
|
|
567
|
-
|
|
568
|
-
sourceRef.landingUrl = sourceRef.landingUrl ?? normalizeSourceRef(request.nextUrl.toString()) ?? undefined;
|
|
569
|
-
sourceRef.landingPath = sourceRef.landingPath ?? normalizeSourceRef(request.nextUrl.pathname) ?? undefined;
|
|
570
|
-
sourceRef.landingHost = sourceRef.landingHost ?? normalizeHost(request.nextUrl.host) ?? undefined;
|
|
571
|
-
sourceRef.ref = sourceRef.ref ?? normalizeQueryParam(queryRef) ?? undefined;
|
|
572
|
-
|
|
573
|
-
let normalizedHttpRef: string | null = null;
|
|
574
|
-
const candidates = [customRef, headerRef];
|
|
575
|
-
for (const candidate of candidates) {
|
|
576
|
-
const normalized = normalizeSourceRef(candidate);
|
|
577
|
-
if (normalized) {
|
|
578
|
-
normalizedHttpRef = normalized;
|
|
579
|
-
sourceRef.httpRefer = sourceRef.httpRefer ?? normalized;
|
|
580
|
-
break;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const searchParams = request.nextUrl.searchParams;
|
|
585
|
-
applySearchParams(sourceRef, searchParams);
|
|
586
|
-
|
|
587
|
-
if (normalizedHttpRef) {
|
|
588
|
-
try {
|
|
589
|
-
const refererUrl = new URL(normalizedHttpRef);
|
|
590
|
-
sourceRef.refererHost = sourceRef.refererHost ?? normalizeHost(refererUrl.host) ?? undefined;
|
|
591
|
-
sourceRef.refererPath = sourceRef.refererPath ?? normalizeSourceRef(refererUrl.pathname) ?? undefined;
|
|
592
|
-
sourceRef.refererDomain = sourceRef.refererDomain ?? getRootDomain(refererUrl.host) ?? undefined;
|
|
593
|
-
applySearchParams(sourceRef, refererUrl.searchParams);
|
|
594
|
-
} catch (error) {
|
|
595
|
-
console.warn('Failed to parse referer url for utm/ref:', error);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
finalizeAttribution(sourceRef);
|
|
600
|
-
|
|
601
|
-
return Object.keys(sourceRef).length > 0 ? sourceRef : null;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* Query the user by Clerk user ID and return response data.
|
|
607
|
-
*/
|
|
608
|
-
async function getUserByClerkId(clerkUserId: string): Promise<XUserResponse | null> {
|
|
609
|
-
const entities = await fetchUserContextByClerkUserId(clerkUserId);
|
|
610
|
-
if (!entities) {
|
|
611
|
-
return null;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
return createSuccessResponse({
|
|
615
|
-
entities,
|
|
616
|
-
isNewUser: false,
|
|
617
|
-
});
|
|
618
|
-
}
|
|
2
|
+
import { handleFingerprintRequest } from './route-shared';
|
|
3
|
+
import type { NextRequest } from 'next/server';
|
|
619
4
|
|
|
620
5
|
/**
|
|
621
|
-
*
|
|
622
|
-
*/
|
|
623
|
-
async function getUserByFingerprintId(fingerprintId: string): Promise<XUserResponse | null> {
|
|
624
|
-
const result = await fetchLatestUserContextByFingerprintId(fingerprintId);
|
|
625
|
-
if (!result) {
|
|
626
|
-
return null;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const { totalUsersOnDevice, hasAnonymousUser, ...entities } = result;
|
|
630
|
-
|
|
631
|
-
return createSuccessResponse({
|
|
632
|
-
entities,
|
|
633
|
-
isNewUser: false,
|
|
634
|
-
options: {
|
|
635
|
-
totalUsersOnDevice,
|
|
636
|
-
hasAnonymousUser,
|
|
637
|
-
},
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
* Shared fingerprint request handling logic.
|
|
643
|
-
*/
|
|
644
|
-
async function handleFingerprintRequest(request: NextRequest, options: { createIfNotExists?: boolean; } = {}) {
|
|
645
|
-
// Extract the fingerprint ID from the request.
|
|
646
|
-
const fingerprintId = extractFingerprintFromNextRequest(request);
|
|
647
|
-
// Validate the fingerprint ID.
|
|
648
|
-
if (!fingerprintId) {
|
|
649
|
-
return createErrorResponse('Invalid or missing fingerprint ID');
|
|
650
|
-
}
|
|
651
|
-
console.log('Received fingerprintId:', fingerprintId);
|
|
652
|
-
|
|
653
|
-
const authIdentity = await getOptionalServerAuthIdentity();
|
|
654
|
-
const clerkUserId = authIdentity?.providerUserId ?? null;
|
|
655
|
-
try {
|
|
656
|
-
// Prefer Clerk user ID lookup when the user is authenticated.
|
|
657
|
-
let existingUserResult: XUserResponse | null = null;
|
|
658
|
-
if (clerkUserId) {
|
|
659
|
-
// Authenticated users are always resolved by clerkUserId.
|
|
660
|
-
existingUserResult = await getUserByClerkId(clerkUserId);
|
|
661
|
-
if (existingUserResult && existingUserResult.xUser.fingerprintId !== fingerprintId) {
|
|
662
|
-
// The authenticated user's fingerprint changed. Clerk still identifies the account as the same user.
|
|
663
|
-
// Trust clerkUserId as the source of truth and keep resolving the user's own data by login identity.
|
|
664
|
-
// A single fingerprint can be associated with multiple accounts, so no mutation is needed here.
|
|
665
|
-
console.warn(`Current login user used diff fp_ids: ${clerkUserId}, db_fp_id=${existingUserResult.xUser.fingerprintId}, req_fp_id=${fingerprintId}`);
|
|
666
|
-
}
|
|
667
|
-
} else {
|
|
668
|
-
// For anonymous requests, fall back to fingerprint lookup.
|
|
669
|
-
existingUserResult = await getUserByFingerprintId(fingerprintId);
|
|
670
|
-
}
|
|
671
|
-
if (existingUserResult) {
|
|
672
|
-
return NextResponse.json(existingUserResult);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// If the user does not exist and creation is disabled, return 404.
|
|
676
|
-
if (!options.createIfNotExists) {
|
|
677
|
-
return createErrorResponse('User not found', 404);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
const sourceRef = extractSourceRef(request);
|
|
681
|
-
|
|
682
|
-
const anonymousInitResult = await anonymousAggregateService.getOrCreateByFingerprintId(
|
|
683
|
-
fingerprintId,
|
|
684
|
-
{ sourceRef: sourceRef?? undefined}
|
|
685
|
-
);
|
|
686
|
-
|
|
687
|
-
if (anonymousInitResult.isNewUser) {
|
|
688
|
-
console.log(`Created new anonymous user ${anonymousInitResult.user.userId} with fingerprint ${fingerprintId}`);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// Return the created or existing context.
|
|
692
|
-
const response = createSuccessResponse({
|
|
693
|
-
entities: {
|
|
694
|
-
user: anonymousInitResult.user,
|
|
695
|
-
credit: anonymousInitResult.credit,
|
|
696
|
-
subscription: anonymousInitResult.subscription,
|
|
697
|
-
},
|
|
698
|
-
isNewUser: anonymousInitResult.isNewUser,
|
|
699
|
-
options: {
|
|
700
|
-
totalUsersOnDevice: anonymousInitResult.totalUsersOnDevice,
|
|
701
|
-
hasAnonymousUser: anonymousInitResult.hasAnonymousUser,
|
|
702
|
-
},
|
|
703
|
-
});
|
|
704
|
-
return NextResponse.json(response);
|
|
705
|
-
|
|
706
|
-
} catch (error) {
|
|
707
|
-
console.error('Fingerprint request error:', error);
|
|
708
|
-
return createErrorResponse('Internal server error', 500);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/**
|
|
713
|
-
* Anonymous user initialization API.
|
|
6
|
+
* Clerk-aware anonymous user initialization API.
|
|
714
7
|
* POST /api/user/anonymous/init
|
|
715
8
|
*/
|
|
716
9
|
export async function POST(request: NextRequest) {
|
|
717
|
-
return handleFingerprintRequest(request, {
|
|
10
|
+
return handleFingerprintRequest(request, {
|
|
11
|
+
createIfNotExists: true,
|
|
12
|
+
getAuthIdentity: getOptionalServerAuthIdentity,
|
|
13
|
+
});
|
|
718
14
|
}
|