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).
682 lines
28 KiB
JavaScript
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; |