300 lines
9.9 KiB
JavaScript
Executable file
300 lines
9.9 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
// FreeScout time tracking CLI — query timelogs and generate invoice Excel
|
|
const mysql = require('mysql2/promise');
|
|
const ExcelJS = require('exceljs');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const credFile = process.env.CRED_FILE || path.join(__dirname, '..', '.credentials', 'services.env');
|
|
const env = {};
|
|
fs.readFileSync(credFile, 'utf8').split('\n').forEach(l => { const m = l.match(/^(\w+)=(.+)$/); if (m) env[m[1]] = m[2]; });
|
|
|
|
async function getConn() {
|
|
return mysql.createConnection({
|
|
host: env.CLOONAR_DB_HOST,
|
|
port: parseInt(env.CLOONAR_DB_PORT || '3306'),
|
|
user: env.CLOONAR_DB_USER,
|
|
password: env.CLOONAR_DB_PASS,
|
|
database: env.FREESCOUT_DB_NAME,
|
|
});
|
|
}
|
|
|
|
// Load custom field dropdown options (id -> {optionId: label})
|
|
async function loadCustomFieldOptions(conn) {
|
|
const [fields] = await conn.query('SELECT id, name, options FROM custom_fields');
|
|
const map = {};
|
|
fields.forEach(f => {
|
|
map[f.name.toLowerCase()] = { id: f.id, options: f.options ? JSON.parse(f.options) : {} };
|
|
});
|
|
return map;
|
|
}
|
|
|
|
function formatHours(seconds) {
|
|
const h = seconds / 3600;
|
|
return Math.round(h * 100) / 100;
|
|
}
|
|
|
|
// Get custom field values for conversations, resolved to labels
|
|
async function getConversationCustomFields(conn, conversationIds, cfMap) {
|
|
if (!conversationIds.length) return {};
|
|
const [rows] = await conn.query(
|
|
'SELECT conversation_id, custom_field_id, value FROM conversation_custom_field WHERE conversation_id IN (?)',
|
|
[conversationIds]
|
|
);
|
|
// Build: { convId: { fieldName: resolvedValue } }
|
|
const result = {};
|
|
const idToField = {};
|
|
Object.entries(cfMap).forEach(([name, f]) => { idToField[f.id] = { name, options: f.options }; });
|
|
|
|
rows.forEach(r => {
|
|
if (!result[r.conversation_id]) result[r.conversation_id] = {};
|
|
const field = idToField[r.custom_field_id];
|
|
if (field) {
|
|
const resolved = field.options[r.value] || r.value || '-';
|
|
result[r.conversation_id][field.name] = resolved;
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// --- Commands ---
|
|
|
|
async function cmdMailboxes() {
|
|
const conn = await getConn();
|
|
const [rows] = await conn.query('SELECT id, name FROM mailboxes ORDER BY name');
|
|
console.log('ID\tNAME');
|
|
rows.forEach(r => console.log(`${r.id}\t${r.name}`));
|
|
await conn.end();
|
|
}
|
|
|
|
async function cmdUsers() {
|
|
const conn = await getConn();
|
|
const [rows] = await conn.query('SELECT id, first_name, last_name, email FROM users ORDER BY first_name');
|
|
console.log('ID\tNAME\tEMAIL');
|
|
rows.forEach(r => console.log(`${r.id}\t${r.first_name} ${r.last_name}\t${r.email}`));
|
|
await conn.end();
|
|
}
|
|
|
|
// Resolve customer name to custom field option ID
|
|
function resolveCustomerOptionId(cfMap, customerName) {
|
|
const cf = cfMap.customer;
|
|
if (!cf) return null;
|
|
const match = Object.entries(cf.options).find(([k, v]) => v.toLowerCase() === customerName.toLowerCase());
|
|
return match ? match[0] : null;
|
|
}
|
|
|
|
async function cmdReport(args) {
|
|
const mailboxId = getFlag(args, '--mailbox');
|
|
const from = getFlag(args, '--from');
|
|
const to = getFlag(args, '--to');
|
|
const userId = getFlag(args, '--user');
|
|
const customerFilter = getFlag(args, '--customer');
|
|
|
|
if (!from || !to) {
|
|
console.error('Usage: freescout report --from YYYY-MM-DD --to YYYY-MM-DD [--mailbox ID] [--user ID] [--customer NAME]');
|
|
process.exit(1);
|
|
}
|
|
|
|
const conn = await getConn();
|
|
const cfMap = await loadCustomFieldOptions(conn);
|
|
|
|
let query = `
|
|
SELECT
|
|
t.time_spent,
|
|
t.created_at,
|
|
c.id as conversation_id,
|
|
c.number as conversation_number,
|
|
c.subject,
|
|
c.customer_email,
|
|
m.name as mailbox_name,
|
|
u.first_name,
|
|
u.last_name
|
|
FROM timelogs t
|
|
JOIN conversations c ON t.conversation_id = c.id
|
|
JOIN mailboxes m ON c.mailbox_id = m.id
|
|
JOIN users u ON t.user_id = u.id
|
|
WHERE t.created_at >= ? AND t.created_at < DATE_ADD(?, INTERVAL 1 DAY)
|
|
`;
|
|
const params = [from, to];
|
|
|
|
if (mailboxId) { query += ' AND c.mailbox_id = ?'; params.push(mailboxId); }
|
|
if (userId) { query += ' AND t.user_id = ?'; params.push(userId); }
|
|
if (customerFilter && cfMap.customer) {
|
|
const optId = resolveCustomerOptionId(cfMap, customerFilter);
|
|
if (!optId) {
|
|
const available = Object.values(cfMap.customer.options).join(', ');
|
|
console.error(`Customer "${customerFilter}" not found. Available: ${available}`);
|
|
await conn.end();
|
|
process.exit(1);
|
|
}
|
|
query += ' AND c.id IN (SELECT conversation_id FROM conversation_custom_field WHERE custom_field_id = ? AND value = ?)';
|
|
params.push(cfMap.customer.id, optId);
|
|
}
|
|
|
|
query += ' ORDER BY t.created_at';
|
|
|
|
const [rows] = await conn.query(query, params);
|
|
|
|
if (rows.length === 0) {
|
|
console.log('No timelogs found for this period.');
|
|
await conn.end();
|
|
return;
|
|
}
|
|
|
|
const convIds = [...new Set(rows.map(r => r.conversation_id))];
|
|
const cfValues = await getConversationCustomFields(conn, convIds, cfMap);
|
|
|
|
console.log('DATE\tCONV#\tSUBJECT\tCUSTOMER\tWEBSITE\tUSER\tHOURS');
|
|
let totalSeconds = 0;
|
|
rows.forEach(r => {
|
|
const date = new Date(r.created_at).toISOString().slice(0, 10);
|
|
const hours = formatHours(r.time_spent);
|
|
totalSeconds += r.time_spent;
|
|
const cf = cfValues[r.conversation_id] || {};
|
|
console.log(`${date}\t#${r.conversation_number}\t${r.subject || '(no subject)'}\t${cf.customer || '-'}\t${cf.website || '-'}\t${r.first_name} ${r.last_name}\t${hours}`);
|
|
});
|
|
console.log(`\nTotal: ${formatHours(totalSeconds)} hours (${rows.length} entries)`);
|
|
|
|
await conn.end();
|
|
}
|
|
|
|
async function cmdExcel(args) {
|
|
const mailboxId = getFlag(args, '--mailbox');
|
|
const from = getFlag(args, '--from');
|
|
const to = getFlag(args, '--to');
|
|
const userId = getFlag(args, '--user');
|
|
const customerFilter = getFlag(args, '--customer');
|
|
const output = getFlag(args, '--output') || `invoice-${from}-${to}.xlsx`;
|
|
|
|
if (!from || !to) {
|
|
console.error('Usage: freescout excel --from YYYY-MM-DD --to YYYY-MM-DD [--mailbox ID] [--user ID] [--customer NAME] [--output file.xlsx]');
|
|
process.exit(1);
|
|
}
|
|
|
|
const conn = await getConn();
|
|
const cfMap = await loadCustomFieldOptions(conn);
|
|
|
|
let query = `
|
|
SELECT
|
|
t.time_spent,
|
|
t.created_at,
|
|
c.id as conversation_id,
|
|
c.number as conversation_number,
|
|
c.subject,
|
|
c.customer_email
|
|
FROM timelogs t
|
|
JOIN conversations c ON t.conversation_id = c.id
|
|
JOIN mailboxes m ON c.mailbox_id = m.id
|
|
JOIN users u ON t.user_id = u.id
|
|
WHERE t.created_at >= ? AND t.created_at < DATE_ADD(?, INTERVAL 1 DAY)
|
|
`;
|
|
const params = [from, to];
|
|
|
|
if (mailboxId) { query += ' AND c.mailbox_id = ?'; params.push(mailboxId); }
|
|
if (userId) { query += ' AND t.user_id = ?'; params.push(userId); }
|
|
if (customerFilter && cfMap.customer) {
|
|
const optId = resolveCustomerOptionId(cfMap, customerFilter);
|
|
if (!optId) {
|
|
const available = Object.values(cfMap.customer.options).join(', ');
|
|
console.error(`Customer "${customerFilter}" not found. Available: ${available}`);
|
|
await conn.end();
|
|
process.exit(1);
|
|
}
|
|
query += ' AND c.id IN (SELECT conversation_id FROM conversation_custom_field WHERE custom_field_id = ? AND value = ?)';
|
|
params.push(cfMap.customer.id, optId);
|
|
}
|
|
|
|
query += ' ORDER BY t.created_at';
|
|
|
|
const [rows] = await conn.query(query, params);
|
|
|
|
if (rows.length === 0) {
|
|
console.log('No timelogs found for this period.');
|
|
await conn.end();
|
|
return;
|
|
}
|
|
|
|
const convIds = [...new Set(rows.map(r => r.conversation_id))];
|
|
const cfValues = await getConversationCustomFields(conn, convIds, cfMap);
|
|
await conn.end();
|
|
|
|
// Group by conversation
|
|
const byConv = {};
|
|
rows.forEach(r => {
|
|
const key = r.conversation_number;
|
|
if (!byConv[key]) {
|
|
const cf = cfValues[r.conversation_id] || {};
|
|
byConv[key] = {
|
|
number: r.conversation_number,
|
|
subject: r.subject || '(no subject)',
|
|
customer: cf.customer || '-',
|
|
website: cf.website || '-',
|
|
totalSeconds: 0,
|
|
};
|
|
}
|
|
byConv[key].totalSeconds += r.time_spent;
|
|
});
|
|
|
|
// Build Excel
|
|
const wb = new ExcelJS.Workbook();
|
|
const ws = wb.addWorksheet('Time Report');
|
|
|
|
ws.columns = [
|
|
{ header: 'Conv #', key: 'number', width: 10 },
|
|
{ header: 'Subject', key: 'subject', width: 40 },
|
|
{ header: 'Customer', key: 'customer', width: 25 },
|
|
{ header: 'Website', key: 'website', width: 25 },
|
|
{ header: 'Hours', key: 'hours', width: 10 },
|
|
];
|
|
|
|
ws.getRow(1).font = { bold: true };
|
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
|
|
let totalSeconds = 0;
|
|
Object.values(byConv).forEach(conv => {
|
|
totalSeconds += conv.totalSeconds;
|
|
ws.addRow({
|
|
number: `#${conv.number}`,
|
|
subject: conv.subject,
|
|
customer: conv.customer,
|
|
website: conv.website,
|
|
hours: formatHours(conv.totalSeconds),
|
|
});
|
|
});
|
|
|
|
const totalRow = ws.addRow({ number: '', subject: '', customer: '', website: 'TOTAL', hours: formatHours(totalSeconds) });
|
|
totalRow.font = { bold: true };
|
|
|
|
await wb.xlsx.writeFile(output);
|
|
console.log(`Written: ${output}`);
|
|
console.log(`${Object.keys(byConv).length} conversations, ${formatHours(totalSeconds)} hours total`);
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
function getFlag(args, flag) {
|
|
const idx = args.indexOf(flag);
|
|
if (idx === -1) return null;
|
|
return args[idx + 1] || null;
|
|
}
|
|
|
|
// --- Main ---
|
|
|
|
const [cmd, ...args] = process.argv.slice(2);
|
|
|
|
const commands_map = { mailboxes: cmdMailboxes, users: cmdUsers, report: cmdReport, excel: cmdExcel };
|
|
|
|
if (!cmd || !commands_map[cmd]) {
|
|
console.log(`Usage: freescout <command> [args]
|
|
|
|
Commands:
|
|
mailboxes List mailboxes
|
|
users List users
|
|
report --from DATE --to DATE Show time report (tab-separated)
|
|
[--mailbox ID] [--user ID] [--customer NAME]
|
|
excel --from DATE --to DATE Generate invoice Excel
|
|
[--mailbox ID] [--user ID] [--customer NAME] [--output file.xlsx]`);
|
|
process.exit(0);
|
|
}
|
|
|
|
commands_map[cmd](args).catch(e => { console.error(e.message); process.exit(1); });
|