init
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
DATABASE_URL="file:../data/app.db"
|
||||||
|
JWT_SECRET="replace-with-a-long-random-string"
|
||||||
|
TEXTLK_API_TOKEN=
|
||||||
|
TEXTLK_SENDER_ID=TextLKDemo
|
||||||
|
SEED_ADMIN_NAME="Super Admin"
|
||||||
|
SEED_ADMIN_PHONE="0715127342"
|
||||||
|
PORT=3000
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/*.db
|
||||||
|
data/*.db-journal
|
||||||
Generated
+1599
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "dealer-network-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"seed": "node prisma/seed.js",
|
||||||
|
"check": "node scripts/api-check.js",
|
||||||
|
"postinstall": "prisma generate"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "node prisma/seed.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.16.2",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prisma": "^6.16.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"dealerId" TEXT,
|
||||||
|
"currentOtp" TEXT,
|
||||||
|
"otpExpiresAt" DATETIME,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "User_dealerId_fkey" FOREIGN KEY ("dealerId") REFERENCES "Dealer" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Dealer" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"district" TEXT,
|
||||||
|
"address" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DealerAssignment" (
|
||||||
|
"salesRepId" TEXT NOT NULL,
|
||||||
|
"dealerId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY ("salesRepId", "dealerId"),
|
||||||
|
CONSTRAINT "DealerAssignment_salesRepId_fkey" FOREIGN KEY ("salesRepId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "DealerAssignment_dealerId_fkey" FOREIGN KEY ("dealerId") REFERENCES "Dealer" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Stock" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"chassisNumber" TEXT NOT NULL,
|
||||||
|
"engineNumber" TEXT NOT NULL,
|
||||||
|
"model" TEXT NOT NULL,
|
||||||
|
"color" TEXT NOT NULL,
|
||||||
|
"currentDealerId" TEXT NOT NULL,
|
||||||
|
"dateReceived" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"currentStage" TEXT NOT NULL DEFAULT 'received',
|
||||||
|
"paymentType" TEXT,
|
||||||
|
"leaseProviderType" TEXT,
|
||||||
|
"financeCompanyName" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Stock_currentDealerId_fkey" FOREIGN KEY ("currentDealerId") REFERENCES "Dealer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StageLog" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"stockId" TEXT NOT NULL,
|
||||||
|
"stage" TEXT NOT NULL,
|
||||||
|
"loggedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"actorId" TEXT,
|
||||||
|
CONSTRAINT "StageLog_stockId_fkey" FOREIGN KEY ("stockId") REFERENCES "Stock" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "StageLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StockTransfer" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"stockId" TEXT NOT NULL,
|
||||||
|
"fromDealerId" TEXT NOT NULL,
|
||||||
|
"toDealerId" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT NOT NULL,
|
||||||
|
"transferredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "StockTransfer_stockId_fkey" FOREIGN KEY ("stockId") REFERENCES "Stock" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "StockTransfer_fromDealerId_fkey" FOREIGN KEY ("fromDealerId") REFERENCES "Dealer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "StockTransfer_toDealerId_fkey" FOREIGN KEY ("toDealerId") REFERENCES "Dealer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "StockTransfer_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Event" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"dealerId" TEXT NOT NULL,
|
||||||
|
"planType" TEXT NOT NULL,
|
||||||
|
"month" INTEGER,
|
||||||
|
"year" INTEGER,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"expense" REAL NOT NULL,
|
||||||
|
"imageUrls" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Event_dealerId_fkey" FOREIGN KEY ("dealerId") REFERENCES "Dealer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Event_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ServicePoint" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"district" TEXT NOT NULL,
|
||||||
|
"address" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "ServicePoint_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Dealer_code_key" ON "Dealer"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Stock_chassisNumber_key" ON "Stock"("chassisNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Stock_engineNumber_key" ON "Stock"("engineNumber");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
phone String @unique
|
||||||
|
role String
|
||||||
|
dealerId String?
|
||||||
|
dealer Dealer? @relation("DealerStaff", fields: [dealerId], references: [id])
|
||||||
|
currentOtp String?
|
||||||
|
otpExpiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
assignments DealerAssignment[] @relation("SalesRepAssignments")
|
||||||
|
stageLogs StageLog[]
|
||||||
|
transfers StockTransfer[]
|
||||||
|
events Event[]
|
||||||
|
servicePoints ServicePoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Dealer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
code String @unique
|
||||||
|
district String?
|
||||||
|
address String?
|
||||||
|
phone String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
staff User[] @relation("DealerStaff")
|
||||||
|
assignments DealerAssignment[]
|
||||||
|
stocks Stock[]
|
||||||
|
transfersFrom StockTransfer[] @relation("TransferFrom")
|
||||||
|
transfersTo StockTransfer[] @relation("TransferTo")
|
||||||
|
events Event[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model DealerAssignment {
|
||||||
|
salesRepId String
|
||||||
|
dealerId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
salesRep User @relation("SalesRepAssignments", fields: [salesRepId], references: [id], onDelete: Cascade)
|
||||||
|
dealer Dealer @relation(fields: [dealerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([salesRepId, dealerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Stock {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
chassisNumber String @unique
|
||||||
|
engineNumber String @unique
|
||||||
|
model String
|
||||||
|
color String
|
||||||
|
currentDealerId String
|
||||||
|
currentDealer Dealer @relation(fields: [currentDealerId], references: [id])
|
||||||
|
dateReceived DateTime @default(now())
|
||||||
|
currentStage String @default("received")
|
||||||
|
paymentType String?
|
||||||
|
leaseProviderType String?
|
||||||
|
financeCompanyName String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
stageLogs StageLog[]
|
||||||
|
transfers StockTransfer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model StageLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
stockId String
|
||||||
|
stock Stock @relation(fields: [stockId], references: [id], onDelete: Cascade)
|
||||||
|
stage String
|
||||||
|
loggedAt DateTime @default(now())
|
||||||
|
actorId String?
|
||||||
|
actor User? @relation(fields: [actorId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model StockTransfer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
stockId String
|
||||||
|
stock Stock @relation(fields: [stockId], references: [id], onDelete: Cascade)
|
||||||
|
fromDealerId String
|
||||||
|
fromDealer Dealer @relation("TransferFrom", fields: [fromDealerId], references: [id])
|
||||||
|
toDealerId String
|
||||||
|
toDealer Dealer @relation("TransferTo", fields: [toDealerId], references: [id])
|
||||||
|
actorId String
|
||||||
|
actor User @relation(fields: [actorId], references: [id])
|
||||||
|
transferredAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Event {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
dealerId String
|
||||||
|
dealer Dealer @relation(fields: [dealerId], references: [id])
|
||||||
|
planType String
|
||||||
|
month Int?
|
||||||
|
year Int?
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
expense Float
|
||||||
|
imageUrls String @default("[]")
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation(fields: [createdById], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model ServicePoint {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
district String
|
||||||
|
address String?
|
||||||
|
phone String?
|
||||||
|
notes String?
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation(fields: [createdById], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||||
|
|
||||||
|
const prisma = require('../src/db');
|
||||||
|
const { isValidPhone, normalizePhone } = require('../src/lib/phone');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const name = String(process.env.SEED_ADMIN_NAME || 'Super Admin').trim();
|
||||||
|
const phone = normalizePhone(process.env.SEED_ADMIN_PHONE);
|
||||||
|
if (!isValidPhone(phone)) {
|
||||||
|
throw new Error('SEED_ADMIN_PHONE must be a Sri Lankan mobile number');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAdmin = await prisma.user.findFirst({ where: { role: 'superAdmin' } });
|
||||||
|
const admin = existingAdmin
|
||||||
|
? await prisma.user.update({
|
||||||
|
where: { id: existingAdmin.id },
|
||||||
|
data: { name, phone, role: 'superAdmin', dealerId: null },
|
||||||
|
})
|
||||||
|
: await prisma.user.upsert({
|
||||||
|
where: { phone },
|
||||||
|
update: { name, role: 'superAdmin', dealerId: null },
|
||||||
|
create: { name, phone, role: 'superAdmin' },
|
||||||
|
});
|
||||||
|
console.log(`Seeded super admin ${admin.name} (${admin.phone})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
process.chdir(root);
|
||||||
|
|
||||||
|
const dbPath = path.join(root, 'data', 'api-check.db');
|
||||||
|
for (const file of [dbPath, `${dbPath}-journal`, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
||||||
|
if (fs.existsSync(file)) fs.unlinkSync(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.DATABASE_URL = 'file:../data/api-check.db';
|
||||||
|
process.env.JWT_SECRET = 'api-check-secret';
|
||||||
|
process.env.TEXTLK_API_TOKEN = '';
|
||||||
|
process.env.SEED_ADMIN_NAME = 'Super Admin';
|
||||||
|
process.env.SEED_ADMIN_PHONE = '94770000001';
|
||||||
|
|
||||||
|
execSync('npx prisma db push --skip-generate --accept-data-loss', {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = require('../src/app');
|
||||||
|
const prisma = require('../src/db');
|
||||||
|
const { ageBadge } = require('../src/lib/stockRules');
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(port, method, urlPath, { token, body } = {}) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}${urlPath}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let json = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
json = { raw: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: response.status, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAgo(days) {
|
||||||
|
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(port) {
|
||||||
|
assert(ageBadge(29) === 'green', '29 days is green');
|
||||||
|
assert(ageBadge(30) === 'yellow', '30 days is yellow');
|
||||||
|
assert(ageBadge(45) === 'yellow', '45 days is yellow');
|
||||||
|
assert(ageBadge(46) === 'red', '46 days is red');
|
||||||
|
|
||||||
|
const unknown = await request(port, 'POST', '/api/auth/send-otp', {
|
||||||
|
body: { phone: '94770000099' },
|
||||||
|
});
|
||||||
|
assert(unknown.status === 404, `unknown phone should be 404, got ${unknown.status}`);
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { name: 'Super Admin', phone: '94770000001', role: 'superAdmin' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = await request(port, 'POST', '/api/auth/send-otp', {
|
||||||
|
body: { phone: '94770000001' },
|
||||||
|
});
|
||||||
|
assert(sent.status === 200 && sent.json.success === true, 'OTP send failed');
|
||||||
|
|
||||||
|
const adminRow = await prisma.user.findUnique({ where: { phone: '94770000001' } });
|
||||||
|
const wrongCode = adminRow.currentOtp === '000000' ? '111111' : '000000';
|
||||||
|
const badOtp = await request(port, 'POST', '/api/auth/verify-otp', {
|
||||||
|
body: { phone: '94770000001', otp: wrongCode },
|
||||||
|
});
|
||||||
|
assert(badOtp.status === 401, 'wrong OTP should be rejected');
|
||||||
|
|
||||||
|
const verified = await request(port, 'POST', '/api/auth/verify-otp', {
|
||||||
|
body: { phone: '94770000001', otp: adminRow.currentOtp },
|
||||||
|
});
|
||||||
|
assert(verified.status === 200 && verified.json.token, 'OTP verify failed');
|
||||||
|
const adminToken = verified.json.token;
|
||||||
|
|
||||||
|
const me = await request(port, 'GET', '/api/auth/me', { token: adminToken });
|
||||||
|
assert(me.status === 200 && me.json.role === 'superAdmin', '/me failed');
|
||||||
|
const missing = await request(port, 'GET', '/api/auth/me');
|
||||||
|
assert(missing.status === 401, 'missing token should be 401');
|
||||||
|
|
||||||
|
const dealerA = await request(port, 'POST', '/api/admin/dealers', {
|
||||||
|
token: adminToken,
|
||||||
|
body: { name: 'Kandy Motors', code: 'KDY', district: 'Kandy' },
|
||||||
|
});
|
||||||
|
const dealerB = await request(port, 'POST', '/api/admin/dealers', {
|
||||||
|
token: adminToken,
|
||||||
|
body: { name: 'Galle Motors', code: 'GAL', district: 'Galle' },
|
||||||
|
});
|
||||||
|
const dealerC = await request(port, 'POST', '/api/admin/dealers', {
|
||||||
|
token: adminToken,
|
||||||
|
body: { name: 'Jaffna Motors', code: 'JAF', district: 'Jaffna' },
|
||||||
|
});
|
||||||
|
assert(dealerA.status === 201 && dealerB.status === 201 && dealerC.status === 201, 'dealer create failed');
|
||||||
|
|
||||||
|
const rep = await request(port, 'POST', '/api/admin/sales-reps', {
|
||||||
|
token: adminToken,
|
||||||
|
body: {
|
||||||
|
name: 'Nimal Perera',
|
||||||
|
phone: '94771111111',
|
||||||
|
dealerIds: [dealerA.json.data.id, dealerB.json.data.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(rep.status === 201, `sales rep create failed ${rep.status}`);
|
||||||
|
|
||||||
|
const dealerUser = await request(port, 'POST', '/api/admin/dealer-users', {
|
||||||
|
token: adminToken,
|
||||||
|
body: { name: 'Kandy Desk', phone: '94772222222', dealerId: dealerA.json.data.id },
|
||||||
|
});
|
||||||
|
assert(dealerUser.status === 201, 'dealer user create failed');
|
||||||
|
|
||||||
|
async function login(phone) {
|
||||||
|
const otpSend = await request(port, 'POST', '/api/auth/send-otp', { body: { phone } });
|
||||||
|
assert(otpSend.status === 200, `otp send failed for ${phone}`);
|
||||||
|
const row = await prisma.user.findUnique({ where: { phone } });
|
||||||
|
const otpVerify = await request(port, 'POST', '/api/auth/verify-otp', {
|
||||||
|
body: { phone, otp: row.currentOtp },
|
||||||
|
});
|
||||||
|
assert(otpVerify.status === 200, `otp verify failed for ${phone}`);
|
||||||
|
return otpVerify.json.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repToken = await login('94771111111');
|
||||||
|
const dealerToken = await login('94772222222');
|
||||||
|
|
||||||
|
const forbiddenStock = await request(port, 'POST', '/api/stocks', {
|
||||||
|
token: dealerToken,
|
||||||
|
body: {
|
||||||
|
chassisNumber: 'NOPE1',
|
||||||
|
engineNumber: 'ENG-NOPE',
|
||||||
|
model: 'gn125',
|
||||||
|
color: 'Red',
|
||||||
|
currentDealerId: dealerA.json.data.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(forbiddenStock.status === 403, 'dealer should not add stock');
|
||||||
|
|
||||||
|
async function addStock(chassis, engine, days, dealerId) {
|
||||||
|
const created = await request(port, 'POST', '/api/stocks', {
|
||||||
|
token: adminToken,
|
||||||
|
body: {
|
||||||
|
chassisNumber: chassis,
|
||||||
|
engineNumber: engine,
|
||||||
|
model: 'gn125',
|
||||||
|
color: 'Black',
|
||||||
|
currentDealerId: dealerId,
|
||||||
|
dateReceived: daysAgo(days),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(created.status === 201, `stock create failed for ${chassis}: ${created.status} ${JSON.stringify(created.json)}`);
|
||||||
|
return created.json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const green = await addStock('CH-GREEN', 'EN-GREEN', 10, dealerA.json.data.id);
|
||||||
|
const yellow = await addStock('CH-YELLOW', 'EN-YELLOW', 35, dealerA.json.data.id);
|
||||||
|
const red = await addStock('CH-RED', 'EN-RED', 50, dealerA.json.data.id);
|
||||||
|
assert(green.badge === 'green' && green.ageInDays === 10, `green badge mismatch ${JSON.stringify(green)}`);
|
||||||
|
assert(yellow.badge === 'yellow' && yellow.ageInDays === 35, 'yellow badge mismatch');
|
||||||
|
assert(red.badge === 'red' && red.ageInDays === 50, 'red badge mismatch');
|
||||||
|
assert(green.stageLogDates.received, 'received stage was not logged');
|
||||||
|
|
||||||
|
const skip = await request(port, 'PATCH', '/api/stocks/CH-GREEN/stage', {
|
||||||
|
token: adminToken,
|
||||||
|
body: { stage: 'plateReceived' },
|
||||||
|
});
|
||||||
|
assert(skip.status === 400, 'skipping stages should fail');
|
||||||
|
|
||||||
|
const ordered = await request(port, 'PATCH', '/api/stocks/CH-GREEN/stage', {
|
||||||
|
token: dealerToken,
|
||||||
|
body: { stage: 'orderedByCustomer' },
|
||||||
|
});
|
||||||
|
assert(ordered.status === 200, 'dealer stage update failed');
|
||||||
|
|
||||||
|
const docs = await request(port, 'PATCH', '/api/stocks/CH-GREEN/stage', {
|
||||||
|
token: dealerToken,
|
||||||
|
body: { stage: 'documentsProcessed' },
|
||||||
|
});
|
||||||
|
assert(docs.status === 200, 'documents stage failed');
|
||||||
|
|
||||||
|
const payMissing = await request(port, 'PATCH', '/api/stocks/CH-GREEN/stage', {
|
||||||
|
token: dealerToken,
|
||||||
|
body: { stage: 'paymentApproved' },
|
||||||
|
});
|
||||||
|
assert(payMissing.status === 400, 'payment without type should fail');
|
||||||
|
|
||||||
|
const paid = await request(port, 'PATCH', '/api/stocks/CH-GREEN/stage', {
|
||||||
|
token: dealerToken,
|
||||||
|
body: {
|
||||||
|
stage: 'paymentApproved',
|
||||||
|
paymentType: 'lease',
|
||||||
|
leaseProviderType: 'thirdPartyFinance',
|
||||||
|
financeCompanyName: 'Sampath Finance',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(paid.status === 200 && paid.json.data.paymentType === 'lease', 'lease payment failed');
|
||||||
|
assert(paid.json.data.stageLogDates.paymentApproved, 'payment stage log missing');
|
||||||
|
|
||||||
|
const outside = await request(port, 'POST', '/api/stocks/CH-YELLOW/transfer', {
|
||||||
|
token: repToken,
|
||||||
|
body: { dealerId: dealerC.json.data.id },
|
||||||
|
});
|
||||||
|
assert(outside.status === 403, 'rep should not transfer outside assignment');
|
||||||
|
|
||||||
|
const moved = await request(port, 'POST', '/api/stocks/CH-YELLOW/transfer', {
|
||||||
|
token: repToken,
|
||||||
|
body: { dealerId: dealerB.json.data.id },
|
||||||
|
});
|
||||||
|
assert(moved.status === 200 && moved.json.data.currentDealerId === dealerB.json.data.id, 'transfer failed');
|
||||||
|
|
||||||
|
const eventBad = await request(port, 'POST', '/api/events', {
|
||||||
|
token: dealerToken,
|
||||||
|
body: {
|
||||||
|
dealerId: dealerA.json.data.id,
|
||||||
|
planType: 'monthlySpecial',
|
||||||
|
title: 'April push',
|
||||||
|
description: 'Showroom weekend',
|
||||||
|
expense: 15000,
|
||||||
|
imageUrls: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(eventBad.status === 400, 'monthly plan without month should fail');
|
||||||
|
|
||||||
|
const eventOk = await request(port, 'POST', '/api/events', {
|
||||||
|
token: dealerToken,
|
||||||
|
body: {
|
||||||
|
dealerId: dealerA.json.data.id,
|
||||||
|
planType: 'monthlySpecial',
|
||||||
|
month: 4,
|
||||||
|
year: 2026,
|
||||||
|
title: 'April push',
|
||||||
|
description: 'Showroom weekend',
|
||||||
|
expense: 15000,
|
||||||
|
imageUrls: ['https://example.com/poster.jpg'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(eventOk.status === 201, 'event create failed');
|
||||||
|
|
||||||
|
const otherDealerEvent = await request(port, 'POST', '/api/events', {
|
||||||
|
token: dealerToken,
|
||||||
|
body: {
|
||||||
|
dealerId: dealerB.json.data.id,
|
||||||
|
planType: 'general',
|
||||||
|
title: 'Nope',
|
||||||
|
description: 'Wrong dealer',
|
||||||
|
expense: 1,
|
||||||
|
imageUrls: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(otherDealerEvent.status === 403, 'dealer should not post events for another dealer');
|
||||||
|
|
||||||
|
const pointForbidden = await request(port, 'POST', '/api/service-points', {
|
||||||
|
token: dealerToken,
|
||||||
|
body: { name: 'Hidden', district: 'Ratnapura' },
|
||||||
|
});
|
||||||
|
assert(pointForbidden.status === 403, 'dealer should not add service points');
|
||||||
|
|
||||||
|
const point = await request(port, 'POST', '/api/service-points', {
|
||||||
|
token: repToken,
|
||||||
|
body: {
|
||||||
|
name: 'Ratnapura Cycle Repair',
|
||||||
|
district: 'Ratnapura',
|
||||||
|
address: 'Main street',
|
||||||
|
notes: 'engine repair',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(point.status === 201, 'service point create failed');
|
||||||
|
|
||||||
|
const found = await request(
|
||||||
|
port,
|
||||||
|
'GET',
|
||||||
|
'/api/service-points?district=Ratnapura&search=repair',
|
||||||
|
{ token: dealerToken }
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
found.status === 200 && found.json.data.some((item) => item.name === 'Ratnapura Cycle Repair'),
|
||||||
|
'service point search failed'
|
||||||
|
);
|
||||||
|
|
||||||
|
const reassigned = await request(port, 'PATCH', '/api/admin/assign-dealer', {
|
||||||
|
token: adminToken,
|
||||||
|
body: { salesRepId: rep.json.data.id, dealerId: dealerC.json.data.id, assigned: true },
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
reassigned.status === 200 && reassigned.json.data.assignedDealerIds.includes(dealerC.json.data.id),
|
||||||
|
'assign dealer failed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const server = await new Promise((resolve) => {
|
||||||
|
const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await run(server.address().port);
|
||||||
|
console.log('API checks passed');
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
await prisma.$disconnect();
|
||||||
|
for (const file of [dbPath, `${dbPath}-journal`, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
||||||
|
if (fs.existsSync(file)) fs.unlinkSync(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||||
|
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const dealerRoutes = require('./routes/dealers');
|
||||||
|
const stockRoutes = require('./routes/stocks');
|
||||||
|
const eventRoutes = require('./routes/events');
|
||||||
|
const servicePointRoutes = require('./routes/servicePoints');
|
||||||
|
const adminRoutes = require('./routes/admin');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/dealers', dealerRoutes);
|
||||||
|
app.use('/api/stocks', stockRoutes);
|
||||||
|
app.use('/api/events', eventRoutes);
|
||||||
|
app.use('/api/service-points', servicePointRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ success: false, message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (res.headersSent) return next(err);
|
||||||
|
if (err.code === 'P2002') {
|
||||||
|
return res.status(409).json({ success: false, message: 'Already exists' });
|
||||||
|
}
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
return res.status(status).json({
|
||||||
|
success: false,
|
||||||
|
message: status >= 500 ? 'Server error' : err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
module.exports = prisma;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const app = require('./app');
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT) || 3000;
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`API listening on ${port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
function assignedDealerIds(user) {
|
||||||
|
return (user.assignments || []).map((assignment) => assignment.dealerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccessDealer(user, dealerId) {
|
||||||
|
if (!dealerId) return false;
|
||||||
|
if (user.role === 'superAdmin') return true;
|
||||||
|
if (user.role === 'dealer') return user.dealerId === dealerId;
|
||||||
|
if (user.role === 'salesRep') return assignedDealerIds(user).includes(dealerId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dealerScopeWhere(user) {
|
||||||
|
if (user.role === 'superAdmin') return {};
|
||||||
|
if (user.role === 'dealer') return { currentDealerId: user.dealerId || '__none__' };
|
||||||
|
return { currentDealerId: { in: assignedDealerIds(user) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventScopeWhere(user) {
|
||||||
|
if (user.role === 'superAdmin') return {};
|
||||||
|
if (user.role === 'dealer') return { dealerId: user.dealerId || '__none__' };
|
||||||
|
return { dealerId: { in: assignedDealerIds(user) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
assignedDealerIds,
|
||||||
|
canAccessDealer,
|
||||||
|
dealerScopeWhere,
|
||||||
|
eventScopeWhere,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
function httpError(status, message) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = status;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asyncHandler(fn) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { httpError, asyncHandler };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const PHONE_PATTERN = /^947\d{8}$/;
|
||||||
|
|
||||||
|
function normalizePhone(value) {
|
||||||
|
let phone = String(value || '').trim().replace(/[\s-]/g, '');
|
||||||
|
if (phone.startsWith('+')) phone = phone.slice(1);
|
||||||
|
if (/^0\d{9}$/.test(phone)) phone = `94${phone.slice(1)}`;
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPhone(phone) {
|
||||||
|
return PHONE_PATTERN.test(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PHONE_PATTERN, normalizePhone, isValidPhone };
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const STAGES = [
|
||||||
|
'received',
|
||||||
|
'orderedByCustomer',
|
||||||
|
'documentsProcessed',
|
||||||
|
'paymentApproved',
|
||||||
|
'rmvRegistration',
|
||||||
|
'plateReceived',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODELS = ['gn125', 'boxer125', 'click150i'];
|
||||||
|
const PAYMENT_TYPES = ['cash', 'lease'];
|
||||||
|
const LEASE_PROVIDERS = ['gci', 'thirdPartyFinance'];
|
||||||
|
|
||||||
|
function ageInDays(dateReceived, now = Date.now()) {
|
||||||
|
const diffTime = Math.abs(now - new Date(dateReceived).getTime());
|
||||||
|
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageBadge(days) {
|
||||||
|
if (days < 30) return 'green';
|
||||||
|
if (days <= 45) return 'yellow';
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStage(current) {
|
||||||
|
const index = STAGES.indexOf(current);
|
||||||
|
if (index < 0 || index >= STAGES.length - 1) return null;
|
||||||
|
return STAGES[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageLogMap(logs) {
|
||||||
|
const map = {};
|
||||||
|
for (const log of logs) {
|
||||||
|
map[log.stage] = log.loggedAt;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeStock(stock) {
|
||||||
|
const days = ageInDays(stock.dateReceived);
|
||||||
|
return {
|
||||||
|
id: stock.id,
|
||||||
|
chassisNumber: stock.chassisNumber,
|
||||||
|
engineNumber: stock.engineNumber,
|
||||||
|
model: stock.model,
|
||||||
|
color: stock.color,
|
||||||
|
currentDealerId: stock.currentDealerId,
|
||||||
|
dealerName: stock.currentDealer ? stock.currentDealer.name : null,
|
||||||
|
dateReceived: stock.dateReceived,
|
||||||
|
currentStage: stock.currentStage,
|
||||||
|
stageLogDates: stageLogMap(stock.stageLogs || []),
|
||||||
|
paymentType: stock.paymentType,
|
||||||
|
leaseProviderType: stock.leaseProviderType,
|
||||||
|
financeCompanyName: stock.financeCompanyName,
|
||||||
|
ageInDays: days,
|
||||||
|
badge: ageBadge(days),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
STAGES,
|
||||||
|
MODELS,
|
||||||
|
PAYMENT_TYPES,
|
||||||
|
LEASE_PROVIDERS,
|
||||||
|
ageInDays,
|
||||||
|
ageBadge,
|
||||||
|
nextStage,
|
||||||
|
serializeStock,
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
function publicUser(user, dealers) {
|
||||||
|
const profile = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
phone: user.phone,
|
||||||
|
role: user.role,
|
||||||
|
dealerId: user.dealerId,
|
||||||
|
assignedDealerIds: (user.assignments || []).map((assignment) => assignment.dealerId),
|
||||||
|
};
|
||||||
|
if (dealers) {
|
||||||
|
profile.dealers = dealers.map((dealer) => ({
|
||||||
|
id: dealer.id,
|
||||||
|
name: dealer.name,
|
||||||
|
code: dealer.code,
|
||||||
|
district: dealer.district,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPublicSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
role: true,
|
||||||
|
dealerId: true,
|
||||||
|
createdAt: true,
|
||||||
|
assignments: { select: { dealerId: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { publicUser, userPublicSelect };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const prisma = require('../db');
|
||||||
|
const { userPublicSelect } = require('../lib/users');
|
||||||
|
|
||||||
|
async function requireAuth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ success: false, message: 'Missing token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
select: userPublicSelect,
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ success: false, message: 'Invalid token' });
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
return next();
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ success: false, message: 'Invalid token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRole(...roles) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuth, requireRole };
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../db');
|
||||||
|
const { asyncHandler, httpError } = require('../lib/errors');
|
||||||
|
const { isValidPhone, normalizePhone } = require('../lib/phone');
|
||||||
|
const { canAccessDealer } = require('../lib/access');
|
||||||
|
const { userPublicSelect, publicUser } = require('../lib/users');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/dealers',
|
||||||
|
requireRole('superAdmin'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const dealers = await prisma.dealer.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
return res.json({ success: true, data: dealers });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/dealers',
|
||||||
|
requireRole('superAdmin'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const name = String(req.body?.name || '').trim();
|
||||||
|
const code = String(req.body?.code || '').trim();
|
||||||
|
const district = String(req.body?.district || '').trim() || null;
|
||||||
|
const address = String(req.body?.address || '').trim() || null;
|
||||||
|
const rawPhone = String(req.body?.phone || '').trim();
|
||||||
|
const phone = rawPhone ? normalizePhone(rawPhone) : null;
|
||||||
|
if (!name || !code) throw httpError(400, 'Name and code are required');
|
||||||
|
if (phone && !isValidPhone(phone)) {
|
||||||
|
throw httpError(400, 'Dealer phone must be a Sri Lankan mobile number');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dealer = await prisma.dealer.create({
|
||||||
|
data: { name, code, district, address, phone },
|
||||||
|
});
|
||||||
|
return res.status(201).json({ success: true, data: dealer });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/sales-reps',
|
||||||
|
requireRole('superAdmin'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const reps = await prisma.user.findMany({
|
||||||
|
where: { role: 'salesRep' },
|
||||||
|
select: userPublicSelect,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data: reps.map((rep) => publicUser(rep)) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/sales-reps',
|
||||||
|
requireRole('superAdmin'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const name = String(req.body?.name || '').trim();
|
||||||
|
const phone = normalizePhone(req.body?.phone);
|
||||||
|
const dealerIds = Array.isArray(req.body?.dealerIds) ? req.body.dealerIds : [];
|
||||||
|
if (!name || !isValidPhone(phone)) {
|
||||||
|
throw httpError(400, 'Name and a 947XXXXXXXX phone are required');
|
||||||
|
}
|
||||||
|
if (!dealerIds.every((id) => typeof id === 'string' && id.trim())) {
|
||||||
|
throw httpError(400, 'dealerIds must be a list of ids');
|
||||||
|
}
|
||||||
|
const uniqueIds = [...new Set(dealerIds.map((id) => id.trim()))];
|
||||||
|
if (uniqueIds.length) {
|
||||||
|
const found = await prisma.dealer.findMany({ where: { id: { in: uniqueIds } } });
|
||||||
|
if (found.length !== uniqueIds.length) throw httpError(404, 'Dealer not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
role: 'salesRep',
|
||||||
|
assignments: {
|
||||||
|
create: uniqueIds.map((dealerId) => ({ dealerId })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: userPublicSelect,
|
||||||
|
});
|
||||||
|
return res.status(201).json({ success: true, data: publicUser(user) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/assign-dealer',
|
||||||
|
requireRole('superAdmin'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const salesRepId = String(req.body?.salesRepId || '').trim();
|
||||||
|
const dealerId = String(req.body?.dealerId || '').trim();
|
||||||
|
const assigned = req.body?.assigned !== false;
|
||||||
|
if (!salesRepId || !dealerId) {
|
||||||
|
throw httpError(400, 'salesRepId and dealerId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rep, dealer] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { id: salesRepId } }),
|
||||||
|
prisma.dealer.findUnique({ where: { id: dealerId } }),
|
||||||
|
]);
|
||||||
|
if (!rep || rep.role !== 'salesRep') throw httpError(404, 'Sales rep not found');
|
||||||
|
if (!dealer) throw httpError(404, 'Dealer not found');
|
||||||
|
|
||||||
|
if (assigned) {
|
||||||
|
await prisma.dealerAssignment.upsert({
|
||||||
|
where: { salesRepId_dealerId: { salesRepId, dealerId } },
|
||||||
|
update: {},
|
||||||
|
create: { salesRepId, dealerId },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.dealerAssignment.deleteMany({ where: { salesRepId, dealerId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.findUnique({
|
||||||
|
where: { id: salesRepId },
|
||||||
|
select: userPublicSelect,
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data: publicUser(updated) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/dealer-users',
|
||||||
|
requireRole('superAdmin', 'salesRep'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const name = String(req.body?.name || '').trim();
|
||||||
|
const phone = normalizePhone(req.body?.phone);
|
||||||
|
const dealerId = String(req.body?.dealerId || '').trim();
|
||||||
|
if (!name || !isValidPhone(phone) || !dealerId) {
|
||||||
|
throw httpError(400, 'Name, phone, and dealerId are required');
|
||||||
|
}
|
||||||
|
if (!canAccessDealer(req.user, dealerId)) throw httpError(403, 'Forbidden');
|
||||||
|
|
||||||
|
const dealer = await prisma.dealer.findUnique({ where: { id: dealerId } });
|
||||||
|
if (!dealer) throw httpError(404, 'Dealer not found');
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { name, phone, role: 'dealer', dealerId },
|
||||||
|
select: userPublicSelect,
|
||||||
|
});
|
||||||
|
return res.status(201).json({ success: true, data: publicUser(user) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const express = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const prisma = require('../db');
|
||||||
|
const { asyncHandler, httpError } = require('../lib/errors');
|
||||||
|
const { isValidPhone, normalizePhone } = require('../lib/phone');
|
||||||
|
const { publicUser, userPublicSelect } = require('../lib/users');
|
||||||
|
const { assignedDealerIds } = require('../lib/access');
|
||||||
|
const { sendSmsOtp } = require('../services/smsService');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const OTP_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function generateOtp() {
|
||||||
|
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accessibleDealers(user) {
|
||||||
|
if (user.role === 'superAdmin') {
|
||||||
|
return prisma.dealer.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
}
|
||||||
|
if (user.role === 'dealer') {
|
||||||
|
if (!user.dealerId) return [];
|
||||||
|
const dealer = await prisma.dealer.findUnique({ where: { id: user.dealerId } });
|
||||||
|
return dealer ? [dealer] : [];
|
||||||
|
}
|
||||||
|
const ids = assignedDealerIds(user);
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
return prisma.dealer.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/send-otp',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const phone = normalizePhone(req.body?.phone);
|
||||||
|
if (!isValidPhone(phone)) {
|
||||||
|
throw httpError(400, 'Phone must be in 947XXXXXXXX format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { phone } });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Not Registered' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const otp = generateOtp();
|
||||||
|
const otpExpiresAt = new Date(Date.now() + OTP_TTL_MS);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { currentOtp: otp, otpExpiresAt },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendSmsOtp(phone, otp);
|
||||||
|
} catch (error) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { currentOtp: null, otpExpiresAt: null },
|
||||||
|
});
|
||||||
|
throw httpError(502, error.message || 'Failed to send OTP SMS');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, message: 'OTP sent via SMS' });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/verify-otp',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const phone = normalizePhone(req.body?.phone);
|
||||||
|
const otp = String(req.body?.otp || '').trim();
|
||||||
|
if (!isValidPhone(phone) || !/^\d{6}$/.test(otp)) {
|
||||||
|
throw httpError(400, 'Phone and a 6-digit OTP are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { phone },
|
||||||
|
select: {
|
||||||
|
...userPublicSelect,
|
||||||
|
currentOtp: true,
|
||||||
|
otpExpiresAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user || !user.currentOtp || !user.otpExpiresAt) {
|
||||||
|
throw httpError(401, 'Invalid OTP');
|
||||||
|
}
|
||||||
|
if (user.otpExpiresAt.getTime() < Date.now()) {
|
||||||
|
throw httpError(401, 'OTP expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = Buffer.from(user.currentOtp);
|
||||||
|
const given = Buffer.from(otp);
|
||||||
|
const matches = stored.length === given.length && crypto.timingSafeEqual(stored, given);
|
||||||
|
if (!matches) {
|
||||||
|
throw httpError(401, 'Invalid OTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { currentOtp: null, otpExpiresAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: '10y',
|
||||||
|
});
|
||||||
|
const dealers = await accessibleDealers(user);
|
||||||
|
return res.json({ token, user: publicUser(user, dealers) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/me',
|
||||||
|
requireAuth,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const dealers = await accessibleDealers(req.user);
|
||||||
|
return res.json(publicUser(req.user, dealers));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../db');
|
||||||
|
const { asyncHandler } = require('../lib/errors');
|
||||||
|
const { assignedDealerIds } = require('../lib/access');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireAuth,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
let dealers;
|
||||||
|
if (req.user.role === 'superAdmin') {
|
||||||
|
dealers = await prisma.dealer.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
} else if (req.user.role === 'dealer') {
|
||||||
|
dealers = req.user.dealerId
|
||||||
|
? await prisma.dealer.findMany({ where: { id: req.user.dealerId } })
|
||||||
|
: [];
|
||||||
|
} else {
|
||||||
|
const ids = assignedDealerIds(req.user);
|
||||||
|
dealers = ids.length
|
||||||
|
? await prisma.dealer.findMany({ where: { id: { in: ids } }, orderBy: { name: 'asc' } })
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: dealers.map((dealer) => ({
|
||||||
|
id: dealer.id,
|
||||||
|
name: dealer.name,
|
||||||
|
code: dealer.code,
|
||||||
|
district: dealer.district,
|
||||||
|
address: dealer.address,
|
||||||
|
phone: dealer.phone,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../db');
|
||||||
|
const { asyncHandler, httpError } = require('../lib/errors');
|
||||||
|
const { canAccessDealer, eventScopeWhere } = require('../lib/access');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const PLAN_TYPES = ['general', 'monthlySpecial'];
|
||||||
|
|
||||||
|
function serializeEvent(event) {
|
||||||
|
let imageUrls = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.imageUrls || '[]');
|
||||||
|
imageUrls = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
imageUrls = [];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
dealerId: event.dealerId,
|
||||||
|
dealerName: event.dealer ? event.dealer.name : null,
|
||||||
|
planType: event.planType,
|
||||||
|
month: event.month,
|
||||||
|
year: event.year,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
expense: event.expense,
|
||||||
|
imageUrls,
|
||||||
|
createdById: event.createdById,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const where = eventScopeWhere(req.user);
|
||||||
|
const dealerId = typeof req.query.dealerId === 'string' ? req.query.dealerId.trim() : '';
|
||||||
|
const planType = typeof req.query.planType === 'string' ? req.query.planType.trim() : '';
|
||||||
|
|
||||||
|
if (dealerId) {
|
||||||
|
if (!canAccessDealer(req.user, dealerId)) throw httpError(403, 'Forbidden');
|
||||||
|
where.dealerId = dealerId;
|
||||||
|
}
|
||||||
|
if (planType) {
|
||||||
|
if (!PLAN_TYPES.includes(planType)) throw httpError(400, 'Invalid planType');
|
||||||
|
where.planType = planType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where,
|
||||||
|
include: { dealer: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data: events.map(serializeEvent) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const title = String(req.body?.title || '').trim();
|
||||||
|
const description = String(req.body?.description || '').trim();
|
||||||
|
const planType = String(req.body?.planType || '').trim();
|
||||||
|
const dealerId = String(req.body?.dealerId || req.user.dealerId || '').trim();
|
||||||
|
const expense = Number(req.body?.expense);
|
||||||
|
const imageUrls = Array.isArray(req.body?.imageUrls) ? req.body.imageUrls : [];
|
||||||
|
|
||||||
|
if (!title || !description || !dealerId) {
|
||||||
|
throw httpError(400, 'Title, description, and dealer are required');
|
||||||
|
}
|
||||||
|
if (!PLAN_TYPES.includes(planType)) throw httpError(400, 'Invalid planType');
|
||||||
|
if (!Number.isFinite(expense) || expense < 0) throw httpError(400, 'Expense must be a number');
|
||||||
|
if (!imageUrls.every((url) => typeof url === 'string' && url.trim())) {
|
||||||
|
throw httpError(400, 'imageUrls must be a list of URL strings');
|
||||||
|
}
|
||||||
|
if (!canAccessDealer(req.user, dealerId)) throw httpError(403, 'Forbidden');
|
||||||
|
|
||||||
|
let month = null;
|
||||||
|
let year = null;
|
||||||
|
if (planType === 'monthlySpecial') {
|
||||||
|
month = Number(req.body?.month);
|
||||||
|
year = Number(req.body?.year);
|
||||||
|
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
||||||
|
throw httpError(400, 'month is required for a monthly plan');
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
|
||||||
|
throw httpError(400, 'year is required for a monthly plan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dealer = await prisma.dealer.findUnique({ where: { id: dealerId } });
|
||||||
|
if (!dealer) throw httpError(404, 'Dealer not found');
|
||||||
|
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
dealerId,
|
||||||
|
planType,
|
||||||
|
month,
|
||||||
|
year,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
expense,
|
||||||
|
imageUrls: JSON.stringify(imageUrls.map((url) => url.trim())),
|
||||||
|
createdById: req.user.id,
|
||||||
|
},
|
||||||
|
include: { dealer: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ success: true, data: serializeEvent(event) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../db');
|
||||||
|
const { asyncHandler, httpError } = require('../lib/errors');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function serializePoint(point) {
|
||||||
|
return {
|
||||||
|
id: point.id,
|
||||||
|
name: point.name,
|
||||||
|
district: point.district,
|
||||||
|
address: point.address,
|
||||||
|
phone: point.phone,
|
||||||
|
notes: point.notes,
|
||||||
|
createdAt: point.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const district = typeof req.query.district === 'string' ? req.query.district.trim() : '';
|
||||||
|
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
|
||||||
|
const where = {};
|
||||||
|
if (district) where.district = district;
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: search } },
|
||||||
|
{ district: { contains: search } },
|
||||||
|
{ address: { contains: search } },
|
||||||
|
{ phone: { contains: search } },
|
||||||
|
{ notes: { contains: search } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = await prisma.servicePoint.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data: points.map(serializePoint) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
requireRole('salesRep', 'superAdmin'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const name = String(req.body?.name || '').trim();
|
||||||
|
const district = String(req.body?.district || '').trim();
|
||||||
|
const address = String(req.body?.address || '').trim() || null;
|
||||||
|
const phone = String(req.body?.phone || '').trim() || null;
|
||||||
|
const notes = String(req.body?.notes || '').trim() || null;
|
||||||
|
if (!name || !district) throw httpError(400, 'Name and district are required');
|
||||||
|
|
||||||
|
const point = await prisma.servicePoint.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
district,
|
||||||
|
address,
|
||||||
|
phone,
|
||||||
|
notes,
|
||||||
|
createdById: req.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.status(201).json({ success: true, data: serializePoint(point) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../db');
|
||||||
|
const { asyncHandler, httpError } = require('../lib/errors');
|
||||||
|
const { canAccessDealer, dealerScopeWhere } = require('../lib/access');
|
||||||
|
const {
|
||||||
|
MODELS,
|
||||||
|
PAYMENT_TYPES,
|
||||||
|
LEASE_PROVIDERS,
|
||||||
|
nextStage,
|
||||||
|
serializeStock,
|
||||||
|
} = require('../lib/stockRules');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const stockInclude = {
|
||||||
|
currentDealer: true,
|
||||||
|
stageLogs: { orderBy: { loggedAt: 'asc' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const where = dealerScopeWhere(req.user);
|
||||||
|
const dealerId = typeof req.query.dealerId === 'string' ? req.query.dealerId.trim() : '';
|
||||||
|
if (dealerId) {
|
||||||
|
if (!canAccessDealer(req.user, dealerId)) {
|
||||||
|
throw httpError(403, 'Forbidden');
|
||||||
|
}
|
||||||
|
where.currentDealerId = dealerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stocks = await prisma.stock.findMany({
|
||||||
|
where,
|
||||||
|
include: stockInclude,
|
||||||
|
orderBy: { dateReceived: 'asc' },
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data: stocks.map(serializeStock) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
requireRole('salesRep', 'superAdmin'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const chassisNumber = String(req.body?.chassisNumber || '').trim();
|
||||||
|
const engineNumber = String(req.body?.engineNumber || '').trim();
|
||||||
|
const model = String(req.body?.model || '').trim();
|
||||||
|
const color = String(req.body?.color || '').trim();
|
||||||
|
const currentDealerId = String(req.body?.currentDealerId || '').trim();
|
||||||
|
const dateReceived = req.body?.dateReceived ? new Date(req.body.dateReceived) : new Date();
|
||||||
|
|
||||||
|
if (!chassisNumber || !engineNumber || !color || !currentDealerId) {
|
||||||
|
throw httpError(400, 'Chassis, engine, color, and dealer are required');
|
||||||
|
}
|
||||||
|
if (!MODELS.includes(model)) {
|
||||||
|
throw httpError(400, 'Invalid model');
|
||||||
|
}
|
||||||
|
if (Number.isNaN(dateReceived.getTime())) {
|
||||||
|
throw httpError(400, 'Invalid received date');
|
||||||
|
}
|
||||||
|
if (!canAccessDealer(req.user, currentDealerId)) {
|
||||||
|
throw httpError(403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dealer = await prisma.dealer.findUnique({ where: { id: currentDealerId } });
|
||||||
|
if (!dealer) throw httpError(404, 'Dealer not found');
|
||||||
|
|
||||||
|
const stock = await prisma.stock.create({
|
||||||
|
data: {
|
||||||
|
chassisNumber,
|
||||||
|
engineNumber,
|
||||||
|
model,
|
||||||
|
color,
|
||||||
|
currentDealerId,
|
||||||
|
dateReceived,
|
||||||
|
currentStage: 'received',
|
||||||
|
stageLogs: {
|
||||||
|
create: {
|
||||||
|
stage: 'received',
|
||||||
|
loggedAt: dateReceived,
|
||||||
|
actorId: req.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: stockInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ success: true, data: serializeStock(stock) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/:chassisNumber/stage',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const chassisNumber = req.params.chassisNumber;
|
||||||
|
const stock = await prisma.stock.findUnique({
|
||||||
|
where: { chassisNumber },
|
||||||
|
include: stockInclude,
|
||||||
|
});
|
||||||
|
if (!stock) throw httpError(404, 'Stock not found');
|
||||||
|
if (!canAccessDealer(req.user, stock.currentDealerId)) {
|
||||||
|
throw httpError(403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = nextStage(stock.currentStage);
|
||||||
|
const stage = String(req.body?.stage || '').trim();
|
||||||
|
if (!expected) throw httpError(400, 'Stock is already at the final stage');
|
||||||
|
if (stage !== expected) {
|
||||||
|
throw httpError(400, `Next stage must be ${expected}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { currentStage: stage };
|
||||||
|
if (stage === 'paymentApproved') {
|
||||||
|
const paymentType = String(req.body?.paymentType || '').trim();
|
||||||
|
if (!PAYMENT_TYPES.includes(paymentType)) {
|
||||||
|
throw httpError(400, 'paymentType must be cash or lease');
|
||||||
|
}
|
||||||
|
data.paymentType = paymentType;
|
||||||
|
if (paymentType === 'lease') {
|
||||||
|
const leaseProviderType = String(req.body?.leaseProviderType || '').trim();
|
||||||
|
if (!LEASE_PROVIDERS.includes(leaseProviderType)) {
|
||||||
|
throw httpError(400, 'leaseProviderType must be gci or thirdPartyFinance');
|
||||||
|
}
|
||||||
|
data.leaseProviderType = leaseProviderType;
|
||||||
|
if (leaseProviderType === 'thirdPartyFinance') {
|
||||||
|
const financeCompanyName = String(req.body?.financeCompanyName || '').trim();
|
||||||
|
if (!financeCompanyName) throw httpError(400, 'financeCompanyName is required');
|
||||||
|
data.financeCompanyName = financeCompanyName;
|
||||||
|
} else {
|
||||||
|
data.financeCompanyName = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.leaseProviderType = null;
|
||||||
|
data.financeCompanyName = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.stock.update({
|
||||||
|
where: { id: stock.id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
stageLogs: {
|
||||||
|
create: { stage, actorId: req.user.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: stockInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true, data: serializeStock(updated) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:chassisNumber/transfer',
|
||||||
|
requireRole('salesRep', 'superAdmin'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const chassisNumber = req.params.chassisNumber;
|
||||||
|
const toDealerId = String(req.body?.dealerId || '').trim();
|
||||||
|
if (!toDealerId) throw httpError(400, 'dealerId is required');
|
||||||
|
|
||||||
|
const stock = await prisma.stock.findUnique({ where: { chassisNumber } });
|
||||||
|
if (!stock) throw httpError(404, 'Stock not found');
|
||||||
|
if (stock.currentDealerId === toDealerId) {
|
||||||
|
throw httpError(400, 'Stock is already at that dealer');
|
||||||
|
}
|
||||||
|
if (!canAccessDealer(req.user, stock.currentDealerId) || !canAccessDealer(req.user, toDealerId)) {
|
||||||
|
throw httpError(403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.dealer.findUnique({ where: { id: toDealerId } });
|
||||||
|
if (!target) throw httpError(404, 'Dealer not found');
|
||||||
|
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.stockTransfer.create({
|
||||||
|
data: {
|
||||||
|
stockId: stock.id,
|
||||||
|
fromDealerId: stock.currentDealerId,
|
||||||
|
toDealerId,
|
||||||
|
actorId: req.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return tx.stock.update({
|
||||||
|
where: { id: stock.id },
|
||||||
|
data: { currentDealerId: toDealerId },
|
||||||
|
include: stockInclude,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true, data: serializeStock(updated) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const sendSmsOtp = async (phoneNumber, otpCode) => {
|
||||||
|
if (!process.env.TEXTLK_API_TOKEN) {
|
||||||
|
console.log(`DEV OTP for ${phoneNumber}: ${otpCode}`);
|
||||||
|
return { dev: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
'https://app.text.lk/api/v3/sms/send',
|
||||||
|
{
|
||||||
|
recipient: phoneNumber,
|
||||||
|
sender_id: process.env.TEXTLK_SENDER_ID || 'TextLKDemo',
|
||||||
|
type: 'plain',
|
||||||
|
message: `Your login OTP code is: ${otpCode}. Valid for 5 minutes.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.TEXTLK_API_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Text.lk SMS Error:', error.response?.data || error.message);
|
||||||
|
throw new Error('Failed to send OTP SMS');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { sendSmsOtp };
|
||||||
Reference in New Issue
Block a user