200 lines
5.2 KiB
TypeScript
200 lines
5.2 KiB
TypeScript
import { FastifyInstance } from "fastify"
|
|
import { db } from "../db"
|
|
import { users, auditLogs } from "../db/schema"
|
|
import { eq, max } from "drizzle-orm"
|
|
import { z } from "zod"
|
|
import argon2 from "argon2"
|
|
|
|
const UserUpdateSchema = z.object({
|
|
name: z.string().min(1).optional(),
|
|
role: z.string().optional()
|
|
})
|
|
|
|
const UserCreateSchema = z.object({
|
|
email: z.string().email(),
|
|
name: z.string().min(1),
|
|
role: z.string(),
|
|
password: z.string().min(8)
|
|
})
|
|
|
|
const PasswordChangeSchema = z.object({
|
|
oldPassword: z.string(),
|
|
newPassword: z.string().min(8)
|
|
})
|
|
|
|
const UserDeleteSchema = z.object({
|
|
password: z.string()
|
|
})
|
|
|
|
export default async function userRoutes(fastify: FastifyInstance) {
|
|
fastify.addHook("preHandler", async (request, reply) => {
|
|
try {
|
|
await request.jwtVerify()
|
|
const userId = (request.user as { sub: string } | undefined)?.sub
|
|
if (userId) {
|
|
// Touch last active via audit log entry
|
|
await db.insert(auditLogs).values({
|
|
userId,
|
|
action: "heartbeat",
|
|
timestamp: new Date(),
|
|
})
|
|
}
|
|
} catch (err) {
|
|
return reply.code(401).send({ message: "Unauthorized" })
|
|
}
|
|
})
|
|
|
|
const isAdmin = (request: any) => {
|
|
return (request.user as { sub: string, role: string })?.role === "admin"
|
|
}
|
|
|
|
fastify.get("/me", async (request, reply) => {
|
|
const userId = (request.user as { sub: string } | undefined)?.sub
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({ message: "User ID not found in token" })
|
|
}
|
|
|
|
const [user] = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1)
|
|
|
|
if (!user) {
|
|
return reply.code(404).send({ message: "User not found" })
|
|
}
|
|
|
|
return user
|
|
})
|
|
|
|
fastify.patch("/me", async (request, reply) => {
|
|
const userId = (request.user as { sub: string } | undefined)?.sub
|
|
if (!userId) {
|
|
return reply.code(401).send({ message: "User ID not found in token" })
|
|
}
|
|
|
|
const body = UserUpdateSchema.parse(request.body)
|
|
|
|
const [user] = await db
|
|
.update(users)
|
|
.set(body)
|
|
.where(eq(users.id, userId))
|
|
.returning()
|
|
|
|
if (!user) {
|
|
return reply.code(404).send({ message: "User not found" })
|
|
}
|
|
|
|
return user
|
|
})
|
|
|
|
fastify.post("/me/password", async (request, reply) => {
|
|
const userId = (request.user as { sub: string } | undefined)?.sub
|
|
if (!userId) {
|
|
return reply.code(401).send({ message: "User ID not found in token" })
|
|
}
|
|
|
|
const body = PasswordChangeSchema.parse(request.body)
|
|
|
|
const [user] = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1)
|
|
|
|
if (!user) {
|
|
return reply.code(404).send({ message: "User not found" })
|
|
}
|
|
|
|
const isPasswordCorrect = await argon2.verify(user.passwordHash, body.oldPassword)
|
|
if (!isPasswordCorrect) {
|
|
return reply.code(401).send({ message: "Incorrect old password" })
|
|
}
|
|
|
|
const newPasswordHash = await argon2.hash(body.newPassword)
|
|
|
|
await db
|
|
.update(users)
|
|
.set({ passwordHash: newPasswordHash })
|
|
.where(eq(users.id, userId))
|
|
|
|
return reply.send({ message: "Password updated successfully" })
|
|
})
|
|
|
|
fastify.delete("/me", async (request, reply) => {
|
|
const userId = (request.user as { sub: string } | undefined)?.sub
|
|
if (!userId) {
|
|
return reply.code(401).send({ message: "User ID not found in token" })
|
|
}
|
|
|
|
const body = UserDeleteSchema.parse(request.body)
|
|
|
|
const [user] = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1)
|
|
|
|
if (!user) {
|
|
return reply.code(404).send({ message: "User not found" })
|
|
}
|
|
|
|
const isPasswordCorrect = await argon2.verify(user.passwordHash, body.password)
|
|
if (!isPasswordCorrect) {
|
|
return reply.code(401).send({ message: "Incorrect password" })
|
|
}
|
|
|
|
await db
|
|
.delete(users)
|
|
.where(eq(users.id, userId))
|
|
|
|
return reply.send({ message: "User account deleted successfully" })
|
|
})
|
|
|
|
fastify.get("/", async (request, reply) => {
|
|
if (!isAdmin(request)) {
|
|
return reply.code(403).send({ message: "Forbidden: Admin role required" })
|
|
}
|
|
|
|
return await db.select().from(users)
|
|
})
|
|
|
|
fastify.get("/presence", async (request, reply) => {
|
|
const userId = (request.user as { sub: string } | undefined)?.sub
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({ message: "Unauthorized" })
|
|
}
|
|
|
|
if (!isAdmin(request)) {
|
|
// Non-admins only see their own presence
|
|
const [presence] = await db
|
|
.select({
|
|
userId: auditLogs.userId,
|
|
lastActiveAt: max(auditLogs.timestamp),
|
|
})
|
|
.from(auditLogs)
|
|
.where(eq(auditLogs.userId, userId))
|
|
.groupBy(auditLogs.userId)
|
|
|
|
return presence ? { [presence.userId]: presence.lastActiveAt } : {}
|
|
}
|
|
|
|
// Admins see everyone
|
|
const results = await db
|
|
.select({
|
|
userId: auditLogs.userId,
|
|
lastActiveAt: max(auditLogs.timestamp),
|
|
})
|
|
.from(auditLogs)
|
|
.groupBy(auditLogs.userId)
|
|
|
|
const presenceMap: Record<string, Date> = {}
|
|
results.forEach(row => {
|
|
if (row.userId) presenceMap[row.userId] = row.lastActiveAt!
|
|
})
|
|
|
|
return presenceMap
|
|
})
|
|
} |