Files
cannaiq/backend/src/routes/users.ts
Kelly 249d3c1b7f fix: Build args format for version info + schema-tolerant routes
CI/CD:
- Fix build_args format in woodpecker CI (comma-separated, not YAML list)
- This fixes "unknown" SHA/version showing on remote deployments

Backend schema-tolerant fixes (graceful fallbacks when tables missing):
- users.ts: Check which columns exist before querying
- worker-registry.ts: Return empty result if table doesn't exist
- task-service.ts: Add tableExists() helper, handle missing tables/views
- proxies.ts: Return totalProxies in test-all response

Frontend fixes:
- Proxies: Use total from response for accurate progress display
- SEO PagesTab: Dim Generate button when no AI provider active

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 09:53:21 -07:00

266 lines
9.2 KiB
TypeScript

import { Router } from 'express';
import bcrypt from 'bcrypt';
import { pool } from '../db/pool';
import { authMiddleware, requireRole, AuthRequest } from '../auth/middleware';
const router = Router();
// All routes require authentication and admin/superadmin role
router.use(authMiddleware);
router.use(requireRole('admin', 'superadmin'));
// Get all users with search and filter
router.get('/', async (req: AuthRequest, res) => {
try {
const { search, domain } = req.query;
// Check which columns exist (schema-tolerant)
const columnsResult = await pool.query(`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
`);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// Build column list based on what exists
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
if (existingColumns.has('first_name')) selectCols.push('first_name');
if (existingColumns.has('last_name')) selectCols.push('last_name');
if (existingColumns.has('phone')) selectCols.push('phone');
if (existingColumns.has('domain')) selectCols.push('domain');
let query = `SELECT ${selectCols.join(', ')} FROM users WHERE 1=1`;
const params: any[] = [];
let paramIndex = 1;
// Search by email (and optionally first_name, last_name if they exist)
if (search && typeof search === 'string') {
const searchClauses = ['email ILIKE $' + paramIndex];
if (existingColumns.has('first_name')) searchClauses.push('first_name ILIKE $' + paramIndex);
if (existingColumns.has('last_name')) searchClauses.push('last_name ILIKE $' + paramIndex);
query += ` AND (${searchClauses.join(' OR ')})`;
params.push(`%${search}%`);
paramIndex++;
}
// Filter by domain (if column exists)
if (domain && typeof domain === 'string' && existingColumns.has('domain')) {
query += ` AND domain = $${paramIndex}`;
params.push(domain);
paramIndex++;
}
query += ` ORDER BY created_at DESC`;
const result = await pool.query(query, params);
res.json({ users: result.rows });
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({ error: 'Failed to fetch users' });
}
});
// Get single user
router.get('/:id', async (req: AuthRequest, res) => {
try {
const { id } = req.params;
// Check which columns exist (schema-tolerant)
const columnsResult = await pool.query(`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
`);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
if (existingColumns.has('first_name')) selectCols.push('first_name');
if (existingColumns.has('last_name')) selectCols.push('last_name');
if (existingColumns.has('phone')) selectCols.push('phone');
if (existingColumns.has('domain')) selectCols.push('domain');
const result = await pool.query(`
SELECT ${selectCols.join(', ')}
FROM users
WHERE id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ user: result.rows[0] });
} catch (error) {
console.error('Error fetching user:', error);
res.status(500).json({ error: 'Failed to fetch user' });
}
});
// Create user
router.post('/', async (req: AuthRequest, res) => {
try {
const { email, password, role, first_name, last_name, phone, domain } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
// Check for valid role
const validRoles = ['admin', 'analyst', 'viewer'];
if (role && !validRoles.includes(role)) {
return res.status(400).json({ error: 'Invalid role. Must be: admin, analyst, or viewer' });
}
// Check for valid domain
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
if (domain && !validDomains.includes(domain)) {
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
}
// Check if email already exists
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
if (existing.rows.length > 0) {
return res.status(400).json({ error: 'Email already exists' });
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
const result = await pool.query(`
INSERT INTO users (email, password_hash, role, first_name, last_name, phone, domain)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
`, [email, passwordHash, role || 'viewer', first_name || null, last_name || null, phone || null, domain || 'cannaiq.co']);
res.status(201).json({ user: result.rows[0] });
} catch (error) {
console.error('Error creating user:', error);
res.status(500).json({ error: 'Failed to create user' });
}
});
// Update user
router.put('/:id', async (req: AuthRequest, res) => {
try {
const { id } = req.params;
const { email, password, role, first_name, last_name, phone, domain } = req.body;
// Check if user exists
const existing = await pool.query('SELECT id FROM users WHERE id = $1', [id]);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
// Check for valid role
const validRoles = ['admin', 'analyst', 'viewer', 'superadmin'];
if (role && !validRoles.includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
// Check for valid domain
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
if (domain && !validDomains.includes(domain)) {
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
}
// Prevent non-superadmin from modifying superadmin users
const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]);
if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') {
return res.status(403).json({ error: 'Cannot modify superadmin users' });
}
// Build update query dynamically
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (email) {
// Check if email already taken by another user
const emailCheck = await pool.query('SELECT id FROM users WHERE email = $1 AND id != $2', [email, id]);
if (emailCheck.rows.length > 0) {
return res.status(400).json({ error: 'Email already in use' });
}
updates.push(`email = $${paramIndex++}`);
values.push(email);
}
if (password) {
const passwordHash = await bcrypt.hash(password, 10);
updates.push(`password_hash = $${paramIndex++}`);
values.push(passwordHash);
}
if (role) {
updates.push(`role = $${paramIndex++}`);
values.push(role);
}
// Handle profile fields (allow setting to null with explicit undefined check)
if (first_name !== undefined) {
updates.push(`first_name = $${paramIndex++}`);
values.push(first_name || null);
}
if (last_name !== undefined) {
updates.push(`last_name = $${paramIndex++}`);
values.push(last_name || null);
}
if (phone !== undefined) {
updates.push(`phone = $${paramIndex++}`);
values.push(phone || null);
}
if (domain !== undefined) {
updates.push(`domain = $${paramIndex++}`);
values.push(domain);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
updates.push(`updated_at = NOW()`);
values.push(id);
const result = await pool.query(`
UPDATE users
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
`, values);
res.json({ user: result.rows[0] });
} catch (error) {
console.error('Error updating user:', error);
res.status(500).json({ error: 'Failed to update user' });
}
});
// Delete user
router.delete('/:id', async (req: AuthRequest, res) => {
try {
const { id } = req.params;
// Prevent deleting yourself
if (req.user?.id === parseInt(id)) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
// Prevent non-superadmin from deleting superadmin users
const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]);
if (targetUser.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') {
return res.status(403).json({ error: 'Cannot delete superadmin users' });
}
await pool.query('DELETE FROM users WHERE id = $1', [id]);
res.json({ success: true });
} catch (error) {
console.error('Error deleting user:', error);
res.status(500).json({ error: 'Failed to delete user' });
}
});
export default router;