diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 4bb4ab0..56a4a7d 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -61,6 +61,9 @@ jobs: - name: Setup test database run: npx prisma db push + - name: Seed database + run: npx ts-node prisma/seed.ts + - name: Test run: npm run test:coverage -- --runInBand env: @@ -73,4 +76,4 @@ jobs: AUTH_DEV_DEVELOPER_TOKEN: dev-developer-token - name: Build - run: npm run build \ No newline at end of file + run: npm run build diff --git a/src/modules/dashboard/dashboard.controller.ts b/src/modules/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..4d8d663 --- /dev/null +++ b/src/modules/dashboard/dashboard.controller.ts @@ -0,0 +1,81 @@ +// Dashboard Controller + +import { Request, Response } from "express"; +import * as DashboardService from "./dashboard.service"; +import { User } from "../../types"; + +function isUser(obj: unknown): obj is User { + return typeof obj === "object" && obj !== null && "role" in obj; +} + +/** + * @swagger + * /dashboard/totals: + * get: + * summary: Get dashboard totals + * tags: + * - Dashboard + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Totals by role + */ +export const getTotals = async (req: Request, res: Response): Promise => { + if (!isUser(req.user)) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + const result = await DashboardService.getTotals(req.user); + res.json(result); +}; + +/** + * @swagger + * /dashboard/tree-counts: + * get: + * summary: Get dashboard tree counts + * tags: + * - Dashboard + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Tree counts by role + */ +export const getTreeCounts = async ( + req: Request, + res: Response, +): Promise => { + if (!isUser(req.user)) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + const result = await DashboardService.getTreeCounts(req.user); + res.json(result); +}; + +/** + * @swagger + * /dashboard/scan-stats: + * get: + * summary: Get dashboard scan stats + * tags: + * - Dashboard + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Scan stats by role + */ +export const getScanStats = async ( + req: Request, + res: Response, +): Promise => { + if (!isUser(req.user)) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + const result = await DashboardService.getScanStats(req.user); + res.json(result); +}; diff --git a/src/modules/dashboard/dashboard.routes.ts b/src/modules/dashboard/dashboard.routes.ts new file mode 100644 index 0000000..5aff2a5 --- /dev/null +++ b/src/modules/dashboard/dashboard.routes.ts @@ -0,0 +1,25 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { getTotals, getTreeCounts, getScanStats } from "./dashboard.controller"; +import { authMiddleware } from "../../middleware/auth.middleware"; + +const router = Router(); + +type AsyncRouteHandler = ( + req: Request, + res: Response, + next: NextFunction, +) => Promise; + +function asyncHandler( + fn: AsyncRouteHandler, +): (req: Request, res: Response, next: NextFunction) => void { + return (req, res, next) => { + void fn(req, res, next).catch(next); + }; +} + +router.get("/totals", authMiddleware, asyncHandler(getTotals)); +router.get("/tree-counts", authMiddleware, asyncHandler(getTreeCounts)); +router.get("/scan-stats", authMiddleware, asyncHandler(getScanStats)); + +export default router; diff --git a/src/modules/dashboard/dashboard.service.ts b/src/modules/dashboard/dashboard.service.ts new file mode 100644 index 0000000..4e4e60b --- /dev/null +++ b/src/modules/dashboard/dashboard.service.ts @@ -0,0 +1,135 @@ +// Dashboard Service + +import { User, UserRole } from "../../types"; +import { prisma } from "../../lib/prisma"; + +export async function getTotals(user: User) { + // Example: Admin sees all, others see limited + let totalUsers = 0; + let totalProjects = 0; + let totalTrees = 0; + let totalPartners = 0; + + if (user.role === UserRole.Admin) { + totalUsers = await prisma.user.count(); + totalProjects = await prisma.project.count(); + totalTrees = await prisma.treeScan.count(); + totalPartners = await prisma.partner.count(); + } else { + // For non-admin, return only their projects/trees (customize as needed) + totalUsers = 1; + totalProjects = await prisma.userProject.count({ + where: { userId: user.id }, + }); + totalTrees = await prisma.treeScan.count({ where: { farmerId: user.id } }); + totalPartners = 0; + } + + return { + totalUsers, + totalProjects, + totalTrees, + totalPartners, + role: user.role, + }; +} + +export async function getTreeCounts(user: User) { + // For admin: count trees by species + if (user.role === UserRole.Admin) { + const speciesCounts = await prisma.treeType.findMany({ + select: { + name: true, + _count: { + select: { treeScans: true }, + }, + }, + }); + const species = speciesCounts.map( + (s: { name: string; _count: { treeScans: number } }) => ({ + name: s.name, + count: s._count.treeScans, + }), + ); + const total = species.reduce( + (sum: number, s: { name: string; count: number }) => sum + s.count, + 0, + ); + return { species, total, role: user.role }; + } else { + // For non-admin, only their trees + const userTrees = await prisma.treeScan.findMany({ + where: { farmerId: user.id }, + select: { species: { select: { name: true } } }, + }); + const counts: Record = {}; + userTrees.forEach((t: { species?: { name?: string } }) => { + const name = t.species?.name || "Unknown"; + counts[name] = (counts[name] || 0) + 1; + }); + const species = Object.entries(counts).map( + ([name, count]: [string, number]) => ({ name, count }), + ); + const total = userTrees.length; + return { species, total, role: user.role }; + } +} + +export async function getScanStats(user: User) { + // For admin: all scan stats + if (user.role === UserRole.Admin) { + const totalScans = await prisma.treeScan.count(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const scansToday = await prisma.treeScan.count({ + where: { createdAt: { gte: today } }, + }); + // Example: status field not in schema, so use isValid/isArchived as proxy + const pending = await prisma.treeScan.count({ where: { isValid: false } }); + const approved = await prisma.treeScan.count({ + where: { isValid: true, isArchived: false }, + }); + const rejected = await prisma.treeScan.count({ + where: { isArchived: true }, + }); + return { + totalScans, + scansToday, + scansByStatus: { + pending, + approved, + rejected, + }, + role: user.role, + }; + } else { + // For non-admin, only their scans + const totalScans = await prisma.treeScan.count({ + where: { farmerId: user.id }, + }); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const scansToday = await prisma.treeScan.count({ + where: { farmerId: user.id, createdAt: { gte: today } }, + }); + const pending = await prisma.treeScan.count({ + where: { farmerId: user.id, isValid: false }, + }); + const approved = await prisma.treeScan.count({ + where: { farmerId: user.id, isValid: true, isArchived: false }, + }); + const rejected = await prisma.treeScan.count({ + where: { farmerId: user.id, isArchived: true }, + }); + return { + totalScans, + scansToday, + scansByStatus: { + pending, + approved, + rejected, + }, + role: user.role, + }; + } +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 3ad61b6..f0cd205 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -11,6 +11,7 @@ import { userProjectAssignmentRoutes } from "../modules/user-project-assignment" import { partnersRoutes } from "../modules/partners"; import treeScansRoutes from "../modules/tree-scans"; +import dashboardRoutes from "../modules/dashboard/dashboard.routes"; //dashboard const router = Router(); @@ -27,4 +28,6 @@ router.use("/partners", partnersRoutes); router.use("/tree-scans", treeScansRoutes); +router.use("/dashboard", dashboardRoutes); + export default router; diff --git a/tests/integration/dashboard-widgets.test.ts b/tests/integration/dashboard-widgets.test.ts index 73e2381..9370275 100644 --- a/tests/integration/dashboard-widgets.test.ts +++ b/tests/integration/dashboard-widgets.test.ts @@ -1,5 +1,47 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); +import request from "supertest"; +const API_URL = "http://localhost:3000"; +const ADMIN_TOKEN = "dev-admin-token"; + +describe("Dashboard Endpoints", () => { + it("GET /dashboard/totals should return totals by role", async () => { + const res = await request(API_URL) + .get("/dashboard/totals") + .set("Authorization", `Bearer ${ADMIN_TOKEN}`); + if (res.statusCode !== 200) { + console.log("Response for /dashboard/totals:", res.statusCode, res.body); + } + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty("role"); + }); + + it("GET /dashboard/tree-counts should return tree counts by role", async () => { + const res = await request(API_URL) + .get("/dashboard/tree-counts") + .set("Authorization", `Bearer ${ADMIN_TOKEN}`); + if (res.statusCode !== 200) { + console.log( + "Response for /dashboard/tree-counts:", + res.statusCode, + res.body, + ); + } + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty("role"); + }); + + it("GET /dashboard/scan-stats should return scan stats by role", async () => { + const res = await request(API_URL) + .get("/dashboard/scan-stats") + .set("Authorization", `Bearer ${ADMIN_TOKEN}`); + if (res.statusCode !== 200) { + console.log( + "Response for /dashboard/scan-stats:", + res.statusCode, + res.body, + ); + } + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty("role"); }); }); + diff --git a/tests/unit/dashboard-widgets.test.ts b/tests/unit/dashboard-widgets.test.ts index 73e2381..bb63712 100644 --- a/tests/unit/dashboard-widgets.test.ts +++ b/tests/unit/dashboard-widgets.test.ts @@ -1,5 +1,35 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); +import * as DashboardService from "../../src/modules/dashboard/dashboard.service"; +import { User, UserRole } from "../../src/types"; + +describe("Dashboard Service", () => { + const mockUser: User = { + id: 1, + name: "Test Admin", + email: "admin@example.com", + role: UserRole.Admin, + card_id: null, + account_active: true, + can_sign_in: true, + preferred_language: null, + country_id: null, + admin_location_id: null, + created_at: new Date(), + updated_at: new Date(), + }; + + it("getTotals returns correct structure", async () => { + const result = await DashboardService.getTotals(mockUser); + expect(result).toHaveProperty("role", UserRole.Admin); + }); + + it("getTreeCounts returns correct structure", async () => { + const result = await DashboardService.getTreeCounts(mockUser); + expect(result).toHaveProperty("role", UserRole.Admin); + }); + + it("getScanStats returns correct structure", async () => { + const result = await DashboardService.getScanStats(mockUser); + expect(result).toHaveProperty("role", UserRole.Admin); }); }); +