feat: build TeaBoy OS — bilingual Arabic/English internal OS platform

Completed full-stack TeaBoy OS build:

Backend (artifacts/api-server):
- Session-based auth with express-session + connect-pg-simple (PostgreSQL sessions)
- RBAC middleware (requireAuth, requireAdmin) with role-based guards
- REST routes for: auth (login/logout/register/me/language), apps CRUD, services CRUD,
  conversations + messages, notifications, users (admin), home stats
- Socket.IO mounted on same HTTP server at /api/socket.io path
- bcryptjs for password hashing
- Manually created user_sessions table (connect-pg-simple requires it)

Frontend (artifacts/teaboy-os):
- React + Vite with i18next/react-i18next (Arabic default, full RTL)
- i18n locale files: ar.json + en.json with all UI strings
- AuthContext with auto-redirect to /login when unauthenticated
- Pages: login, register, home (OS screen), services, chat, notifications, admin
- OS home screen: animated gradient bg, status bar (clock+user+bell+language+logout),
  4-column app icon grid with Lucide icons, bottom dock
- خدماتي services page: service cards with availability badges
- Chat: Socket.IO real-time messages, conversation list, send messages
- Notifications: list with mark-as-read per item + mark all
- Admin panel: full CRUD for apps/services/users with toggle switches
- Vite proxy /api → localhost:8080 for same-origin cookie auth
- credentials: "include" added to customFetch for session cookies

Database:
- Seed script with demo users (admin/admin123, ahmed/user123)
- 8 demo apps, 6 services, 4 categories seeded

