This commit is contained in:
2026-10-01 11:52:17 +05:30
commit d303c576fd
26 changed files with 3229 additions and 0 deletions
+7
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
data/*.db
data/*.db-journal
View File
+1599
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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");
+3
View File
@@ -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"
+128
View File
@@ -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
}
+35
View File
@@ -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();
});
+322
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
module.exports = prisma;
+6
View File
@@ -0,0 +1,6 @@
const app = require('./app');
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => {
console.log(`API listening on ${port}`);
});
+30
View File
@@ -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,
};
+13
View File
@@ -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 };
+14
View File
@@ -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 };
+69
View File
@@ -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,
};
+31
View File
@@ -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 };
+37
View File
@@ -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 };
+150
View File
@@ -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;
+123
View File
@@ -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;
+41
View File
@@ -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;
+116
View File
@@ -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;
+72
View File
@@ -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;
+195
View File
@@ -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;
+33
View File
@@ -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 };