Implement email verification lockout and missing phase 2 items
This commit is contained in:
@@ -13,14 +13,34 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
CREATE TABLE IF NOT EXISTS qr_codes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id UUID, -- If created inside a team workspace
|
||||
type VARCHAR(20) NOT NULL, -- 'static' or 'dynamic'
|
||||
data_type VARCHAR(50) NOT NULL, -- 'URL', 'Wi-Fi', etc.
|
||||
destination_url TEXT,
|
||||
routing_data JSONB, -- Stores specific data for vCard, App Store, etc.
|
||||
short_url_id VARCHAR(50) UNIQUE, -- Only for dynamic routing e.g. 'xyz123'
|
||||
design_data JSONB, -- Custom styling
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id UUID,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
member_email VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) DEFAULT 'editor',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner_id, member_email)
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const { Pool } = require('pg');
|
||||
const pool = new Pool({host: 'localhost', port: 5432, user: 'postgres', password: 'password123', database: 'ckqr'});
|
||||
async function run() {
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(255) PRIMARY KEY,
|
||||
value TEXT,
|
||||
category VARCHAR(50),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
console.log('Created app_settings table.');
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO app_settings (key, value, category) VALUES
|
||||
('smtp_host', 'smtp.office365.com', 'smtp'),
|
||||
('smtp_port', '587', 'smtp'),
|
||||
('smtp_user', 'noreply@cavininfotech.com', 'smtp'),
|
||||
('smtp_pass', 'xmkshjlszjbkcyzb', 'smtp'),
|
||||
('smtp_secure', 'false', 'smtp')
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||
`);
|
||||
console.log('Inserted SMTP settings.');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
run();
|
||||
@@ -80,6 +80,15 @@ router.post('/login', async (req, res) => {
|
||||
const isMatch = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
if (!user.is_email_verified) {
|
||||
const createdAt = new Date(user.created_at);
|
||||
const now = new Date();
|
||||
const daysSinceCreation = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceCreation > 5) {
|
||||
return res.status(403).json({ error: 'Account locked. Please verify your email address to continue.', needsVerification: true });
|
||||
}
|
||||
}
|
||||
|
||||
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' });
|
||||
|
||||
|
||||
+82
-15
@@ -12,6 +12,56 @@ const pgPool = new Pool({
|
||||
password: process.env.PGPASSWORD || 'postgres',
|
||||
database: process.env.PGDATABASE || 'ckqr',
|
||||
});
|
||||
// === BULK OPERATIONS ===
|
||||
|
||||
// POST /api/qr/bulk-delete
|
||||
router.post('/bulk-delete', verifyToken, async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ error: 'No IDs provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pgPool.query(
|
||||
'DELETE FROM qr_codes WHERE id = ANY($1) AND user_id = $2 RETURNING id',
|
||||
[ids, req.user.id]
|
||||
);
|
||||
res.json({ success: true, deleted: result.rowCount });
|
||||
} catch (err) {
|
||||
console.error('Error in bulk delete:', err);
|
||||
res.status(500).json({ error: 'Server error during bulk delete' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/qr/bulk-move
|
||||
router.post('/bulk-move', verifyToken, async (req, res) => {
|
||||
const { ids, folderId } = req.body;
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ error: 'No IDs provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
let targetFolder = folderId;
|
||||
if (targetFolder === 'default') targetFolder = null;
|
||||
|
||||
// Verify folder ownership if not default
|
||||
if (targetFolder) {
|
||||
const folderRes = await pgPool.query('SELECT id FROM folders WHERE id = $1 AND user_id = $2', [targetFolder, req.user.id]);
|
||||
if (folderRes.rows.length === 0) {
|
||||
return res.status(403).json({ error: 'Folder access denied' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pgPool.query(
|
||||
'UPDATE qr_codes SET folder_id = $1 WHERE id = ANY($2) AND user_id = $3 RETURNING id',
|
||||
[targetFolder, ids, req.user.id]
|
||||
);
|
||||
res.json({ success: true, moved: result.rowCount });
|
||||
} catch (err) {
|
||||
console.error('Error in bulk move:', err);
|
||||
res.status(500).json({ error: 'Server error during bulk move' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all QRs for user
|
||||
router.get('/', verifyToken, async (req, res) => {
|
||||
@@ -57,7 +107,7 @@ router.get('/:id', verifyToken, async (req, res) => {
|
||||
|
||||
// Generate QR Code
|
||||
router.post('/generate', verifyToken, requireEmailVerification, async (req, res) => {
|
||||
const { name, type, dataType, destinationUrl, designData, customSlug, folderId } = req.body;
|
||||
const { name, type, dataType, destinationUrl, routingData, designData, customSlug, folderId, tags } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!type || !dataType) {
|
||||
@@ -66,11 +116,11 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res)
|
||||
|
||||
try {
|
||||
let shortUrlId = null;
|
||||
let finalQrContent = destinationUrl;
|
||||
let finalQrContent = destinationUrl; // Only valid if static and URL
|
||||
|
||||
// In static mode, the finalQrContent is whatever payload we send (handled mostly by frontend),
|
||||
// but if dynamic, we generate the shortUrlId.
|
||||
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();
|
||||
|
||||
@@ -84,16 +134,20 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res)
|
||||
|
||||
// In production, this would use the real domain (e.g. https://ck-qr.com/l/)
|
||||
finalQrContent = `${process.env.FRONTEND_URL || 'http://localhost:4000'}/l/${shortUrlId}`;
|
||||
} else {
|
||||
// If static, destinationUrl is sent as the actual payload by the frontend for simple URLs,
|
||||
// but for complex types, frontend puts the raw payload in designData.data.
|
||||
// We just store destinationUrl for backward compatibility.
|
||||
}
|
||||
|
||||
const result = await pgPool.query(
|
||||
`INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, short_url_id, design_data, folder_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[userId, name || 'Untitled QR', type, dataType, destinationUrl, shortUrlId, designData || {}, folderId || null]
|
||||
`INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, routing_data, short_url_id, design_data, folder_id, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
||||
[userId, name || 'Untitled QR', type, dataType, destinationUrl || null, routingData || null, shortUrlId, designData || {}, folderId || null, tags || []]
|
||||
);
|
||||
|
||||
// Generate Image (Data URI for simple response, could be saved to S3 later)
|
||||
const qrImage = await QRCode.toDataURL(finalQrContent);
|
||||
const qrImage = await QRCode.toDataURL(finalQrContent || 'preview');
|
||||
|
||||
res.json({
|
||||
message: 'QR Code generated',
|
||||
@@ -108,7 +162,7 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res)
|
||||
|
||||
// Update QR Code (Editable Link / Move Folder / Update Design)
|
||||
router.put('/:id', verifyToken, requireEmailVerification, async (req, res) => {
|
||||
const { name, destinationUrl, shortUrlId, designData, folderId } = req.body;
|
||||
const { name, dataType, destinationUrl, routingData, shortUrlId, designData, folderId, tags } = req.body;
|
||||
const qrId = req.params.id;
|
||||
const userId = req.user.id;
|
||||
|
||||
@@ -134,12 +188,25 @@ router.put('/:id', verifyToken, requireEmailVerification, async (req, res) => {
|
||||
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),
|
||||
design_data = COALESCE($4, design_data),
|
||||
folder_id = $5
|
||||
WHERE id = $6 RETURNING *`,
|
||||
[name, destinationUrl, newShortUrlId, designData || qr.design_data, folderId !== undefined ? (folderId === 'default' ? null : folderId) : qr.folder_id, qrId]
|
||||
data_type = COALESCE($2, data_type),
|
||||
destination_url = $3,
|
||||
routing_data = COALESCE($4, routing_data),
|
||||
short_url_id = COALESCE($5, short_url_id),
|
||||
design_data = COALESCE($6, design_data),
|
||||
folder_id = $7,
|
||||
tags = $8
|
||||
WHERE id = $9 RETURNING *`,
|
||||
[
|
||||
name,
|
||||
dataType,
|
||||
destinationUrl !== undefined ? destinationUrl : qr.destination_url,
|
||||
routingData !== undefined ? routingData : qr.routing_data,
|
||||
newShortUrlId,
|
||||
designData || qr.design_data,
|
||||
folderId !== undefined ? (folderId === 'default' ? null : folderId) : qr.folder_id,
|
||||
tags !== undefined ? tags : qr.tags,
|
||||
qrId
|
||||
]
|
||||
);
|
||||
|
||||
res.json({ message: 'QR Code updated', qrRecord: updated.rows[0] });
|
||||
|
||||
+43
-6
@@ -34,7 +34,7 @@ router.get('/:shortUrlId', async (req, res) => {
|
||||
} else {
|
||||
// 2. Not in cache, check DB
|
||||
const result = await pgPool.query(
|
||||
`SELECT q.id as qr_id, q.destination_url, u.role
|
||||
`SELECT q.id as qr_id, q.destination_url, q.data_type, q.routing_data, u.role
|
||||
FROM qr_codes q
|
||||
JOIN users u ON q.user_id = u.id
|
||||
WHERE q.short_url_id = $1`,
|
||||
@@ -62,15 +62,52 @@ router.get('/:shortUrlId', async (req, res) => {
|
||||
[qrData.qr_id, ip, req.headers['user-agent'], os, deviceType, country]
|
||||
).catch(err => console.error('Failed to log scan:', err));
|
||||
|
||||
// 4. Check Tier & Redirect
|
||||
if (qrData.role === 'free') {
|
||||
const encodedUrl = encodeURIComponent(qrData.destination_url);
|
||||
// 4. Determine final destination based on data_type
|
||||
let finalDestination = qrData.destination_url;
|
||||
let isDirectAction = false;
|
||||
let isDownload = false;
|
||||
let downloadContent = '';
|
||||
|
||||
if (qrData.data_type === 'App Store' && qrData.routing_data) {
|
||||
const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data;
|
||||
if (os === 'iOS' || os === 'Mac OS') {
|
||||
finalDestination = rd.ios || rd.fallback;
|
||||
} else if (os === 'Android') {
|
||||
finalDestination = rd.android || rd.fallback;
|
||||
} else {
|
||||
finalDestination = rd.fallback || rd.ios || rd.android;
|
||||
}
|
||||
} else if (qrData.data_type === 'Email' && qrData.routing_data) {
|
||||
const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data;
|
||||
finalDestination = `mailto:${rd.email}?subject=${encodeURIComponent(rd.subject || '')}&body=${encodeURIComponent(rd.body || '')}`;
|
||||
isDirectAction = true;
|
||||
} else if (qrData.data_type === 'SMS' && qrData.routing_data) {
|
||||
const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data;
|
||||
finalDestination = `sms:${rd.phone}?body=${encodeURIComponent(rd.message || '')}`;
|
||||
isDirectAction = true;
|
||||
} else if (qrData.data_type === 'vCard' && qrData.routing_data) {
|
||||
const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data;
|
||||
isDownload = true;
|
||||
downloadContent = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:${rd.lastName || ''};${rd.firstName || ''}\r\nFN:${rd.firstName || ''} ${rd.lastName || ''}\r\nORG:${rd.company || ''}\r\nTITLE:${rd.title || ''}\r\nTEL:${rd.phone || ''}\r\nEMAIL:${rd.email || ''}\r\nEND:VCARD`;
|
||||
} else if (qrData.data_type === 'Wi-Fi') {
|
||||
return res.send('Wi-Fi QR Codes are meant to be scanned natively as static QRs.');
|
||||
}
|
||||
|
||||
if (isDownload) {
|
||||
res.setHeader('Content-Type', 'text/vcard');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="contact.vcf"');
|
||||
return res.send(downloadContent);
|
||||
}
|
||||
|
||||
// 5. Check Tier & Redirect
|
||||
if (qrData.role === 'free' && !isDirectAction) {
|
||||
const encodedUrl = encodeURIComponent(finalDestination);
|
||||
// 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);
|
||||
// Paid users or Direct Actions (mailto/sms) skip the ad
|
||||
return res.redirect(302, finalDestination);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@@ -145,6 +145,22 @@ ADD COLUMN IF NOT EXISTS is_email_verified BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(255);
|
||||
`).catch(console.error);
|
||||
|
||||
pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';`).catch(console.error);
|
||||
pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS routing_data JSONB;`).catch(console.error);
|
||||
pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS workspace_id UUID;`).catch(console.error);
|
||||
pgPool.query(`ALTER TABLE folders ADD COLUMN IF NOT EXISTS workspace_id UUID;`).catch(console.error);
|
||||
|
||||
pgPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
member_email VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) DEFAULT 'editor',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner_id, member_email)
|
||||
);
|
||||
`).catch(console.error);
|
||||
|
||||
pgPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
|
||||
Reference in New Issue
Block a user