diff --git a/backend/src/errors/AppError.ts b/backend/src/errors/AppError.ts new file mode 100644 index 0000000..974fa1a --- /dev/null +++ b/backend/src/errors/AppError.ts @@ -0,0 +1,41 @@ +export class AppError extends Error { + public readonly statusCode: number; + public readonly isOperational: boolean; + + constructor( + message: string, + statusCode: number = 500, + isOperational: boolean = true + ) { + super(message); + this.statusCode = statusCode; + this.isOperational = isOperational; + + // Maintains proper stack trace for where our error was thrown + Error.captureStackTrace(this, this.constructor); + } +} + +export class NotFoundError extends AppError { + constructor(message: string = "Resource not found") { + super(message, 404); + } +} + +export class BadRequestError extends AppError { + constructor(message: string = "Bad request") { + super(message, 400); + } +} + +export class ConflictError extends AppError { + constructor(message: string = "Conflict") { + super(message, 409); + } +} + +export class UnauthorizedError extends AppError { + constructor(message: string = "Unauthorized") { + super(message, 401); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 76e4124..4307a30 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -41,8 +41,8 @@ async function main() { saveUninitialized: false, name: "sessionId", cookie: { - secure: false, - httpOnly: false, + secure: config.nodeEnv === "production", + httpOnly: true, maxAge: config.session.maxAge, sameSite: "lax", }, diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index 1356552..79a9780 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -1,5 +1,6 @@ -import { Request, Response, NextFunction } from 'express'; -import { Prisma } from '@prisma/client'; +import { Request, Response, NextFunction } from "express"; +import { Prisma } from "@prisma/client"; +import { AppError } from "../errors/AppError"; export interface ApiError extends Error { statusCode?: number; @@ -7,48 +8,58 @@ export interface ApiError extends Error { } export function errorHandler( - err: ApiError, + err: ApiError | AppError, _req: Request, res: Response, - _next: NextFunction + _next: NextFunction, ): void { - console.error('Error:', err); - + console.error("Error:", err); + + // Handle operational AppErrors + if (err instanceof AppError) { + res.status(err.statusCode).json({ + error: err.isOperational ? err.message : "Internal server error", + }); + return; + } + // Prisma errors if (err instanceof Prisma.PrismaClientKnownRequestError) { switch (err.code) { - case 'P2002': - res.status(409).json({ error: 'Resource already exists' }); + case "P2002": + res.status(409).json({ error: "Resource already exists" }); return; - case 'P2025': - res.status(404).json({ error: 'Resource not found' }); + case "P2025": + res.status(404).json({ error: "Resource not found" }); return; - case 'P2003': - res.status(400).json({ error: 'Invalid reference to related resource' }); + case "P2003": + res + .status(400) + .json({ error: "Invalid reference to related resource" }); return; default: - res.status(500).json({ error: 'Database error' }); + res.status(500).json({ error: "Database error" }); return; } } - + if (err instanceof Prisma.PrismaClientValidationError) { - res.status(400).json({ error: 'Invalid data format' }); + res.status(400).json({ error: "Invalid data format" }); return; } - - const statusCode = err.statusCode || 500; - const message = err.message || 'Internal server error'; - - res.status(statusCode).json({ - error: statusCode === 500 ? 'Internal server error' : message + + // Legacy support for errors with statusCode property + const statusCode = (err as ApiError).statusCode || 500; + + res.status(statusCode).json({ + error: statusCode === 500 ? "Internal server error" : err.message, }); } export function notFoundHandler( _req: Request, res: Response, - _next: NextFunction + _next: NextFunction, ): void { - res.status(404).json({ error: 'Endpoint not found' }); -} \ No newline at end of file + res.status(404).json({ error: "Endpoint not found" }); +} diff --git a/backend/src/services/client.service.ts b/backend/src/services/client.service.ts index 16b677b..c6e44be 100644 --- a/backend/src/services/client.service.ts +++ b/backend/src/services/client.service.ts @@ -1,11 +1,12 @@ -import { prisma } from '../prisma/client'; -import type { CreateClientInput, UpdateClientInput } from '../types'; +import { prisma } from "../prisma/client"; +import { NotFoundError } from "../errors/AppError"; +import type { CreateClientInput, UpdateClientInput } from "../types"; export class ClientService { async findAll(userId: string) { return prisma.client.findMany({ where: { userId }, - orderBy: { name: 'asc' }, + orderBy: { name: "asc" }, }); } @@ -27,9 +28,7 @@ export class ClientService { async update(id: string, userId: string, data: UpdateClientInput) { const client = await this.findById(id, userId); if (!client) { - const error = new Error('Client not found') as Error & { statusCode: number }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Client not found"); } return prisma.client.update({ @@ -41,13 +40,11 @@ export class ClientService { async delete(id: string, userId: string) { const client = await this.findById(id, userId); if (!client) { - const error = new Error('Client not found') as Error & { statusCode: number }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Client not found"); } await prisma.client.delete({ where: { id }, }); } -} \ No newline at end of file +} diff --git a/backend/src/services/project.service.ts b/backend/src/services/project.service.ts index c8d527b..854b4de 100644 --- a/backend/src/services/project.service.ts +++ b/backend/src/services/project.service.ts @@ -1,6 +1,6 @@ -import { prisma } from '../prisma/client'; - -import type { CreateProjectInput, UpdateProjectInput } from '../types'; +import { prisma } from "../prisma/client"; +import { NotFoundError, BadRequestError } from "../errors/AppError"; +import type { CreateProjectInput, UpdateProjectInput } from "../types"; export class ProjectService { async findAll(userId: string, clientId?: string) { @@ -9,7 +9,7 @@ export class ProjectService { userId, ...(clientId && { clientId }), }, - orderBy: { name: 'asc' }, + orderBy: { name: "asc" }, include: { client: { select: { @@ -40,11 +40,9 @@ export class ProjectService { const client = await prisma.client.findFirst({ where: { id: data.clientId, userId }, }); - + if (!client) { - const error = new Error('Client not found or does not belong to user') as Error & { statusCode: number }; - error.statusCode = 400; - throw error; + throw new BadRequestError("Client not found or does not belong to user"); } return prisma.project.create({ @@ -69,9 +67,7 @@ export class ProjectService { async update(id: string, userId: string, data: UpdateProjectInput) { const project = await this.findById(id, userId); if (!project) { - const error = new Error('Project not found') as Error & { statusCode: number }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Project not found"); } // If clientId is being updated, verify it belongs to the user @@ -79,11 +75,11 @@ export class ProjectService { const client = await prisma.client.findFirst({ where: { id: data.clientId, userId }, }); - + if (!client) { - const error = new Error('Client not found or does not belong to user') as Error & { statusCode: number }; - error.statusCode = 400; - throw error; + throw new BadRequestError( + "Client not found or does not belong to user", + ); } } @@ -109,13 +105,11 @@ export class ProjectService { async delete(id: string, userId: string) { const project = await this.findById(id, userId); if (!project) { - const error = new Error('Project not found') as Error & { statusCode: number }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Project not found"); } await prisma.project.delete({ where: { id }, }); } -} \ No newline at end of file +} diff --git a/backend/src/services/timeEntry.service.ts b/backend/src/services/timeEntry.service.ts index 7d9281e..e3fea4f 100644 --- a/backend/src/services/timeEntry.service.ts +++ b/backend/src/services/timeEntry.service.ts @@ -1,4 +1,9 @@ import { prisma } from "../prisma/client"; +import { + NotFoundError, + BadRequestError, + ConflictError, +} from "../errors/AppError"; import type { CreateTimeEntryInput, UpdateTimeEntryInput, @@ -238,11 +243,7 @@ export class TimeEntryService { // Validate end time is after start time if (endTime <= startTime) { - const error = new Error("End time must be after start time") as Error & { - statusCode: number; - }; - error.statusCode = 400; - throw error; + throw new BadRequestError("End time must be after start time"); } // Verify the project belongs to the user @@ -251,11 +252,7 @@ export class TimeEntryService { }); if (!project) { - const error = new Error("Project not found") as Error & { - statusCode: number; - }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Project not found"); } // Check for overlapping entries @@ -265,11 +262,9 @@ export class TimeEntryService { endTime, ); if (hasOverlap) { - const error = new Error( + throw new ConflictError( "This time entry overlaps with an existing entry", - ) as Error & { statusCode: number }; - error.statusCode = 400; - throw error; + ); } return prisma.timeEntry.create({ @@ -301,11 +296,7 @@ export class TimeEntryService { async update(id: string, userId: string, data: UpdateTimeEntryInput) { const entry = await this.findById(id, userId); if (!entry) { - const error = new Error("Time entry not found") as Error & { - statusCode: number; - }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Time entry not found"); } const startTime = data.startTime @@ -315,11 +306,7 @@ export class TimeEntryService { // Validate end time is after start time if (endTime <= startTime) { - const error = new Error("End time must be after start time") as Error & { - statusCode: number; - }; - error.statusCode = 400; - throw error; + throw new BadRequestError("End time must be after start time"); } // If project changed, verify it belongs to the user @@ -329,11 +316,7 @@ export class TimeEntryService { }); if (!project) { - const error = new Error("Project not found") as Error & { - statusCode: number; - }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Project not found"); } } @@ -345,11 +328,9 @@ export class TimeEntryService { id, ); if (hasOverlap) { - const error = new Error( + throw new ConflictError( "This time entry overlaps with an existing entry", - ) as Error & { statusCode: number }; - error.statusCode = 400; - throw error; + ); } return prisma.timeEntry.update({ @@ -381,11 +362,7 @@ export class TimeEntryService { async delete(id: string, userId: string) { const entry = await this.findById(id, userId); if (!entry) { - const error = new Error("Time entry not found") as Error & { - statusCode: number; - }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Time entry not found"); } await prisma.timeEntry.delete({ diff --git a/backend/src/services/timer.service.ts b/backend/src/services/timer.service.ts index 5d28125..9d8661a 100644 --- a/backend/src/services/timer.service.ts +++ b/backend/src/services/timer.service.ts @@ -1,5 +1,14 @@ -import { prisma } from '../prisma/client'; -import type { StartTimerInput, UpdateTimerInput, StopTimerInput } from '../types'; +import { prisma } from "../prisma/client"; +import { + NotFoundError, + BadRequestError, + ConflictError, +} from "../errors/AppError"; +import type { + StartTimerInput, + UpdateTimerInput, + StopTimerInput, +} from "../types"; export class TimerService { async getOngoingTimer(userId: string) { @@ -27,9 +36,7 @@ export class TimerService { // Check if user already has an ongoing timer const existingTimer = await this.getOngoingTimer(userId); if (existingTimer) { - const error = new Error('Timer is already running') as Error & { statusCode: number }; - error.statusCode = 400; - throw error; + throw new BadRequestError("Timer is already running"); } // If projectId provided, verify it belongs to the user @@ -40,9 +47,7 @@ export class TimerService { }); if (!project) { - const error = new Error('Project not found') as Error & { statusCode: number }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Project not found"); } projectId = data.projectId; @@ -75,9 +80,7 @@ export class TimerService { async update(userId: string, data: UpdateTimerInput) { const timer = await this.getOngoingTimer(userId); if (!timer) { - const error = new Error('No timer is running') as Error & { statusCode: number }; - error.statusCode = 404; - throw error; + throw new NotFoundError("No timer is running"); } // If projectId is explicitly null, clear the project @@ -92,9 +95,7 @@ export class TimerService { }); if (!project) { - const error = new Error('Project not found') as Error & { statusCode: number }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Project not found"); } projectId = data.projectId; @@ -124,14 +125,12 @@ export class TimerService { async stop(userId: string, data?: StopTimerInput) { const timer = await this.getOngoingTimer(userId); if (!timer) { - const error = new Error('No timer is running') as Error & { statusCode: number }; - error.statusCode = 404; - throw error; + throw new NotFoundError("No timer is running"); } // Determine which project to use let projectId = timer.projectId; - + // If data.projectId is provided, use it instead if (data?.projectId) { const project = await prisma.project.findFirst({ @@ -139,9 +138,7 @@ export class TimerService { }); if (!project) { - const error = new Error('Project not found') as Error & { statusCode: number }; - error.statusCode = 404; - throw error; + throw new NotFoundError("Project not found"); } projectId = data.projectId; @@ -149,20 +146,24 @@ export class TimerService { // If no project is selected, throw error requiring project selection if (!projectId) { - const error = new Error('Please select a project before stopping the timer') as Error & { statusCode: number }; - error.statusCode = 400; - throw error; + throw new BadRequestError( + "Please select a project before stopping the timer", + ); } const endTime = new Date(); const startTime = timer.startTime; // Check for overlapping entries - const hasOverlap = await this.hasOverlappingEntries(userId, startTime, endTime); + const hasOverlap = await this.hasOverlappingEntries( + userId, + startTime, + endTime, + ); if (hasOverlap) { - const error = new Error('This time entry overlaps with an existing entry') as Error & { statusCode: number }; - error.statusCode = 400; - throw error; + throw new ConflictError( + "This time entry overlaps with an existing entry", + ); } // Delete ongoing timer and create time entry in a transaction @@ -201,16 +202,14 @@ export class TimerService { private async hasOverlappingEntries( userId: string, startTime: Date, - endTime: Date + endTime: Date, ): Promise { const count = await prisma.timeEntry.count({ where: { userId, - OR: [ - { startTime: { lt: endTime }, endTime: { gt: startTime } }, - ], + OR: [{ startTime: { lt: endTime }, endTime: { gt: startTime } }], }, }); return count > 0; } -} \ No newline at end of file +} diff --git a/docker-compose.yml b/docker-compose.yml index 40c7f90..0aa74d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: OIDC_REDIRECT_URI: "${API_URL}/auth/callback" SESSION_SECRET: ${SESSION_SECRET} PORT: 3001 - NODE_ENV: production + NODE_ENV: development APP_URL: "${APP_URL}" ports: - "3001:3001" @@ -47,3 +47,4 @@ services: volumes: postgres_data: + diff --git a/frontend/src/contexts/TimerContext.tsx b/frontend/src/contexts/TimerContext.tsx index d91b75f..bf2d370 100644 --- a/frontend/src/contexts/TimerContext.tsx +++ b/frontend/src/contexts/TimerContext.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, + useRef, type ReactNode, } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -24,9 +25,8 @@ const TimerContext = createContext(undefined); export function TimerProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const [elapsedSeconds, setElapsedSeconds] = useState(0); - const [elapsedInterval, setElapsedInterval] = useState | null>(null); + // Use ref for interval ID to avoid stale closure issues + const intervalRef = useRef | null>(null); const { data: ongoingTimer, isLoading } = useQuery({ queryKey: ["ongoingTimer"], @@ -42,22 +42,28 @@ export function TimerProvider({ children }: { children: ReactNode }) { const initialElapsed = Math.floor((now - startTime) / 1000); setElapsedSeconds(initialElapsed); + // Clear any existing interval first + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + // Start interval to update elapsed time - const interval = setInterval(() => { + intervalRef.current = setInterval(() => { setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000)); }, 1000); - setElapsedInterval(interval); } else { setElapsedSeconds(0); - if (elapsedInterval) { - clearInterval(elapsedInterval); - setElapsedInterval(null); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; } } + // Cleanup on unmount or when ongoingTimer changes return () => { - if (elapsedInterval) { - clearInterval(elapsedInterval); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; } }; }, [ongoingTimer]);