"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.io = exports.app = void 0; const http_1 = __importDefault(require("http")); const socket_io_1 = require("socket.io"); const node_cron_1 = __importDefault(require("node-cron")); const redis_1 = require("./lib/redis"); const prisma_1 = require("./lib/prisma"); const storage_1 = require("./lib/storage"); const app_1 = require("./app"); const tokens_1 = require("./security/tokens"); const sessionCookies_1 = require("./security/sessionCookies"); const notificationService_1 = require("./services/notificationService"); const subscription_service_1 = require("./modules/subscriptions/subscription.service"); const app = (0, app_1.createApp)(); exports.app = app; const server = http_1.default.createServer(app); (0, storage_1.assertStorageConfiguration)(); // ─── Socket.io ──────────────────────────────────────────────── const io = new socket_io_1.Server(server, { cors: { origin: app_1.corsOrigins, credentials: true, methods: ['GET', 'POST'] }, }); exports.io = io; function readCookieFromHeader(cookieHeader, name) { if (!cookieHeader) return null; for (const chunk of cookieHeader.split(';')) { const [rawName, ...rawValue] = chunk.trim().split('='); if (rawName === name) return decodeURIComponent(rawValue.join('=')); } return null; } function getSocketSessionToken(socket) { const explicitToken = socket.handshake.auth?.token; if (typeof explicitToken === 'string' && explicitToken.trim()) return explicitToken.trim(); const cookieHeader = socket.request.headers.cookie; return (readCookieFromHeader(cookieHeader, (0, sessionCookies_1.getSessionCookieName)('employee')) ?? readCookieFromHeader(cookieHeader, (0, sessionCookies_1.getSessionCookieName)('admin')) ?? readCookieFromHeader(cookieHeader, (0, sessionCookies_1.getSessionCookieName)('renter')) ?? undefined); } // Authenticate socket connections via JWT before joining user rooms io.use((socket, next) => { const token = getSocketSessionToken(socket); if (!token) return next(); // unauthenticated connections allowed; they just don't join rooms try { const payload = (0, tokens_1.verifyAnyActorToken)(token); socket.authenticatedUserId = payload.sub; next(); } catch { next(new Error('invalid_token')); } }); io.on('connection', (socket) => { const userId = socket.authenticatedUserId; if (userId) { socket.join(`user:${userId}`); } }); // Redis pub/sub → broadcast to connected clients const subscriber = redis_1.redis.duplicate(); subscriber.psubscribe('notifications:*', (err) => { if (err) console.error('[Redis] Subscribe error:', err); }); subscriber.on('pmessage', (_pattern, channel, message) => { try { const parsed = JSON.parse(message); const userId = channel.replace('notifications:', ''); io.to(`user:${userId}`).emit('notification', parsed); } catch (err) { console.error('[Redis] Invalid notification message:', err); } }); // ─── Scheduled jobs ─────────────────────────────────────────── // Daily: flag expiring/expired licenses node_cron_1.default.schedule('0 8 * * *', async () => { const customers = await prisma_1.prisma.customer.findMany({ where: { licenseExpiry: { not: null } } }); for (const c of customers) { if (!c.licenseExpiry) continue; const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); const expired = c.licenseExpiry <= new Date(); const expiring = !expired && daysLeft < 90; if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) { await prisma_1.prisma.customer.update({ where: { id: c.id }, data: { licenseExpired: expired, licenseExpiringSoon: expiring, licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID' } }); } } }); // Hourly: expire trials that ended without payment node_cron_1.default.schedule('0 * * * *', async () => { const n = await (0, subscription_service_1.runTrialExpirationJob)(); if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`); }); // Hourly: payment_pending → past_due after 7 days node_cron_1.default.schedule('15 * * * *', async () => { const n = await (0, subscription_service_1.runPaymentPendingTimeoutJob)(); if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`); }); // Hourly: past_due → suspended after 7 days node_cron_1.default.schedule('30 * * * *', async () => { const n = await (0, subscription_service_1.runPastDueTimeoutJob)(); if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`); }); // Daily: suspended → cancelled after 16 days; and period-end cancellations node_cron_1.default.schedule('0 1 * * *', async () => { const nSuspend = await (0, subscription_service_1.runSuspensionTimeoutJob)(); const nPeriod = await (0, subscription_service_1.runPeriodEndCancellationJob)(); if (nSuspend > 0) console.log(`[subscription] suspension_timeout: ${nSuspend} cancelled`); if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`); }); // Daily: send trial-ending reminders (3 days before trial end) node_cron_1.default.schedule('0 9 * * *', async () => { const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); const subscriptions = await prisma_1.prisma.subscription.findMany({ where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } }, include: { company: { include: { employees: { where: { role: 'OWNER' } } } } }, }); for (const sub of subscriptions) { const owner = sub.company.employees[0]; if (owner) { await (0, notificationService_1.sendNotification)({ type: 'SUBSCRIPTION_TRIAL_ENDING', companyId: sub.companyId, employeeId: owner.id, channels: ['IN_APP'], templateKey: 'subscription.trial_ending', templateVariables: { trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), }, }).catch((err) => { console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err)); }); } } }); // Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based). // Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future. node_cron_1.default.schedule('0 8 * * *', async () => { const now = new Date(); const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); // Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type. // We keep only the LATEST log per vehicle+type so that once the owner logs a new service the // old overdue log is superseded and notifications stop automatically. const allCandidates = await prisma_1.prisma.maintenanceLog.findMany({ where: { OR: [ { nextDueAt: { lte: in30Days } }, { nextDueMileage: { not: null } }, ], }, include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } }, orderBy: { performedAt: 'desc' }, }); // Keep only the most-recent log per vehicle+type combination const latestByKey = new Map(); for (const log of allCandidates) { const key = `${log.vehicleId}:${log.type}`; if (!latestByKey.has(key)) latestByKey.set(key, log); } for (const log of latestByKey.values()) { const vehicle = log.vehicle; const company = vehicle.company; const recipient = company.employees[0]; if (!recipient) continue; // Determine date-based urgency let isOverdueByDate = false; let daysLeft = null; let dueSoonByDate = false; if (log.nextDueAt) { daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); isOverdueByDate = log.nextDueAt <= now; dueSoonByDate = !isOverdueByDate && daysLeft <= 30; } // Determine odometer-based urgency let isOverdueByOdometer = false; let kmLeft = null; let dueSoonByOdometer = false; if (log.nextDueMileage != null && vehicle.mileage != null) { kmLeft = log.nextDueMileage - vehicle.mileage; isOverdueByOdometer = kmLeft <= 0; dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500; } // Skip if the latest log is no longer due (owner has updated it) const isOverdue = isOverdueByDate || isOverdueByOdometer; const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer); if (!isOverdue && !isDueSoon) continue; // Dedup: don't send more than once per day for the same log const alreadySent = await prisma_1.prisma.notification.findFirst({ where: { type: 'VEHICLE_MAINTENANCE_DUE', companyId: company.id, createdAt: { gte: oneDayAgo }, data: { path: ['maintenanceLogId'], equals: log.id }, }, }); if (alreadySent) continue; // Build human-readable description const dueParts = []; if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt.toLocaleDateString()}`); else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`); if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft).toLocaleString()} km ago)`); else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`); const title = isOverdue ? `Overdue: ${log.type} — ${vehicle.make} ${vehicle.model}` : `${log.type} due soon — ${vehicle.make} ${vehicle.model}`; const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`; await prisma_1.prisma.notification.create({ data: { type: 'VEHICLE_MAINTENANCE_DUE', title, body, data: { vehicleId: vehicle.id, maintenanceLogId: log.id, maintenanceType: log.type, isOverdue, daysLeft, kmLeft, isOverdueByDate, isOverdueByOdometer, }, companyId: company.id, employeeId: recipient.id, channel: 'IN_APP', status: 'DELIVERED', }, }); } }); // ─── Start ──────────────────────────────────────────────────── const PORT = Number(process.env.API_PORT ?? 4000); const HOST = process.env.API_HOST ?? '0.0.0.0'; server.listen(PORT, HOST, () => { console.log(`[API] Server running on ${HOST}:${PORT}`); }); //# sourceMappingURL=index.js.map