Files
QR-master/src/lib/qr.ts
2025-11-05 12:02:59 +01:00

227 lines
6.2 KiB
TypeScript

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<typeof createQRSchema>) {
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<z.infer<typeof createQRSchema>>) {
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<string> {
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<Buffer> {
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);
}
}