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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user