EmberClone/apps/api/src/routes/time-entries.ts

260 lines
6.7 KiB
TypeScript

import { FastifyInstance } from "fastify"
import { db } from "../db"
import { timeEntries } from "../db/schema"
import { eq, and, gte, lte, isNull } from "drizzle-orm"
import { z } from "zod"
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)
})
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()
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()
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()
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 [updated] = await db
.update(timeEntries)
.set({
...body,
startTime: body.startTime ? new Date(body.startTime) : undefined,
endTime: body.endTime ? new Date(body.endTime) : (body.endTime === null ? null : undefined)
})
.where(eq(timeEntries.id, id))
.returning()
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))
return reply.code(204).send()
})
fastify.get("/export", 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)
const header = "id,description,projectId,startTime,endTime,durationMinutes\n"
const rows = entries.map(e => {
const start = new Date(e.startTime)
const end = e.endTime ? new Date(e.endTime) : null
const duration = end ? Math.round((end.getTime() - start.getTime()) / 60000) : ""
return [
e.id,
`"${e.description.replace(/"/g, '""')}"`,
e.projectId || "",
start.toISOString(),
end ? end.toISOString() : "",
duration
].join(",")
}).join("\n")
reply
.header("Content-Type", "text/csv")
.header("Content-Disposition", "attachment; filename=time-entries.csv")
.send(header + rows)
})
}