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