delete and undo

This commit is contained in:
2026-10-10 10:43:24 +05:30
parent 575b00172d
commit ee71d4c24e
25 changed files with 3604 additions and 3469 deletions
+6 -6
View File
@@ -1,6 +1,6 @@
DATABASE_URL="file:../data/app.db" DATABASE_URL="file:../data/app.db"
JWT_SECRET="replace-with-a-long-random-string" JWT_SECRET="replace-with-a-long-random-string"
SEED_ADMIN_NAME="Super Admin" SEED_ADMIN_NAME="Super Admin"
SEED_ADMIN_PHONE="0715127342" SEED_ADMIN_PHONE="0715127342"
SEED_ADMIN_PASSWORD="change-this-password" SEED_ADMIN_PASSWORD="change-this-password"
PORT=3000 PORT=3000
+4 -4
View File
@@ -1,4 +1,4 @@
node_modules/ node_modules/
.env .env
data/*.db data/*.db
data/*.db-journal data/*.db-journal
+1584 -1584
View File
File diff suppressed because it is too large Load Diff
+29 -29
View File
@@ -1,29 +1,29 @@
{ {
"name": "dealer-network-api", "name": "dealer-network-api",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"dev": "node --watch src/index.js", "dev": "node --watch src/index.js",
"start": "prisma migrate deploy && node prisma/seed.js && node src/index.js", "start": "prisma migrate deploy && node prisma/seed.js && node src/index.js",
"seed": "node prisma/seed.js", "seed": "node prisma/seed.js",
"check": "node scripts/api-check.js", "check": "node scripts/api-check.js",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"prisma": { "prisma": {
"seed": "node prisma/seed.js" "seed": "node prisma/seed.js"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
"axios": "^1.12.2", "axios": "^1.12.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"prisma": "^6.16.2" "prisma": "^6.16.2"
}, },
"overrides": { "overrides": {
"deepmerge-ts": "^8.0.2" "deepmerge-ts": "^8.0.2"
} }
} }
@@ -1,34 +1,34 @@
-- CreateTable -- CreateTable
CREATE TABLE "LoginRequest" ( CREATE TABLE "LoginRequest" (
"id" TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"status" TEXT NOT NULL, "status" TEXT NOT NULL,
"secretHash" TEXT NOT NULL, "secretHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL, "expiresAt" DATETIME NOT NULL,
"decidedById" TEXT, "decidedById" TEXT,
"decidedAt" DATETIME, "decidedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoginRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "LoginRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "LoginRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE CONSTRAINT "LoginRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
); );
-- RedefineTables -- RedefineTables
PRAGMA defer_foreign_keys=ON; PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" ( CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"phone" TEXT NOT NULL, "phone" TEXT NOT NULL,
"role" TEXT NOT NULL, "role" TEXT NOT NULL,
"dealerId" TEXT, "dealerId" TEXT,
"passwordHash" TEXT, "passwordHash" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" DATETIME NOT NULL,
CONSTRAINT "User_dealerId_fkey" FOREIGN KEY ("dealerId") REFERENCES "Dealer" ("id") ON DELETE SET NULL ON UPDATE CASCADE CONSTRAINT "User_dealerId_fkey" FOREIGN KEY ("dealerId") REFERENCES "Dealer" ("id") ON DELETE SET NULL ON UPDATE CASCADE
); );
INSERT INTO "new_User" ("createdAt", "dealerId", "id", "name", "phone", "role", "updatedAt") SELECT "createdAt", "dealerId", "id", "name", "phone", "role", "updatedAt" FROM "User"; INSERT INTO "new_User" ("createdAt", "dealerId", "id", "name", "phone", "role", "updatedAt") SELECT "createdAt", "dealerId", "id", "name", "phone", "role", "updatedAt" FROM "User";
DROP TABLE "User"; DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User"; ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone"); CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
PRAGMA foreign_keys=ON; PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF; PRAGMA defer_foreign_keys=OFF;
@@ -1,2 +1,2 @@
-- AlterTable -- AlterTable
ALTER TABLE "Stock" ADD COLUMN "plateNumber" TEXT; ALTER TABLE "Stock" ADD COLUMN "plateNumber" TEXT;
+143 -143
View File
@@ -1,143 +1,143 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
phone String @unique phone String @unique
role String role String
dealerId String? dealerId String?
dealer Dealer? @relation("DealerStaff", fields: [dealerId], references: [id]) dealer Dealer? @relation("DealerStaff", fields: [dealerId], references: [id])
passwordHash String? passwordHash String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
assignments DealerAssignment[] @relation("SalesRepAssignments") assignments DealerAssignment[] @relation("SalesRepAssignments")
stageLogs StageLog[] stageLogs StageLog[]
transfers StockTransfer[] transfers StockTransfer[]
events Event[] events Event[]
servicePoints ServicePoint[] servicePoints ServicePoint[]
loginRequests LoginRequest[] @relation("DealerLoginRequests") loginRequests LoginRequest[] @relation("DealerLoginRequests")
loginDecisions LoginRequest[] @relation("LoginDecisions") loginDecisions LoginRequest[] @relation("LoginDecisions")
} }
model LoginRequest { model LoginRequest {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation("DealerLoginRequests", fields: [userId], references: [id], onDelete: Cascade) user User @relation("DealerLoginRequests", fields: [userId], references: [id], onDelete: Cascade)
status String status String
secretHash String secretHash String
expiresAt DateTime expiresAt DateTime
decidedById String? decidedById String?
decidedBy User? @relation("LoginDecisions", fields: [decidedById], references: [id]) decidedBy User? @relation("LoginDecisions", fields: [decidedById], references: [id])
decidedAt DateTime? decidedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model Dealer { model Dealer {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
code String @unique code String @unique
district String? district String?
address String? address String?
phone String? phone String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
staff User[] @relation("DealerStaff") staff User[] @relation("DealerStaff")
assignments DealerAssignment[] assignments DealerAssignment[]
stocks Stock[] stocks Stock[]
transfersFrom StockTransfer[] @relation("TransferFrom") transfersFrom StockTransfer[] @relation("TransferFrom")
transfersTo StockTransfer[] @relation("TransferTo") transfersTo StockTransfer[] @relation("TransferTo")
events Event[] events Event[]
} }
model DealerAssignment { model DealerAssignment {
salesRepId String salesRepId String
dealerId String dealerId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
salesRep User @relation("SalesRepAssignments", fields: [salesRepId], references: [id], onDelete: Cascade) salesRep User @relation("SalesRepAssignments", fields: [salesRepId], references: [id], onDelete: Cascade)
dealer Dealer @relation(fields: [dealerId], references: [id], onDelete: Cascade) dealer Dealer @relation(fields: [dealerId], references: [id], onDelete: Cascade)
@@id([salesRepId, dealerId]) @@id([salesRepId, dealerId])
} }
model Stock { model Stock {
id String @id @default(cuid()) id String @id @default(cuid())
chassisNumber String @unique chassisNumber String @unique
engineNumber String @unique engineNumber String @unique
model String model String
color String color String
currentDealerId String currentDealerId String
currentDealer Dealer @relation(fields: [currentDealerId], references: [id]) currentDealer Dealer @relation(fields: [currentDealerId], references: [id])
dateReceived DateTime @default(now()) dateReceived DateTime @default(now())
currentStage String @default("received") currentStage String @default("received")
paymentType String? paymentType String?
leaseProviderType String? leaseProviderType String?
financeCompanyName String? financeCompanyName String?
plateNumber String? plateNumber String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
stageLogs StageLog[] stageLogs StageLog[]
transfers StockTransfer[] transfers StockTransfer[]
} }
model StageLog { model StageLog {
id String @id @default(cuid()) id String @id @default(cuid())
stockId String stockId String
stock Stock @relation(fields: [stockId], references: [id], onDelete: Cascade) stock Stock @relation(fields: [stockId], references: [id], onDelete: Cascade)
stage String stage String
loggedAt DateTime @default(now()) loggedAt DateTime @default(now())
actorId String? actorId String?
actor User? @relation(fields: [actorId], references: [id]) actor User? @relation(fields: [actorId], references: [id])
} }
model StockTransfer { model StockTransfer {
id String @id @default(cuid()) id String @id @default(cuid())
stockId String stockId String
stock Stock @relation(fields: [stockId], references: [id], onDelete: Cascade) stock Stock @relation(fields: [stockId], references: [id], onDelete: Cascade)
fromDealerId String fromDealerId String
fromDealer Dealer @relation("TransferFrom", fields: [fromDealerId], references: [id]) fromDealer Dealer @relation("TransferFrom", fields: [fromDealerId], references: [id])
toDealerId String toDealerId String
toDealer Dealer @relation("TransferTo", fields: [toDealerId], references: [id]) toDealer Dealer @relation("TransferTo", fields: [toDealerId], references: [id])
actorId String actorId String
actor User @relation(fields: [actorId], references: [id]) actor User @relation(fields: [actorId], references: [id])
transferredAt DateTime @default(now()) transferredAt DateTime @default(now())
} }
model Event { model Event {
id String @id @default(cuid()) id String @id @default(cuid())
dealerId String dealerId String
dealer Dealer @relation(fields: [dealerId], references: [id]) dealer Dealer @relation(fields: [dealerId], references: [id])
planType String planType String
month Int? month Int?
year Int? year Int?
title String title String
description String description String
expense Float expense Float
imageUrls String @default("[]") imageUrls String @default("[]")
createdById String createdById String
createdBy User @relation(fields: [createdById], references: [id]) createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model ServicePoint { model ServicePoint {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
district String district String
address String? address String?
phone String? phone String?
notes String? notes String?
createdById String createdById String
createdBy User @relation(fields: [createdById], references: [id]) createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
+41 -41
View File
@@ -1,41 +1,41 @@
const path = require('path'); const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const prisma = require('../src/db'); const prisma = require('../src/db');
const { isValidPhone, normalizePhone } = require('../src/lib/phone'); const { isValidPhone, normalizePhone } = require('../src/lib/phone');
const { hashPassword } = require('../src/lib/passwords'); const { hashPassword } = require('../src/lib/passwords');
async function main() { async function main() {
const name = String(process.env.SEED_ADMIN_NAME || 'Super Admin').trim(); const name = String(process.env.SEED_ADMIN_NAME || 'Super Admin').trim();
const phone = normalizePhone(process.env.SEED_ADMIN_PHONE); const phone = normalizePhone(process.env.SEED_ADMIN_PHONE);
const password = String(process.env.SEED_ADMIN_PASSWORD || ''); const password = String(process.env.SEED_ADMIN_PASSWORD || '');
if (!isValidPhone(phone)) { if (!isValidPhone(phone)) {
throw new Error('SEED_ADMIN_PHONE must be a Sri Lankan mobile number'); throw new Error('SEED_ADMIN_PHONE must be a Sri Lankan mobile number');
} }
if (password.length < 8) { if (password.length < 8) {
throw new Error('SEED_ADMIN_PASSWORD must be at least 8 characters'); throw new Error('SEED_ADMIN_PASSWORD must be at least 8 characters');
} }
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const existingAdmin = await prisma.user.findFirst({ where: { role: 'superAdmin' } }); const existingAdmin = await prisma.user.findFirst({ where: { role: 'superAdmin' } });
const admin = existingAdmin const admin = existingAdmin
? await prisma.user.update({ ? await prisma.user.update({
where: { id: existingAdmin.id }, where: { id: existingAdmin.id },
data: { name, phone, role: 'superAdmin', dealerId: null, passwordHash }, data: { name, phone, role: 'superAdmin', dealerId: null, passwordHash },
}) })
: await prisma.user.upsert({ : await prisma.user.upsert({
where: { phone }, where: { phone },
update: { name, role: 'superAdmin', dealerId: null, passwordHash }, update: { name, role: 'superAdmin', dealerId: null, passwordHash },
create: { name, phone, role: 'superAdmin', passwordHash }, create: { name, phone, role: 'superAdmin', passwordHash },
}); });
console.log(`Seeded super admin ${admin.name} (${admin.phone})`); console.log(`Seeded super admin ${admin.name} (${admin.phone})`);
} }
main() main()
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
process.exitCode = 1; process.exitCode = 1;
}) })
.finally(async () => { .finally(async () => {
await prisma.$disconnect(); await prisma.$disconnect();
}); });
+554 -486
View File
File diff suppressed because it is too large Load Diff
+46 -46
View File
@@ -1,46 +1,46 @@
const path = require('path'); const path = require('path');
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const dealerRoutes = require('./routes/dealers'); const dealerRoutes = require('./routes/dealers');
const stockRoutes = require('./routes/stocks'); const stockRoutes = require('./routes/stocks');
const eventRoutes = require('./routes/events'); const eventRoutes = require('./routes/events');
const servicePointRoutes = require('./routes/servicePoints'); const servicePointRoutes = require('./routes/servicePoints');
const adminRoutes = require('./routes/admin'); const adminRoutes = require('./routes/admin');
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/dealers', dealerRoutes); app.use('/api/dealers', dealerRoutes);
app.use('/api/stocks', stockRoutes); app.use('/api/stocks', stockRoutes);
app.use('/api/events', eventRoutes); app.use('/api/events', eventRoutes);
app.use('/api/service-points', servicePointRoutes); app.use('/api/service-points', servicePointRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ success: false, message: 'Not found' }); res.status(404).json({ success: false, message: 'Not found' });
}); });
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
if (res.headersSent) return next(err); if (res.headersSent) return next(err);
if (err.code === 'P2002') { if (err.code === 'P2002') {
return res.status(409).json({ success: false, message: 'Already exists' }); return res.status(409).json({ success: false, message: 'Already exists' });
} }
const status = err.status || 500; const status = err.status || 500;
if (status >= 500) console.error(err); if (status >= 500) console.error(err);
return res.status(status).json({ return res.status(status).json({
success: false, success: false,
message: status >= 500 ? 'Server error' : err.message, message: status >= 500 ? 'Server error' : err.message,
}); });
}); });
module.exports = app; module.exports = app;
+5 -5
View File
@@ -1,5 +1,5 @@
const { PrismaClient } = require('@prisma/client'); const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
module.exports = prisma; module.exports = prisma;
+6 -6
View File
@@ -1,6 +1,6 @@
const app = require('./app'); const app = require('./app');
const port = Number(process.env.PORT) || 3000; const port = Number(process.env.PORT) || 3000;
app.listen(port, () => { app.listen(port, () => {
console.log(`API listening on ${port}`); console.log(`API listening on ${port}`);
}); });
+30 -30
View File
@@ -1,30 +1,30 @@
function assignedDealerIds(user) { function assignedDealerIds(user) {
return (user.assignments || []).map((assignment) => assignment.dealerId); return (user.assignments || []).map((assignment) => assignment.dealerId);
} }
function canAccessDealer(user, dealerId) { function canAccessDealer(user, dealerId) {
if (!dealerId) return false; if (!dealerId) return false;
if (user.role === 'superAdmin') return true; if (user.role === 'superAdmin') return true;
if (user.role === 'dealer') return user.dealerId === dealerId; if (user.role === 'dealer') return user.dealerId === dealerId;
if (user.role === 'salesRep') return assignedDealerIds(user).includes(dealerId); if (user.role === 'salesRep') return assignedDealerIds(user).includes(dealerId);
return false; return false;
} }
function dealerScopeWhere(user) { function dealerScopeWhere(user) {
if (user.role === 'superAdmin') return {}; if (user.role === 'superAdmin') return {};
if (user.role === 'dealer') return { currentDealerId: user.dealerId || '__none__' }; if (user.role === 'dealer') return { currentDealerId: user.dealerId || '__none__' };
return { currentDealerId: { in: assignedDealerIds(user) } }; return { currentDealerId: { in: assignedDealerIds(user) } };
} }
function eventScopeWhere(user) { function eventScopeWhere(user) {
if (user.role === 'superAdmin') return {}; if (user.role === 'superAdmin') return {};
if (user.role === 'dealer') return { dealerId: user.dealerId || '__none__' }; if (user.role === 'dealer') return { dealerId: user.dealerId || '__none__' };
return { dealerId: { in: assignedDealerIds(user) } }; return { dealerId: { in: assignedDealerIds(user) } };
} }
module.exports = { module.exports = {
assignedDealerIds, assignedDealerIds,
canAccessDealer, canAccessDealer,
dealerScopeWhere, dealerScopeWhere,
eventScopeWhere, eventScopeWhere,
}; };
+13 -13
View File
@@ -1,13 +1,13 @@
function httpError(status, message) { function httpError(status, message) {
const err = new Error(message); const err = new Error(message);
err.status = status; err.status = status;
return err; return err;
} }
function asyncHandler(fn) { function asyncHandler(fn) {
return (req, res, next) => { return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next); Promise.resolve(fn(req, res, next)).catch(next);
}; };
} }
module.exports = { httpError, asyncHandler }; module.exports = { httpError, asyncHandler };
+14 -14
View File
@@ -1,14 +1,14 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const ROUNDS = 10; const ROUNDS = 10;
function hashPassword(password) { function hashPassword(password) {
return bcrypt.hash(password, ROUNDS); return bcrypt.hash(password, ROUNDS);
} }
function checkPassword(password, passwordHash) { function checkPassword(password, passwordHash) {
if (!passwordHash) return false; if (!passwordHash) return false;
return bcrypt.compare(password, passwordHash); return bcrypt.compare(password, passwordHash);
} }
module.exports = { hashPassword, checkPassword }; module.exports = { hashPassword, checkPassword };
+14 -14
View File
@@ -1,14 +1,14 @@
const PHONE_PATTERN = /^947\d{8}$/; const PHONE_PATTERN = /^947\d{8}$/;
function normalizePhone(value) { function normalizePhone(value) {
let phone = String(value || '').trim().replace(/[\s-]/g, ''); let phone = String(value || '').trim().replace(/[\s-]/g, '');
if (phone.startsWith('+')) phone = phone.slice(1); if (phone.startsWith('+')) phone = phone.slice(1);
if (/^0\d{9}$/.test(phone)) phone = `94${phone.slice(1)}`; if (/^0\d{9}$/.test(phone)) phone = `94${phone.slice(1)}`;
return phone; return phone;
} }
function isValidPhone(phone) { function isValidPhone(phone) {
return PHONE_PATTERN.test(phone); return PHONE_PATTERN.test(phone);
} }
module.exports = { PHONE_PATTERN, normalizePhone, isValidPhone }; module.exports = { PHONE_PATTERN, normalizePhone, isValidPhone };
+70 -70
View File
@@ -1,70 +1,70 @@
const STAGES = [ const STAGES = [
'received', 'received',
'orderedByCustomer', 'orderedByCustomer',
'documentsProcessed', 'documentsProcessed',
'paymentApproved', 'paymentApproved',
'rmvRegistration', 'rmvRegistration',
'plateReceived', 'plateReceived',
]; ];
const MODELS = ['gn125', 'boxer125', 'click150i']; const MODELS = ['gn125', 'boxer125', 'click150i'];
const PAYMENT_TYPES = ['cash', 'lease']; const PAYMENT_TYPES = ['cash', 'lease'];
const LEASE_PROVIDERS = ['gci', 'thirdPartyFinance', 'both']; const LEASE_PROVIDERS = ['gci', 'thirdPartyFinance', 'both'];
function ageInDays(dateReceived, now = Date.now()) { function ageInDays(dateReceived, now = Date.now()) {
const diffTime = Math.abs(now - new Date(dateReceived).getTime()); const diffTime = Math.abs(now - new Date(dateReceived).getTime());
return Math.floor(diffTime / (1000 * 60 * 60 * 24)); return Math.floor(diffTime / (1000 * 60 * 60 * 24));
} }
function ageBadge(days) { function ageBadge(days) {
if (days < 30) return 'green'; if (days < 30) return 'green';
if (days <= 45) return 'yellow'; if (days <= 45) return 'yellow';
return 'red'; return 'red';
} }
function nextStage(current) { function nextStage(current) {
const index = STAGES.indexOf(current); const index = STAGES.indexOf(current);
if (index < 0 || index >= STAGES.length - 1) return null; if (index < 0 || index >= STAGES.length - 1) return null;
return STAGES[index + 1]; return STAGES[index + 1];
} }
function stageLogMap(logs) { function stageLogMap(logs) {
const map = {}; const map = {};
for (const log of logs) { for (const log of logs) {
map[log.stage] = log.loggedAt; map[log.stage] = log.loggedAt;
} }
return map; return map;
} }
function serializeStock(stock) { function serializeStock(stock) {
const days = ageInDays(stock.dateReceived); const days = ageInDays(stock.dateReceived);
return { return {
id: stock.id, id: stock.id,
chassisNumber: stock.chassisNumber, chassisNumber: stock.chassisNumber,
engineNumber: stock.engineNumber, engineNumber: stock.engineNumber,
model: stock.model, model: stock.model,
color: stock.color, color: stock.color,
currentDealerId: stock.currentDealerId, currentDealerId: stock.currentDealerId,
dealerName: stock.currentDealer ? stock.currentDealer.name : null, dealerName: stock.currentDealer ? stock.currentDealer.name : null,
dateReceived: stock.dateReceived, dateReceived: stock.dateReceived,
currentStage: stock.currentStage, currentStage: stock.currentStage,
stageLogDates: stageLogMap(stock.stageLogs || []), stageLogDates: stageLogMap(stock.stageLogs || []),
paymentType: stock.paymentType, paymentType: stock.paymentType,
leaseProviderType: stock.leaseProviderType, leaseProviderType: stock.leaseProviderType,
financeCompanyName: stock.financeCompanyName, financeCompanyName: stock.financeCompanyName,
plateNumber: stock.plateNumber, plateNumber: stock.plateNumber,
ageInDays: days, ageInDays: days,
badge: ageBadge(days), badge: ageBadge(days),
}; };
} }
module.exports = { module.exports = {
STAGES, STAGES,
MODELS, MODELS,
PAYMENT_TYPES, PAYMENT_TYPES,
LEASE_PROVIDERS, LEASE_PROVIDERS,
ageInDays, ageInDays,
ageBadge, ageBadge,
nextStage, nextStage,
serializeStock, serializeStock,
}; };
+31 -31
View File
@@ -1,31 +1,31 @@
function publicUser(user, dealers) { function publicUser(user, dealers) {
const profile = { const profile = {
id: user.id, id: user.id,
name: user.name, name: user.name,
phone: user.phone, phone: user.phone,
role: user.role, role: user.role,
dealerId: user.dealerId, dealerId: user.dealerId,
assignedDealerIds: (user.assignments || []).map((assignment) => assignment.dealerId), assignedDealerIds: (user.assignments || []).map((assignment) => assignment.dealerId),
}; };
if (dealers) { if (dealers) {
profile.dealers = dealers.map((dealer) => ({ profile.dealers = dealers.map((dealer) => ({
id: dealer.id, id: dealer.id,
name: dealer.name, name: dealer.name,
code: dealer.code, code: dealer.code,
district: dealer.district, district: dealer.district,
})); }));
} }
return profile; return profile;
} }
const userPublicSelect = { const userPublicSelect = {
id: true, id: true,
name: true, name: true,
phone: true, phone: true,
role: true, role: true,
dealerId: true, dealerId: true,
createdAt: true, createdAt: true,
assignments: { select: { dealerId: true } }, assignments: { select: { dealerId: true } },
}; };
module.exports = { publicUser, userPublicSelect }; module.exports = { publicUser, userPublicSelect };
+37 -37
View File
@@ -1,37 +1,37 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const prisma = require('../db'); const prisma = require('../db');
const { userPublicSelect } = require('../lib/users'); const { userPublicSelect } = require('../lib/users');
async function requireAuth(req, res, next) { async function requireAuth(req, res, next) {
const header = req.headers.authorization || ''; const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7).trim() : ''; const token = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
if (!token) { if (!token) {
return res.status(401).json({ success: false, message: 'Missing token' }); return res.status(401).json({ success: false, message: 'Missing token' });
} }
try { try {
const payload = jwt.verify(token, process.env.JWT_SECRET); const payload = jwt.verify(token, process.env.JWT_SECRET);
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },
select: userPublicSelect, select: userPublicSelect,
}); });
if (!user) { if (!user) {
return res.status(401).json({ success: false, message: 'Invalid token' }); return res.status(401).json({ success: false, message: 'Invalid token' });
} }
req.user = user; req.user = user;
return next(); return next();
} catch { } catch {
return res.status(401).json({ success: false, message: 'Invalid token' }); return res.status(401).json({ success: false, message: 'Invalid token' });
} }
} }
function requireRole(...roles) { function requireRole(...roles) {
return (req, res, next) => { return (req, res, next) => {
if (!roles.includes(req.user.role)) { if (!roles.includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' }); return res.status(403).json({ success: false, message: 'Forbidden' });
} }
return next(); return next();
}; };
} }
module.exports = { requireAuth, requireRole }; module.exports = { requireAuth, requireRole };
+198 -198
View File
@@ -1,198 +1,198 @@
const express = require('express'); const express = require('express');
const prisma = require('../db'); const prisma = require('../db');
const { asyncHandler, httpError } = require('../lib/errors'); const { asyncHandler, httpError } = require('../lib/errors');
const { isValidPhone, normalizePhone } = require('../lib/phone'); const { isValidPhone, normalizePhone } = require('../lib/phone');
const { hashPassword } = require('../lib/passwords'); const { hashPassword } = require('../lib/passwords');
const { userPublicSelect, publicUser } = require('../lib/users'); const { userPublicSelect, publicUser } = require('../lib/users');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth, requireRole } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.get( router.get(
'/dealers', '/dealers',
requireRole('superAdmin'), requireRole('superAdmin'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const dealers = await prisma.dealer.findMany({ orderBy: { name: 'asc' } }); const dealers = await prisma.dealer.findMany({ orderBy: { name: 'asc' } });
return res.json({ success: true, data: dealers }); return res.json({ success: true, data: dealers });
}) })
); );
router.post( router.post(
'/dealers', '/dealers',
requireRole('superAdmin', 'salesRep'), requireRole('superAdmin', 'salesRep'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const name = String(req.body?.name || '').trim(); const name = String(req.body?.name || '').trim();
const code = String(req.body?.code || '').trim(); const code = String(req.body?.code || '').trim();
const district = String(req.body?.district || '').trim() || null; const district = String(req.body?.district || '').trim() || null;
const address = String(req.body?.address || '').trim() || null; const address = String(req.body?.address || '').trim() || null;
const phone = normalizePhone(req.body?.phone); const phone = normalizePhone(req.body?.phone);
if (!name || !code || !isValidPhone(phone)) { if (!name || !code || !isValidPhone(phone)) {
throw httpError(400, 'Name, code, and a Sri Lankan mobile number are required'); throw httpError(400, 'Name, code, and a Sri Lankan mobile number are required');
} }
const dealer = await prisma.$transaction(async (tx) => { const dealer = await prisma.$transaction(async (tx) => {
const created = await tx.dealer.create({ const created = await tx.dealer.create({
data: { name, code, district, address, phone }, data: { name, code, district, address, phone },
}); });
await tx.user.create({ await tx.user.create({
data: { name, phone, role: 'dealer', dealerId: created.id }, data: { name, phone, role: 'dealer', dealerId: created.id },
}); });
if (req.user.role === 'salesRep') { if (req.user.role === 'salesRep') {
await tx.dealerAssignment.create({ await tx.dealerAssignment.create({
data: { salesRepId: req.user.id, dealerId: created.id }, data: { salesRepId: req.user.id, dealerId: created.id },
}); });
} }
return created; return created;
}); });
return res.status(201).json({ success: true, data: dealer }); return res.status(201).json({ success: true, data: dealer });
}) })
); );
router.delete( router.delete(
'/dealers/:id', '/dealers/:id',
requireRole('superAdmin'), requireRole('superAdmin'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const id = String(req.params.id || '').trim(); const id = String(req.params.id || '').trim();
const dealer = await prisma.dealer.findUnique({ where: { id } }); const dealer = await prisma.dealer.findUnique({ where: { id } });
if (!dealer) throw httpError(404, 'Dealer not found'); if (!dealer) throw httpError(404, 'Dealer not found');
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
const staff = await tx.user.findMany({ const staff = await tx.user.findMany({
where: { dealerId: id }, where: { dealerId: id },
select: { id: true }, select: { id: true },
}); });
const staffIds = staff.map((user) => user.id); const staffIds = staff.map((user) => user.id);
const stocks = await tx.stock.findMany({ const stocks = await tx.stock.findMany({
where: { currentDealerId: id }, where: { currentDealerId: id },
select: { id: true }, select: { id: true },
}); });
const stockIds = stocks.map((stock) => stock.id); const stockIds = stocks.map((stock) => stock.id);
await tx.stockTransfer.deleteMany({ await tx.stockTransfer.deleteMany({
where: { where: {
OR: [ OR: [
{ fromDealerId: id }, { fromDealerId: id },
{ toDealerId: id }, { toDealerId: id },
...(stockIds.length ? [{ stockId: { in: stockIds } }] : []), ...(stockIds.length ? [{ stockId: { in: stockIds } }] : []),
], ],
}, },
}); });
if (staffIds.length) { if (staffIds.length) {
await tx.stockTransfer.updateMany({ await tx.stockTransfer.updateMany({
where: { actorId: { in: staffIds } }, where: { actorId: { in: staffIds } },
data: { actorId: req.user.id }, data: { actorId: req.user.id },
}); });
await tx.event.updateMany({ await tx.event.updateMany({
where: { createdById: { in: staffIds }, dealerId: { not: id } }, where: { createdById: { in: staffIds }, dealerId: { not: id } },
data: { createdById: req.user.id }, data: { createdById: req.user.id },
}); });
await tx.servicePoint.updateMany({ await tx.servicePoint.updateMany({
where: { createdById: { in: staffIds } }, where: { createdById: { in: staffIds } },
data: { createdById: req.user.id }, data: { createdById: req.user.id },
}); });
} }
await tx.event.deleteMany({ where: { dealerId: id } }); await tx.event.deleteMany({ where: { dealerId: id } });
await tx.stock.deleteMany({ where: { currentDealerId: id } }); await tx.stock.deleteMany({ where: { currentDealerId: id } });
if (staffIds.length) { if (staffIds.length) {
await tx.user.deleteMany({ where: { id: { in: staffIds } } }); await tx.user.deleteMany({ where: { id: { in: staffIds } } });
} }
await tx.dealer.delete({ where: { id } }); await tx.dealer.delete({ where: { id } });
}); });
return res.json({ success: true }); return res.json({ success: true });
}) })
); );
router.get( router.get(
'/sales-reps', '/sales-reps',
requireRole('superAdmin'), requireRole('superAdmin'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const reps = await prisma.user.findMany({ const reps = await prisma.user.findMany({
where: { role: 'salesRep' }, where: { role: 'salesRep' },
select: userPublicSelect, select: userPublicSelect,
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
return res.json({ success: true, data: reps.map((rep) => publicUser(rep)) }); return res.json({ success: true, data: reps.map((rep) => publicUser(rep)) });
}) })
); );
router.post( router.post(
'/sales-reps', '/sales-reps',
requireRole('superAdmin'), requireRole('superAdmin'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const name = String(req.body?.name || '').trim(); const name = String(req.body?.name || '').trim();
const phone = normalizePhone(req.body?.phone); const phone = normalizePhone(req.body?.phone);
const password = String(req.body?.password || ''); const password = String(req.body?.password || '');
const dealerIds = Array.isArray(req.body?.dealerIds) ? req.body.dealerIds : []; const dealerIds = Array.isArray(req.body?.dealerIds) ? req.body.dealerIds : [];
if (!name || !isValidPhone(phone)) { if (!name || !isValidPhone(phone)) {
throw httpError(400, 'Name and a Sri Lankan mobile number are required'); throw httpError(400, 'Name and a Sri Lankan mobile number are required');
} }
if (password.length < 8) { if (password.length < 8) {
throw httpError(400, 'Password must be at least 8 characters'); throw httpError(400, 'Password must be at least 8 characters');
} }
if (!dealerIds.every((id) => typeof id === 'string' && id.trim())) { if (!dealerIds.every((id) => typeof id === 'string' && id.trim())) {
throw httpError(400, 'dealerIds must be a list of ids'); throw httpError(400, 'dealerIds must be a list of ids');
} }
const uniqueIds = [...new Set(dealerIds.map((id) => id.trim()))]; const uniqueIds = [...new Set(dealerIds.map((id) => id.trim()))];
if (uniqueIds.length) { if (uniqueIds.length) {
const found = await prisma.dealer.findMany({ where: { id: { in: uniqueIds } } }); const found = await prisma.dealer.findMany({ where: { id: { in: uniqueIds } } });
if (found.length !== uniqueIds.length) throw httpError(404, 'Dealer not found'); if (found.length !== uniqueIds.length) throw httpError(404, 'Dealer not found');
} }
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
name, name,
phone, phone,
role: 'salesRep', role: 'salesRep',
passwordHash: await hashPassword(password), passwordHash: await hashPassword(password),
assignments: { assignments: {
create: uniqueIds.map((dealerId) => ({ dealerId })), create: uniqueIds.map((dealerId) => ({ dealerId })),
}, },
}, },
select: userPublicSelect, select: userPublicSelect,
}); });
return res.status(201).json({ success: true, data: publicUser(user) }); return res.status(201).json({ success: true, data: publicUser(user) });
}) })
); );
router.patch( router.patch(
'/assign-dealer', '/assign-dealer',
requireRole('superAdmin'), requireRole('superAdmin'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const salesRepId = String(req.body?.salesRepId || '').trim(); const salesRepId = String(req.body?.salesRepId || '').trim();
const dealerId = String(req.body?.dealerId || '').trim(); const dealerId = String(req.body?.dealerId || '').trim();
const assigned = req.body?.assigned !== false; const assigned = req.body?.assigned !== false;
if (!salesRepId || !dealerId) { if (!salesRepId || !dealerId) {
throw httpError(400, 'salesRepId and dealerId are required'); throw httpError(400, 'salesRepId and dealerId are required');
} }
const [rep, dealer] = await Promise.all([ const [rep, dealer] = await Promise.all([
prisma.user.findUnique({ where: { id: salesRepId } }), prisma.user.findUnique({ where: { id: salesRepId } }),
prisma.dealer.findUnique({ where: { id: dealerId } }), prisma.dealer.findUnique({ where: { id: dealerId } }),
]); ]);
if (!rep || rep.role !== 'salesRep') throw httpError(404, 'Sales rep not found'); if (!rep || rep.role !== 'salesRep') throw httpError(404, 'Sales rep not found');
if (!dealer) throw httpError(404, 'Dealer not found'); if (!dealer) throw httpError(404, 'Dealer not found');
if (assigned) { if (assigned) {
await prisma.dealerAssignment.upsert({ await prisma.dealerAssignment.upsert({
where: { salesRepId_dealerId: { salesRepId, dealerId } }, where: { salesRepId_dealerId: { salesRepId, dealerId } },
update: {}, update: {},
create: { salesRepId, dealerId }, create: { salesRepId, dealerId },
}); });
} else { } else {
await prisma.dealerAssignment.deleteMany({ where: { salesRepId, dealerId } }); await prisma.dealerAssignment.deleteMany({ where: { salesRepId, dealerId } });
} }
const updated = await prisma.user.findUnique({ const updated = await prisma.user.findUnique({
where: { id: salesRepId }, where: { id: salesRepId },
select: userPublicSelect, select: userPublicSelect,
}); });
return res.json({ success: true, data: publicUser(updated) }); return res.json({ success: true, data: publicUser(updated) });
}) })
); );
module.exports = router; module.exports = router;
+238 -238
View File
@@ -1,238 +1,238 @@
const crypto = require('crypto'); const crypto = require('crypto');
const express = require('express'); const express = require('express');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const prisma = require('../db'); const prisma = require('../db');
const { asyncHandler, httpError } = require('../lib/errors'); const { asyncHandler, httpError } = require('../lib/errors');
const { isValidPhone, normalizePhone } = require('../lib/phone'); const { isValidPhone, normalizePhone } = require('../lib/phone');
const { checkPassword } = require('../lib/passwords'); const { checkPassword } = require('../lib/passwords');
const { publicUser, userPublicSelect } = require('../lib/users'); const { publicUser, userPublicSelect } = require('../lib/users');
const { assignedDealerIds, canAccessDealer } = require('../lib/access'); const { assignedDealerIds, canAccessDealer } = require('../lib/access');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth, requireRole } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
const REQUEST_TTL_MS = 5 * 60 * 1000; const REQUEST_TTL_MS = 5 * 60 * 1000;
function signToken(user) { function signToken(user) {
return jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, { return jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, {
expiresIn: '10y', expiresIn: '10y',
}); });
} }
async function accessibleDealers(user) { async function accessibleDealers(user) {
if (user.role === 'superAdmin') { if (user.role === 'superAdmin') {
return prisma.dealer.findMany({ orderBy: { name: 'asc' } }); return prisma.dealer.findMany({ orderBy: { name: 'asc' } });
} }
if (user.role === 'dealer') { if (user.role === 'dealer') {
if (!user.dealerId) return []; if (!user.dealerId) return [];
const dealer = await prisma.dealer.findUnique({ where: { id: user.dealerId } }); const dealer = await prisma.dealer.findUnique({ where: { id: user.dealerId } });
return dealer ? [dealer] : []; return dealer ? [dealer] : [];
} }
const ids = assignedDealerIds(user); const ids = assignedDealerIds(user);
if (ids.length === 0) return []; if (ids.length === 0) return [];
return prisma.dealer.findMany({ return prisma.dealer.findMany({
where: { id: { in: ids } }, where: { id: { in: ids } },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
} }
function hashSecret(secret) { function hashSecret(secret) {
return crypto.createHash('sha256').update(secret).digest('hex'); return crypto.createHash('sha256').update(secret).digest('hex');
} }
function secretsMatch(storedHash, secret) { function secretsMatch(storedHash, secret) {
const given = Buffer.from(hashSecret(secret)); const given = Buffer.from(hashSecret(secret));
const stored = Buffer.from(storedHash); const stored = Buffer.from(storedHash);
return stored.length === given.length && crypto.timingSafeEqual(stored, given); return stored.length === given.length && crypto.timingSafeEqual(stored, given);
} }
async function expireStaleRequests() { async function expireStaleRequests() {
await prisma.loginRequest.updateMany({ await prisma.loginRequest.updateMany({
where: { status: 'pending', expiresAt: { lt: new Date() } }, where: { status: 'pending', expiresAt: { lt: new Date() } },
data: { status: 'expired' }, data: { status: 'expired' },
}); });
} }
router.post( router.post(
'/staff-login', '/staff-login',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const phone = normalizePhone(req.body?.phone); const phone = normalizePhone(req.body?.phone);
const password = String(req.body?.password || ''); const password = String(req.body?.password || '');
if (!isValidPhone(phone) || !password) { if (!isValidPhone(phone) || !password) {
throw httpError(400, 'Phone and password are required'); throw httpError(400, 'Phone and password are required');
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { phone }, where: { phone },
select: { ...userPublicSelect, passwordHash: true }, select: { ...userPublicSelect, passwordHash: true },
}); });
const allowed = user && (user.role === 'superAdmin' || user.role === 'salesRep'); const allowed = user && (user.role === 'superAdmin' || user.role === 'salesRep');
const matches = allowed && (await checkPassword(password, user.passwordHash)); const matches = allowed && (await checkPassword(password, user.passwordHash));
if (!matches) { if (!matches) {
throw httpError(401, 'Invalid phone or password'); throw httpError(401, 'Invalid phone or password');
} }
const dealers = await accessibleDealers(user); const dealers = await accessibleDealers(user);
return res.json({ token: signToken(user), user: publicUser(user, dealers) }); return res.json({ token: signToken(user), user: publicUser(user, dealers) });
}) })
); );
router.post( router.post(
'/dealer-login', '/dealer-login',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const phone = normalizePhone(req.body?.phone); const phone = normalizePhone(req.body?.phone);
if (!isValidPhone(phone)) { if (!isValidPhone(phone)) {
throw httpError(400, 'Phone must be a Sri Lankan mobile number'); throw httpError(400, 'Phone must be a Sri Lankan mobile number');
} }
const user = await prisma.user.findUnique({ where: { phone } }); const user = await prisma.user.findUnique({ where: { phone } });
if (!user || user.role !== 'dealer') { if (!user || user.role !== 'dealer') {
return res.status(404).json({ success: false, message: 'Not Registered' }); return res.status(404).json({ success: false, message: 'Not Registered' });
} }
await prisma.loginRequest.updateMany({ await prisma.loginRequest.updateMany({
where: { userId: user.id, status: 'pending' }, where: { userId: user.id, status: 'pending' },
data: { status: 'expired' }, data: { status: 'expired' },
}); });
const secret = crypto.randomBytes(32).toString('hex'); const secret = crypto.randomBytes(32).toString('hex');
const request = await prisma.loginRequest.create({ const request = await prisma.loginRequest.create({
data: { data: {
userId: user.id, userId: user.id,
status: 'pending', status: 'pending',
secretHash: hashSecret(secret), secretHash: hashSecret(secret),
expiresAt: new Date(Date.now() + REQUEST_TTL_MS), expiresAt: new Date(Date.now() + REQUEST_TTL_MS),
}, },
}); });
return res.status(201).json({ requestId: request.id, secret }); return res.status(201).json({ requestId: request.id, secret });
}) })
); );
router.get( router.get(
'/dealer-login/:id', '/dealer-login/:id',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const secret = String(req.query.secret || ''); const secret = String(req.query.secret || '');
const request = await prisma.loginRequest.findUnique({ const request = await prisma.loginRequest.findUnique({
where: { id: req.params.id }, where: { id: req.params.id },
include: { user: { select: userPublicSelect } }, include: { user: { select: userPublicSelect } },
}); });
if (!request || !secret || !secretsMatch(request.secretHash, secret)) { if (!request || !secret || !secretsMatch(request.secretHash, secret)) {
throw httpError(404, 'Login request not found'); throw httpError(404, 'Login request not found');
} }
if (request.status === 'pending' && request.expiresAt.getTime() < Date.now()) { if (request.status === 'pending' && request.expiresAt.getTime() < Date.now()) {
await prisma.loginRequest.update({ await prisma.loginRequest.update({
where: { id: request.id }, where: { id: request.id },
data: { status: 'expired' }, data: { status: 'expired' },
}); });
return res.json({ status: 'expired' }); return res.json({ status: 'expired' });
} }
if (request.status !== 'approved') { if (request.status !== 'approved') {
return res.json({ status: request.status }); return res.json({ status: request.status });
} }
const consumed = await prisma.loginRequest.updateMany({ const consumed = await prisma.loginRequest.updateMany({
where: { id: request.id, status: 'approved' }, where: { id: request.id, status: 'approved' },
data: { status: 'consumed' }, data: { status: 'consumed' },
}); });
if (consumed.count !== 1) { if (consumed.count !== 1) {
return res.json({ status: 'consumed' }); return res.json({ status: 'consumed' });
} }
const dealers = await accessibleDealers(request.user); const dealers = await accessibleDealers(request.user);
return res.json({ return res.json({
status: 'approved', status: 'approved',
token: signToken(request.user), token: signToken(request.user),
user: publicUser(request.user, dealers), user: publicUser(request.user, dealers),
}); });
}) })
); );
router.get( router.get(
'/login-requests', '/login-requests',
requireAuth, requireAuth,
requireRole('superAdmin', 'salesRep'), requireRole('superAdmin', 'salesRep'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
await expireStaleRequests(); await expireStaleRequests();
const where = { status: 'pending' }; const where = { status: 'pending' };
if (req.user.role === 'salesRep') { if (req.user.role === 'salesRep') {
const ids = assignedDealerIds(req.user); const ids = assignedDealerIds(req.user);
where.user = { dealerId: { in: ids.length ? ids : ['__none__'] } }; where.user = { dealerId: { in: ids.length ? ids : ['__none__'] } };
} }
const requests = await prisma.loginRequest.findMany({ const requests = await prisma.loginRequest.findMany({
where, where,
include: { user: { include: { dealer: true } } }, include: { user: { include: { dealer: true } } },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}); });
return res.json({ return res.json({
success: true, success: true,
data: requests.map((request) => ({ data: requests.map((request) => ({
id: request.id, id: request.id,
status: request.status, status: request.status,
createdAt: request.createdAt, createdAt: request.createdAt,
expiresAt: request.expiresAt, expiresAt: request.expiresAt,
user: { user: {
id: request.user.id, id: request.user.id,
name: request.user.name, name: request.user.name,
phone: request.user.phone, phone: request.user.phone,
dealerId: request.user.dealerId, dealerId: request.user.dealerId,
}, },
dealerName: request.user.dealer ? request.user.dealer.name : null, dealerName: request.user.dealer ? request.user.dealer.name : null,
})), })),
}); });
}) })
); );
async function decideRequest(req, status) { async function decideRequest(req, status) {
await expireStaleRequests(); await expireStaleRequests();
const request = await prisma.loginRequest.findUnique({ const request = await prisma.loginRequest.findUnique({
where: { id: req.params.id }, where: { id: req.params.id },
include: { user: true }, include: { user: true },
}); });
if (!request || request.status !== 'pending') { if (!request || request.status !== 'pending') {
throw httpError(404, 'Login request not found'); throw httpError(404, 'Login request not found');
} }
if (!canAccessDealer(req.user, request.user.dealerId)) { if (!canAccessDealer(req.user, request.user.dealerId)) {
throw httpError(403, 'Forbidden'); throw httpError(403, 'Forbidden');
} }
await prisma.loginRequest.update({ await prisma.loginRequest.update({
where: { id: request.id }, where: { id: request.id },
data: { status, decidedById: req.user.id, decidedAt: new Date() }, data: { status, decidedById: req.user.id, decidedAt: new Date() },
}); });
return { id: request.id, status }; return { id: request.id, status };
} }
router.post( router.post(
'/login-requests/:id/approve', '/login-requests/:id/approve',
requireAuth, requireAuth,
requireRole('superAdmin', 'salesRep'), requireRole('superAdmin', 'salesRep'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const result = await decideRequest(req, 'approved'); const result = await decideRequest(req, 'approved');
return res.json({ success: true, data: result }); return res.json({ success: true, data: result });
}) })
); );
router.post( router.post(
'/login-requests/:id/reject', '/login-requests/:id/reject',
requireAuth, requireAuth,
requireRole('superAdmin', 'salesRep'), requireRole('superAdmin', 'salesRep'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const result = await decideRequest(req, 'rejected'); const result = await decideRequest(req, 'rejected');
return res.json({ success: true, data: result }); return res.json({ success: true, data: result });
}) })
); );
router.get( router.get(
'/me', '/me',
requireAuth, requireAuth,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const dealers = await accessibleDealers(req.user); const dealers = await accessibleDealers(req.user);
return res.json(publicUser(req.user, dealers)); return res.json(publicUser(req.user, dealers));
}) })
); );
module.exports = router; module.exports = router;
+41 -41
View File
@@ -1,41 +1,41 @@
const express = require('express'); const express = require('express');
const prisma = require('../db'); const prisma = require('../db');
const { asyncHandler } = require('../lib/errors'); const { asyncHandler } = require('../lib/errors');
const { assignedDealerIds } = require('../lib/access'); const { assignedDealerIds } = require('../lib/access');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
router.get( router.get(
'/', '/',
requireAuth, requireAuth,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
let dealers; let dealers;
if (req.user.role === 'superAdmin') { if (req.user.role === 'superAdmin') {
dealers = await prisma.dealer.findMany({ orderBy: { name: 'asc' } }); dealers = await prisma.dealer.findMany({ orderBy: { name: 'asc' } });
} else if (req.user.role === 'dealer') { } else if (req.user.role === 'dealer') {
dealers = req.user.dealerId dealers = req.user.dealerId
? await prisma.dealer.findMany({ where: { id: req.user.dealerId } }) ? await prisma.dealer.findMany({ where: { id: req.user.dealerId } })
: []; : [];
} else { } else {
const ids = assignedDealerIds(req.user); const ids = assignedDealerIds(req.user);
dealers = ids.length dealers = ids.length
? await prisma.dealer.findMany({ where: { id: { in: ids } }, orderBy: { name: 'asc' } }) ? await prisma.dealer.findMany({ where: { id: { in: ids } }, orderBy: { name: 'asc' } })
: []; : [];
} }
return res.json({ return res.json({
success: true, success: true,
data: dealers.map((dealer) => ({ data: dealers.map((dealer) => ({
id: dealer.id, id: dealer.id,
name: dealer.name, name: dealer.name,
code: dealer.code, code: dealer.code,
district: dealer.district, district: dealer.district,
address: dealer.address, address: dealer.address,
phone: dealer.phone, phone: dealer.phone,
})), })),
}); });
}) })
); );
module.exports = router; module.exports = router;
+116 -116
View File
@@ -1,116 +1,116 @@
const express = require('express'); const express = require('express');
const prisma = require('../db'); const prisma = require('../db');
const { asyncHandler, httpError } = require('../lib/errors'); const { asyncHandler, httpError } = require('../lib/errors');
const { canAccessDealer, eventScopeWhere } = require('../lib/access'); const { canAccessDealer, eventScopeWhere } = require('../lib/access');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
const PLAN_TYPES = ['general', 'monthlySpecial']; const PLAN_TYPES = ['general', 'monthlySpecial'];
function serializeEvent(event) { function serializeEvent(event) {
let imageUrls = []; let imageUrls = [];
try { try {
const parsed = JSON.parse(event.imageUrls || '[]'); const parsed = JSON.parse(event.imageUrls || '[]');
imageUrls = Array.isArray(parsed) ? parsed : []; imageUrls = Array.isArray(parsed) ? parsed : [];
} catch { } catch {
imageUrls = []; imageUrls = [];
} }
return { return {
id: event.id, id: event.id,
dealerId: event.dealerId, dealerId: event.dealerId,
dealerName: event.dealer ? event.dealer.name : null, dealerName: event.dealer ? event.dealer.name : null,
planType: event.planType, planType: event.planType,
month: event.month, month: event.month,
year: event.year, year: event.year,
title: event.title, title: event.title,
description: event.description, description: event.description,
expense: event.expense, expense: event.expense,
imageUrls, imageUrls,
createdById: event.createdById, createdById: event.createdById,
createdAt: event.createdAt, createdAt: event.createdAt,
}; };
} }
router.use(requireAuth); router.use(requireAuth);
router.get( router.get(
'/', '/',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const where = eventScopeWhere(req.user); const where = eventScopeWhere(req.user);
const dealerId = typeof req.query.dealerId === 'string' ? req.query.dealerId.trim() : ''; const dealerId = typeof req.query.dealerId === 'string' ? req.query.dealerId.trim() : '';
const planType = typeof req.query.planType === 'string' ? req.query.planType.trim() : ''; const planType = typeof req.query.planType === 'string' ? req.query.planType.trim() : '';
if (dealerId) { if (dealerId) {
if (!canAccessDealer(req.user, dealerId)) throw httpError(403, 'Forbidden'); if (!canAccessDealer(req.user, dealerId)) throw httpError(403, 'Forbidden');
where.dealerId = dealerId; where.dealerId = dealerId;
} }
if (planType) { if (planType) {
if (!PLAN_TYPES.includes(planType)) throw httpError(400, 'Invalid planType'); if (!PLAN_TYPES.includes(planType)) throw httpError(400, 'Invalid planType');
where.planType = planType; where.planType = planType;
} }
const events = await prisma.event.findMany({ const events = await prisma.event.findMany({
where, where,
include: { dealer: true }, include: { dealer: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
return res.json({ success: true, data: events.map(serializeEvent) }); return res.json({ success: true, data: events.map(serializeEvent) });
}) })
); );
router.post( router.post(
'/', '/',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const title = String(req.body?.title || '').trim(); const title = String(req.body?.title || '').trim();
const description = String(req.body?.description || '').trim(); const description = String(req.body?.description || '').trim();
const planType = String(req.body?.planType || '').trim(); const planType = String(req.body?.planType || '').trim();
const dealerId = String(req.body?.dealerId || req.user.dealerId || '').trim(); const dealerId = String(req.body?.dealerId || req.user.dealerId || '').trim();
const expense = Number(req.body?.expense); const expense = Number(req.body?.expense);
const imageUrls = Array.isArray(req.body?.imageUrls) ? req.body.imageUrls : []; const imageUrls = Array.isArray(req.body?.imageUrls) ? req.body.imageUrls : [];
if (!title || !description || !dealerId) { if (!title || !description || !dealerId) {
throw httpError(400, 'Title, description, and dealer are required'); throw httpError(400, 'Title, description, and dealer are required');
} }
if (!PLAN_TYPES.includes(planType)) throw httpError(400, 'Invalid planType'); if (!PLAN_TYPES.includes(planType)) throw httpError(400, 'Invalid planType');
if (!Number.isFinite(expense) || expense < 0) throw httpError(400, 'Expense must be a number'); if (!Number.isFinite(expense) || expense < 0) throw httpError(400, 'Expense must be a number');
if (!imageUrls.every((url) => typeof url === 'string' && url.trim())) { if (!imageUrls.every((url) => typeof url === 'string' && url.trim())) {
throw httpError(400, 'imageUrls must be a list of URL strings'); throw httpError(400, 'imageUrls must be a list of URL strings');
} }
if (!canAccessDealer(req.user, dealerId)) throw httpError(403, 'Forbidden'); if (!canAccessDealer(req.user, dealerId)) throw httpError(403, 'Forbidden');
let month = null; let month = null;
let year = null; let year = null;
if (planType === 'monthlySpecial') { if (planType === 'monthlySpecial') {
month = Number(req.body?.month); month = Number(req.body?.month);
year = Number(req.body?.year); year = Number(req.body?.year);
if (!Number.isInteger(month) || month < 1 || month > 12) { if (!Number.isInteger(month) || month < 1 || month > 12) {
throw httpError(400, 'month is required for a monthly plan'); throw httpError(400, 'month is required for a monthly plan');
} }
if (!Number.isInteger(year) || year < 2000 || year > 2100) { if (!Number.isInteger(year) || year < 2000 || year > 2100) {
throw httpError(400, 'year is required for a monthly plan'); throw httpError(400, 'year is required for a monthly plan');
} }
} }
const dealer = await prisma.dealer.findUnique({ where: { id: dealerId } }); const dealer = await prisma.dealer.findUnique({ where: { id: dealerId } });
if (!dealer) throw httpError(404, 'Dealer not found'); if (!dealer) throw httpError(404, 'Dealer not found');
const event = await prisma.event.create({ const event = await prisma.event.create({
data: { data: {
dealerId, dealerId,
planType, planType,
month, month,
year, year,
title, title,
description, description,
expense, expense,
imageUrls: JSON.stringify(imageUrls.map((url) => url.trim())), imageUrls: JSON.stringify(imageUrls.map((url) => url.trim())),
createdById: req.user.id, createdById: req.user.id,
}, },
include: { dealer: true }, include: { dealer: true },
}); });
return res.status(201).json({ success: true, data: serializeEvent(event) }); return res.status(201).json({ success: true, data: serializeEvent(event) });
}) })
); );
module.exports = router; module.exports = router;
+72 -72
View File
@@ -1,72 +1,72 @@
const express = require('express'); const express = require('express');
const prisma = require('../db'); const prisma = require('../db');
const { asyncHandler, httpError } = require('../lib/errors'); const { asyncHandler, httpError } = require('../lib/errors');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth, requireRole } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
function serializePoint(point) { function serializePoint(point) {
return { return {
id: point.id, id: point.id,
name: point.name, name: point.name,
district: point.district, district: point.district,
address: point.address, address: point.address,
phone: point.phone, phone: point.phone,
notes: point.notes, notes: point.notes,
createdAt: point.createdAt, createdAt: point.createdAt,
}; };
} }
router.use(requireAuth); router.use(requireAuth);
router.get( router.get(
'/', '/',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const district = typeof req.query.district === 'string' ? req.query.district.trim() : ''; const district = typeof req.query.district === 'string' ? req.query.district.trim() : '';
const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
const where = {}; const where = {};
if (district) where.district = district; if (district) where.district = district;
if (search) { if (search) {
where.OR = [ where.OR = [
{ name: { contains: search } }, { name: { contains: search } },
{ district: { contains: search } }, { district: { contains: search } },
{ address: { contains: search } }, { address: { contains: search } },
{ phone: { contains: search } }, { phone: { contains: search } },
{ notes: { contains: search } }, { notes: { contains: search } },
]; ];
} }
const points = await prisma.servicePoint.findMany({ const points = await prisma.servicePoint.findMany({
where, where,
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
return res.json({ success: true, data: points.map(serializePoint) }); return res.json({ success: true, data: points.map(serializePoint) });
}) })
); );
router.post( router.post(
'/', '/',
requireRole('salesRep', 'superAdmin'), requireRole('salesRep', 'superAdmin'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const name = String(req.body?.name || '').trim(); const name = String(req.body?.name || '').trim();
const district = String(req.body?.district || '').trim(); const district = String(req.body?.district || '').trim();
const address = String(req.body?.address || '').trim() || null; const address = String(req.body?.address || '').trim() || null;
const phone = String(req.body?.phone || '').trim() || null; const phone = String(req.body?.phone || '').trim() || null;
const notes = String(req.body?.notes || '').trim() || null; const notes = String(req.body?.notes || '').trim() || null;
if (!name || !district) throw httpError(400, 'Name and district are required'); if (!name || !district) throw httpError(400, 'Name and district are required');
const point = await prisma.servicePoint.create({ const point = await prisma.servicePoint.create({
data: { data: {
name, name,
district, district,
address, address,
phone, phone,
notes, notes,
createdById: req.user.id, createdById: req.user.id,
}, },
}); });
return res.status(201).json({ success: true, data: serializePoint(point) }); return res.status(201).json({ success: true, data: serializePoint(point) });
}) })
); );
module.exports = router; module.exports = router;
+276 -209
View File
@@ -1,209 +1,276 @@
const express = require('express'); const express = require('express');
const prisma = require('../db'); const prisma = require('../db');
const { asyncHandler, httpError } = require('../lib/errors'); const { asyncHandler, httpError } = require('../lib/errors');
const { canAccessDealer, dealerScopeWhere } = require('../lib/access'); const { canAccessDealer, dealerScopeWhere } = require('../lib/access');
const { const {
MODELS, MODELS,
PAYMENT_TYPES, PAYMENT_TYPES,
LEASE_PROVIDERS, LEASE_PROVIDERS,
nextStage, nextStage,
serializeStock, serializeStock,
} = require('../lib/stockRules'); } = require('../lib/stockRules');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth, requireRole } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
const stockInclude = { const stockInclude = {
currentDealer: true, currentDealer: true,
stageLogs: { orderBy: { loggedAt: 'asc' } }, stageLogs: { orderBy: { loggedAt: 'asc' } },
}; };
router.use(requireAuth); router.use(requireAuth);
router.get( router.get(
'/', '/',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const where = dealerScopeWhere(req.user); const where = dealerScopeWhere(req.user);
const dealerId = typeof req.query.dealerId === 'string' ? req.query.dealerId.trim() : ''; const dealerId = typeof req.query.dealerId === 'string' ? req.query.dealerId.trim() : '';
if (dealerId) { if (dealerId) {
if (!canAccessDealer(req.user, dealerId)) { if (!canAccessDealer(req.user, dealerId)) {
throw httpError(403, 'Forbidden'); throw httpError(403, 'Forbidden');
} }
where.currentDealerId = dealerId; where.currentDealerId = dealerId;
} }
const stocks = await prisma.stock.findMany({ const stocks = await prisma.stock.findMany({
where, where,
include: stockInclude, include: stockInclude,
orderBy: { dateReceived: 'asc' }, orderBy: { dateReceived: 'asc' },
}); });
return res.json({ success: true, data: stocks.map(serializeStock) }); return res.json({ success: true, data: stocks.map(serializeStock) });
}) })
); );
router.post( router.post(
'/', '/',
requireRole('dealer', 'salesRep', 'superAdmin'), requireRole('dealer', 'salesRep', 'superAdmin'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const chassisNumber = String(req.body?.chassisNumber || '').trim(); const chassisNumber = String(req.body?.chassisNumber || '').trim();
const engineNumber = String(req.body?.engineNumber || '').trim(); const engineNumber = String(req.body?.engineNumber || '').trim();
const model = String(req.body?.model || '').trim(); const model = String(req.body?.model || '').trim();
const color = String(req.body?.color || '').trim(); const color = String(req.body?.color || '').trim();
let currentDealerId = String(req.body?.currentDealerId || '').trim(); let currentDealerId = String(req.body?.currentDealerId || '').trim();
const dateReceived = req.body?.dateReceived ? new Date(req.body.dateReceived) : new Date(); const dateReceived = req.body?.dateReceived ? new Date(req.body.dateReceived) : new Date();
if (req.user.role === 'dealer') { if (req.user.role === 'dealer') {
if (!req.user.dealerId) throw httpError(403, 'Forbidden'); if (!req.user.dealerId) throw httpError(403, 'Forbidden');
if (currentDealerId && currentDealerId !== req.user.dealerId) { if (currentDealerId && currentDealerId !== req.user.dealerId) {
throw httpError(403, 'Forbidden'); throw httpError(403, 'Forbidden');
} }
currentDealerId = req.user.dealerId; currentDealerId = req.user.dealerId;
} }
if (!chassisNumber || !engineNumber || !color || !currentDealerId) { if (!chassisNumber || !engineNumber || !color || !currentDealerId) {
throw httpError(400, 'Chassis, engine, color, and dealer are required'); throw httpError(400, 'Chassis, engine, color, and dealer are required');
} }
if (!MODELS.includes(model)) { if (!MODELS.includes(model)) {
throw httpError(400, 'Invalid model'); throw httpError(400, 'Invalid model');
} }
if (Number.isNaN(dateReceived.getTime())) { if (Number.isNaN(dateReceived.getTime())) {
throw httpError(400, 'Invalid received date'); throw httpError(400, 'Invalid received date');
} }
if (!canAccessDealer(req.user, currentDealerId)) { if (!canAccessDealer(req.user, currentDealerId)) {
throw httpError(403, 'Forbidden'); throw httpError(403, 'Forbidden');
} }
const dealer = await prisma.dealer.findUnique({ where: { id: currentDealerId } }); const dealer = await prisma.dealer.findUnique({ where: { id: currentDealerId } });
if (!dealer) throw httpError(404, 'Dealer not found'); if (!dealer) throw httpError(404, 'Dealer not found');
const stock = await prisma.stock.create({ const stock = await prisma.stock.create({
data: { data: {
chassisNumber, chassisNumber,
engineNumber, engineNumber,
model, model,
color, color,
currentDealerId, currentDealerId,
dateReceived, dateReceived,
currentStage: 'received', currentStage: 'received',
stageLogs: { stageLogs: {
create: { create: {
stage: 'received', stage: 'received',
loggedAt: dateReceived, loggedAt: dateReceived,
actorId: req.user.id, actorId: req.user.id,
}, },
}, },
}, },
include: stockInclude, include: stockInclude,
}); });
return res.status(201).json({ success: true, data: serializeStock(stock) }); return res.status(201).json({ success: true, data: serializeStock(stock) });
}) })
); );
router.patch( router.patch(
'/:chassisNumber/stage', '/:chassisNumber/stage',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const chassisNumber = req.params.chassisNumber; const chassisNumber = req.params.chassisNumber;
const stock = await prisma.stock.findUnique({ const stock = await prisma.stock.findUnique({
where: { chassisNumber }, where: { chassisNumber },
include: stockInclude, include: stockInclude,
}); });
if (!stock) throw httpError(404, 'Stock not found'); if (!stock) throw httpError(404, 'Stock not found');
if (!canAccessDealer(req.user, stock.currentDealerId)) { if (!canAccessDealer(req.user, stock.currentDealerId)) {
throw httpError(403, 'Forbidden'); throw httpError(403, 'Forbidden');
} }
const expected = nextStage(stock.currentStage); const expected = nextStage(stock.currentStage);
const stage = String(req.body?.stage || '').trim(); const stage = String(req.body?.stage || '').trim();
if (!expected) throw httpError(400, 'Stock is already at the final stage'); if (!expected) throw httpError(400, 'Stock is already at the final stage');
if (stage !== expected) { if (stage !== expected) {
throw httpError(400, `Next stage must be ${expected}`); throw httpError(400, `Next stage must be ${expected}`);
} }
const data = { currentStage: stage }; const data = { currentStage: stage };
if (stage === 'paymentApproved') { if (stage === 'paymentApproved') {
const paymentType = String(req.body?.paymentType || '').trim(); const paymentType = String(req.body?.paymentType || '').trim();
if (!PAYMENT_TYPES.includes(paymentType)) { if (!PAYMENT_TYPES.includes(paymentType)) {
throw httpError(400, 'paymentType must be cash or lease'); throw httpError(400, 'paymentType must be cash or lease');
} }
data.paymentType = paymentType; data.paymentType = paymentType;
if (paymentType === 'lease') { if (paymentType === 'lease') {
const leaseProviderType = String(req.body?.leaseProviderType || '').trim(); const leaseProviderType = String(req.body?.leaseProviderType || '').trim();
if (!LEASE_PROVIDERS.includes(leaseProviderType)) { if (!LEASE_PROVIDERS.includes(leaseProviderType)) {
throw httpError(400, 'Choose GCI, finance, or both'); throw httpError(400, 'Choose GCI, finance, or both');
} }
data.leaseProviderType = leaseProviderType; data.leaseProviderType = leaseProviderType;
const usesFinance = leaseProviderType === 'thirdPartyFinance' || leaseProviderType === 'both'; const usesFinance = leaseProviderType === 'thirdPartyFinance' || leaseProviderType === 'both';
if (usesFinance) { if (usesFinance) {
const financeCompanyName = String(req.body?.financeCompanyName || '').trim(); const financeCompanyName = String(req.body?.financeCompanyName || '').trim();
if (!financeCompanyName) throw httpError(400, 'financeCompanyName is required'); if (!financeCompanyName) throw httpError(400, 'financeCompanyName is required');
data.financeCompanyName = financeCompanyName; data.financeCompanyName = financeCompanyName;
} else { } else {
data.financeCompanyName = null; data.financeCompanyName = null;
} }
} else { } else {
data.leaseProviderType = null; data.leaseProviderType = null;
data.financeCompanyName = null; data.financeCompanyName = null;
} }
} }
if (stage === 'plateReceived') { if (stage === 'plateReceived') {
const plateNumber = String(req.body?.plateNumber || '').trim(); const plateNumber = String(req.body?.plateNumber || '').trim();
if (!plateNumber) throw httpError(400, 'Plate number is required'); if (!plateNumber) throw httpError(400, 'Plate number is required');
data.plateNumber = plateNumber; data.plateNumber = plateNumber;
} }
const updated = await prisma.stock.update({ const updated = await prisma.stock.update({
where: { id: stock.id }, where: { id: stock.id },
data: { data: {
...data, ...data,
stageLogs: { stageLogs: {
create: { stage, actorId: req.user.id }, create: { stage, actorId: req.user.id },
}, },
}, },
include: stockInclude, include: stockInclude,
}); });
return res.json({ success: true, data: serializeStock(updated) }); return res.json({ success: true, data: serializeStock(updated) });
}) })
); );
router.post( router.post(
'/:chassisNumber/transfer', '/:chassisNumber/stage/undo',
requireRole('salesRep', 'superAdmin'), requireRole('salesRep', 'superAdmin'),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const chassisNumber = req.params.chassisNumber; const chassisNumber = req.params.chassisNumber;
const toDealerId = String(req.body?.dealerId || '').trim(); const stock = await prisma.stock.findUnique({
if (!toDealerId) throw httpError(400, 'dealerId is required'); where: { chassisNumber },
include: stockInclude,
const stock = await prisma.stock.findUnique({ where: { chassisNumber } }); });
if (!stock) throw httpError(404, 'Stock not found'); if (!stock) throw httpError(404, 'Stock not found');
if (stock.currentDealerId === toDealerId) { if (!canAccessDealer(req.user, stock.currentDealerId)) {
throw httpError(400, 'Stock is already at that dealer'); throw httpError(403, 'Forbidden');
} }
if (!canAccessDealer(req.user, stock.currentDealerId) || !canAccessDealer(req.user, toDealerId)) {
throw httpError(403, 'Forbidden'); const logs = [...stock.stageLogs].sort((a, b) => {
} const diff = new Date(a.loggedAt).getTime() - new Date(b.loggedAt).getTime();
if (diff !== 0) return diff;
const target = await prisma.dealer.findUnique({ where: { id: toDealerId } }); return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
if (!target) throw httpError(404, 'Dealer not found'); });
const latest = logs[logs.length - 1];
const updated = await prisma.$transaction(async (tx) => { if (!latest || logs.length < 2 || stock.currentStage === 'received' || latest.stage === 'received') {
await tx.stockTransfer.create({ throw httpError(400, 'Nothing to undo');
data: { }
stockId: stock.id, if (latest.stage !== stock.currentStage) {
fromDealerId: stock.currentDealerId, throw httpError(400, 'Stage log does not match the current stage');
toDealerId, }
actorId: req.user.id,
}, const previous = logs[logs.length - 2];
}); const data = { currentStage: previous.stage };
return tx.stock.update({ if (latest.stage === 'paymentApproved') {
where: { id: stock.id }, data.paymentType = null;
data: { currentDealerId: toDealerId }, data.leaseProviderType = null;
include: stockInclude, data.financeCompanyName = null;
}); }
}); if (latest.stage === 'plateReceived') {
data.plateNumber = null;
return res.json({ success: true, data: serializeStock(updated) }); }
})
); const updated = await prisma.$transaction(async (tx) => {
await tx.stageLog.delete({ where: { id: latest.id } });
module.exports = router; return tx.stock.update({
where: { id: stock.id },
data,
include: stockInclude,
});
});
return res.json({ success: true, data: serializeStock(updated) });
})
);
router.delete(
'/:chassisNumber',
requireRole('salesRep', 'superAdmin'),
asyncHandler(async (req, res) => {
const chassisNumber = req.params.chassisNumber;
const stock = await prisma.stock.findUnique({ where: { chassisNumber } });
if (!stock) throw httpError(404, 'Stock not found');
if (!canAccessDealer(req.user, stock.currentDealerId)) {
throw httpError(403, 'Forbidden');
}
await prisma.stock.delete({ where: { id: stock.id } });
return res.json({ success: true });
})
);
router.post(
'/:chassisNumber/transfer',
requireRole('salesRep', 'superAdmin'),
asyncHandler(async (req, res) => {
const chassisNumber = req.params.chassisNumber;
const toDealerId = String(req.body?.dealerId || '').trim();
if (!toDealerId) throw httpError(400, 'dealerId is required');
const stock = await prisma.stock.findUnique({ where: { chassisNumber } });
if (!stock) throw httpError(404, 'Stock not found');
if (stock.currentDealerId === toDealerId) {
throw httpError(400, 'Stock is already at that dealer');
}
if (!canAccessDealer(req.user, stock.currentDealerId) || !canAccessDealer(req.user, toDealerId)) {
throw httpError(403, 'Forbidden');
}
const target = await prisma.dealer.findUnique({ where: { id: toDealerId } });
if (!target) throw httpError(404, 'Dealer not found');
const updated = await prisma.$transaction(async (tx) => {
await tx.stockTransfer.create({
data: {
stockId: stock.id,
fromDealerId: stock.currentDealerId,
toDealerId,
actorId: req.user.id,
},
});
return tx.stock.update({
where: { id: stock.id },
data: { currentDealerId: toDealerId },
include: stockInclude,
});
});
return res.json({ success: true, data: serializeStock(updated) });
})
);
module.exports = router;