diff --git a/src/routes/links.test.ts b/src/routes/links.test.ts index 4b579b0..eb17457 100644 --- a/src/routes/links.test.ts +++ b/src/routes/links.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const dbQueryMock = vi.fn(); +const { dbQueryMock } = vi.hoisted(() => ({ + dbQueryMock: vi.fn(), +})); vi.mock('../lib/database.js', () => ({ db: { @@ -15,17 +17,25 @@ describe('resolveLinkTemplateId', () => { dbQueryMock.mockReset(); }); - it('returns null when the provided template id does not exist', async () => { - dbQueryMock.mockResolvedValueOnce({ rows: [] }); - - await expect(resolveLinkTemplateId('00000000-0000-0000-0000-000000000000', null)).resolves.toBeNull(); - }); - - it('falls back to template slug when the provided id is not usable', async () => { + it('falls back to the default template when the provided id does not exist', async () => { dbQueryMock.mockResolvedValueOnce({ rows: [] }); dbQueryMock.mockResolvedValueOnce({ rows: [{ id: '11111111-1111-1111-1111-111111111111' }] }); + await expect(resolveLinkTemplateId('00000000-0000-0000-0000-000000000000', null)).resolves.toBe('11111111-1111-1111-1111-111111111111'); + expect(dbQueryMock).toHaveBeenNthCalledWith(2, 'SELECT id FROM link_templates WHERE is_default = true LIMIT 1'); + }); + + it('uses the template slug when it can be resolved', async () => { + dbQueryMock.mockResolvedValueOnce({ rows: [{ id: '11111111-1111-1111-1111-111111111111' }] }); + await expect(resolveLinkTemplateId(null, 'default')).resolves.toBe('11111111-1111-1111-1111-111111111111'); - expect(dbQueryMock).toHaveBeenNthCalledWith(2, 'SELECT id FROM link_templates WHERE slug = $1 LIMIT 1', ['default']); + expect(dbQueryMock).toHaveBeenCalledWith('SELECT id FROM link_templates WHERE slug = $1 LIMIT 1', ['default']); + }); + + it('falls back to the default template when the provided template id is invalid', async () => { + dbQueryMock.mockResolvedValueOnce({ rows: [{ id: '22222222-2222-2222-2222-222222222222' }] }); + + await expect(resolveLinkTemplateId('not-a-uuid', null)).resolves.toBe('22222222-2222-2222-2222-222222222222'); + expect(dbQueryMock).toHaveBeenCalledWith('SELECT id FROM link_templates WHERE is_default = true LIMIT 1'); }); }); diff --git a/src/routes/links.ts b/src/routes/links.ts index 58fa46d..01ebd71 100644 --- a/src/routes/links.ts +++ b/src/routes/links.ts @@ -13,6 +13,43 @@ async function getTemplateSlug(templateId: string | null): Promise { + const normalizedTemplateId = templateId?.trim(); + const normalizedSlug = templateSlug?.trim(); + + if (normalizedTemplateId) { + const parsed = z.string().uuid().safeParse(normalizedTemplateId); + if (parsed.success) { + const result = await db.query( + 'SELECT id FROM link_templates WHERE id = $1 LIMIT 1', + [normalizedTemplateId] + ); + if (result.rows[0]?.id) { + return result.rows[0].id; + } + } + } + + if (normalizedSlug) { + const result = await db.query( + 'SELECT id FROM link_templates WHERE slug = $1 LIMIT 1', + [normalizedSlug] + ); + if (result.rows[0]?.id) { + return result.rows[0].id; + } + } + + const defaultResult = await db.query( + 'SELECT id FROM link_templates WHERE is_default = true LIMIT 1' + ); + + return defaultResult.rows[0]?.id ?? null; +} + const createLinkSchema = z.object({ userId: z.string().uuid().optional(), templateId: z.string().uuid().optional(), @@ -182,6 +219,7 @@ export async function linkRoutes(fastify: FastifyInstance) { } const originalUrl = data.originalUrl || 'https://angkorlifes.com'; + const resolvedTemplateId = await resolveLinkTemplateId(data.templateId, null); const result = await db.query( `INSERT INTO links ( @@ -195,7 +233,7 @@ export async function linkRoutes(fastify: FastifyInstance) { RETURNING *`, [ data.userId || null, - data.templateId || null, + resolvedTemplateId, shortCode, originalUrl, data.title || null, @@ -238,6 +276,10 @@ export async function linkRoutes(fastify: FastifyInstance) { const { userId } = request.query; const data = updateLinkSchema.parse(request.body); + const resolvedData = { + ...data, + templateId: data.templateId === undefined ? undefined : await resolveLinkTemplateId(data.templateId, null), + }; // Capture current identifiers for cache invalidation after the update const oldLinkResult = await db.query( @@ -250,7 +292,7 @@ export async function linkRoutes(fastify: FastifyInstance) { const values: any[] = []; let paramIndex = 1; - Object.entries(data).forEach(([key, value]) => { + Object.entries(resolvedData).forEach(([key, value]) => { if (value !== undefined) { if (key === 'utmParameters' || key === 'targetingRules' || key === 'deepLinkParameters') { updates.push(`${key.replace(/([A-Z])/g, '_$1').toLowerCase()} = $${paramIndex}`); diff --git a/src/routes/sdk.ts b/src/routes/sdk.ts index 4146d04..7d0bd27 100644 --- a/src/routes/sdk.ts +++ b/src/routes/sdk.ts @@ -12,6 +12,7 @@ import { triggerWebhooks } from '../lib/webhook.js'; import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js'; import { emitClickEvent } from '../lib/event-emitter.js'; import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js'; +import { resolveLinkTemplateId } from './links.js'; /** * SDK Routes - Mobile SDK endpoints for deferred deep linking @@ -66,6 +67,7 @@ export async function sdkRoutes(fastify: FastifyInstance) { const originalUrl = body.originalUrl || 'https://angkorlifes.com'; const shortCode = body.customCode || 'sdk-link'; + const resolvedTemplateId = await resolveLinkTemplateId(body.templateId, null); const result = await db.query( `INSERT INTO links ( @@ -79,7 +81,7 @@ export async function sdkRoutes(fastify: FastifyInstance) { RETURNING *`, [ body.userId || null, - body.templateId || null, + resolvedTemplateId, shortCode, originalUrl, body.title || null,