import { FastifyInstance } from "fastify" import { db } from "../db" import { timeEntries, timeEntryAttachments } from "../db/schema" import { eq, and, gte, lte, isNull, inArray } from "drizzle-orm" import { z } from "zod" import { webhookDispatcher } from "../services/webhooks" const TimeEntrySchema = z.object({ projectId: z.string().uuid().optional(), description: z.string().min(1), startTime: z.string().datetime(), endTime: z.string().datetime().optional() }) const TimeEntryUpdateSchema = TimeEntrySchema.partial() const StartEntrySchema = z.object({ projectId: z.string().uuid().optional(), description: z.string().min(1) }) const BulkDeleteSchema = z.object({ ids: z.array(z.string().uuid()) }) const AttachmentSchema = z.object({ documentIds: z.array(z.string().uuid()) }) export default async function timeEntryRoutes(fastify: FastifyInstance) { fastify.addHook("preHandler", async (request, reply) => { try { await request.jwtVerify() } catch (err) { return reply.code(401).send({ message: "Unauthorized" }) } }) fastify.get("/", async (request, reply) => { const { from, to } = request.query as { from?: string; to?: string } const user = request.user as { sub: string; role: string } const filters = [] if (user.role !== "admin") { filters.push(eq(timeEntries.userId, user.sub)) } if (from) { filters.push(gte(timeEntries.startTime, new Date(from))) } if (to) { filters.push(lte(timeEntries.startTime, new Date(to))) } const entries = await db .select() .from(timeEntries) .where(filters.length ? and(...filters) : undefined as any) .orderBy(timeEntries.startTime) return entries }) fastify.get("/running", async (request, reply) => { const user = request.user as { sub: string } const [entry] = await db .select() .from(timeEntries) .where( and( eq(timeEntries.userId, user.sub), isNull(timeEntries.endTime) ) ) .limit(1) if (!entry) { return reply.code(404).send({ message: "No running time entry found" }) } return entry }) fastify.get("/:id", async (request, reply) => { const { id } = request.params as { id: string } const user = request.user as { sub: string; role: string } const [entry] = await db .select() .from(timeEntries) .where( and( eq(timeEntries.id, id), user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) ) ) .limit(1) if (!entry) { return reply.code(404).send({ message: "Time entry not found" }) } return entry }) fastify.post("/start", async (request, reply) => { const user = request.user as { sub: string } const body = StartEntrySchema.parse(request.body) const [entry] = await db .insert(timeEntries) .values({ ...body, userId: user.sub, startTime: new Date(), endTime: null }) .returning() await webhookDispatcher.triggerEvent('time_entry.created', entry) return reply.code(201).send(entry) }) fastify.post("/", async (request, reply) => { const user = request.user as { sub: string } const body = TimeEntrySchema.parse(request.body) const [entry] = await db .insert(timeEntries) .values({ ...body, userId: user.sub, startTime: new Date(body.startTime), endTime: body.endTime ? new Date(body.endTime) : null }) .returning() await webhookDispatcher.triggerEvent('time_entry.created', entry) return reply.code(201).send(entry) }) fastify.post("/:id/stop", async (request, reply) => { const { id } = request.params as { id: string } const user = request.user as { sub: string; role: string } const [entry] = await db .select() .from(timeEntries) .where( and( eq(timeEntries.id, id), user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) ) ) .limit(1) if (!entry) { return reply.code(404).send({ message: "Time entry not found" }) } const [updated] = await db .update(timeEntries) .set({ endTime: new Date() }) .where(eq(timeEntries.id, id)) .returning() await webhookDispatcher.triggerEvent('time_entry.updated', updated) return updated }) fastify.patch("/:id", async (request, reply) => { const { id } = request.params as { id: string } const user = request.user as { sub: string; role: string } const body = TimeEntryUpdateSchema.parse(request.body) const [entry] = await db .select() .from(timeEntries) .where( and( eq(timeEntries.id, id), user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) ) ) .limit(1) if (!entry) { return reply.code(404).send({ message: "Time entry not found" }) } const updateData: any = { ...body } if (body.startTime) updateData.startTime = new Date(body.startTime) if (body.endTime) updateData.endTime = new Date(body.endTime) const [updated] = await db .update(timeEntries) .set(updateData) .where(eq(timeEntries.id, id)) .returning() await webhookDispatcher.triggerEvent('time_entry.updated', updated) return updated }) fastify.delete("/:id", async (request, reply) => { const { id } = request.params as { id: string } const user = request.user as { sub: string; role: string } const [entry] = await db .select() .from(timeEntries) .where( and( eq(timeEntries.id, id), user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) ) ) .limit(1) if (!entry) { return reply.code(404).send({ message: "Time entry not found" }) } await db.delete(timeEntries).where(eq(timeEntries.id, id)) await webhookDispatcher.triggerEvent('time_entry.deleted', entry) return reply.code(204).send() }) fastify.delete("/bulk", async (request, reply) => { const user = request.user as { sub: string; role: string } const { ids } = BulkDeleteSchema.parse(request.body) const deletedEntries = await db .select() .from(timeEntries) .where( and( inArray(timeEntries.id, ids), user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) ) ) await db.delete(timeEntries).where(inArray(timeEntries.id, ids)) for (const entry of deletedEntries) { await webhookDispatcher.triggerEvent('time_entry.deleted', entry) } return reply.code(204).send() }) fastify.post("/:id/attachments", async (request, reply) => { const { id } = request.params as { id: string } const { documentIds } = AttachmentSchema.parse(request.body) const user = request.user as { sub: string; role: string } const [entry] = await db .select() .from(timeEntries) .where( and( eq(timeEntries.id, id), user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) ) ) .limit(1) if (!entry) { return reply.code(404).send({ message: "Time entry not found" }) } await db.delete(timeEntryAttachments).where(eq(timeEntryAttachments.timeEntryId, id)) const attachments = documentIds.map(docId => ({ timeEntryId: id, documentId: docId })) await db.insert(timeEntryAttachments).values(attachments) await webhookDispatcher.triggerEvent('time_entry.updated', entry) return reply.code(200).send({ success: true }) }) }