Skip to content

Commit 88489e6

Browse files
committed
feat(tracking-links): implement collision handling for unique tracking code generation and enhance validation for tracking codes
1 parent 1be9493 commit 88489e6

3 files changed

Lines changed: 273 additions & 18 deletions

File tree

server/api/public/track/[code].get.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { eq, sql } from 'drizzle-orm'
22
import { trackingLink } from '../../../database/schema'
33

4+
/** Tracking codes are 8-char base64url strings */
5+
const TRACKING_CODE_RE = /^[A-Za-z0-9_-]{1,100}$/
6+
47
/**
58
* GET /api/public/track/:code
69
* Public endpoint — no auth required.
@@ -10,7 +13,7 @@ import { trackingLink } from '../../../database/schema'
1013
*/
1114
export default defineEventHandler(async (event) => {
1215
const code = getRouterParam(event, 'code')
13-
if (!code || code.length > 100) {
16+
if (!code || !TRACKING_CODE_RE.test(code)) {
1417
throw createError({ statusCode: 400, statusMessage: 'Invalid tracking code' })
1518
}
1619

@@ -32,7 +35,12 @@ export default defineEventHandler(async (event) => {
3235
.set({ clickCount: sql`${trackingLink.clickCount} + 1` })
3336
.where(eq(trackingLink.id, link.id))
3437
.then(() => {})
35-
.catch(() => {})
38+
.catch((err) => {
39+
logWarn('tracking_link.click_increment_failed', {
40+
tracking_link_id: link.id,
41+
error_message: err instanceof Error ? err.message : String(err),
42+
})
43+
})
3644
}
3745

3846
// Build redirect URL with ref param

server/api/tracking-links/index.post.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,34 @@ export default defineEventHandler(async (event) => {
2424
}
2525
}
2626

27-
// Generate a unique short code (8 chars, URL-safe)
28-
const code = generateTrackingCode()
29-
30-
const [created] = await db.insert(trackingLink).values({
31-
organizationId: orgId,
32-
jobId: body.jobId ?? null,
33-
channel: body.channel,
34-
name: body.name,
35-
code,
36-
utmSource: body.utmSource ?? null,
37-
utmMedium: body.utmMedium ?? null,
38-
utmCampaign: body.utmCampaign ?? null,
39-
utmTerm: body.utmTerm ?? null,
40-
utmContent: body.utmContent ?? null,
41-
createdById: userId,
42-
}).returning()
27+
// Generate a unique short code (8 chars, URL-safe) with collision retry
28+
const MAX_RETRIES = 3
29+
let created
30+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
31+
const code = generateTrackingCode()
32+
try {
33+
const [row] = await db.insert(trackingLink).values({
34+
organizationId: orgId,
35+
jobId: body.jobId ?? null,
36+
channel: body.channel,
37+
name: body.name,
38+
code,
39+
utmSource: body.utmSource ?? null,
40+
utmMedium: body.utmMedium ?? null,
41+
utmCampaign: body.utmCampaign ?? null,
42+
utmTerm: body.utmTerm ?? null,
43+
utmContent: body.utmContent ?? null,
44+
createdById: userId,
45+
}).returning()
46+
created = row
47+
break
48+
} catch (err: unknown) {
49+
const isUniqueViolation = err instanceof Error && err.message.includes('unique')
50+
if (!isUniqueViolation || attempt === MAX_RETRIES - 1) {
51+
throw err
52+
}
53+
}
54+
}
4355

4456
setResponseStatus(event, 201)
4557
return created

tests/unit/source-tracking.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { describe, it, expect } from 'vitest'
2+
import {
3+
createTrackingLinkSchema,
4+
updateTrackingLinkSchema,
5+
trackingLinkIdSchema,
6+
trackingLinkQuerySchema,
7+
sourceStatsQuerySchema,
8+
applicationSourceSchema,
9+
} from '../../server/utils/schemas/trackingLink'
10+
11+
// ─────────────────────────────────────────────
12+
// 1. createTrackingLinkSchema
13+
// ─────────────────────────────────────────────
14+
15+
describe('createTrackingLinkSchema', () => {
16+
it('accepts minimal valid input (name only)', () => {
17+
const result = createTrackingLinkSchema.parse({ name: 'LinkedIn Q1' })
18+
expect(result.name).toBe('LinkedIn Q1')
19+
expect(result.channel).toBe('custom') // default
20+
expect(result.jobId).toBeUndefined()
21+
})
22+
23+
it('accepts full input with all optional fields', () => {
24+
const result = createTrackingLinkSchema.parse({
25+
name: 'Full campaign',
26+
channel: 'linkedin',
27+
jobId: 'job-1',
28+
utmSource: 'linkedin',
29+
utmMedium: 'social',
30+
utmCampaign: 'q1-hiring',
31+
utmTerm: 'engineering',
32+
utmContent: 'banner',
33+
})
34+
expect(result.channel).toBe('linkedin')
35+
expect(result.utmCampaign).toBe('q1-hiring')
36+
})
37+
38+
it('rejects empty name', () => {
39+
expect(() => createTrackingLinkSchema.parse({ name: '' })).toThrow()
40+
})
41+
42+
it('rejects name exceeding 200 chars', () => {
43+
expect(() => createTrackingLinkSchema.parse({ name: 'x'.repeat(201) })).toThrow()
44+
})
45+
46+
it('rejects invalid channel value', () => {
47+
expect(() => createTrackingLinkSchema.parse({ name: 'ok', channel: 'invalid_channel' })).toThrow()
48+
})
49+
50+
it('rejects UTM fields exceeding 200 chars', () => {
51+
expect(() =>
52+
createTrackingLinkSchema.parse({ name: 'ok', utmSource: 'a'.repeat(201) }),
53+
).toThrow()
54+
})
55+
56+
it('strips unknown properties', () => {
57+
const result = createTrackingLinkSchema.parse({
58+
name: 'test',
59+
__proto__: { admin: true },
60+
malicious: 'payload',
61+
} as any)
62+
expect((result as any).malicious).toBeUndefined()
63+
})
64+
})
65+
66+
// ─────────────────────────────────────────────
67+
// 2. updateTrackingLinkSchema
68+
// ─────────────────────────────────────────────
69+
70+
describe('updateTrackingLinkSchema', () => {
71+
it('accepts empty update (all optional)', () => {
72+
const result = updateTrackingLinkSchema.parse({})
73+
expect(Object.keys(result)).toHaveLength(0)
74+
})
75+
76+
it('accepts partial update', () => {
77+
const result = updateTrackingLinkSchema.parse({ isActive: false })
78+
expect(result.isActive).toBe(false)
79+
expect(result.name).toBeUndefined()
80+
})
81+
82+
it('rejects empty name', () => {
83+
expect(() => updateTrackingLinkSchema.parse({ name: '' })).toThrow()
84+
})
85+
86+
it('rejects name exceeding 200 chars', () => {
87+
expect(() => updateTrackingLinkSchema.parse({ name: 'x'.repeat(201) })).toThrow()
88+
})
89+
90+
it('accepts valid channel change', () => {
91+
const result = updateTrackingLinkSchema.parse({ channel: 'indeed' })
92+
expect(result.channel).toBe('indeed')
93+
})
94+
95+
it('rejects non-boolean isActive', () => {
96+
expect(() => updateTrackingLinkSchema.parse({ isActive: 'yes' })).toThrow()
97+
})
98+
})
99+
100+
// ─────────────────────────────────────────────
101+
// 3. trackingLinkIdSchema
102+
// ─────────────────────────────────────────────
103+
104+
describe('trackingLinkIdSchema', () => {
105+
it('accepts a valid ID string', () => {
106+
const result = trackingLinkIdSchema.parse({ id: 'abc-123' })
107+
expect(result.id).toBe('abc-123')
108+
})
109+
110+
it('rejects empty ID', () => {
111+
expect(() => trackingLinkIdSchema.parse({ id: '' })).toThrow()
112+
})
113+
114+
it('rejects missing ID', () => {
115+
expect(() => trackingLinkIdSchema.parse({})).toThrow()
116+
})
117+
})
118+
119+
// ─────────────────────────────────────────────
120+
// 4. trackingLinkQuerySchema
121+
// ─────────────────────────────────────────────
122+
123+
describe('trackingLinkQuerySchema', () => {
124+
it('provides defaults for page and limit', () => {
125+
const result = trackingLinkQuerySchema.parse({})
126+
expect(result.page).toBe(1)
127+
expect(result.limit).toBe(50)
128+
})
129+
130+
it('coerces string page/limit to numbers', () => {
131+
const result = trackingLinkQuerySchema.parse({ page: '3', limit: '25' })
132+
expect(result.page).toBe(3)
133+
expect(result.limit).toBe(25)
134+
})
135+
136+
it('rejects page < 1', () => {
137+
expect(() => trackingLinkQuerySchema.parse({ page: '0' })).toThrow()
138+
})
139+
140+
it('rejects limit > 100', () => {
141+
expect(() => trackingLinkQuerySchema.parse({ limit: '101' })).toThrow()
142+
})
143+
144+
it('transforms isActive string to boolean', () => {
145+
const active = trackingLinkQuerySchema.parse({ isActive: 'true' })
146+
expect(active.isActive).toBe(true)
147+
148+
const inactive = trackingLinkQuerySchema.parse({ isActive: 'false' })
149+
expect(inactive.isActive).toBe(false)
150+
})
151+
152+
it('leaves isActive undefined when not provided', () => {
153+
const result = trackingLinkQuerySchema.parse({})
154+
expect(result.isActive).toBeUndefined()
155+
})
156+
157+
it('accepts optional jobId and channel filters', () => {
158+
const result = trackingLinkQuerySchema.parse({ jobId: 'j-1', channel: 'linkedin' })
159+
expect(result.jobId).toBe('j-1')
160+
expect(result.channel).toBe('linkedin')
161+
})
162+
})
163+
164+
// ─────────────────────────────────────────────
165+
// 5. sourceStatsQuerySchema
166+
// ─────────────────────────────────────────────
167+
168+
describe('sourceStatsQuerySchema', () => {
169+
it('accepts empty query (all optional)', () => {
170+
const result = sourceStatsQuerySchema.parse({})
171+
expect(result.jobId).toBeUndefined()
172+
expect(result.from).toBeUndefined()
173+
expect(result.to).toBeUndefined()
174+
})
175+
176+
it('accepts valid ISO datetime strings', () => {
177+
const result = sourceStatsQuerySchema.parse({
178+
from: '2025-01-01T00:00:00Z',
179+
to: '2025-12-31T23:59:59Z',
180+
})
181+
expect(result.from).toBe('2025-01-01T00:00:00Z')
182+
expect(result.to).toBe('2025-12-31T23:59:59Z')
183+
})
184+
185+
it('rejects non-datetime from/to strings', () => {
186+
expect(() => sourceStatsQuerySchema.parse({ from: 'yesterday' })).toThrow()
187+
})
188+
189+
it('accepts optional jobId', () => {
190+
const result = sourceStatsQuerySchema.parse({ jobId: 'job-abc' })
191+
expect(result.jobId).toBe('job-abc')
192+
})
193+
})
194+
195+
// ─────────────────────────────────────────────
196+
// 6. applicationSourceSchema (public apply body)
197+
// ─────────────────────────────────────────────
198+
199+
describe('applicationSourceSchema', () => {
200+
it('accepts empty object (all optional)', () => {
201+
const result = applicationSourceSchema.parse({})
202+
expect(result.ref).toBeUndefined()
203+
})
204+
205+
it('accepts valid ref and UTM fields', () => {
206+
const result = applicationSourceSchema.parse({
207+
ref: 'TRACK123',
208+
utmSource: 'linkedin',
209+
utmMedium: 'social',
210+
utmCampaign: 'spring-2025',
211+
utmTerm: 'engineer',
212+
utmContent: 'banner-ad',
213+
})
214+
expect(result.ref).toBe('TRACK123')
215+
expect(result.utmSource).toBe('linkedin')
216+
})
217+
218+
it('rejects ref exceeding 100 chars', () => {
219+
expect(() => applicationSourceSchema.parse({ ref: 'x'.repeat(101) })).toThrow()
220+
})
221+
222+
it('rejects UTM fields exceeding 200 chars', () => {
223+
expect(() =>
224+
applicationSourceSchema.parse({ utmSource: 'a'.repeat(201) }),
225+
).toThrow()
226+
})
227+
228+
it('strips unknown properties', () => {
229+
const result = applicationSourceSchema.parse({
230+
ref: 'ok',
231+
xss: '<script>alert(1)</script>',
232+
} as any)
233+
expect((result as any).xss).toBeUndefined()
234+
})
235+
})

0 commit comments

Comments
 (0)