servers/reporting — @leadmetrics/server-reporting
A dedicated Node.js background service that sends three scheduled daily email reports via the
notification queue. It runs as a standalone process using node-cron and never dispatches emails
directly — it enqueues jobs into notifications__email for the notifications server to process.
Source: apps/servers/reporting/
Why a Separate Service
| Concern | Reason |
|---|---|
| Isolation | Cron failures never affect the API or agent workers |
| Independent scaling | Reporting can run as a single-replica low-resource process |
| Timezone-aware scheduling | Tenant reports fire at 10 PM in each tenant’s local timezone — a single 30-min cron sweeps all timezones |
| Deduplication | Each report has a unique dedupeKey per date — re-runs on the same day are idempotent |
Three Daily Reports
| Report | Schedule | Recipient | Description |
|---|---|---|---|
| Tenant Daily Report | 10 PM tenant local time | Tenant (via NotificationPreference → fallback owner) | Activities done, due, and overdue; period summary |
| Admin Tenant Report | 10 PM IST (16:30 UTC) | ADMIN_REPORT_EMAIL | New tenants, status changes, running totals by status |
| System Usage Report | 10 PM IST (16:30 UTC) | ADMIN_REPORT_EMAIL | Sessions, agent runs, tokens, cost, adapter breakdown |
All three use templateSlug → email_template DB lookup → Handlebars rendering via the
notifications server.
Email Templates (seeded in packages/db/src/seed.ts)
| Slug | Subject | Key Variables |
|---|---|---|
tenant_daily_report | Your Leadmetrics daily update — {{reportDate}} | tenantName, reportDate, periodSummary, activitiesDoneTable, activitiesDueTable, overdueTable, doneCount, dueCount, overdueCount |
admin_tenant_report | Leadmetrics admin: tenant report — {{reportDate}} | reportDate, statsRow, newTenantsTable, onboardedTable, inactiveTable, newCount, onboardedCount, inactiveCount, grandTotal |
system_usage_report | Leadmetrics system: usage report — {{reportDate}} | reportDate, statsRow, adapterBreakdownTable, totalRuns, totalCostUsd, totalInputTokens, totalOutputTokens, dailyActiveSessions, activeTenantCount |
HTML tables are pre-rendered as strings in the job before being passed as variables.
Templates use triple-brace {{{tableVar}}} Handlebars syntax for unescaped HTML injection.
Scheduler (src/scheduler.ts)
Two crons run in the same process:
Tenant cron — every 30 minutes
0,30 * * * *Iterates all active tenants, checks getLocalHour(tenant.timezone) === 22, and enqueues
a tenant daily report for any tenant whose local hour just hit 22. Deduplication via
dedupeKey = tenant_daily_report__{tenantId}__{localDate} prevents double-sends.
Admin + System cron — fixed UTC
30 16 * * * # 16:30 UTC = 22:00 ISTRuns the admin tenant report and system usage report once daily.
Timezone Handling (src/timezone.ts)
resolveTenantTimezone(tenant)
1. Use tenant.timezone if set (IANA string, e.g. "Asia/Kolkata")
2. Map tenant.country to a timezone (40+ country entries)
3. Default: "Asia/Kolkata" (IST)All UTC-conversion functions use Intl.DateTimeFormat — no external timezone library needed.
Recipients
Tenant Daily Report
NotificationPreferencewheretenantId = X AND category = "daily_activity"— uses the storedrecipientsJSON array.- Fallback: first
TenantMemberbycreatedAt asc(the owner) — usesuser.email. - If still no recipients: skipped with a warning log.
Admin & System Reports
- Always sent to
config.ADMIN_REPORT_EMAIL/config.ADMIN_REPORT_NAME. - Uses
tenantId: "__platform__"— resolves to platform SendGrid default (no tenant provider).
Deduplication
BullMQ jobId + deduplication.id are both set to the dedupeKey. This means:
- Re-running the trigger on the same calendar day has no effect — the jobs already exist.
- To force a re-send (e.g. in development), clear the failed/completed jobs from Redis first:
# List and delete failed jobs
docker exec ragmanager-redis redis-cli ZRANGE bull:notifications__email:failed 0 -1
docker exec ragmanager-redis redis-cli DEL bull:notifications__email:<jobId>
docker exec ragmanager-redis redis-cli DEL bull:notifications__email:failed
docker exec ragmanager-redis redis-cli DEL bull:notifications__email:completedOne-Off Trigger (trigger.ts)
Run all three jobs immediately (bypasses the cron schedule):
cd apps/servers/reporting
tsx --env-file .env trigger.tsAlso requires the notifications server to be running:
cd apps/servers/notifications
tsx --env-file .env src/index.tsSchema Additions
Two fields were added to support system usage reporting:
model Tenant {
timezone String? // IANA timezone, e.g. "Asia/Kolkata"; falls back to country map → IST
}
model AgentRun {
inputTokens Int? // LLM input tokens for this run
outputTokens Int? // LLM output tokens for this run
}Config (src/config.ts)
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL | yes | — | Prisma connection string |
REDIS_URL | no | redis://localhost:6379 | BullMQ connection |
DASHBOARD_URL | no | http://localhost:3000 | Used in email links |
ADMIN_REPORT_EMAIL | no | moble@leadmetrics.ai | Recipient for admin + system reports |
ADMIN_REPORT_NAME | no | Moble Joseph | Display name for admin recipient |
TENANT_REPORT_CRON | no | 0,30 * * * * | Cron expression for tenant sweep |
ADMIN_REPORT_CRON | no | 30 16 * * * | Cron expression for admin + system reports |
File Structure
apps/servers/reporting/
|-- src/
| |-- index.ts Entry point — loads config, starts scheduler, handles shutdown
| |-- config.ts Zod env validation
| |-- scheduler.ts Two node-cron jobs wired to job functions
| |-- timezone.ts resolveTenantTimezone, getTodayBoundsUtc, getLocalHour, etc.
| |
| +-- jobs/
| |-- tenant-daily-report.ts Queries activities; enqueues per tenant
| |-- admin-tenant-report.ts Queries tenant status changes; enqueues to admin
| +-- system-usage-report.ts Queries AgentRun aggregates; enqueues to admin
|
|-- trigger.ts One-off script to fire all 3 jobs immediately
|-- .env
|-- package.json
+-- tsconfig.json