import { z } from 'zod'; import QRCode from 'qrcode'; import { db } from './db'; import { generateSlug, hashIP } from './hash'; import { getCountryFromHeaders, parseUserAgent } from './geo'; import { ContentType, QRType, QRStatus } from '@prisma/client'; import Redis from 'ioredis'; import { env } from './env'; // Redis client (optional) let redis: Redis | null = null; if (env.REDIS_URL) { try { redis = new Redis(env.REDIS_URL); } catch (error) { console.warn('Redis connection failed, falling back to direct DB writes'); } } // Validation schemas const qrContentSchema = z.object({ url: z.string().url().optional(), phone: z.string().optional(), email: z.string().email().optional(), message: z.string().optional(), text: z.string().optional(), // VCARD fields firstName: z.string().optional(), lastName: z.string().optional(), organization: z.string().optional(), title: z.string().optional(), // GEO fields latitude: z.number().optional(), longitude: z.number().optional(), label: z.string().optional(), }); const qrStyleSchema = z.object({ foregroundColor: z.string().default('#000000'), backgroundColor: z.string().default('#FFFFFF'), cornerStyle: z.enum(['square', 'rounded']).default('square'), size: z.number().min(100).max(1000).default(200), }); const createQRSchema = z.object({ title: z.string().min(1).max(100), type: z.nativeEnum(QRType).default(QRType.DYNAMIC), contentType: z.nativeEnum(ContentType).default(ContentType.URL), content: qrContentSchema, tags: z.array(z.string()).default([]), style: qrStyleSchema.default({}), }); export async function createQR(userId: string, data: z.infer) { const validated = createQRSchema.parse(data); const slug = generateSlug(validated.title); const qrCode = await db.qRCode.create({ data: { userId, title: validated.title, type: validated.type, contentType: validated.contentType, content: validated.content, tags: validated.tags, style: validated.style, slug, status: QRStatus.ACTIVE, }, }); return qrCode; } export async function updateQR(id: string, userId: string, data: Partial>) { const qrCode = await db.qRCode.findFirst({ where: { id, userId }, }); if (!qrCode) { throw new Error('QR Code not found'); } const updateData: any = {}; if (data.title) updateData.title = data.title; if (data.content) updateData.content = data.content; if (data.tags) updateData.tags = data.tags; if (data.style) updateData.style = data.style; return db.qRCode.update({ where: { id }, data: updateData, }); } export async function generateQRCodeSVG(content: string, style: any = {}): Promise { const options = { type: 'svg' as const, width: style.size || 200, color: { dark: style.foregroundColor || '#000000', light: style.backgroundColor || '#FFFFFF', }, margin: 2, }; return QRCode.toString(content, options); } export async function generateQRCodePNG(content: string, style: any = {}): Promise { const options = { width: style.size || 200, color: { dark: style.foregroundColor || '#000000', light: style.backgroundColor || '#FFFFFF', }, margin: 2, }; return QRCode.toBuffer(content, options); } export function getQRContent(qr: any): string { const { contentType, content } = qr; switch (contentType) { case 'URL': return content.url || ''; case 'PHONE': return `tel:${content.phone || ''}`; case 'SMS': const message = content.message ? `?body=${encodeURIComponent(content.message)}` : ''; return `sms:${content.phone || ''}${message}`; case 'WHATSAPP': const whatsappMessage = content.message ? `?text=${encodeURIComponent(content.message)}` : ''; return `https://wa.me/${content.phone || ''}${whatsappMessage}`; case 'VCARD': return `BEGIN:VCARD VERSION:3.0 FN:${content.firstName || ''} ${content.lastName || ''} ORG:${content.organization || ''} TITLE:${content.title || ''} EMAIL:${content.email || ''} TEL:${content.phone || ''} END:VCARD`; case 'GEO': const lat = content.latitude || 0; const lon = content.longitude || 0; const label = content.label ? `?q=${encodeURIComponent(content.label)}` : ''; return `geo:${lat},${lon}${label}`; case 'TEXT': return content.text || ''; default: return content.url || ''; } } export async function trackScan(qrId: string, request: Request) { const headers = request.headers; const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || '127.0.0.1'; const userAgent = headers.get('user-agent'); const referrer = headers.get('referer'); const dnt = headers.get('dnt'); // Respect Do Not Track if (dnt === '1') { // Only increment aggregate counter, skip detailed tracking return; } const ipHash = hashIP(ip); const country = getCountryFromHeaders(headers); const { device, os } = parseUserAgent(userAgent); // Parse UTM parameters from referrer let utmSource: string | null = null; let utmMedium: string | null = null; let utmCampaign: string | null = null; if (referrer) { try { const url = new URL(referrer); utmSource = url.searchParams.get('utm_source'); utmMedium = url.searchParams.get('utm_medium'); utmCampaign = url.searchParams.get('utm_campaign'); } catch (e) { // Invalid referrer URL } } // Check if this is a unique scan (same IP hash within 24 hours) const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const existingScan = await db.qRScan.findFirst({ where: { qrId, ipHash, ts: { gte: dayAgo }, }, }); const isUnique = !existingScan; const scanData = { qrId, ipHash, userAgent, device, os, country, referrer, utmSource, utmMedium, utmCampaign, isUnique, }; // Fire-and-forget tracking if (redis) { // Queue to Redis for background processing redis.lpush('qr_scans', JSON.stringify(scanData)).catch(console.error); } else { // Direct database write db.qRScan.create({ data: scanData }).catch(console.error); } }