Initial commit of CK-QR Platform

This commit is contained in:
Mohan Ki
2026-07-27 13:42:18 +05:30
commit 60dcb029dc
19 changed files with 2582 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
node_modules/
.next/
dist/
build/
postgres-data/
redis-data/
*.log
.DS_Store
.env
.env.local
.env.development
.env.test
.env.production
.idea/
.vscode/
+92
View File
@@ -0,0 +1,92 @@
# Product Scope Document: CK-QR Platform
**Project Name:** CK-QR
**Document Version:** 1.0
**Prepared by:** Cavin Infotech Pvt. Ltd.
## 1. Executive Summary
**Product Objective:** To develop and deploy a comprehensive, scalable SaaS platform that allows users to generate, customize, manage, and track Static and Dynamic QR codes.
**Target Audience:** Individual users, marketers, and enterprise businesses requiring centralized QR code management, custom branding, and real-time scan analytics.
**Monetization Strategy:** A freemium model where basic and dynamic generation is free (ad-supported routing), driving conversions to paid tiers for ad-free scanning, custom domains, and advanced analytics.
## 2. Core Functional Modules (In-Scope)
### A. User Management & Authentication
* **Authentication:** Registration/Login via Email, Google SSO, and Apple SSO.
* **Role-Based Access:** System must support Guest (unregistered), Free, Trial, Pro, and Enterprise user states.
* **Team Workspaces:** Multi-seat management for Enterprise tiers to collaborate on shared QR campaigns.
### B. QR Code Generation Engine
* **Dynamic QR Codes:** URL routing system allowing users to update destination content post-generation without altering the printed QR image.
* **Supported Data Types:**
* *Standard/Open-Source:* URL, vCard/MeCard, Text, Email (`mailto:`), SMS (`sms:`), Phone Call (`tel:`), Wi-Fi (WPA/WEP/Hidden), Location (Geo URI), Event (vCalendar/iCalendar), and GS1 Digital Link.
* *Dynamic/Custom:* PDF/Document (including Excel/PPTX), App Store routing (OS-dependent), Image Galleries, Social Media (Link in Bio), Audio/Video (YouTube/MP3), Crypto Wallet addresses, Messaging (WhatsApp/Telegram/Line), Payments (UPI/PayPal), Forms & Surveys (Google Forms/Microsoft Forms), and Multi-URL/Conditional Routing (routing based on OS, time, location, or scan limits).
### C. QR Customization Studio
* **Design Templates:** Library of pre-configured multiple design templates for various industries and use-cases (e.g., sleek, playful, corporate).
* **Visual Editor (Patterns & Eyes):** Advanced interface for modifying data patterns (e.g., dots, squares, rounded, liquid) and customizing corner eye/frame shapes (outer and inner eye styling).
* **Color & Branding:** Support for single colors, linear/radial gradients, background colors, and transparent backgrounds.
* **Custom Logo Integration:** Central logo placement with support for custom image uploads, social media presets (Facebook, Instagram, LinkedIn, etc.), automatic background removal behind the logo, and logo resizing/scaling.
* **Frames & Call-To-Action:** Pre-designed customizable frames with integrated call-to-action text (e.g., "Scan Me", "View Menu", "Get Wi-Fi") and editable fonts/colors.
### D. QR Management Dashboard
* **Asset Organization:** Folders and tagging systems for campaign management.
* **Dynamic Controls:** Update destination URLs, temporarily pause campaigns (routes to a "Campaign Ended" holding page), or archive codes.
* **Bulk Operations:** Delete, categorize, or download multiple QR codes simultaneously.
### E. Scan Analytics & Tracking
* **Metrics:** Total scans, unique devices, operating system breakdown, and location tracking (Country/City).
* **Visualization:** Time-based charts (day/week/month).
* **Data Export:** CSV exports for external reporting.
* **Integrations:** Advanced tracking integration via Google Analytics and Meta Pixel for retargeting and deeper user insights.
### F. Web-Based QR Scanner
* **In-Browser Scanning:** A dedicated web interface utilizing device cameras (webcam/smartphone) to scan QR codes without requiring a native mobile app download.
* **Scan History:** Keeping a session or local log of recently scanned codes for the user.
## 3. Account Tier & Subscription Architecture
The platform operates on a tiered structure, utilizing a robust downgrade logic to ensure dynamic links remain functional even if subscriptions lapse.
| Feature / Capability | Guest / Free | Free Trial (14-Day) | Pro / Enterprise Paid |
| :--- | :--- | :--- | :--- |
| **Static QR Codes** | Unlimited | Unlimited | Unlimited |
| **Dynamic QR Codes** | Up to 5 | Unlimited | Unlimited |
| **Scan Interstitial Ads** | Yes (Ad-supported) | No Ads | No Ads |
| **Custom Domains** | CK-QR domain only | Custom subdomains | Custom domain / White-label |
| **Export Formats** | PNG, JPEG | PNG, JPEG, SVG, PDF | All + Bulk zip export |
| **File Storage Limit** | 5 MB per upload | 20 MB per upload | 100 MB - 1 GB+ per upload |
**Trial Expiration Logic:**
When a 14-day Pro Trial expires without payment, the account is downgraded to the Free Tier. Dynamic links do not break. Instead, the routing behavior reverts to ad-supported mode (serving a 35 second interstitial ad before redirecting) and vector exports are locked.
## 4. Technical Architecture & Infrastructure
To handle high-volume concurrent scans, rapid URL redirection, and secure data handling, the system will utilize a modern, highly available stack.
* **Frontend UI:** React.js / Next.js for a responsive dashboard and SEO-optimized landing pages.
* **Backend API:** Node.js backend executing asynchronous, non-blocking requests for rapid link routing and QR generation.
* **Process Management:** PM2 will be utilized for Node.js process management, clustering, and ensuring backend zero-downtime restarts.
* **Deployment & Networking:** The application will be containerized using Docker. Nginx will serve as the reverse proxy and load balancer to manage incoming scan traffic and route it to the appropriate backend containers.
* **Database Engine:** PostgreSQL for relational user and billing data, paired with a Redis caching layer for active dynamic URLs to ensure sub-second redirection latency.
* **Storage & CDN:** AWS S3 or Cloudflare R2 for storing user-uploaded assets (logos, PDFs) and serving them globally.
## 5. Development Phases
* **Phase 1: Foundation & Core Engine (Weeks 1-4)**
Database schema design, Node.js API setup, authentication workflows, and the core QR generation and routing logic.
* **Phase 2: Customization Studio & Dashboard (Weeks 5-8)**
Frontend development of the React.js dashboard, visual QR customization studio, folder management, and user asset storage.
* **Phase 3: Subscription & Analytics (Weeks 9-11)**
Integration of the payment gateway (e.g., Stripe), trial expiration logic, ad-interstitial middleware, and scan analytics tracking.
* **Phase 4: Optimization & Launch (Weeks 12-14)**
Docker containerization, Nginx configuration, PM2 load testing for high-concurrency scans, QA testing, and production deployment.
## 6. Out of Scope (Phase 1)
* Native iOS and Android mobile applications.
* Public API access for third-party developers.
* Complete white-labeling of the dashboard for enterprise resellers.
## 7. Assumptions & Risks
* **Link Spam & Abuse:** Malicious actors may use free dynamic QRs for phishing.
* *Mitigation:* Implement automated URL scanning (e.g., Google Safe Browsing API) upon creation and enforce email verification for dynamic creation.
* **Ad-Blocker Interference:** Browser ad-blockers may prevent the ad-supported redirect logic from firing.
* *Mitigation:* Server-side routing delays rather than client-side ad rendering for free tiers.
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
# Also install nodemon globally for dev hot-reloading
RUN npm install -g nodemon
COPY . .
EXPOSE 4001
EXPOSE 9229
CMD ["nodemon", "--inspect=0.0.0.0:9229", "server.js"]
+20
View File
@@ -0,0 +1,20 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'free',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS qr_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL, -- 'static' or 'dynamic'
data_type VARCHAR(50) NOT NULL, -- 'URL', 'Wi-Fi', etc.
destination_url TEXT,
short_url_id VARCHAR(50) UNIQUE, -- Only for dynamic routing e.g. 'xyz123'
design_data JSONB, -- Custom styling
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS qr_scans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
qr_code_id UUID REFERENCES qr_codes(id) ON DELETE CASCADE,
ip_address VARCHAR(45),
user_agent TEXT,
os VARCHAR(100),
device_type VARCHAR(50),
scanned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT,
category VARCHAR(50),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Insert some default keys so they show up in the UI
INSERT INTO app_settings (key, value, category) VALUES
('icici_merchant_id', '', 'payments'),
('icici_api_key', '', 'payments'),
('google_safe_browsing_key', '', 'security'),
('google_client_id', '', 'auth'),
('apple_service_id', '', 'auth')
ON CONFLICT (key) DO NOTHING;
+3
View File
@@ -0,0 +1,3 @@
INSERT INTO users (email, password_hash, role)
VALUES ('admin@ckqr.com', '$2b$10$ezY4oBvmZUc667XCKbl98.hKicJbOG9yaV9lFbZXwbeXWgMuN7L/6', 'enterprise')
ON CONFLICT (email) DO NOTHING;
+22
View File
@@ -0,0 +1,22 @@
const jwt = require('jsonwebtoken');
const verifyToken = (req, res, next) => {
let token = req.headers['authorization'];
// Check cookies if no auth header
if (!token && req.cookies && req.cookies.token) {
token = req.cookies.token;
} else if (token && token.startsWith('Bearer ')) {
token = token.slice(7, token.length).trimLeft();
}
if (!token) return res.status(403).json({ error: 'Token missing from header' });
jwt.verify(token, process.env.JWT_SECRET || 'supersecret123', (err, decoded) => {
if (err) return res.status(401).json({ error: 'Unauthorized' });
req.user = decoded;
next();
});
};
module.exports = { verifyToken };
+1740
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.22.0",
"qrcode": "^1.5.4",
"redis": "^6.1.0",
"shortid": "^2.2.17",
"ua-parser-js": "^2.0.10"
}
}
+91
View File
@@ -0,0 +1,91 @@
const express = require('express');
const router = express.Router();
const { Pool } = require('pg');
// In a real app, you would have an admin middleware protecting this
// const { verifyAdminToken } = require('../middleware/adminAuth');
const pgPool = new Pool({
host: process.env.PGHOST || 'postgres',
port: process.env.PGPORT || 5432,
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'ckqr',
});
// List all users
router.get('/users', async (req, res) => {
try {
const result = await pgPool.query('SELECT id, email, role, created_at FROM users ORDER BY created_at DESC');
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server Error' });
}
});
// Update a user's tier
router.put('/users/:id/tier', async (req, res) => {
const { id } = req.params;
const { role } = req.body; // 'free', 'pro', 'enterprise'
if (!['free', 'pro', 'enterprise'].includes(role)) {
return res.status(400).json({ error: 'Invalid role specified' });
}
try {
const result = await pgPool.query(
'UPDATE users SET role = $1 WHERE id = $2 RETURNING id, email, role',
[role, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ message: 'User tier updated successfully', user: result.rows[0] });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server Error' });
}
});
// Get all API settings
router.get('/settings', async (req, res) => {
try {
const result = await pgPool.query('SELECT * FROM app_settings ORDER BY category, key');
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server Error fetching settings' });
}
});
// Update multiple API settings
router.put('/settings', async (req, res) => {
const { settings } = req.body; // Array of { key, value }
if (!Array.isArray(settings)) return res.status(400).json({ error: 'Invalid settings format' });
const client = await pgPool.connect();
try {
await client.query('BEGIN');
for (const setting of settings) {
await client.query(
`INSERT INTO app_settings (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`,
[setting.key, setting.value]
);
}
await client.query('COMMIT');
res.json({ message: 'Settings updated successfully' });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'Server Error updating settings' });
} finally {
client.release();
}
});
module.exports = router;
+61
View File
@@ -0,0 +1,61 @@
const express = require('express');
const router = express.Router();
const { Pool } = require('pg');
const { verifyToken } = require('../middleware/auth');
const pgPool = new Pool({
host: process.env.PGHOST || 'postgres',
port: process.env.PGPORT || 5432,
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'ckqr',
});
router.get('/:qrCodeId', verifyToken, async (req, res) => {
const { qrCodeId } = req.params;
const userId = req.user.id;
try {
// Ensure this user owns the QR code
const checkOwner = await pgPool.query('SELECT id FROM qr_codes WHERE id = $1 AND user_id = $2', [qrCodeId, userId]);
if (checkOwner.rows.length === 0) {
return res.status(403).json({ error: 'Unauthorized or QR code not found' });
}
// Get total scans
const totalQuery = await pgPool.query('SELECT COUNT(*) as total FROM qr_scans WHERE qr_code_id = $1', [qrCodeId]);
// Get OS breakdown
const osQuery = await pgPool.query(
'SELECT os, COUNT(*) as count FROM qr_scans WHERE qr_code_id = $1 GROUP BY os',
[qrCodeId]
);
// Get Device Type breakdown
const deviceQuery = await pgPool.query(
'SELECT device_type, COUNT(*) as count FROM qr_scans WHERE qr_code_id = $1 GROUP BY device_type',
[qrCodeId]
);
// Get Scans over time (last 7 days grouped by date)
const timeQuery = await pgPool.query(
`SELECT DATE(scanned_at) as date, COUNT(*) as count
FROM qr_scans
WHERE qr_code_id = $1 AND scanned_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE(scanned_at) ORDER BY date ASC`,
[qrCodeId]
);
res.json({
totalScans: parseInt(totalQuery.rows[0].total),
osBreakdown: osQuery.rows,
deviceBreakdown: deviceQuery.rows,
timeline: timeQuery.rows
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server Error fetching analytics' });
}
});
module.exports = router;
+86
View File
@@ -0,0 +1,86 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { Pool } = require('pg');
const pgPool = new Pool({
host: process.env.PGHOST || 'postgres',
port: process.env.PGPORT || 5432,
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'ckqr',
});
const JWT_SECRET = process.env.JWT_SECRET || 'supersecret123';
// Register
router.post('/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
try {
const checkUser = await pgPool.query('SELECT id FROM users WHERE email = $1', [email]);
if (checkUser.rows.length > 0) return res.status(400).json({ error: 'User already exists' });
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(password, salt);
const newUser = await pgPool.query(
'INSERT INTO users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id, email, role',
[email, hash, 'free_trial']
);
const token = jwt.sign({ id: newUser.rows[0].id, email: newUser.rows[0].email, role: newUser.rows[0].role }, JWT_SECRET, { expiresIn: '14d' });
res.cookie('token', token, { httpOnly: true, maxAge: 14 * 24 * 60 * 60 * 1000, secure: false, sameSite: 'lax' });
res.status(201).json({ message: 'User registered', user: newUser.rows[0] });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// Login
router.post('/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
try {
const userQuery = await pgPool.query('SELECT * FROM users WHERE email = $1', [email]);
if (userQuery.rows.length === 0) return res.status(404).json({ error: 'User not found' });
const user = userQuery.rows[0];
const isMatch = await bcrypt.compare(password, user.password_hash);
if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '14d' });
res.cookie('token', token, { httpOnly: true, maxAge: 14 * 24 * 60 * 60 * 1000, secure: false, sameSite: 'lax' });
res.json({ user: { id: user.id, email: user.email, role: user.role } });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// Get Current User (Validate Session)
const { verifyToken } = require('../middleware/auth');
router.get('/me', verifyToken, async (req, res) => {
try {
const userQuery = await pgPool.query('SELECT id, email, role FROM users WHERE id = $1', [req.user.id]);
if (userQuery.rows.length === 0) return res.status(404).json({ error: 'User not found' });
res.json({ user: userQuery.rows[0] });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// Logout
router.post('/logout', (req, res) => {
res.clearCookie('token');
res.json({ message: 'Logged out' });
});
module.exports = router;
+29
View File
@@ -0,0 +1,29 @@
const express = require('express');
const router = express.Router();
// Placeholder for ICICI Payment Gateway initialization
router.post('/icici/initiate', async (req, res) => {
const { amount, plan, userId } = req.body;
// Here you would construct the ICICI request payload, compute checksums, etc.
// and return the URL or parameters for the frontend to redirect the user to ICICI.
console.log(`Initiating ICICI payment for user ${userId}, plan: ${plan}, amount: ${amount}`);
res.json({
message: 'Payment initiation logic goes here',
redirectUrl: 'https://placeholder.icicibank.com/pay'
});
});
// Placeholder for ICICI Webhook/Callback
router.post('/icici/webhook', async (req, res) => {
// Here ICICI will post back payment success or failure
// We would verify the signature, and if successful, upgrade the user in the database.
console.log('Received ICICI Webhook:', req.body);
res.status(200).send('Webhook received');
});
module.exports = router;
+137
View File
@@ -0,0 +1,137 @@
const express = require('express');
const router = express.Router();
const QRCode = require('qrcode');
const shortid = require('shortid');
const { verifyToken } = require('../middleware/auth');
const { Pool } = require('pg');
const pgPool = new Pool({
host: process.env.PGHOST || 'postgres',
port: process.env.PGPORT || 5432,
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'ckqr',
});
// Get all QRs for user
router.get('/', verifyToken, async (req, res) => {
try {
const result = await pgPool.query(
`SELECT q.*,
COALESCE((SELECT COUNT(*) FROM qr_scans s WHERE s.qr_code_id = q.id), 0) as scans
FROM qr_codes q
WHERE q.user_id = $1
ORDER BY q.created_at DESC`,
[req.user.id]
);
res.json({ qr_codes: result.rows });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error fetching QR codes' });
}
});
// Generate QR Code
router.post('/generate', verifyToken, async (req, res) => {
const { name, type, dataType, destinationUrl, designData, customSlug } = req.body;
const userId = req.user.id;
if (!type || !dataType) {
return res.status(400).json({ error: 'Missing type or dataType' });
}
try {
let shortUrlId = null;
let finalQrContent = destinationUrl;
if (type === 'dynamic') {
if (!destinationUrl) return res.status(400).json({ error: 'Destination URL is required for dynamic QR' });
// Allow custom slug or generate random
shortUrlId = customSlug ? customSlug.trim() : shortid.generate();
// Enforce uniqueness for custom slug
if (customSlug) {
const existing = await pgPool.query('SELECT id FROM qr_codes WHERE short_url_id = $1', [shortUrlId]);
if (existing.rows.length > 0) {
return res.status(409).json({ error: 'That custom link is already taken. Please choose another.' });
}
}
// In production, this would use the real domain (e.g. https://ck-qr.com/l/)
finalQrContent = `${process.env.BASE_URL || 'http://localhost:4001'}/l/${shortUrlId}`;
}
const result = await pgPool.query(
`INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, short_url_id, design_data)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[userId, name || 'Untitled QR', type, dataType, destinationUrl, shortUrlId, designData || {}]
);
// Generate Image (Data URI for simple response, could be saved to S3 later)
const qrImage = await QRCode.toDataURL(finalQrContent);
res.json({
message: 'QR Code generated',
qrRecord: result.rows[0],
qrImage: qrImage
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error generating QR code' });
}
});
// Update QR Code (Editable Link)
router.put('/:id', verifyToken, async (req, res) => {
const { name, destinationUrl, shortUrlId } = req.body;
const qrId = req.params.id;
const userId = req.user.id;
try {
// Verify ownership
const qrCheck = await pgPool.query('SELECT * FROM qr_codes WHERE id = $1 AND user_id = $2', [qrId, userId]);
if (qrCheck.rows.length === 0) {
return res.status(404).json({ error: 'QR Code not found or unauthorized' });
}
const qr = qrCheck.rows[0];
let newShortUrlId = shortUrlId ? shortUrlId.trim() : qr.short_url_id;
// Check if slug changed and enforce uniqueness
if (newShortUrlId !== qr.short_url_id) {
const existing = await pgPool.query('SELECT id FROM qr_codes WHERE short_url_id = $1', [newShortUrlId]);
if (existing.rows.length > 0) {
return res.status(409).json({ error: 'That custom link is already taken. Please choose another.' });
}
}
// Update DB
const updated = await pgPool.query(
`UPDATE qr_codes
SET name = COALESCE($1, name),
destination_url = COALESCE($2, destination_url),
short_url_id = COALESCE($3, short_url_id)
WHERE id = $4 RETURNING *`,
[name, destinationUrl, newShortUrlId, qrId]
);
res.json({ message: 'QR Code updated', qrRecord: updated.rows[0] });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error updating QR code' });
}
});
// Delete QR Code
router.delete('/:id', verifyToken, async (req, res) => {
try {
const result = await pgPool.query('DELETE FROM qr_codes WHERE id = $1 AND user_id = $2 RETURNING id', [req.params.id, req.user.id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json({ message: 'QR Code deleted' });
} catch (err) {
res.status(500).json({ error: 'Error deleting QR code' });
}
});
module.exports = router;
+81
View File
@@ -0,0 +1,81 @@
const express = require('express');
const router = express.Router();
const { Pool } = require('pg');
const { createClient } = require('redis');
const UAParser = require('ua-parser-js');
const pgPool = new Pool({
host: process.env.PGHOST || 'postgres',
port: process.env.PGPORT || 5432,
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'ckqr',
});
const redisClient = createClient({
url: `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || 6379}`
});
redisClient.on('error', (err) => console.log('Redis Client Error', err));
(async () => {
if (!redisClient.isOpen) await redisClient.connect();
})();
router.get('/:shortUrlId', async (req, res) => {
const { shortUrlId } = req.params;
const cacheKey = `qr:${shortUrlId}:full`;
try {
let qrData = null;
// 1. Check Cache
const cachedData = await redisClient.get(cacheKey);
if (cachedData) {
qrData = JSON.parse(cachedData);
} else {
// 2. Not in cache, check DB
const result = await pgPool.query(
`SELECT q.id as qr_id, q.destination_url, u.role
FROM qr_codes q
JOIN users u ON q.user_id = u.id
WHERE q.short_url_id = $1`,
[shortUrlId]
);
if (result.rows.length === 0) {
return res.status(404).send('QR Code not found');
}
qrData = result.rows[0];
await redisClient.setEx(cacheKey, 3600, JSON.stringify(qrData));
}
// 3. Track Analytics (Async so it doesn't block redirection)
const parser = new UAParser(req.headers['user-agent']);
const os = parser.getOS().name || 'Unknown';
const deviceType = parser.getDevice().type || 'desktop';
const ip = req.ip || req.connection.remoteAddress;
pgPool.query(
`INSERT INTO qr_scans (qr_code_id, ip_address, user_agent, os, device_type)
VALUES ($1, $2, $3, $4, $5)`,
[qrData.qr_id, ip, req.headers['user-agent'], os, deviceType]
).catch(err => console.error('Failed to log scan:', err));
// 4. Check Tier & Redirect
if (qrData.role === 'free') {
const encodedUrl = encodeURIComponent(qrData.destination_url);
// In production, this points to the real Next.js domain
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:4000';
return res.redirect(302, `${frontendUrl}/ad-redirect?url=${encodedUrl}`);
} else {
// Paid users skip the ad
return res.redirect(302, qrData.destination_url);
}
} catch (err) {
console.error(err);
res.status(500).send('Server Error during redirection');
}
});
module.exports = router;
+78
View File
@@ -0,0 +1,78 @@
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const { createClient } = require('redis');
const cookieParser = require('cookie-parser');
require('dotenv').config();
const app = express();
const port = process.env.PORT || 4001;
app.use(cors({ origin: 'http://localhost:4000', credentials: true }));
app.use(express.json());
// Routes
const authRoutes = require('./routes/auth');
const qrRoutes = require('./routes/qr');
const redirectRoutes = require('./routes/redirect');
const analyticsRoutes = require('./routes/analytics');
const adminRoutes = require('./routes/admin');
const paymentsRoutes = require('./routes/payments');
app.use('/api/auth', authRoutes);
app.use('/api/qr', qrRoutes);
app.use('/api/analytics', analyticsRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/payments', paymentsRoutes);
app.use('/l', redirectRoutes);
// Postgres setup
const pgPool = new Pool({
host: process.env.PGHOST || 'postgres',
port: process.env.PGPORT || 5432,
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'ckqr',
});
// Redis setup
const redisClient = createClient({
url: `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || 6379}`
});
redisClient.on('error', (err) => console.log('Redis Client Error', err));
app.get('/api/health', async (req, res) => {
let pgStatus = 'Unknown';
let redisStatus = 'Unknown';
try {
const pgRes = await pgPool.query('SELECT NOW()');
pgStatus = 'Connected';
} catch (err) {
pgStatus = `Error: ${err.message}`;
}
try {
if (!redisClient.isOpen) {
await redisClient.connect();
}
await redisClient.ping();
redisStatus = 'Connected';
} catch (err) {
redisStatus = `Error: ${err.message}`;
}
res.json({
status: 'ok',
services: {
api: 'Running',
postgres: pgStatus,
redis: redisStatus
}
});
});
app.listen(port, () => {
console.log(`CK-QR API listening on port ${port}`);
});
Submodule
+1
Submodule client added at bcc39bfd52
+61
View File
@@ -0,0 +1,61 @@
version: '3.8'
services:
frontend:
build:
context: ./client
dockerfile: Dockerfile.dev
ports:
- "4000:4000"
volumes:
- ./client:/app
- /app/node_modules
environment:
- PORT=4000
depends_on:
- backend
backend:
build:
context: ./api
dockerfile: Dockerfile.dev
ports:
- "4001:4001"
- "9229:9229"
volumes:
- ./api:/app
- /app/node_modules
environment:
- PORT=4001
- PGHOST=postgres
- PGPORT=5432
- PGUSER=postgres
- PGPASSWORD=postgres
- PGDATABASE=ckqr
- REDIS_HOST=redis
- REDIS_PORT=6379
depends_on:
- postgres
- redis
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=ckqr
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data: