diff --git a/prisma/migrations/20261010120000_parts/migration.sql b/prisma/migrations/20261010120000_parts/migration.sql new file mode 100644 index 0000000..f626e0e --- /dev/null +++ b/prisma/migrations/20261010120000_parts/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Part" ( + "id" TEXT NOT NULL PRIMARY KEY, + "partNumber" TEXT NOT NULL, + "product" TEXT NOT NULL, + "price" REAL NOT NULL, + "uom" TEXT NOT NULL, + "model" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Part_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Part_partNumber_key" ON "Part"("partNumber"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b947409..0dab78a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model User { transfers StockTransfer[] events Event[] servicePoints ServicePoint[] + parts Part[] loginRequests LoginRequest[] @relation("DealerLoginRequests") loginDecisions LoginRequest[] @relation("LoginDecisions") } @@ -141,3 +142,16 @@ model ServicePoint { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model Part { + id String @id @default(cuid()) + partNumber String @unique + product String + price Float + uom String + model String + createdById String + createdBy User @relation(fields: [createdById], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/scripts/api-check.js b/scripts/api-check.js index 268a3e1..3111cc4 100644 --- a/scripts/api-check.js +++ b/scripts/api-check.js @@ -476,6 +476,58 @@ async function run(port) { 'service point search failed' ); + const partForbidden = await request(port, 'POST', '/api/parts', { + token: dealerToken, + body: { + partNumber: 'CG125-B010', + product: 'CLUTCH HOUSING - CG125', + price: 9800, + uom: 'PCS', + model: 'CG - 125', + }, + }); + assert(partForbidden.status === 403, 'dealer should not add parts'); + + const partMissing = await request(port, 'POST', '/api/parts', { + token: repToken, + body: { partNumber: 'CG125-B010', product: 'CLUTCH HOUSING - CG125' }, + }); + assert(partMissing.status === 400, 'part create should require every field'); + + const part = await request(port, 'POST', '/api/parts', { + token: repToken, + body: { + partNumber: 'CG125-B010', + product: 'CLUTCH HOUSING - CG125', + price: 9800, + uom: 'PCS', + model: 'CG - 125', + }, + }); + assert(part.status === 201, 'part create failed'); + + const duplicatePart = await request(port, 'POST', '/api/parts', { + token: repToken, + body: { + partNumber: 'CG125-B010', + product: 'CLUTCH HOUSING - CG125', + price: 9800, + uom: 'PCS', + model: 'CG - 125', + }, + }); + assert(duplicatePart.status === 409, 'duplicate part number should be rejected'); + + const foundPart = await request(port, 'GET', '/api/parts?model=CG%20-%20125&search=CLUTCH', { + token: dealerToken, + }); + assert( + foundPart.status === 200 && + foundPart.json.data.some((item) => item.partNumber === 'CG125-B010' && item.price === 9800) && + foundPart.json.models.includes('CG - 125'), + 'part 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 }, diff --git a/src/app.js b/src/app.js index 93f0002..1febb3d 100644 --- a/src/app.js +++ b/src/app.js @@ -8,6 +8,7 @@ const dealerRoutes = require('./routes/dealers'); const stockRoutes = require('./routes/stocks'); const eventRoutes = require('./routes/events'); const servicePointRoutes = require('./routes/servicePoints'); +const partRoutes = require('./routes/parts'); const adminRoutes = require('./routes/admin'); const app = express(); @@ -24,6 +25,7 @@ app.use('/api/dealers', dealerRoutes); app.use('/api/stocks', stockRoutes); app.use('/api/events', eventRoutes); app.use('/api/service-points', servicePointRoutes); +app.use('/api/parts', partRoutes); app.use('/api/admin', adminRoutes); app.use((req, res) => { diff --git a/src/routes/parts.js b/src/routes/parts.js new file mode 100644 index 0000000..c314ce8 --- /dev/null +++ b/src/routes/parts.js @@ -0,0 +1,88 @@ +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 serializePart(part) { + return { + id: part.id, + partNumber: part.partNumber, + product: part.product, + price: part.price, + uom: part.uom, + model: part.model, + createdAt: part.createdAt, + }; +} + +function requiredText(value) { + return String(value || '').trim(); +} + +router.use(requireAuth); + +router.get( + '/', + asyncHandler(async (req, res) => { + const model = typeof req.query.model === 'string' ? req.query.model.trim() : ''; + const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; + const where = {}; + if (model) where.model = model; + if (search) { + where.OR = [{ partNumber: { contains: search } }, { product: { contains: search } }]; + } + + const [parts, modelRows] = await Promise.all([ + prisma.part.findMany({ + where, + orderBy: { partNumber: 'asc' }, + }), + prisma.part.findMany({ + distinct: ['model'], + select: { model: true }, + orderBy: { model: 'asc' }, + }), + ]); + + return res.json({ + success: true, + data: parts.map(serializePart), + models: modelRows.map((row) => row.model), + }); + }) +); + +router.post( + '/', + requireRole('salesRep', 'superAdmin'), + asyncHandler(async (req, res) => { + const partNumber = requiredText(req.body?.partNumber); + const product = requiredText(req.body?.product); + const uom = requiredText(req.body?.uom); + const model = requiredText(req.body?.model); + const rawPrice = req.body?.price; + const price = typeof rawPrice === 'number' ? rawPrice : Number(requiredText(rawPrice)); + if (!partNumber || !product || !uom || !model || rawPrice === '' || rawPrice == null) { + throw httpError(400, 'Part number, product, price, UOM, and model are required'); + } + if (!Number.isFinite(price) || price < 0) { + throw httpError(400, 'Price must be a non-negative number'); + } + + const part = await prisma.part.create({ + data: { + partNumber, + product, + price, + uom, + model, + createdById: req.user.id, + }, + }); + return res.status(201).json({ success: true, data: serializePart(part) }); + }) +); + +module.exports = router;