archetecture security fix
This commit is contained in:
Vendored
+259
@@ -0,0 +1,259 @@
|
||||
"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
|
||||
Reference in New Issue
Block a user