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