2026-07-06 11:12:06 -05:00
import type { Db } from '../types/db' ;
2026-05-03 19:51:57 -05:00
const nodemailer = require ( 'nodemailer' ) ;
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
const { getDb , getSetting } = require ( '../db/database.cts' ) ;
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
const { decryptSecret , encryptSecret } = require ( './encryptionService.cts' ) ;
const { accountingActiveSql } = require ( './paymentAccountingService.cts' ) ;
2026-05-03 19:51:57 -05:00
const {
markNotificationError ,
markNotificationSuccess ,
markNotificationTestSuccess ,
2026-07-06 10:47:16 -05:00
} = require ( './statusRuntime.cts' ) ;
2026-07-06 11:12:06 -05:00
const { localDateString } = require ( '../utils/dates.mts' ) as typeof import ( '../utils/dates.mts' ) ;
const { fromCents } = require ( '../utils/money.mts' ) as typeof import ( '../utils/money.mts' ) ;
type Row = Record < string , any > ;
2026-05-03 19:51:57 -05:00
2026-06-03 21:43:54 -05:00
// ── Push notification channels ────────────────────────────────────────────────
2026-07-06 11:12:06 -05:00
const PUSH_PRIORITY : Record < string , string > = { upcoming : 'default' , soon : 'high' , today : 'high' , overdue : 'urgent' } ;
const PUSH_TAGS : Record < string , string [ ] > = { upcoming : [ 'bell' ] , soon : [ 'warning' ] , today : [ 'rotating_light' ] , overdue : [ 'rotating_light' , 'red_circle' ] } ;
const DISCORD_COLOR : Record < string , number > = { upcoming : 0x4f46e5 , soon : 0xd97706 , today : 0xdc7308 , overdue : 0xdc2626 } ;
2026-06-03 21:43:54 -05:00
2026-07-06 11:12:06 -05:00
function safeDecrypt ( stored : any ) : string {
2026-06-03 21:43:54 -05:00
if ( ! stored ) return '' ;
try { return decryptSecret ( stored ) ; } catch { return stored ; }
}
2026-07-06 11:12:06 -05:00
async function sendNtfy ( url : string , token : any , title : string , body : string , urgency = 'soon' ) {
const headers : Record < string , string > = {
2026-06-03 21:43:54 -05:00
'Title' : title ,
'Priority' : PUSH_PRIORITY [ urgency ] || 'default' ,
'Tags' : ( PUSH_TAGS [ urgency ] || [ 'bell' ] ) . join ( ',' ) ,
'Content-Type' : 'text/plain' ,
} ;
if ( token ) headers [ 'Authorization' ] = ` Bearer ${ token } ` ;
const res = await fetch ( url , { method : 'POST' , headers , body } ) ;
if ( ! res . ok ) throw new Error ( ` ntfy returned ${ res . status } ` ) ;
}
2026-07-06 11:12:06 -05:00
async function sendGotify ( url : string , token : any , title : string , body : string , urgency = 'soon' ) {
const priority = ( ( { upcoming : 4 , soon : 6 , today : 7 , overdue : 9 } as Record < string , number > ) [ urgency ] ) ? ? 5 ;
2026-06-03 21:43:54 -05:00
const endpoint = url . replace ( /\/$/ , '' ) + '/message?token=' + encodeURIComponent ( token ) ;
const res = await fetch ( endpoint , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ( { title , message : body , priority } ) ,
} ) ;
if ( ! res . ok ) throw new Error ( ` Gotify returned ${ res . status } ` ) ;
}
2026-07-06 11:12:06 -05:00
async function sendDiscord ( webhookUrl : string , title : string , body : string , urgency = 'soon' ) {
2026-06-03 21:43:54 -05:00
const color = DISCORD_COLOR [ urgency ] ? ? 0x4f46e5 ;
const res = await fetch ( webhookUrl , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ( {
embeds : [ { title , description : body , color ,
footer : { text : 'Bill Tracker' } ,
timestamp : new Date ( ) . toISOString ( ) } ] ,
} ) ,
} ) ;
if ( ! res . ok ) throw new Error ( ` Discord returned ${ res . status } ` ) ;
}
2026-07-06 11:12:06 -05:00
async function sendTelegram ( botToken : any , chatId : any , title : string , body : string ) {
2026-06-03 21:43:54 -05:00
const text = ` * ${ title } * \ n ${ body } ` ;
const url = ` https://api.telegram.org/bot ${ botToken } /sendMessage ` ;
const res = await fetch ( url , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ( { chat_id : chatId , text , parse_mode : 'Markdown' } ) ,
} ) ;
if ( ! res . ok ) {
2026-07-06 11:12:06 -05:00
const err : any = await res . json ( ) . catch ( ( ) = > ( { } ) ) ;
2026-06-03 21:43:54 -05:00
throw new Error ( ` Telegram error: ${ err . description || res . status } ` ) ;
}
}
// Dispatch push to whichever channel this user has configured.
2026-07-06 11:12:06 -05:00
async function sendPushToUser ( user : Row , title : string , body : string , urgency : any ) {
2026-06-03 21:43:54 -05:00
if ( ! user . notify_push_enabled || ! user . push_channel || ! user . push_url ) return ;
const url = safeDecrypt ( user . push_url ) ;
const token = safeDecrypt ( user . push_token ) ;
switch ( user . push_channel ) {
case 'ntfy' : return sendNtfy ( url , token , title , body , urgency ) ;
case 'gotify' : return sendGotify ( url , token , title , body , urgency ) ;
case 'discord' : return sendDiscord ( url , title , body , urgency ) ;
case 'telegram' : return sendTelegram ( token , user . push_chat_id , title , body ) ;
default : throw new Error ( ` Unknown push channel: ${ user . push_channel } ` ) ;
}
}
2026-07-06 11:12:06 -05:00
async function sendTestPush ( user : Row ) {
2026-06-03 21:43:54 -05:00
await sendPushToUser (
user ,
'Bill Tracker — Test Notification' ,
'Your push notification channel is working correctly.' ,
'upcoming' ,
) ;
}
2026-05-03 19:51:57 -05:00
// ── SMTP transport ────────────────────────────────────────────────────────────
2026-07-06 11:12:06 -05:00
function getSmtpPassword ( ) : string {
2026-05-31 15:06:10 -05:00
const stored = getSetting ( 'notify_smtp_password' ) ;
if ( ! stored ) return '' ;
try {
return decryptSecret ( stored ) ;
} catch {
return stored ; // legacy plaintext — works until re-saved via admin UI
}
}
2026-07-06 11:12:06 -05:00
function createTransport ( ) : any {
2026-05-03 19:51:57 -05:00
const host = getSetting ( 'notify_smtp_host' ) ;
const port = parseInt ( getSetting ( 'notify_smtp_port' ) || '587' , 10 ) ;
const encryption = getSetting ( 'notify_smtp_encryption' ) || 'starttls' ;
const username = getSetting ( 'notify_smtp_username' ) ;
2026-05-31 15:06:10 -05:00
const password = getSmtpPassword ( ) ;
2026-05-03 19:51:57 -05:00
const selfSigned = getSetting ( 'notify_smtp_self_signed' ) === 'true' ;
if ( ! host ) throw new Error ( 'SMTP host is not configured' ) ;
return nodemailer . createTransport ( {
host ,
port ,
secure : encryption === 'ssl' ,
auth : username ? { user : username , pass : password } : undefined ,
tls : {
rejectUnauthorized : ! selfSigned ,
. . . ( encryption === 'none' ? { ignoreTLS : true } : { } ) ,
} ,
} ) ;
}
2026-07-06 11:12:06 -05:00
function senderAddress ( ) : string {
2026-05-03 19:51:57 -05:00
const name = getSetting ( 'notify_sender_name' ) || 'Bill Tracker' ;
const address = getSetting ( 'notify_sender_address' ) ;
if ( ! address ) throw new Error ( 'Sender address is not configured' ) ;
return ` " ${ name } " < ${ address } > ` ;
}
// ── Email templates ───────────────────────────────────────────────────────────
2026-07-06 11:12:06 -05:00
function leadDaysOf ( bill : any ) : number {
2026-07-03 18:16:10 -05:00
return Number . isInteger ( bill ? . reminder_days_before ) ? bill.reminder_days_before : 3 ;
}
2026-07-06 11:12:06 -05:00
function reminderTypeFor ( bill : any , diffDays : number ) : string | null {
2026-07-03 18:16:10 -05:00
const leadDays = leadDaysOf ( bill ) ;
if ( diffDays >= 2 && diffDays === leadDays ) return 'due_3d' ;
if ( diffDays === 1 ) return 'due_1d' ;
if ( diffDays === 0 ) return 'due_today' ;
if ( diffDays < 0 ) return 'overdue' ;
return null ;
}
2026-07-06 11:12:06 -05:00
const TYPE_META : Record < string , { subject : ( b : any ) = > string ; urgency : string } > = {
2026-07-03 18:16:10 -05:00
due_3d : { subject : ( b ) = > ` Reminder: ${ b . name } due in ${ leadDaysOf ( b ) } days ` , urgency : 'upcoming' } ,
2026-05-03 19:51:57 -05:00
due_1d : { subject : ( b ) = > ` Reminder: ${ b . name } due tomorrow ` , urgency : 'soon' } ,
due_today : { subject : ( b ) = > ` Due today: ${ b . name } ` , urgency : 'today' } ,
overdue : { subject : ( b ) = > ` Overdue: ${ b . name } hasn't been paid ` , urgency : 'overdue' } ,
} ;
2026-07-06 11:12:06 -05:00
const URGENCY_COLOR : Record < string , string > = {
2026-05-03 19:51:57 -05:00
upcoming : '#4f46e5' ,
soon : '#d97706' ,
today : '#dc7308' ,
overdue : '#dc2626' ,
} ;
2026-07-06 11:12:06 -05:00
function buildEmailHtml ( bill : Row , type : string , dueDate : any ) : string {
2026-05-03 19:51:57 -05:00
const meta = TYPE_META [ type ] ;
const color = URGENCY_COLOR [ meta . urgency ] ;
2026-06-11 20:12:31 -05:00
const amount = '$' + ( fromCents ( bill . expected_amount ) || 0 ) . toFixed ( 2 ) ;
2026-07-06 11:12:06 -05:00
const fmt = ( d : any ) = > {
2026-05-03 19:51:57 -05:00
if ( ! d ) return '—' ;
const [ y , m , day ] = d . split ( '-' ) ;
return ` ${ parseInt ( m ) } / ${ parseInt ( day ) } / ${ y } ` ;
} ;
2026-07-06 11:12:06 -05:00
// QA-B14-04: escape the bill name everywhere it lands in the HTML.
2026-07-02 22:18:05 -05:00
const name = esc ( bill . name ) ;
2026-07-06 11:12:06 -05:00
const messages : Record < string , string > = {
2026-07-03 18:16:10 -05:00
due_3d : ` <strong> ${ name } </strong> is due in ${ leadDaysOf ( bill ) } days. ` ,
2026-07-02 22:18:05 -05:00
due_1d : ` <strong> ${ name } </strong> is due <strong>tomorrow</strong>. ` ,
due_today : ` <strong> ${ name } </strong> is due <strong>today</strong>. ` ,
overdue : ` <strong> ${ name } </strong> was due on ${ fmt ( dueDate ) } and has not been marked as paid. ` ,
2026-05-03 19:51:57 -05:00
} ;
return ` <!DOCTYPE html>
< html >
< body style = "margin:0;padding:0;background:#f5f6fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;" >
< table width = "100%" cellpadding = "0" cellspacing = "0" style = "padding:32px 16px;" >
< tr > < td align = "center" >
< table width = "540" cellpadding = "0" cellspacing = "0" style = "background:#fff;border-radius:8px;border:1px solid #e2e4eb;overflow:hidden;" >
< tr >
< td style = "background:${color};padding:16px 24px;" >
< span style = "color:white;font-size:18px;font-weight:700;" > Bill Tracker < / span >
< / td >
< / tr >
< tr >
< td style = "padding:28px 24px;" >
< p style = "margin:0 0 20px;font-size:15px;color:#1a1d2e;line-height:1.6;" >
$ { messages [ type ] }
< / p >
< table width = "100%" cellpadding = "0" cellspacing = "0"
style = "border:1px solid #e2e4eb;border-radius:6px;overflow:hidden;font-size:14px;" >
< tr style = "background:#f5f6fa;" >
< td style = "padding:10px 14px;color:#6b7280;font-weight:600;border-bottom:1px solid #e2e4eb;" > Bill < / td >
< td style = "padding:10px 14px;border-bottom:1px solid #e2e4eb;" > $ { esc ( bill . name ) } < / td >
< / tr >
< tr >
< td style = "padding:10px 14px;color:#6b7280;font-weight:600;border-bottom:1px solid #e2e4eb;" > Due Date < / td >
< td style = "padding:10px 14px;border-bottom:1px solid #e2e4eb;" > $ { fmt ( dueDate ) } < / td >
< / tr >
< tr style = "background:#f5f6fa;" >
< td style = "padding:10px 14px;color:#6b7280;font-weight:600;" > Amount < / td >
< td style = "padding:10px 14px;font-weight:700;" > $ { amount } < / td >
< / tr >
< / table >
< p style = "margin:24px 0 0;font-size:12px;color:#9ca3af;" >
Sent by Bill Tracker & middot ; $ { new Date ( ) . toLocaleDateString ( ) }
< / p >
< / td >
< / tr >
< / table >
< / td > < / tr >
< / table >
< / body >
< / html > ` ;
}
2026-07-06 11:12:06 -05:00
function esc ( s : any ) : string {
2026-05-03 19:51:57 -05:00
return String ( s || '' ) . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
}
// ── Send ──────────────────────────────────────────────────────────────────────
2026-07-06 11:12:06 -05:00
async function sendEmail ( to : any , subject : string , html : string ) {
2026-05-03 19:51:57 -05:00
const transport = createTransport ( ) ;
await transport . sendMail ( { from : senderAddress ( ) , to , subject , html } ) ;
}
2026-07-06 11:12:06 -05:00
async function sendTestEmail ( to : any ) {
2026-05-03 19:51:57 -05:00
const html = ` <!DOCTYPE html>
< html > < body style = "font-family:sans-serif;padding:32px;" >
< h2 style = "color:#4f46e5;" > Bill Tracker — Test Email < / h2 >
< p > Your SMTP configuration is working correctly . < / p >
< p style = "color:#6b7280;font-size:12px;" > Sent at $ { new Date ( ) . toISOString ( ) } < / p >
< / body > < / html > ` ;
try {
await sendEmail ( to , 'Bill Tracker — Test Email' , html ) ;
markNotificationTestSuccess ( ) ;
} catch ( err ) {
markNotificationError ( err ) ;
throw err ;
}
}
// ── Notification tracking ─────────────────────────────────────────────────────
2026-07-06 11:12:06 -05:00
function hasNotification ( db : Db , billId : any , userId : any , year : number , month : number , type : string , date : string ) : boolean {
2026-05-03 19:51:57 -05:00
return ! ! db . prepare ( `
SELECT 1 FROM notifications
WHERE bill_id = ? AND user_id = ? AND year = ? AND month = ? AND type = ? AND sent_date = ?
` ).get(billId, userId, year, month, type, date);
}
2026-07-06 11:12:06 -05:00
function recordNotification ( db : Db , billId : any , userId : any , year : number , month : number , type : string , date : string ) : void {
2026-05-03 19:51:57 -05:00
try {
db . prepare ( `
INSERT INTO notifications ( bill_id , user_id , year , month , type , sent_date )
VALUES ( ? , ? , ? , ? , ? , ? )
` ).run(billId, userId, year, month, type, date);
} catch {
// Unique constraint: already recorded, ignore
}
}
// ── Main notification runner (called by daily worker) ─────────────────────────
async function runNotifications() {
2026-07-06 11:12:06 -05:00
const db : Db = getDb ( ) ;
2026-05-03 19:51:57 -05:00
2026-06-03 21:43:54 -05:00
const emailReady = getSetting ( 'notify_smtp_enabled' ) === 'true'
&& ! ! getSetting ( 'notify_smtp_host' )
&& ! ! getSetting ( 'notify_sender_address' ) ;
// Check whether any user has push notifications enabled
const pushReady = ! ! db . prepare (
"SELECT 1 FROM users WHERE notify_push_enabled=1 AND push_channel IS NOT NULL LIMIT 1"
) . get ( ) ;
// Nothing to do if neither email nor push is configured
if ( ! emailReady && ! pushReady ) return ;
2026-05-03 19:51:57 -05:00
const now = new Date ( ) ;
const year = now . getFullYear ( ) ;
const month = now . getMonth ( ) + 1 ;
2026-06-10 19:42:51 -05:00
const today = localDateString ( now ) ;
2026-05-03 19:51:57 -05:00
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
const { getCycleRange , resolveDueDate } = require ( './statusService.cts' ) ;
2026-05-03 19:51:57 -05:00
2026-05-16 10:34:32 -05:00
const bills = db . prepare ( 'SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL' ) . all ( ) ;
2026-05-03 19:51:57 -05:00
const allowUserConfig = getSetting ( 'notify_allow_user_config' ) === 'true' ;
const globalRecipient = getSetting ( 'notify_global_recipient' ) ;
// Gather recipient list
2026-07-06 11:12:06 -05:00
const recipients : any [ ] = [ ] ;
2026-05-03 19:51:57 -05:00
if ( allowUserConfig ) {
const users = db . prepare (
2026-06-03 21:43:54 -05:00
"SELECT * FROM users WHERE active = 1 AND role='user' AND notifications_enabled=1 AND (notification_email IS NOT NULL AND notification_email != '' OR notify_push_enabled=1)"
2026-05-03 19:51:57 -05:00
) . all ( ) ;
recipients . push ( . . . users ) ;
} else if ( globalRecipient ) {
recipients . push ( {
id : 0 , notification_email : globalRecipient ,
notify_3d : 1 , notify_1d : 1 , notify_due : 1 , notify_overdue : 1 ,
} ) ;
}
if ( ! recipients . length ) return ;
2026-07-06 11:12:06 -05:00
const errors : any [ ] = [ ] ;
2026-05-03 19:51:57 -05:00
2026-07-06 11:12:06 -05:00
const billIds = bills . map ( ( b : Row ) = > b . id ) ;
2026-06-04 21:00:59 -05:00
const monthStart = ` ${ year } - ${ String ( month ) . padStart ( 2 , '0' ) } -01 ` ;
2026-06-10 19:42:51 -05:00
const monthEnd = localDateString ( new Date ( year , month , 0 ) ) ;
2026-07-06 11:12:06 -05:00
const paidMap = new Map < any , any > ( ) ;
2026-06-04 21:00:59 -05:00
if ( billIds . length > 0 ) {
const placeholders = billIds . map ( ( ) = > '?' ) . join ( ',' ) ;
const paidRows = db . prepare ( `
SELECT bill_id , SUM ( amount ) AS paid_sum
FROM payments
WHERE bill_id IN ( $ { placeholders } )
AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
2026-06-07 01:05:48 -05:00
AND $ { accountingActiveSql ( ) }
2026-06-04 21:00:59 -05:00
GROUP BY bill_id
` ).all(...billIds, monthStart, monthEnd);
for ( const row of paidRows ) paidMap . set ( row . bill_id , row . paid_sum ) ;
}
const sentRows = db . prepare ( `
SELECT bill_id , user_id , type FROM notifications
WHERE year = ? AND month = ? AND sent_date = ?
` ).all(year, month, today);
2026-07-06 11:12:06 -05:00
const sentSet = new Set < string > ( sentRows . map ( ( n : Row ) = > ` ${ n . bill_id } : ${ n . user_id } : ${ n . type } ` ) ) ;
2026-06-04 21:00:59 -05:00
2026-05-03 19:51:57 -05:00
for ( const bill of bills ) {
2026-05-16 20:26:09 -05:00
const dueDate = resolveDueDate ( bill , year , month ) ;
if ( ! dueDate ) continue ;
2026-06-04 21:00:59 -05:00
const totalPaid = paidMap . get ( bill . id ) ? ? 0 ;
2026-05-03 19:51:57 -05:00
const isPaid = totalPaid >= bill . expected_amount ;
if ( isPaid ) continue ;
const due = new Date ( dueDate + 'T00:00:00' ) ;
2026-05-10 15:25:47 -05:00
const todayDate = new Date ( now . getFullYear ( ) , now . getMonth ( ) , now . getDate ( ) ) ;
const dueDay = new Date ( due . getFullYear ( ) , due . getMonth ( ) , due . getDate ( ) ) ;
2026-07-06 11:12:06 -05:00
const diffDays = Math . round ( ( dueDay . getTime ( ) - todayDate . getTime ( ) ) / 86400000 ) ;
2026-05-03 19:51:57 -05:00
2026-07-03 18:16:10 -05:00
const type = reminderTypeFor ( bill , diffDays ) ;
2026-05-03 19:51:57 -05:00
if ( ! type ) continue ;
fix: notification privacy leak — per-user bills no longer sent to all recipients (v0.23.2)
CRITICAL security fix: In per-user notification mode, the notification runner
was fetching ALL active bills globally and sending each bill's details to
every opted-in recipient regardless of ownership. This meant User A's bill
names, amounts, and due dates could be emailed to User B.
Fix: Added ownership filter in the recipient loop:
if (allowUserConfig && bill.user_id !== recipient.id) continue;
Also added a defensive guard for bills with no user_id (orphaned bills),
which are now skipped with a console.warn instead of being broadcast.
Global notification mode (single admin recipient) is unaffected.
Security audit: Private_Hudson confirmed the fix is airtight. All other
routes (bills, payments, tracker, analytics, export, calendar, summary,
categories) properly scope data by user_id.
Version bump: 0.23.1 → 0.23.2 (security patch)
2026-05-10 12:34:53 -05:00
if ( ! bill . user_id ) {
console . warn ( ` [notifications] Bill id= ${ bill . id } name=" ${ bill . name } " has no user_id — skipping ` ) ;
continue ;
}
2026-05-03 19:51:57 -05:00
for ( const recipient of recipients ) {
fix: notification privacy leak — per-user bills no longer sent to all recipients (v0.23.2)
CRITICAL security fix: In per-user notification mode, the notification runner
was fetching ALL active bills globally and sending each bill's details to
every opted-in recipient regardless of ownership. This meant User A's bill
names, amounts, and due dates could be emailed to User B.
Fix: Added ownership filter in the recipient loop:
if (allowUserConfig && bill.user_id !== recipient.id) continue;
Also added a defensive guard for bills with no user_id (orphaned bills),
which are now skipped with a console.warn instead of being broadcast.
Global notification mode (single admin recipient) is unaffected.
Security audit: Private_Hudson confirmed the fix is airtight. All other
routes (bills, payments, tracker, analytics, export, calendar, summary,
categories) properly scope data by user_id.
Version bump: 0.23.1 → 0.23.2 (security patch)
2026-05-10 12:34:53 -05:00
if ( allowUserConfig && bill . user_id !== recipient . id ) continue ;
2026-05-03 19:51:57 -05:00
if ( type === 'due_3d' && ! recipient . notify_3d ) continue ;
if ( type === 'due_1d' && ! recipient . notify_1d ) continue ;
if ( type === 'due_today' && ! recipient . notify_due ) continue ;
if ( type === 'overdue' && ! recipient . notify_overdue ) continue ;
2026-06-04 21:00:59 -05:00
if ( sentSet . has ( ` ${ bill . id } : ${ recipient . id } : ${ type } ` ) ) continue ;
2026-05-03 19:51:57 -05:00
const meta = TYPE_META [ type ] ;
const subject = meta . subject ( bill ) ;
2026-06-03 21:43:54 -05:00
const urgency = meta . urgency ;
2026-06-11 20:12:31 -05:00
const amount = '$' + ( fromCents ( bill . expected_amount ) || 0 ) . toFixed ( 2 ) ;
2026-06-03 21:43:54 -05:00
const pushBody = ` ${ subject } · ${ amount } ` ;
let sent = false ;
// Email
if ( recipient . notification_email ) {
const html = buildEmailHtml ( bill , type , dueDate ) ;
try {
await sendEmail ( recipient . notification_email , subject , html ) ;
sent = true ;
2026-07-06 11:12:06 -05:00
} catch ( err : any ) {
2026-06-03 21:43:54 -05:00
errors . push ( ` email/ ${ recipient . notification_email } / ${ bill . name } : ${ err . message } ` ) ;
}
}
2026-05-03 19:51:57 -05:00
2026-06-03 21:43:54 -05:00
// Push (ntfy / Gotify / Discord / Telegram)
if ( recipient . notify_push_enabled && recipient . push_channel && recipient . push_url ) {
try {
await sendPushToUser ( recipient , subject , pushBody , urgency ) ;
sent = true ;
2026-07-06 11:12:06 -05:00
} catch ( err : any ) {
2026-06-03 21:43:54 -05:00
errors . push ( ` push/ ${ recipient . push_channel } / ${ bill . name } : ${ err . message } ` ) ;
}
2026-05-03 19:51:57 -05:00
}
2026-06-03 21:43:54 -05:00
2026-06-04 21:00:59 -05:00
if ( sent ) {
recordNotification ( db , bill . id , recipient . id , year , month , type , today ) ;
sentSet . add ( ` ${ bill . id } : ${ recipient . id } : ${ type } ` ) ;
}
2026-05-03 19:51:57 -05:00
}
}
if ( errors . length ) {
markNotificationError ( new Error ( errors . join ( '; ' ) ) ) ;
console . error ( '[notifications] Send errors:' , errors ) ;
} else {
markNotificationSuccess ( ) ;
console . log ( ` [notifications] Run complete for ${ today } ` ) ;
}
}
2026-05-30 14:33:55 -05:00
// ── Drift / price-change digest email ────────────────────────────────────────
2026-07-06 11:12:06 -05:00
function fmtAmt ( n : any ) : string {
2026-05-30 14:33:55 -05:00
return '$' + Number ( n || 0 ) . toFixed ( 2 ) ;
}
2026-07-06 11:12:06 -05:00
function buildDriftDigestHtml ( bills : any [ ] ) : string {
2026-05-30 14:33:55 -05:00
const color = '#d97706' ; // amber-600
2026-07-06 11:12:06 -05:00
const rows = bills . map ( ( b : Row ) = > {
2026-05-30 14:33:55 -05:00
const sign = b . direction === 'up' ? '+' : '' ;
const arrow = b . direction === 'up' ? '▲' : '▼' ;
const arrowColor = b . direction === 'up' ? '#d97706' : '#0d9488' ;
return `
< tr >
< td style = "padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;" > $ { esc ( b . name ) } < / td >
< td style = "padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;text-decoration:line-through;color:#9ca3af;" > $ { fmtAmt ( b . expected_amount ) } < / td >
< td style = "padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;font-weight:600;" > $ { fmtAmt ( b . recent_amount ) } < / td >
< td style = "padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:13px;color:${arrowColor};font-weight:700;" > $ { arrow } $ { sign } $ { b . drift_pct } % < / td >
< / tr > ` ;
} ) . join ( '' ) ;
return ` <!DOCTYPE html>
< html >
< body style = "margin:0;padding:0;background:#f5f6fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;" >
< table width = "100%" cellpadding = "0" cellspacing = "0" style = "padding:32px 16px;" >
< tr > < td align = "center" >
< table width = "540" cellpadding = "0" cellspacing = "0" style = "background:#fff;border-radius:8px;border:1px solid #e2e4eb;overflow:hidden;" >
< tr >
< td style = "background:${color};padding:16px 24px;" >
< p style = "margin:0;color:#fff;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;" > Bill Tracker < / p >
< h1 style = "margin:4px 0 0;color:#fff;font-size:20px;font-weight:700;" > Price Changes Detected < / h1 >
< / td >
< / tr >
< tr >
< td style = "padding:20px 24px 8px;" >
< p style = "margin:0;color:#374151;font-size:14px;" >
The following bills have been paid at amounts different from their expected amounts for the past $ { bills [ 0 ] ? . months_sampled ? ? 2 } + months .
< / p >
< / td >
< / tr >
< tr >
< td style = "padding:8px 24px 20px;" >
< table width = "100%" cellpadding = "0" cellspacing = "0" style = "border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;" >
< thead >
< tr style = "background:#fef9c3;" >
< th style = "padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;" > Bill < / th >
< th style = "padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;" > Was < / th >
< th style = "padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;" > Now ~ < / th >
< th style = "padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;" > Change < / th >
< / tr >
< / thead >
< tbody > $ { rows } < / tbody >
< / table >
< / td >
< / tr >
< tr >
< td style = "padding:0 24px 24px;" >
< p style = "margin:0;color:#6b7280;font-size:13px;" >
You can update the expected amounts or dismiss these alerts in Bill Tracker .
< / p >
< / td >
< / tr >
< / table >
< / td > < / tr >
< / table >
< / body >
< / html > ` ;
}
async function runDriftNotifications() {
if ( getSetting ( 'notify_smtp_enabled' ) !== 'true' ) return ;
if ( ! getSetting ( 'notify_smtp_host' ) ) return ;
if ( ! getSetting ( 'notify_sender_address' ) ) return ;
2026-07-06 11:12:06 -05:00
const db : Db = getDb ( ) ;
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
const { getDriftReport } = require ( './driftService.cts' ) ;
2026-05-30 14:33:55 -05:00
const now = new Date ( ) ;
const year = now . getFullYear ( ) ;
const month = now . getMonth ( ) + 1 ;
2026-06-10 19:42:51 -05:00
const today = localDateString ( now ) ;
2026-05-30 14:33:55 -05:00
const allowUserConfig = getSetting ( 'notify_allow_user_config' ) === 'true' ;
const globalRecipient = getSetting ( 'notify_global_recipient' ) ;
2026-07-06 11:12:06 -05:00
const recipients : any [ ] = [ ] ;
2026-05-30 14:33:55 -05:00
if ( allowUserConfig ) {
const users = db . prepare (
"SELECT * FROM users WHERE active=1 AND role='user' AND notifications_enabled=1 AND notify_amount_change=1 AND notification_email IS NOT NULL AND notification_email != ''"
) . all ( ) ;
recipients . push ( . . . users ) ;
} else if ( globalRecipient ) {
recipients . push ( { id : 0 , notification_email : globalRecipient , notify_amount_change : 1 } ) ;
}
for ( const recipient of recipients ) {
try {
const report = getDriftReport ( recipient . id , now ) ;
2026-07-06 11:12:06 -05:00
const newBills = ( report . bills || [ ] ) . filter ( ( b : Row ) = >
2026-05-30 14:33:55 -05:00
! hasNotification ( db , b . id , recipient . id , year , month , 'amount_change' , today )
) ;
if ( ! newBills . length ) continue ;
await sendEmail (
recipient . notification_email ,
'Price Change Alert: Your bill amounts have changed' ,
buildDriftDigestHtml ( newBills )
) ;
for ( const b of newBills ) {
recordNotification ( db , b . id , recipient . id , year , month , 'amount_change' , today ) ;
}
2026-07-06 11:12:06 -05:00
} catch ( err : any ) {
2026-05-30 14:33:55 -05:00
console . error ( '[drift notifications] Error for recipient' , recipient . notification_email , ':' , err . message ) ;
}
}
}
module .exports = { runNotifications , runDriftNotifications , sendTestEmail , createTransport } ;
2026-07-06 11:12:06 -05:00
// Push helpers, exposed for the test-push route + tests (QA-B10-01: assigned AFTER the export above).
2026-07-02 22:11:34 -05:00
module .exports._push = { sendNtfy , sendGotify , sendDiscord , sendTelegram , sendTestPush , sendPushToUser , encryptSecret } ;
2026-07-02 22:18:05 -05:00
module .exports._email = { buildEmailHtml , esc } ;
2026-07-03 18:16:10 -05:00
module .exports._reminders = { reminderTypeFor , leadDaysOf , TYPE_META } ;