delete and undo
This commit is contained in:
+6
-6
@@ -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
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
node_modules/
|
||||
.env
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
|
||||
Generated
+1584
-1584
File diff suppressed because it is too large
Load Diff
+29
-29
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+46
-46
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user