All e2e tests pass: login, home screen, services, language toggle, admin, logout
This commit is contained in:
riyadhafraa
2026-04-20 09:20:50 +00:00
parent 593ce6a418
commit 001e9023b2
115 changed files with 14720 additions and 79 deletions
+8 -1
View File
@@ -12,17 +12,24 @@
"dependencies": {
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"bcryptjs": "^3.0.3",
"connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2",
"drizzle-orm": "catalog:",
"express": "^5",
"express-session": "^1.19.0",
"pino": "^9",
"pino-http": "^10"
"pino-http": "^10",
"socket.io": "^4.8.3"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "catalog:",
"esbuild": "^0.27.3",
"esbuild-plugin-pino": "^2.3.3",
+33 -1
View File
@@ -1,11 +1,18 @@
import express, { type Express } from "express";
import cors from "cors";
import pinoHttp from "pino-http";
import session from "express-session";
import connectPgSimple from "connect-pg-simple";
import router from "./routes";
import { logger } from "./lib/logger";
import { pool } from "@workspace/db";
const PgSession = connectPgSimple(session);
const app: Express = express();
app.set("trust proxy", 1);
app.use(
pinoHttp({
logger,
@@ -25,10 +32,35 @@ app.use(
},
}),
);
app.use(cors());
app.use(
cors({
origin: true,
credentials: true,
}),
);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(
session({
store: new PgSession({
pool,
tableName: "user_sessions",
}),
secret: process.env.SESSION_SECRET ?? "teaboy-os-secret-key-change-in-prod",
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000,
sameSite: "lax",
},
}),
);
app.use("/api", router);
export default app;
+43 -5
View File
@@ -1,5 +1,14 @@
import { createServer } from "http";
import { Server as SocketIOServer } from "socket.io";
import app from "./app";
import { logger } from "./lib/logger";
import { db } from "@workspace/db";
import {
messagesTable,
conversationParticipantsTable,
messageReadsTable,
} from "@workspace/db";
import { eq, and } from "drizzle-orm";
const rawPort = process.env["PORT"];
@@ -15,11 +24,40 @@ if (Number.isNaN(port) || port <= 0) {
throw new Error(`Invalid PORT value: "${rawPort}"`);
}
app.listen(port, (err) => {
if (err) {
logger.error({ err }, "Error listening on port");
process.exit(1);
}
const httpServer = createServer(app);
export const io = new SocketIOServer(httpServer, {
path: "/api/socket.io",
cors: {
origin: "*",
methods: ["GET", "POST"],
},
});
io.on("connection", (socket) => {
logger.info({ socketId: socket.id }, "Socket connected");
socket.on("join", (userId: number) => {
socket.join(`user:${userId}`);
logger.info({ socketId: socket.id, userId }, "User joined room");
});
socket.on(
"join_conversation",
(conversationId: number) => {
socket.join(`conversation:${conversationId}`);
},
);
socket.on("leave_conversation", (conversationId: number) => {
socket.leave(`conversation:${conversationId}`);
});
socket.on("disconnect", () => {
logger.info({ socketId: socket.id }, "Socket disconnected");
});
});
httpServer.listen(port, () => {
logger.info({ port }, "Server listening");
});
@@ -0,0 +1,72 @@
import { type Request, type Response, type NextFunction } from "express";
import { db } from "@workspace/db";
import {
usersTable,
userRolesTable,
rolesTable,
} from "@workspace/db";
import { eq } from "drizzle-orm";
declare module "express-session" {
interface SessionData {
userId: number;
}
}
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}
const [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, req.session.userId));
if (!user || !user.isActive) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
}
export async function requireAdmin(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}
const userRoles = await db
.select({ roleName: rolesTable.name })
.from(userRolesTable)
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
.where(eq(userRolesTable.userId, req.session.userId));
const isAdmin = userRoles.some((r) => r.roleName === "admin");
if (!isAdmin) {
res.status(403).json({ error: "Forbidden" });
return;
}
next();
}
export async function getUserRoles(userId: number): Promise<string[]> {
const roles = await db
.select({ roleName: rolesTable.name })
.from(userRolesTable)
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
.where(eq(userRolesTable.userId, userId));
return roles.map((r) => r.roleName);
}
+103
View File
@@ -0,0 +1,103 @@
import { Router, type IRouter } from "express";
import { eq, asc } from "drizzle-orm";
import { db } from "@workspace/db";
import { appsTable } from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
import {
CreateAppBody,
UpdateAppBody,
GetAppParams,
UpdateAppParams,
DeleteAppParams,
} from "@workspace/api-zod";
const router: IRouter = Router();
router.get("/apps", requireAuth, async (_req, res): Promise<void> => {
const apps = await db
.select()
.from(appsTable)
.where(eq(appsTable.isActive, true))
.orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn));
res.json(apps);
});
router.post("/apps", requireAdmin, async (req, res): Promise<void> => {
const parsed = CreateAppBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [app] = await db.insert(appsTable).values(parsed.data).returning();
res.status(201).json(app);
});
router.get("/apps/:id", requireAuth, async (req, res): Promise<void> => {
const params = GetAppParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [app] = await db
.select()
.from(appsTable)
.where(eq(appsTable.id, params.data.id));
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
res.json(app);
});
router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
const params = UpdateAppParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const parsed = UpdateAppBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [app] = await db
.update(appsTable)
.set(parsed.data)
.where(eq(appsTable.id, params.data.id))
.returning();
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
res.json(app);
});
router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
const params = DeleteAppParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [app] = await db
.delete(appsTable)
.where(eq(appsTable.id, params.data.id))
.returning();
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
res.sendStatus(204);
});
export default router;
+146
View File
@@ -0,0 +1,146 @@
import { Router, type IRouter } from "express";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { db } from "@workspace/db";
import {
usersTable,
userRolesTable,
rolesTable,
} from "@workspace/db";
import { requireAuth, getUserRoles } from "../middlewares/auth";
import { RegisterBody, LoginBody, UpdateLanguageBody } from "@workspace/api-zod";
const router: IRouter = Router();
function buildAuthUser(user: typeof usersTable.$inferSelect, roles: string[]) {
return {
id: user.id,
username: user.username,
email: user.email,
displayNameAr: user.displayNameAr,
displayNameEn: user.displayNameEn,
preferredLanguage: user.preferredLanguage,
avatarUrl: user.avatarUrl,
isActive: user.isActive,
roles,
createdAt: user.createdAt,
};
}
router.post("/auth/register", async (req, res): Promise<void> => {
const parsed = RegisterBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { username, email, password, displayNameAr, displayNameEn, preferredLanguage } = parsed.data;
const existing = await db
.select()
.from(usersTable)
.where(eq(usersTable.username, username));
if (existing.length > 0) {
res.status(400).json({ error: "Username already taken" });
return;
}
const passwordHash = await bcrypt.hash(password, 10);
const [user] = await db
.insert(usersTable)
.values({
username,
email,
passwordHash,
displayNameAr: displayNameAr ?? null,
displayNameEn: displayNameEn ?? null,
preferredLanguage: preferredLanguage ?? "ar",
})
.returning();
const [userRole] = await db.select().from(rolesTable).where(eq(rolesTable.name, "user"));
if (userRole) {
await db.insert(userRolesTable).values({ userId: user.id, roleId: userRole.id });
}
req.session.userId = user.id;
const roles = await getUserRoles(user.id);
res.status(201).json(buildAuthUser(user, roles));
});
router.post("/auth/login", async (req, res): Promise<void> => {
const parsed = LoginBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { username, password } = parsed.data;
const [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.username, username));
if (!user) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
if (!user.isActive) {
res.status(401).json({ error: "Account is inactive" });
return;
}
req.session.userId = user.id;
const roles = await getUserRoles(user.id);
res.json(buildAuthUser(user, roles));
});
router.post("/auth/logout", (req, res): void => {
req.session.destroy(() => {
res.json({ success: true });
});
});
router.get("/auth/me", requireAuth, async (req, res): Promise<void> => {
const [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, req.session.userId!));
if (!user) {
res.status(401).json({ error: "User not found" });
return;
}
const roles = await getUserRoles(user.id);
res.json(buildAuthUser(user, roles));
});
router.patch("/auth/me/language", requireAuth, async (req, res): Promise<void> => {
const parsed = UpdateLanguageBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [user] = await db
.update(usersTable)
.set({ preferredLanguage: parsed.data.language })
.where(eq(usersTable.id, req.session.userId!))
.returning();
const roles = await getUserRoles(user.id);
res.json(buildAuthUser(user, roles));
});
export default router;
@@ -0,0 +1,268 @@
import { Router, type IRouter } from "express";
import { eq, and, desc, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
conversationsTable,
conversationParticipantsTable,
messagesTable,
messageReadsTable,
usersTable,
} from "@workspace/db";
import { requireAuth } from "../middlewares/auth";
import {
CreateConversationBody,
GetConversationParams,
ListMessagesParams,
SendMessageParams,
SendMessageBody,
MarkConversationReadParams,
} from "@workspace/api-zod";
const router: IRouter = Router();
async function buildConversationDetails(conversationId: number, currentUserId: number) {
const [conv] = await db
.select()
.from(conversationsTable)
.where(eq(conversationsTable.id, conversationId));
if (!conv) return null;
const participants = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
avatarUrl: usersTable.avatarUrl,
isAdmin: conversationParticipantsTable.isAdmin,
})
.from(conversationParticipantsTable)
.innerJoin(usersTable, eq(conversationParticipantsTable.userId, usersTable.id))
.where(eq(conversationParticipantsTable.conversationId, conversationId));
const lastMessages = await db
.select()
.from(messagesTable)
.where(eq(messagesTable.conversationId, conversationId))
.orderBy(desc(messagesTable.createdAt))
.limit(1);
let lastMessage = null;
if (lastMessages.length > 0) {
const msg = lastMessages[0];
const [sender] = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
avatarUrl: usersTable.avatarUrl,
isAdmin: sql<boolean>`false`,
})
.from(usersTable)
.where(eq(usersTable.id, msg.senderId));
lastMessage = { ...msg, sender };
}
const unreadResult = await db
.select({ count: sql<number>`count(*)` })
.from(messagesTable)
.where(
and(
eq(messagesTable.conversationId, conversationId),
sql`${messagesTable.id} NOT IN (
SELECT message_id FROM message_reads WHERE user_id = ${currentUserId}
)`,
sql`${messagesTable.senderId} != ${currentUserId}`,
),
);
const unreadCount = Number(unreadResult[0]?.count ?? 0);
return {
...conv,
participants,
lastMessage,
unreadCount,
};
}
router.get("/conversations", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const userConvs = await db
.select({ conversationId: conversationParticipantsTable.conversationId })
.from(conversationParticipantsTable)
.where(eq(conversationParticipantsTable.userId, userId));
const results = await Promise.all(
userConvs.map((c) => buildConversationDetails(c.conversationId, userId)),
);
res.json(results.filter(Boolean));
});
router.post("/conversations", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const parsed = CreateConversationBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { participantIds, nameAr, nameEn, isGroup } = parsed.data;
const allParticipants = [...new Set([userId, ...participantIds])];
const [conv] = await db
.insert(conversationsTable)
.values({
nameAr: nameAr ?? null,
nameEn: nameEn ?? null,
isGroup: isGroup ?? false,
createdBy: userId,
})
.returning();
await db.insert(conversationParticipantsTable).values(
allParticipants.map((pid) => ({
conversationId: conv.id,
userId: pid,
isAdmin: pid === userId,
})),
);
const details = await buildConversationDetails(conv.id, userId);
res.status(201).json(details);
});
router.get("/conversations/:id", requireAuth, async (req, res): Promise<void> => {
const params = GetConversationParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const userId = req.session.userId!;
const details = await buildConversationDetails(params.data.id, userId);
if (!details) {
res.status(404).json({ error: "Conversation not found" });
return;
}
res.json(details);
});
router.get("/conversations/:id/messages", requireAuth, async (req, res): Promise<void> => {
const params = ListMessagesParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const msgs = await db
.select()
.from(messagesTable)
.where(eq(messagesTable.conversationId, params.data.id))
.orderBy(desc(messagesTable.createdAt))
.limit(50);
const messagesWithSenders = await Promise.all(
msgs.reverse().map(async (msg) => {
const [sender] = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
avatarUrl: usersTable.avatarUrl,
isAdmin: sql<boolean>`false`,
})
.from(usersTable)
.where(eq(usersTable.id, msg.senderId));
return { ...msg, sender };
}),
);
res.json(messagesWithSenders);
});
router.post("/conversations/:id/messages", requireAuth, async (req, res): Promise<void> => {
const params = SendMessageParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const parsed = SendMessageBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const userId = req.session.userId!;
const [msg] = await db
.insert(messagesTable)
.values({
conversationId: params.data.id,
senderId: userId,
content: parsed.data.content,
})
.returning();
const [sender] = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
avatarUrl: usersTable.avatarUrl,
isAdmin: sql<boolean>`false`,
})
.from(usersTable)
.where(eq(usersTable.id, userId));
const fullMessage = { ...msg, sender };
const { io } = await import("../index.js");
io.to(`conversation:${params.data.id}`).emit("new_message", fullMessage);
res.status(201).json(fullMessage);
});
router.post("/conversations/:id/read", requireAuth, async (req, res): Promise<void> => {
const params = MarkConversationReadParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const userId = req.session.userId!;
const unreadMessages = await db
.select({ id: messagesTable.id })
.from(messagesTable)
.where(
and(
eq(messagesTable.conversationId, params.data.id),
sql`${messagesTable.id} NOT IN (
SELECT message_id FROM message_reads WHERE user_id = ${userId}
)`,
),
);
if (unreadMessages.length > 0) {
await db.insert(messageReadsTable).values(
unreadMessages.map((m) => ({ messageId: m.id, userId })),
).onConflictDoNothing();
}
res.json({ success: true });
});
export default router;
+14
View File
@@ -1,8 +1,22 @@
import { Router, type IRouter } from "express";
import healthRouter from "./health";
import authRouter from "./auth";
import appsRouter from "./apps";
import servicesRouter from "./services";
import conversationsRouter from "./conversations";
import notificationsRouter from "./notifications";
import usersRouter from "./users";
import statsRouter from "./stats";
const router: IRouter = Router();
router.use(healthRouter);
router.use(authRouter);
router.use(appsRouter);
router.use(servicesRouter);
router.use(conversationsRouter);
router.use(notificationsRouter);
router.use(usersRouter);
router.use(statsRouter);
export default router;
@@ -0,0 +1,63 @@
import { Router, type IRouter } from "express";
import { eq, and, desc } from "drizzle-orm";
import { db } from "@workspace/db";
import { notificationsTable } from "@workspace/db";
import { requireAuth } from "../middlewares/auth";
import {
MarkNotificationReadParams,
} from "@workspace/api-zod";
const router: IRouter = Router();
router.get("/notifications", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const notifications = await db
.select()
.from(notificationsTable)
.where(eq(notificationsTable.userId, userId))
.orderBy(desc(notificationsTable.createdAt))
.limit(50);
res.json(notifications);
});
router.patch("/notifications/:id/read", requireAuth, async (req, res): Promise<void> => {
const params = MarkNotificationReadParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const userId = req.session.userId!;
const [notification] = await db
.update(notificationsTable)
.set({ isRead: true })
.where(
and(
eq(notificationsTable.id, params.data.id),
eq(notificationsTable.userId, userId),
),
)
.returning();
if (!notification) {
res.status(404).json({ error: "Notification not found" });
return;
}
res.json(notification);
});
router.post("/notifications/read-all", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
await db
.update(notificationsTable)
.set({ isRead: true })
.where(eq(notificationsTable.userId, userId));
res.json({ success: true });
});
export default router;
+110
View File
@@ -0,0 +1,110 @@
import { Router, type IRouter } from "express";
import { eq, asc } from "drizzle-orm";
import { db } from "@workspace/db";
import { servicesTable, serviceCategoriesTable } from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
import {
CreateServiceBody,
UpdateServiceBody,
GetServiceParams,
UpdateServiceParams,
DeleteServiceParams,
} from "@workspace/api-zod";
const router: IRouter = Router();
router.get("/services", requireAuth, async (_req, res): Promise<void> => {
const services = await db
.select()
.from(servicesTable)
.orderBy(asc(servicesTable.sortOrder), asc(servicesTable.nameEn));
res.json(services);
});
router.post("/services", requireAdmin, async (req, res): Promise<void> => {
const parsed = CreateServiceBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [service] = await db.insert(servicesTable).values(parsed.data).returning();
res.status(201).json(service);
});
router.get("/service-categories", requireAuth, async (_req, res): Promise<void> => {
const categories = await db
.select()
.from(serviceCategoriesTable)
.orderBy(asc(serviceCategoriesTable.sortOrder));
res.json(categories);
});
router.get("/services/:id", requireAuth, async (req, res): Promise<void> => {
const params = GetServiceParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [service] = await db
.select()
.from(servicesTable)
.where(eq(servicesTable.id, params.data.id));
if (!service) {
res.status(404).json({ error: "Service not found" });
return;
}
res.json(service);
});
router.patch("/services/:id", requireAdmin, async (req, res): Promise<void> => {
const params = UpdateServiceParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const parsed = UpdateServiceBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [service] = await db
.update(servicesTable)
.set(parsed.data)
.where(eq(servicesTable.id, params.data.id))
.returning();
if (!service) {
res.status(404).json({ error: "Service not found" });
return;
}
res.json(service);
});
router.delete("/services/:id", requireAdmin, async (req, res): Promise<void> => {
const params = DeleteServiceParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [service] = await db
.delete(servicesTable)
.where(eq(servicesTable.id, params.data.id))
.returning();
if (!service) {
res.status(404).json({ error: "Service not found" });
return;
}
res.sendStatus(204);
});
export default router;
+63
View File
@@ -0,0 +1,63 @@
import { Router, type IRouter } from "express";
import { eq, and, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
appsTable,
servicesTable,
notificationsTable,
usersTable,
messagesTable,
messageReadsTable,
} from "@workspace/db";
import { requireAuth } from "../middlewares/auth";
const router: IRouter = Router();
router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const [appsCount] = await db
.select({ count: sql<number>`count(*)` })
.from(appsTable)
.where(eq(appsTable.isActive, true));
const [servicesCount] = await db
.select({ count: sql<number>`count(*)` })
.from(servicesTable);
const [unreadNotifCount] = await db
.select({ count: sql<number>`count(*)` })
.from(notificationsTable)
.where(
and(
eq(notificationsTable.userId, userId),
eq(notificationsTable.isRead, false),
),
);
const [usersCount] = await db
.select({ count: sql<number>`count(*)` })
.from(usersTable);
const [unreadMsgCount] = await db
.select({ count: sql<number>`count(*)` })
.from(messagesTable)
.where(
and(
sql`${messagesTable.id} NOT IN (
SELECT message_id FROM message_reads WHERE user_id = ${userId}
)`,
sql`${messagesTable.senderId} != ${userId}`,
),
);
res.json({
totalApps: Number(appsCount?.count ?? 0),
totalServices: Number(servicesCount?.count ?? 0),
unreadNotifications: Number(unreadNotifCount?.count ?? 0),
unreadMessages: Number(unreadMsgCount?.count ?? 0),
totalUsers: Number(usersCount?.count ?? 0),
});
});
export default router;
+116
View File
@@ -0,0 +1,116 @@
import { Router, type IRouter } from "express";
import { eq } from "drizzle-orm";
import { db } from "@workspace/db";
import { usersTable } from "@workspace/db";
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
import {
GetUserParams,
UpdateUserParams,
UpdateUserBody,
DeleteUserParams,
} from "@workspace/api-zod";
const router: IRouter = Router();
function buildUserProfile(user: typeof usersTable.$inferSelect, roles: string[]) {
return {
id: user.id,
username: user.username,
email: user.email,
displayNameAr: user.displayNameAr,
displayNameEn: user.displayNameEn,
preferredLanguage: user.preferredLanguage,
avatarUrl: user.avatarUrl,
isActive: user.isActive,
roles,
createdAt: user.createdAt,
};
}
router.get("/users", requireAdmin, async (_req, res): Promise<void> => {
const users = await db.select().from(usersTable).orderBy(usersTable.createdAt);
const usersWithRoles = await Promise.all(
users.map(async (u) => {
const roles = await getUserRoles(u.id);
return buildUserProfile(u, roles);
}),
);
res.json(usersWithRoles);
});
router.get("/users/:id", requireAdmin, async (req, res): Promise<void> => {
const params = GetUserParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, params.data.id));
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
const roles = await getUserRoles(user.id);
res.json(buildUserProfile(user, roles));
});
router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
const params = UpdateUserParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const parsed = UpdateUserBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const updateData: Partial<typeof usersTable.$inferInsert> = {};
if (parsed.data.displayNameAr !== undefined) updateData.displayNameAr = parsed.data.displayNameAr;
if (parsed.data.displayNameEn !== undefined) updateData.displayNameEn = parsed.data.displayNameEn;
if (parsed.data.isActive !== undefined) updateData.isActive = parsed.data.isActive;
if (parsed.data.preferredLanguage !== undefined) updateData.preferredLanguage = parsed.data.preferredLanguage;
const [user] = await db
.update(usersTable)
.set(updateData)
.where(eq(usersTable.id, params.data.id))
.returning();
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
const roles = await getUserRoles(user.id);
res.json(buildUserProfile(user, roles));
});
router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
const params = DeleteUserParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [user] = await db
.delete(usersTable)
.where(eq(usersTable.id, params.data.id))
.returning();
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
res.sendStatus(204);
});
export default router;