diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 1c15a36a..675d80aa 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -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", diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index f32f71eb..877db043 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -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; diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index b1f024dd..3dec1547 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -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"); }); diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts new file mode 100644 index 00000000..9e72f1a3 --- /dev/null +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts new file mode 100644 index 00000000..b073f7c2 --- /dev/null +++ b/artifacts/api-server/src/routes/apps.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts new file mode 100644 index 00000000..9bffb08f --- /dev/null +++ b/artifacts/api-server/src/routes/auth.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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; diff --git a/artifacts/api-server/src/routes/conversations.ts b/artifacts/api-server/src/routes/conversations.ts new file mode 100644 index 00000000..df42a352 --- /dev/null +++ b/artifacts/api-server/src/routes/conversations.ts @@ -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`false`, + }) + .from(usersTable) + .where(eq(usersTable.id, msg.senderId)); + + lastMessage = { ...msg, sender }; + } + + const unreadResult = await db + .select({ count: sql`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 => { + 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 => { + 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 => { + 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 => { + 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`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 => { + 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`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 => { + 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; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5a1f77ab..a6d1c31c 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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; diff --git a/artifacts/api-server/src/routes/notifications.ts b/artifacts/api-server/src/routes/notifications.ts new file mode 100644 index 00000000..b96c3a17 --- /dev/null +++ b/artifacts/api-server/src/routes/notifications.ts @@ -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 => { + 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 => { + 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 => { + const userId = req.session.userId!; + + await db + .update(notificationsTable) + .set({ isRead: true }) + .where(eq(notificationsTable.userId, userId)); + + res.json({ success: true }); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/services.ts b/artifacts/api-server/src/routes/services.ts new file mode 100644 index 00000000..056a4ec2 --- /dev/null +++ b/artifacts/api-server/src/routes/services.ts @@ -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 => { + 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 => { + 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 => { + const categories = await db + .select() + .from(serviceCategoriesTable) + .orderBy(asc(serviceCategoriesTable.sortOrder)); + res.json(categories); +}); + +router.get("/services/:id", requireAuth, async (req, res): Promise => { + 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 => { + 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 => { + 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; diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts new file mode 100644 index 00000000..53adc096 --- /dev/null +++ b/artifacts/api-server/src/routes/stats.ts @@ -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 => { + const userId = req.session.userId!; + + const [appsCount] = await db + .select({ count: sql`count(*)` }) + .from(appsTable) + .where(eq(appsTable.isActive, true)); + + const [servicesCount] = await db + .select({ count: sql`count(*)` }) + .from(servicesTable); + + const [unreadNotifCount] = await db + .select({ count: sql`count(*)` }) + .from(notificationsTable) + .where( + and( + eq(notificationsTable.userId, userId), + eq(notificationsTable.isRead, false), + ), + ); + + const [usersCount] = await db + .select({ count: sql`count(*)` }) + .from(usersTable); + + const [unreadMsgCount] = await db + .select({ count: sql`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; diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts new file mode 100644 index 00000000..3ebdb3bc --- /dev/null +++ b/artifacts/api-server/src/routes/users.ts @@ -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 => { + 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 => { + 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 => { + 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 = {}; + 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 => { + 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; diff --git a/artifacts/teaboy-os/components.json b/artifacts/teaboy-os/components.json new file mode 100644 index 00000000..3ff62cf0 --- /dev/null +++ b/artifacts/teaboy-os/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/artifacts/teaboy-os/index.html b/artifacts/teaboy-os/index.html new file mode 100644 index 00000000..db7d31d8 --- /dev/null +++ b/artifacts/teaboy-os/index.html @@ -0,0 +1,16 @@ + + + + + + TeaBoy OS + + + + + + +
+ + + diff --git a/artifacts/teaboy-os/package.json b/artifacts/teaboy-os/package.json new file mode 100644 index 00000000..873783ce --- /dev/null +++ b/artifacts/teaboy-os/package.json @@ -0,0 +1,80 @@ +{ + "name": "@workspace/teaboy-os", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-aspect-ratio": "^1.1.3", + "@radix-ui/react-avatar": "^1.1.4", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.7", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-hover-card": "^1.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-menubar": "^1.1.7", + "@radix-ui/react-navigation-menu": "^1.2.6", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-radio-group": "^1.2.4", + "@radix-ui/react-scroll-area": "^1.2.4", + "@radix-ui/react-select": "^2.1.7", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-toast": "^1.2.7", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.0", + "@replit/vite-plugin-cartographer": "catalog:", + "@replit/vite-plugin-dev-banner": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@workspace/api-client-react": "workspace:*", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "catalog:", + "i18next": "^26.0.6", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.55.0", + "react-i18next": "^17.0.4", + "react-icons": "^5.4.0", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.2", + "socket.io-client": "^4.8.3", + "sonner": "^2.0.7", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "vite": "catalog:", + "wouter": "^3.3.5", + "zod": "catalog:" + } +} diff --git a/artifacts/teaboy-os/public/favicon.svg b/artifacts/teaboy-os/public/favicon.svg new file mode 100644 index 00000000..4373d3cd --- /dev/null +++ b/artifacts/teaboy-os/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg new file mode 100644 index 00000000..4e4f3b4d Binary files /dev/null and b/artifacts/teaboy-os/public/opengraph.jpg differ diff --git a/artifacts/teaboy-os/src/App.tsx b/artifacts/teaboy-os/src/App.tsx new file mode 100644 index 00000000..7a9d12f0 --- /dev/null +++ b/artifacts/teaboy-os/src/App.tsx @@ -0,0 +1,54 @@ +import { Switch, Route, Router as WouterRouter } from "wouter"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { AuthProvider } from "@/contexts/AuthContext"; +import NotFound from "@/pages/not-found"; +import LoginPage from "@/pages/login"; +import RegisterPage from "@/pages/register"; +import HomePage from "@/pages/home"; +import ServicesPage from "@/pages/services"; +import ChatPage from "@/pages/chat"; +import NotificationsPage from "@/pages/notifications"; +import AdminPage from "@/pages/admin"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 30_000, + }, + }, +}); + +function Router() { + return ( + + + + + + + + + + + + + ); +} + +function App() { + return ( + + + + + + + + + ); +} + +export default App; diff --git a/artifacts/teaboy-os/src/components/ui/accordion.tsx b/artifacts/teaboy-os/src/components/ui/accordion.tsx new file mode 100644 index 00000000..e1797c93 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/artifacts/teaboy-os/src/components/ui/alert-dialog.tsx b/artifacts/teaboy-os/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..fa2b4429 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/artifacts/teaboy-os/src/components/ui/alert.tsx b/artifacts/teaboy-os/src/components/ui/alert.tsx new file mode 100644 index 00000000..5afd41d1 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/artifacts/teaboy-os/src/components/ui/aspect-ratio.tsx b/artifacts/teaboy-os/src/components/ui/aspect-ratio.tsx new file mode 100644 index 00000000..c4abbf37 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/artifacts/teaboy-os/src/components/ui/avatar.tsx b/artifacts/teaboy-os/src/components/ui/avatar.tsx new file mode 100644 index 00000000..51e507ba --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/artifacts/teaboy-os/src/components/ui/badge.tsx b/artifacts/teaboy-os/src/components/ui/badge.tsx new file mode 100644 index 00000000..3f036658 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/badge.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + // @replit + // Whitespace-nowrap: Badges should never wrap. + "whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + + " hover-elevate ", + { + variants: { + variant: { + default: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-primary text-primary-foreground shadow-xs", + secondary: + // @replit no hover because we use hover-elevate + "border-transparent bg-secondary text-secondary-foreground", + destructive: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-destructive text-destructive-foreground shadow-xs", + // @replit shadow-xs" - use badge outline variable + outline: "text-foreground border [border-color:var(--badge-outline)]", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/artifacts/teaboy-os/src/components/ui/breadcrumb.tsx b/artifacts/teaboy-os/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..60e6c96f --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>