Initial commit of CK-QR Platform
This commit is contained in:
+15
@@ -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/
|
||||||
@@ -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 3–5 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.
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
Generated
+1740
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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:
|
||||||
Reference in New Issue
Block a user