2026-07-06 11:26:50 -05:00
|
|
|
|
import type { Req, Res, Next } from '../types/http';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const express = require('express');
|
2026-07-06 16:05:18 -05:00
|
|
|
|
const { log } = require('../utils/logger.cts');
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const router = express.Router();
|
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, setSetting } = require('../db/database.cts');
|
|
|
|
|
|
const { requireAuth, requireUser, requireAdmin } = require('../middleware/requireAuth.cts');
|
2026-07-06 11:12:06 -05:00
|
|
|
|
const { sendTestEmail } = require('../services/notificationService.cts');
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const { sendTestPush } = require('../services/notificationService.cts')._push || {};
|
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 } = require('../services/encryptionService.cts');
|
|
|
|
|
|
const { encryptSecret } = require('../services/encryptionService.cts');
|
2026-07-10 16:36:27 -05:00
|
|
|
|
const { ApiError, ValidationError } = require('../utils/apiError.cts');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
// ── Admin: SMTP configuration ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/notifications/admin — read all SMTP settings (password masked)
|
2026-07-06 11:26:50 -05:00
|
|
|
|
router.get('/admin', requireAuth, requireAdmin, (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const keys = [
|
2026-07-06 14:23:53 -05:00
|
|
|
|
'notify_smtp_enabled',
|
|
|
|
|
|
'notify_sender_name',
|
|
|
|
|
|
'notify_sender_address',
|
|
|
|
|
|
'notify_smtp_host',
|
|
|
|
|
|
'notify_smtp_port',
|
|
|
|
|
|
'notify_smtp_encryption',
|
|
|
|
|
|
'notify_smtp_self_signed',
|
|
|
|
|
|
'notify_smtp_username',
|
|
|
|
|
|
'notify_smtp_password',
|
|
|
|
|
|
'notify_allow_user_config',
|
|
|
|
|
|
'notify_global_recipient',
|
|
|
|
|
|
'reminder_hour',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
];
|
2026-07-11 06:53:27 -05:00
|
|
|
|
const settings: Record<string, string> = {};
|
2026-05-03 19:51:57 -05:00
|
|
|
|
for (const k of keys) settings[k] = getSetting(k) || '';
|
2026-07-05 16:10:25 -05:00
|
|
|
|
if (!settings.reminder_hour) settings.reminder_hour = '6';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
// Mask password in response
|
|
|
|
|
|
if (settings.notify_smtp_password) settings.notify_smtp_password = '••••••••';
|
|
|
|
|
|
res.json(settings);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// PUT /api/notifications/admin — save SMTP settings
|
2026-07-06 11:26:50 -05:00
|
|
|
|
router.put('/admin', requireAuth, requireAdmin, (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const allowed = [
|
2026-07-06 14:23:53 -05:00
|
|
|
|
'notify_smtp_enabled',
|
|
|
|
|
|
'notify_sender_name',
|
|
|
|
|
|
'notify_sender_address',
|
|
|
|
|
|
'notify_smtp_host',
|
|
|
|
|
|
'notify_smtp_port',
|
|
|
|
|
|
'notify_smtp_encryption',
|
|
|
|
|
|
'notify_smtp_self_signed',
|
|
|
|
|
|
'notify_smtp_username',
|
|
|
|
|
|
'notify_allow_user_config',
|
|
|
|
|
|
'notify_global_recipient',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
];
|
|
|
|
|
|
for (const key of allowed) {
|
|
|
|
|
|
if (req.body[key] !== undefined) setSetting(key, req.body[key]);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Only update password if a real value was sent (not the masked placeholder)
|
|
|
|
|
|
if (req.body.notify_smtp_password && !req.body.notify_smtp_password.startsWith('•')) {
|
2026-05-31 15:06:10 -05:00
|
|
|
|
setSetting('notify_smtp_password', encryptSecret(req.body.notify_smtp_password));
|
2026-05-03 19:51:57 -05:00
|
|
|
|
}
|
2026-07-05 16:10:25 -05:00
|
|
|
|
// Reminder send-hour (0–23, global): persist clamped + live-reschedule the cron.
|
|
|
|
|
|
if (req.body.reminder_hour !== undefined) {
|
|
|
|
|
|
const h = parseInt(req.body.reminder_hour, 10);
|
|
|
|
|
|
const hour = Number.isInteger(h) && h >= 0 && h <= 23 ? h : 6;
|
|
|
|
|
|
setSetting('reminder_hour', String(hour));
|
|
|
|
|
|
try {
|
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
|
|
|
|
require('../workers/dailyWorker.cts').rescheduleDailyWorker();
|
2026-07-11 06:53:27 -05:00
|
|
|
|
} catch (err: any) {
|
2026-07-06 16:05:18 -05:00
|
|
|
|
log.error('[notifications] Failed to reschedule daily worker:', err.message);
|
2026-07-05 16:10:25 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
res.json({ success: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/notifications/test — send a test email
|
2026-07-06 11:26:50 -05:00
|
|
|
|
router.post('/test', requireAuth, requireAdmin, async (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const { to } = req.body;
|
2026-07-10 16:36:27 -05:00
|
|
|
|
if (!to) throw ValidationError('Recipient address required', 'to');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
try {
|
|
|
|
|
|
await sendTestEmail(to);
|
2026-07-11 06:53:27 -05:00
|
|
|
|
} catch (err: any) {
|
2026-07-10 16:36:27 -05:00
|
|
|
|
// Deliberate pass-through: the SMTP error text is the admin's own config
|
|
|
|
|
|
// feedback (bad host/auth/TLS). ApiError messages survive formatError;
|
|
|
|
|
|
// anything not wrapped would be masked as a generic 5xx.
|
|
|
|
|
|
throw new ApiError('NOTIFICATION_TEST_FAILED', err.message || 'Test email failed', 502);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
}
|
2026-07-10 16:36:27 -05:00
|
|
|
|
res.json({ success: true });
|
2026-05-03 19:51:57 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── User: notification preferences ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/notifications/me — user prefs + whether admin has enabled it
|
2026-07-06 11:26:50 -05:00
|
|
|
|
router.get('/me', requireAuth, requireUser, (req: Req, res: Res) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const db = getDb();
|
|
|
|
|
|
const user = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`
|
2026-05-03 19:51:57 -05:00
|
|
|
|
SELECT notification_email, notifications_enabled,
|
2026-05-30 14:33:55 -05:00
|
|
|
|
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change
|
2026-05-03 19:51:57 -05:00
|
|
|
|
FROM users WHERE id = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.get(req.user.id);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
res.json({
|
2026-07-06 14:23:53 -05:00
|
|
|
|
smtp_enabled: getSetting('notify_smtp_enabled') === 'true',
|
|
|
|
|
|
allow_user_config: getSetting('notify_allow_user_config') === 'true',
|
|
|
|
|
|
notification_email: user.notification_email || '',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
notifications_enabled: !!user.notifications_enabled,
|
2026-07-06 14:23:53 -05:00
|
|
|
|
notify_3d: !!user.notify_3d,
|
|
|
|
|
|
notify_1d: !!user.notify_1d,
|
|
|
|
|
|
notify_due: !!user.notify_due,
|
|
|
|
|
|
notify_overdue: !!user.notify_overdue,
|
|
|
|
|
|
notify_amount_change: user.notify_amount_change !== 0,
|
2026-05-03 19:51:57 -05:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// PUT /api/notifications/me — save user prefs
|
2026-07-06 11:26:50 -05:00
|
|
|
|
router.put('/me', requireAuth, requireUser, (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const db = getDb();
|
|
|
|
|
|
const {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
notification_email,
|
|
|
|
|
|
notifications_enabled,
|
|
|
|
|
|
notify_3d,
|
|
|
|
|
|
notify_1d,
|
|
|
|
|
|
notify_due,
|
|
|
|
|
|
notify_overdue,
|
|
|
|
|
|
notify_amount_change,
|
2026-05-03 19:51:57 -05:00
|
|
|
|
} = req.body;
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
db.prepare(
|
|
|
|
|
|
`
|
2026-05-03 19:51:57 -05:00
|
|
|
|
UPDATE users SET
|
|
|
|
|
|
notification_email = ?,
|
|
|
|
|
|
notifications_enabled = ?,
|
|
|
|
|
|
notify_3d = ?,
|
|
|
|
|
|
notify_1d = ?,
|
|
|
|
|
|
notify_due = ?,
|
|
|
|
|
|
notify_overdue = ?,
|
2026-05-30 14:33:55 -05:00
|
|
|
|
notify_amount_change = ?,
|
2026-05-03 19:51:57 -05:00
|
|
|
|
updated_at = datetime('now')
|
|
|
|
|
|
WHERE id = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
).run(
|
|
|
|
|
|
notification_email || null,
|
|
|
|
|
|
notifications_enabled ? 1 : 0,
|
|
|
|
|
|
notify_3d !== false ? 1 : 0,
|
|
|
|
|
|
notify_1d !== false ? 1 : 0,
|
|
|
|
|
|
notify_due !== false ? 1 : 0,
|
2026-05-03 19:51:57 -05:00
|
|
|
|
notify_overdue !== false ? 1 : 0,
|
2026-05-30 14:33:55 -05:00
|
|
|
|
notify_amount_change !== false ? 1 : 0,
|
2026-05-03 19:51:57 -05:00
|
|
|
|
req.user.id,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-03 21:43:54 -05:00
|
|
|
|
// POST /api/notifications/test-push — send a test push notification to the current user
|
2026-07-06 11:26:50 -05:00
|
|
|
|
router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const db = getDb();
|
|
|
|
|
|
const user = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`
|
2026-06-03 21:43:54 -05:00
|
|
|
|
SELECT notify_push_enabled, push_channel, push_url, push_token, push_chat_id
|
|
|
|
|
|
FROM users WHERE id = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.get(req.user.id);
|
2026-06-03 21:43:54 -05:00
|
|
|
|
|
|
|
|
|
|
if (!user?.push_channel || !user?.push_url) {
|
2026-07-10 16:36:27 -05:00
|
|
|
|
throw ValidationError('Push notification channel not configured', 'push_channel');
|
2026-06-03 21:43:54 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Decrypt for sending
|
2026-07-11 06:53:27 -05:00
|
|
|
|
const safeDecrypt = (v: any) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
try {
|
|
|
|
|
|
return v ? decryptSecret(v) : '';
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return v || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-06-03 21:43:54 -05:00
|
|
|
|
const userForPush = {
|
|
|
|
|
|
...user,
|
|
|
|
|
|
notify_push_enabled: 1,
|
2026-07-06 14:23:53 -05:00
|
|
|
|
push_url: safeDecrypt(user.push_url),
|
2026-06-03 21:43:54 -05:00
|
|
|
|
push_token: safeDecrypt(user.push_token),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!sendTestPush) throw new Error('Push service not initialised');
|
|
|
|
|
|
await sendTestPush(userForPush);
|
2026-07-11 06:53:27 -05:00
|
|
|
|
} catch (err: any) {
|
2026-07-10 16:36:27 -05:00
|
|
|
|
// Deliberate pass-through: the push-provider error is the user's own
|
|
|
|
|
|
// channel-config feedback (bad URL/token) — same rationale as /test above.
|
|
|
|
|
|
throw new ApiError('NOTIFICATION_TEST_FAILED', err.message || 'Test push failed', 502);
|
2026-06-03 21:43:54 -05:00
|
|
|
|
}
|
2026-07-10 16:36:27 -05:00
|
|
|
|
res.json({ success: true });
|
2026-06-03 21:43:54 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
module.exports = router;
|