Files
DashDescomplicar/api/services/dashboard.ts
Emanuel Almeida f1756829af security: implement 6 high-severity vulnerability fixes
HIGH-SEVERITY FIXES (Fase 2):

1. Rate Limiting (Vulnerabilidade 2.1)
   - express-rate-limit: 100 req/15min (prod), 1000 req/15min (dev)
   - Applied to all /api/* routes
   - Standard headers for retry-after

2. CORS Restrictions (Vulnerabilidade 2.2)
   - Whitelist: dashboard.descomplicar.pt, desk.descomplicar.pt
   - Localhost only in development
   - CORS blocking logs

3. Input Validation with Zod (Vulnerabilidade 2.4)
   - Generic validateRequest() middleware
   - Schemas: WordPress Monitor, server metrics, dashboard, financial
   - Applied to api/routes/wp-monitor.ts POST endpoint
   - Detailed field-level error messages

4. Backend Authentication OIDC (Vulnerabilidade 2.5 - OPTIONAL)
   - Enabled via OIDC_ENABLED=true
   - Bearer token validation on all APIs
   - Backward compatible (disabled by default)

5. SSH Key-Based Auth Migration (Vulnerabilidade 2.6)
   - Script: /media/ealmeida/Dados/Dev/ClaudeDev/migrate-ssh-keys.sh
   - Generates ed25519 key, copies to 6 servers
   - Instructions to remove passwords from .env
   - .env.example updated with SSH_PRIVATE_KEY_PATH

6. Improved Error Handling (Vulnerabilidade 2.5)
   - Unique error IDs (UUID) for tracking
   - Structured JSON logs in production
   - Stack traces blocked in production
   - Generic messages to client

FILES CHANGED:
- api/server.ts - Complete refactor with all security improvements
- api/middleware/validation.ts - NEW: Zod middleware and schemas
- api/routes/wp-monitor.ts - Added Zod validation on POST
- .env.example - Complete security documentation
- CHANGELOG.md - Full documentation of 9 fixes (3 critical + 6 high)
- package.json + package-lock.json - New dependencies

DEPENDENCIES ADDED:
- express-rate-limit@7.x
- zod@3.x
- express-openid-connect@2.x

AUDIT STATUS:
- npm audit: 0 vulnerabilities
- Hook Regra #47: PASSED

PROGRESS:
- Phase 1 (Critical): 3/3  COMPLETE
- Phase 2 (High): 6/6  COMPLETE
- Phase 3 (Medium): 0/6 - Next
- Phase 4 (Low): 0/5 - Next

Related: AUDIT-REPORT.md vulnerabilities 2.1, 2.2, 2.4, 2.5, 2.6

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 04:09:50 +00:00

289 lines
9.1 KiB
TypeScript
Executable File

/**
* Dashboard Queries Service
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import db from '../db.js'
import type { RowDataPacket } from 'mysql2'
// Helper: Get week range (Monday-Sunday)
function getWeekRange() {
const now = new Date()
const dow = now.getDay()
const diff = dow === 0 ? -6 : 1 - dow
const monday = new Date(now)
monday.setDate(now.getDate() + diff)
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
return {
inicio_semana: monday.toISOString().split('T')[0],
fim_semana: sunday.toISOString().split('T')[0],
inicio_unix: Math.floor(monday.getTime() / 1000)
}
}
// Tasks
export async function getUrgenteTasks() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT t.id, t.name, t.status, t.duedate, p.name as projeto
FROM tbltasks t
LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project'
WHERE t.priority = 4 AND t.status IN (1,4)
`)
return rows
}
export async function getAltaTasks() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT t.id, t.name, t.status, t.duedate, p.name as projeto,
DATEDIFF(t.duedate, CURDATE()) as dias
FROM tbltasks t
LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project'
WHERE t.priority = 3 AND t.status IN (1,4)
`)
return rows
}
export async function getVencidasTasks() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT t.id, t.name, t.duedate, p.name as projeto,
DATEDIFF(CURDATE(), t.duedate) as dias_atraso
FROM tbltasks t
LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project'
WHERE t.duedate < CURDATE()
AND t.status IN (1,4)
AND (t.description NOT LIKE '%[TAGS: ongoing]%' OR t.description IS NULL)
`)
return rows
}
export async function getEmTestesTasks() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT t.id, t.name, t.duedate, p.name as projeto
FROM tbltasks t
LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project'
WHERE t.status = 3
ORDER BY t.duedate ASC
`)
return rows
}
export async function getEstaSemana() {
const { inicio_semana, fim_semana } = getWeekRange()
const [rows] = await db.query<RowDataPacket[]>(`
SELECT t.id, t.name, t.duedate, p.name as projeto,
DATEDIFF(t.duedate, CURDATE()) as dias
FROM tbltasks t
LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project'
WHERE t.duedate BETWEEN ? AND ?
AND t.status IN (1,3,4)
`, [inicio_semana, fim_semana])
return rows
}
export async function getMondayMood() {
const now = new Date()
if (now.getDay() !== 1) return []
const [rows] = await db.query<RowDataPacket[]>(`
SELECT t.id, t.name, t.status, p.name as projeto
FROM tbltasks t
LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project'
INNER JOIN tbltaggables tg ON t.id = tg.rel_id AND tg.rel_type = 'task'
WHERE tg.tag_id = 67 AND t.status IN (1,4)
ORDER BY t.name
`)
return rows
}
// Tickets
export async function getTickets() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT ticketid, subject, priority
FROM tbltickets
WHERE status = 1
`)
return rows
}
// Leads
export async function getContactarLeads() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT l.id, l.name, l.company, DATEDIFF(CURDATE(), l.dateadded) as dias,
(SELECT name FROM tblleads_sources WHERE id = l.source) as source
FROM tblleads l
WHERE l.lost = 0 AND l.junk = 0 AND (
l.status = 14
OR (l.status = 12 AND (
(SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id) IS NULL
OR (SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id) < DATE_SUB(CURDATE(), INTERVAL 60 DAY)
))
)
AND NOT EXISTS (
SELECT 1 FROM tblreminders r
WHERE r.rel_id = l.id AND r.rel_type = 'lead' AND r.date > NOW() AND r.isnotified = 0
)
ORDER BY l.dateadded DESC
`)
return rows
}
export async function getFollowupLeads() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT l.id, l.name, l.company, DATEDIFF(CURDATE(), l.dateadded) as dias,
(SELECT name FROM tblleads_sources WHERE id = l.source) as source,
DATEDIFF(CURDATE(), (SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id)) as dias_sem_contacto
FROM tblleads l
WHERE l.status = 12 AND l.lost = 0 AND l.junk = 0
AND (SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id) >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
AND NOT EXISTS (
SELECT 1 FROM tblreminders r
WHERE r.rel_id = l.id AND r.rel_type = 'lead' AND r.date > NOW() AND r.isnotified = 0
)
ORDER BY (SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id) ASC
`)
return rows
}
export async function getPropostaLeads() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT id, name, company, DATEDIFF(CURDATE(), dateadded) as dias,
(SELECT name FROM tblleads_sources WHERE id = tblleads.source) as source
FROM tblleads
WHERE status = 4 AND lost = 0 AND junk = 0
`)
return rows
}
// Projects
export async function getProjectos() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT p.id, p.name, c.company as cliente,
(SELECT COUNT(*) FROM tbltasks WHERE rel_id = p.id AND rel_type = 'project') as total,
(SELECT COUNT(*) FROM tbltasks WHERE rel_id = p.id AND rel_type = 'project' AND status = 5) as concluidas
FROM tblprojects p
LEFT JOIN tblclients c ON p.clientid = c.userid
WHERE p.status IN (2, 53)
`)
return rows
}
// Timesheet
export async function getTimesheet() {
const { inicio_semana, inicio_unix } = getWeekRange()
const [rows] = await db.query<RowDataPacket[]>(`
SELECT SUM(
CASE
WHEN LENGTH(start_time) < 15
THEN (CAST(end_time AS UNSIGNED) - CAST(start_time AS UNSIGNED)) / 3600
ELSE TIMESTAMPDIFF(SECOND, start_time, end_time) / 3600
END
) as horas
FROM tbltaskstimers
WHERE staff_id = 1 AND end_time IS NOT NULL
AND ((LENGTH(start_time) < 15 AND CAST(start_time AS UNSIGNED) >= ?)
OR (LENGTH(start_time) >= 15 AND start_time >= ?))
`, [inicio_unix, inicio_semana])
const horas = Math.round((rows[0]?.horas || 0) * 10) / 10
const horas_pct = Math.min(100, Math.round((horas / 40) * 100))
return { horas, horas_pct }
}
// Billing 360
export async function getBilling360() {
const [rows] = await db.query<RowDataPacket[]>(`
SELECT
c.client_name,
c.desk_project_id,
c.monthly_hours,
COALESCE(SUM(i.hours_invoiced), 0) as total_invoiced,
(SELECT COALESCE(SUM(
CASE
WHEN LENGTH(tt.start_time) < 15
THEN (CAST(tt.end_time AS UNSIGNED) - CAST(tt.start_time AS UNSIGNED)) / 3600
ELSE TIMESTAMPDIFF(SECOND, tt.start_time, tt.end_time) / 3600
END
), 0)
FROM tbltaskstimers tt
INNER JOIN tbltasks t ON tt.task_id = t.id
WHERE t.rel_id = c.desk_project_id
AND t.rel_type = 'project'
AND tt.end_time IS NOT NULL
) as total_delivered
FROM billing_360_clients c
LEFT JOIN billing_360_invoices i ON c.id = i.client_id
WHERE c.active = 1
GROUP BY c.id
ORDER BY c.client_name
`)
return rows.map(client => {
const total_delivered = Math.round(client.total_delivered * 10) / 10
const balance = Math.round((client.total_invoiced - total_delivered) * 10) / 10
let status: 'credit' | 'debt' | 'ok'
if (balance > 5) status = 'credit'
else if (balance < -5) status = 'debt'
else status = 'ok'
return {
...client,
total_delivered,
balance,
status
}
})
}
// Pipeline
export async function getPipeline() {
const [leads] = await db.query<RowDataPacket[]>(`
SELECT ls.name as fase, COUNT(l.id) as qtd, COALESCE(SUM(l.lead_value), 0) as valor
FROM tblleads l
LEFT JOIN tblleads_status ls ON l.status = ls.id
WHERE l.lost = 0 AND l.junk = 0 AND l.status NOT IN (1)
GROUP BY l.status, ls.name, ls.statusorder
ORDER BY ls.statusorder
`)
const [estimates] = await db.query<RowDataPacket[]>(`
SELECT
CASE status
WHEN 2 THEN 'Enviado'
WHEN 3 THEN 'Visto'
END as fase,
COUNT(*) as qtd,
SUM(subtotal) as valor
FROM tblestimates
WHERE status IN (2,3)
GROUP BY status
`)
const [proposals] = await db.query<RowDataPacket[]>(`
SELECT COUNT(*) as qtd, COALESCE(SUM(subtotal), 0) as valor
FROM tblproposals
WHERE status = 4
`)
const [total] = await db.query<RowDataPacket[]>(`
SELECT (
(SELECT COALESCE(SUM(lead_value), 0) FROM tblleads WHERE lost=0 AND junk=0 AND status NOT IN (1))
+ (SELECT COALESCE(SUM(subtotal), 0) FROM tblestimates WHERE status IN (2,3))
+ (SELECT COALESCE(SUM(subtotal), 0) FROM tblproposals WHERE status = 4)
) as total
`)
return {
pipeline_leads: leads,
pipeline_estimates: estimates,
pipeline_proposals: proposals[0] || { qtd: 0, valor: 0 },
pipeline_valor: Math.round(total[0]?.total || 0)
}
}