Files
picpeak/backend/src/routes/adminDashboard.js
T
Paul Nothaft 9143997f8e style(backend): clear the eslint backlog to zero
929 problems (928 errors, 1 warning) -> 0, exit 0.

Rule breakdown, which corrects the report's premise -- `indent` dominated, not
`quotes`: indent 719, quotes 68, no-unused-vars 54, no-empty 36,
no-useless-escape 22, no-case-declarations 17, no-inner-declarations 6,
no-control-regex 5, no-useless-catch 1, no-console 1 (warn).

--fix handled only indent + quotes (719+68 = exactly the "fixable" count).
no-useless-escape was NOT auto-fixable in this eslint version, so the one
genuinely risky class never went through the autofixer -- all 22 were done by
hand. Two mechanical proofs on the autofix diff: a token-level AST diff
(espree, before vs after) shows exactly 68 differing tokens, all quotes, with
the 719 indent fixes producing zero token changes; and a cooked-value diff of
every string/template/regex literal shows 0 differences.

Regex escapes: eslint was correctly conservative and did not flag the
load-bearing ones -- \- in [^a-zA-Z0-9_\-\.] (unescaping makes an invalid
reversed _ -> . range) or in [!@#$%^&*()_+\-=...] (would become a + -> = range
silently matching ",-."). Every removal was a \/ \[ or \. inside a character
class; all 11 old/new pairs were brute-forced over 794 inputs with 0
mismatches.

Manual fixes: no-empty were all deliberate best-effort catches around activity
logging, annotated rather than restructured; no-case-declarations braced in
two adminBackup switches; no-inner-declarations converted to const arrows
after checking no call precedes the declaration and no this/arguments use;
no-control-regex and no-console got targeted disables with stated reasons;
one `catch (e) { throw e; }` wrapper removed.

Two unused bindings were near-misses worth noting: secureStatic.js's
`fullPath` is a path-traversal guard (safePathJoin throws on escape) and
restoreService.js's `backupManifest` is the throw-on-corrupt-manifest gate
before a rollback -- deleting either would have silently removed a check. Only
the bindings were dropped; the calls stay.

Two real bugs found and deliberately preserved with a comment plus a narrow
disable rather than deleted, since deleting would erase the evidence:
_workflowSeedBoot.js's `booted` is written but never read, so the intended
once-per-process guard is missing its early return and workflows re-seed on
every call; and quoteService.js's VALID_QUOTE_TRANSITIONS is a full state
machine nothing consults, so quote status changes are unvalidated.

Backend test suite: 253 suites / 2552 tests passing, 0 failures, before and
after.

Refs testplan REPORT.md #22 (Part 1.2.02).
2026-09-01 16:46:34 +02:00

682 lines
28 KiB
JavaScript

const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeDays } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat');
const { resolveAdapter } = require('../services/trackers');
const logger = require('../utils/logger');
const { errorResponse, getPagination } = require('../utils/routeHelpers');
const { measureLocalStorageUsage } = require('../services/localStorageUsage');
const router = express.Router();
/**
* Normalise a value coming back from `DATE(timestamp)` into a YYYY-MM-DD
* string. SQLite returns this column as a string already; Postgres' pg
* driver auto-converts it to a JavaScript Date object, which broke the
* old `dateObj.date === row.date` merge below — every Postgres install saw
* an all-zero `chartData[]` even with real traffic (#661 Bug A). Always
* normalise before comparing.
*/
function normaliseDateKey(value) {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
// Strings might arrive with time component, slice defensively.
return String(value).slice(0, 10);
}
/**
* Event ids the caller's dashboard may aggregate over, or `null` when the
* caller is unrestricted (GHSA-c2jj / gqx7 / jhcf).
*
* These endpoints are gated only by `analytics.view`, which the `editor` role
* holds — yet the events *list* restricts editors to their own rows
* (adminEvents/crud.js: `roleName === 'editor'` → `created_by = admin.id`).
* The dashboard therefore reported instance-wide totals, and the analytics
* endpoint returned other admins' gallery names and slugs, to a role that
* cannot see those events anywhere else.
*
* Scoped on `editor` specifically to mirror the events list exactly, so the
* `admin` role's dashboard is unchanged. (`filterOwnedEventIds` uses the
* broader `!== super_admin` rule; the two conventions disagree in this
* codebase and matching the list is the no-regression choice.)
*
* @returns {Promise<number[]|null>} ids to restrict to, or null for no limit
*/
function isScopedAdmin(admin) {
return admin?.roleName === 'editor';
}
/**
* Restrict `query` to the caller's own events.
*
* Uses a SUBQUERY rather than materialising the id list. An editor owning more
* events than the driver's bind-parameter limit (~999 on SQLite, 65535 on
* Postgres) would otherwise blow past it once every id became a placeholder,
* turning all three dashboard endpoints into 500s — and even well below that
* limit the whole list was re-sent for each of the ~10 aggregates per request.
*/
function applyEventScope(query, admin, column) {
if (!isScopedAdmin(admin)) return query;
return query.whereIn(column, db('events').select('id').where('created_by', admin.id));
}
// Get dashboard statistics
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
// Get active events count
const activeEvents = await applyEventScope(db('events'), req.admin, 'id')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.count('id as count')
.first();
// Get events expiring within 7 days
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
const now = new Date();
const expiringEvents = await applyEventScope(db('events'), req.admin, 'id')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
.where('expires_at', '>', now.toISOString())
.count('id as count')
.first();
// Get total photos count
const totalPhotos = await applyEventScope(db('photos'), req.admin, 'event_id')
.count('id as count')
.first();
// The catalogued size of the ORIGINALS. Kept, and still worth showing —
// it answers "how much photography is in here" — but it is emphatically
// NOT storage used, which is what it was labelled for years (#1164).
const catalogedBytes = await applyEventScope(db('photos'), req.admin, 'event_id')
.sum('size_bytes as total')
.first();
// Storage used: what is actually on this machine. In reference mode the
// originals above live on a NAS and contribute nothing here; conversely
// this counts what the sum never did — thumbnails, previews, hero
// renditions and the per-event download cache.
//
// Deliberately NOT event-scoped, unlike everything else on this endpoint:
// it is a disk measurement, and disk is not divisible by which admin owns
// which event. A scoped admin gets the same number a super_admin does,
// which is the honest answer to "is this box running out of space" and
// leaks nothing beyond the aggregate they can already infer from the
// system-health page.
// Skipped entirely on an S3 backend: the objects are in the bucket and a
// walk of STORAGE_PATH would report near-zero, which is worse than the
// catalogued figure those installs had before #1164. `storageUsed` is null
// there and the tile shows the catalogued number it already carries.
const usesLocalBackend = (process.env.STORAGE_BACKEND || 'local').toLowerCase() !== 's3';
let localStorage = null;
try {
if (usesLocalBackend) localStorage = await measureLocalStorageUsage();
} catch (err) {
// The dashboard must render without it; the tile falls back to showing
// the catalogued figure, labelled as such.
logger.warn(`Dashboard storage measurement failed: ${err.message}`);
}
// Get total views (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const totalViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('action', 'view')
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
// Get total downloads (last 30 days) - include both single and bulk downloads
const totalDownloads = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
// Get archived events count
const archivedEvents = await applyEventScope(db('events'), req.admin, 'id')
.where('is_archived', formatBoolean(true))
.count('id as count')
.first();
// Get total events count (all events regardless of status) — used by the
// events list page to render accurate "All (N)" / Total Events counters
// when the table is server-paginated (#346).
const totalEvents = await applyEventScope(db('events'), req.admin, 'id')
.count('id as count')
.first();
// Calculate trends (compare with previous 30 days)
const sixtyDaysAgo = new Date();
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
const previousViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('action', 'view')
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
const previousDownloads = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
// Calculate trend percentages
const viewsTrend = previousViews.count > 0
? ((totalViews.count - previousViews.count) / previousViews.count) * 100
: 0;
const downloadsTrend = previousDownloads.count > 0
? ((totalDownloads.count - previousDownloads.count) / previousDownloads.count) * 100
: 0;
res.json({
activeEvents: activeEvents.count || 0,
expiringEvents: expiringEvents.count || 0,
totalPhotos: totalPhotos.count || 0,
// Real bytes on this disk. Null when the measurement failed, which the
// UI shows as "unavailable" rather than substituting a number that
// means something else.
storageUsed: localStorage ? localStorage.total : null,
// Three states, not two: 'catalog' means the backend is S3 and the
// objects are in the bucket, which is a fact about the install;
// 'unavailable' means the walk failed, which is a fault. Collapsing them
// made a failed local measurement claim the objects live in S3.
storageMeasurement: localStorage ? 'disk' : (usesLocalBackend ? 'unavailable' : 'catalog'),
storageBreakdown: localStorage ? localStorage.breakdown : null,
// True when part of the storage root could not be read, so the total is
// a floor rather than the answer.
storagePartial: localStorage ? localStorage.partial : false,
// Catalogued original bytes — what `storageUsed` used to report (#1164).
catalogedBytes: Number(catalogedBytes.total) || 0,
totalViews: totalViews.count || 0,
totalDownloads: totalDownloads.count || 0,
viewsTrend: Math.round(viewsTrend * 10) / 10,
downloadsTrend: Math.round(downloadsTrend * 10) / 10,
archivedEvents: archivedEvents.count || 0,
totalEvents: totalEvents.count || 0
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch dashboard statistics');
}
});
// Get recent activity
router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
const { limit } = getPagination(req, { limit: 10 });
// Scope the feed to the caller's own events (GHSA-jhcf) — it otherwise
// returned every admin's actions, including actor names and verbatim
// metadata. `activity_logs.event_id` is NULLABLE: system-level entries
// (logins, settings changes) carry no event, and those are deliberately
// EXCLUDED for a scoped caller rather than shown, since they are exactly
// the cross-admin actions this advisory is about.
const activities = await applyEventScope(
db('activity_logs')
.select('activity_logs.*', 'events.event_name')
.leftJoin('events', 'activity_logs.event_id', 'events.id'),
req.admin,
'activity_logs.event_id'
)
.orderBy('activity_logs.created_at', 'desc')
.limit(limit);
// Format activities
const formattedActivities = activities.map(activity => ({
id: activity.id,
type: activity.activity_type,
actorType: activity.actor_type,
actorName: activity.actor_name,
eventName: activity.event_name,
metadata: (() => {
try {
if (!activity.metadata) return {};
if (typeof activity.metadata === 'object') return activity.metadata;
return JSON.parse(activity.metadata);
} catch (e) {
logger.warn('Failed to parse metadata for activity:', activity.id, e.message);
return {};
}
})(),
createdAt: activity.created_at
}));
res.json(formattedActivities);
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch activity log');
}
});
// Get system health status
router.get('/health', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const os = require('os');
// Check database connectivity
let dbStatus = 'healthy';
try {
await db.raw('SELECT 1');
} catch (error) {
dbStatus = 'error';
}
// Check email queue
const [pendingEmails] = await db('email_queue')
.where('status', 'pending')
.count('* as count');
const twentyFourHoursAgo = new Date();
twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24);
const [failedEmails] = await db('email_queue')
.where('status', 'failed')
.where('scheduled_at', '>=', twentyFourHoursAgo.toISOString())
.count('* as count');
const emailStatus = failedEmails.count > 10 ? 'warning' : 'healthy';
// Check disk space (simplified)
const storageStatus = 'healthy'; // In production, check actual disk usage
// Memory usage
const memoryUsage = {
total: os.totalmem(),
free: os.freemem(),
used: os.totalmem() - os.freemem(),
percentage: Math.round(((os.totalmem() - os.freemem()) / os.totalmem()) * 100)
};
const memoryStatus = memoryUsage.percentage > 90 ? 'warning' : 'healthy';
// Overall health
const statuses = [dbStatus, emailStatus, storageStatus, memoryStatus];
let overallHealth = 'healthy';
if (statuses.includes('error')) overallHealth = 'error';
else if (statuses.includes('warning')) overallHealth = 'warning';
res.json({
overall: overallHealth,
services: {
database: dbStatus,
email: emailStatus,
storage: storageStatus,
memory: memoryStatus
},
details: {
emailQueue: {
pending: pendingEmails.count,
failed: failedEmails.count
},
memory: memoryUsage
}
});
} catch (error) {
logger.error('Health check error:', error);
res.status(500).json({
overall: 'error',
error: 'Failed to check system health'
});
}
});
// Get analytics data for charts
router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
const days = sanitizeDays(req.query.days || 7);
// Generate date range
const dates = [];
for (let i = days - 1; i >= 0; i--) {
dates.push({
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
views: 0,
downloads: 0,
uniqueVisitors: 0
});
}
// Calculate the start date for queries
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const startDateStr = startDate.toISOString();
// Get views per day
const viewsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.where('action', 'view')
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Get downloads per day - include both single and bulk downloads
const downloadsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Get unique visitors per day
const visitorsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(DISTINCT ip_address) as count'))
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Merge data into dates array. row.date is normalised because Postgres
// returns DATE() as a JS Date while SQLite returns a string (#661 Bug A).
// Counts come back as strings on Postgres too, so coerce via Number.
viewsData.forEach(row => {
const key = normaliseDateKey(row.date);
const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.views = Number(row.count) || 0;
});
downloadsData.forEach(row => {
const key = normaliseDateKey(row.date);
const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.downloads = Number(row.count) || 0;
});
visitorsData.forEach(row => {
const key = normaliseDateKey(row.date);
const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.uniqueVisitors = Number(row.count) || 0;
});
// Get top galleries by views with additional metrics
const topGalleries = await applyEventScope(db('access_logs'), req.admin, 'access_logs.event_id')
.select('events.id', 'events.event_name', 'events.slug')
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\', \'download_all_presigned\', \'download_selected\') THEN 1 END) as downloads'))
.join('events', 'access_logs.event_id', 'events.id')
.where('access_logs.timestamp', '>=', startDateStr)
.groupBy('events.id', 'events.event_name', 'events.slug')
.orderBy('views', 'desc')
.limit(5);
// Device breakdown — prefer the operator's analytics tracker (Umami /
// Rybbit) when configured (#661 Bug C + #663 Phase 1). The local
// access_logs heuristic below produces 0% on installs where guest user
// agents don't reliably contain "Mobile" / "Tablet" tokens; the tracker
// adapters track devices natively. Falls back to access_logs when no
// tracker is configured (provider=none/custom), the upstream call fails,
// or the response shape doesn't match what we expect.
let devices = { desktop: 0, mobile: 0, tablet: 0 };
let devicesSource = 'access_logs';
// The external tracker reports instance-wide device data with no way to
// filter it by event, so a scoped caller must not receive it (GHSA-gqx7).
// They fall through to the access_logs heuristic, which IS scoped.
const adapter = isScopedAdmin(req.admin) ? null : await resolveAdapter();
if (adapter) {
try {
const trackerDevices = await adapter.fetchDeviceBreakdown({
startMs: startDate.getTime(),
endMs: Date.now(),
});
if (trackerDevices) {
devices = trackerDevices;
devicesSource = adapter.provider;
}
} catch (err) {
logger.warn(`Analytics: ${adapter.provider} device-breakdown fetch failed; falling back to access_logs`, {
error: err.message,
});
}
}
if (devicesSource === 'access_logs') {
// Local heuristic on access_logs user_agent. Coarse — `LIKE` doesn't
// cover every UA shape (some Android browsers, embedded webviews, etc.)
// — and counts come back as strings on Postgres, hence Number() below.
const deviceData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(
db.raw(`
CASE
WHEN user_agent LIKE '%Mobile%' THEN 'mobile'
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet'
ELSE 'desktop'
END as device_type
`),
db.raw('COUNT(*) as count')
)
.where('timestamp', '>=', startDateStr)
.whereNotNull('user_agent')
.groupBy('device_type');
const totalDevices = deviceData.reduce((sum, d) => sum + (Number(d.count) || 0), 0);
if (totalDevices > 0) {
deviceData.forEach(d => {
devices[d.device_type] = Math.round(((Number(d.count) || 0) / totalDevices) * 100);
});
}
}
// Calculate totals for the period (matching /stats logic)
const totalViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('action', 'view')
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalDownloadsCount = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalUniqueVisitors = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('timestamp', '>=', startDateStr)
.countDistinct('ip_address as count')
.first();
res.json({
chartData: dates,
topGalleries,
devices,
devicesSource,
totals: {
views: totalViews?.count || 0,
downloads: totalDownloadsCount?.count || 0,
uniqueVisitors: totalUniqueVisitors?.count || 0
}
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch analytics data');
}
});
/**
* CRM overview stats — quote / invoice counts by status, rolling
* revenue windows, outstanding payments. Used by the CRM Overview
* tab at /admin/clients/overview.
*
* Permission gate: `bills.view` OR `quotes.view` — either CRM
* sub-feature unlocks the headline numbers.
*
* Currency handling: aggregates sum naively across currencies.
* Multi-currency installs get a `currency` field set to the
* business profile's default; admins running mixed-currency books
* should treat the headline figure as approximate. A multi-currency
* breakdown can be added later.
*/
router.get('/crm-stats', adminAuth, async (req, res) => {
try {
// Either CRM permission is enough — both sub-features stand
// alone (one studio may bill manually but quote via picpeak,
// another might invoice but not quote). Permissions aren't
// attached to req.admin synchronously; we have to ask the DB
// via userHasAnyPermission so our inline check matches the
// behaviour of the requirePermission middleware used elsewhere.
const { userHasAnyPermission } = require('../middleware/permissions');
const canSeeBills = await userHasAnyPermission(req.admin.id, ['bills.view']);
const canSeeQuotes = await userHasAnyPermission(req.admin.id, ['quotes.view']);
if (!canSeeBills && !canSeeQuotes) {
return res.status(403).json({ error: 'Forbidden' });
}
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
const monthCutoff = new Date(now - 30 * DAY);
const quarterCutoff = new Date(now - 90 * DAY);
const yearCutoff = new Date(now - 365 * DAY);
// Calendar year-to-date (Jan 1 of the current year, local time) —
// the dashboard's revenue "year" tile can toggle between this and
// the trailing-365-day window.
const calendarYearCutoff = new Date(new Date(now).getFullYear(), 0, 1);
// ---- quotes: counts by status ---------------------------------
let quoteCounts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, converted: 0 };
if (canSeeQuotes) {
try {
const rows = await db('quotes').select('status').count('id as count').groupBy('status');
for (const r of rows) {
if (r.status in quoteCounts) quoteCounts[r.status] = Number(r.count) || 0;
}
} catch (e) {
// Table may not exist on installs without CRM migrations.
// Treat as all-zero so the page still renders.
}
}
// ---- invoices: counts by status -------------------------------
let invoiceCounts = { scheduled: 0, sent: 0, paid: 0, overdue: 0, cancelled: 0 };
let revenueMonthMinor = 0;
let revenueQuarterMinor = 0;
let revenueYearMinor = 0;
let revenueCalendarYearMinor = 0;
let outstandingTotalMinor = 0;
let outstandingCount = 0;
if (canSeeBills) {
try {
// Same exclusions as the outstanding calc — Stornorechnungen
// and monthly drafts skew the per-status counts (Sent
// includes both real outstanding invoices and credit-note
// rows; Scheduled includes mid-period accumulators that the
// admin doesn't think of as "queued invoices" yet).
const rows = await db('invoices')
.andWhere(function() {
this.whereNot('kind', 'storno').orWhereNull('kind');
})
.andWhere(function() {
this.where('is_monthly_draft', false).orWhereNull('is_monthly_draft');
})
.select('status').count('id as count').groupBy('status');
for (const r of rows) {
if (r.status in invoiceCounts) invoiceCounts[r.status] = Number(r.count) || 0;
}
// Revenue windows: sum of `paid_amount_minor` for invoices marked
// PAID whose payment date (`paid_at`) falls inside the window —
// cash-basis recognition (revenue counts when the money arrives).
// Using paid_amount (not total) so partial payments are tracked
// accurately. Stornos excluded — never status='paid' in normal flow,
// the guard is defensive.
//
// `paid_at` is admin-controllable, so an old/imported invoice lands
// in the right window without any special-casing here: the mark-paid
// dialog takes an optional payment date (backdate to when the money
// actually arrived) and the historical-import route anchors paid_at
// to the invoice's issue_date.
const winSum = async (cutoff) => {
const row = await db('invoices')
.where('status', 'paid')
.where('paid_at', '>=', cutoff)
.andWhere(function() {
this.whereNot('kind', 'storno').orWhereNull('kind');
})
.sum('paid_amount_minor as total')
.first();
return Number(row?.total || 0);
};
revenueMonthMinor = await winSum(monthCutoff);
revenueQuarterMinor = await winSum(quarterCutoff);
revenueYearMinor = await winSum(yearCutoff);
revenueCalendarYearMinor = await winSum(calendarYearCutoff);
// Outstanding: every invoice that's been sent but not fully
// paid (sent + overdue). Outstanding = total - paid. We sum
// the gap per row rather than `total - sum(paid)` so partial
// payments contribute correctly.
//
// Exclusions:
// - kind='storno' rows. Stornorechnungen carry status='sent'
// and total_amount_minor < 0; without the filter they slip
// through the status check and inflate `invoiceCount` by 1
// per Storno (the per-row gap math correctly returns 0 for
// the amount, but the row still counts). They're
// accounting-side credit notes, not money the customer
// owes.
// - is_monthly_draft=true rows. Drafts ship via the monthly
// cycle and aren't owed money until they leave draft state.
const openRows = await db('invoices')
.whereIn('status', ['sent', 'overdue'])
.andWhere(function() {
this.whereNot('kind', 'storno').orWhereNull('kind');
})
.andWhere(function() {
// Belt-and-braces: a Storno is uniquely identified by
// having `cancels_invoice_id` set (migration 114). Even
// if `kind` is somehow NULL on a Storno row, this catches
// it. NULL on regular invoices passes through unchanged.
this.whereNull('cancels_invoice_id');
})
.andWhere(function() {
this.where('is_monthly_draft', false).orWhereNull('is_monthly_draft');
})
.andWhere('total_amount_minor', '>=', 0)
.select('total_amount_minor', 'paid_amount_minor', 'late_fee_amount_minor');
for (const r of openRows) {
const total = Number(r.total_amount_minor || 0) + Number(r.late_fee_amount_minor || 0);
const paid = Number(r.paid_amount_minor || 0);
const gap = Math.max(0, total - paid);
if (gap > 0) {
outstandingTotalMinor += gap;
outstandingCount += 1;
}
}
} catch (e) {
// Table missing — treat as empty (same as quotes above).
}
}
// Default currency for the headline figure. Pull from
// business_profile.default_currency when present; the renderer
// accepts the same fallback chain we use elsewhere.
let currency = 'CHF';
try {
const profile = await db('business_profile').where({ id: 1 }).first();
if (profile?.default_currency) currency = String(profile.default_currency).toUpperCase();
} catch (_) { /* leave default */ }
res.json({
currency,
quotes: quoteCounts,
invoices: invoiceCounts,
revenue: {
monthMinor: revenueMonthMinor,
quarterMinor: revenueQuarterMinor,
yearMinor: revenueYearMinor,
calendarYearMinor: revenueCalendarYearMinor,
},
outstanding: {
totalMinor: outstandingTotalMinor,
invoiceCount: outstandingCount,
},
generatedAt: new Date().toISOString(),
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to load CRM stats');
}
});
module.exports = router;