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