improvements

This commit is contained in:
2026-02-16 19:54:15 +01:00
parent 81b4a8ead2
commit 64fd134044
9 changed files with 163 additions and 137 deletions

View File

@@ -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);
}
}

View File

@@ -41,8 +41,8 @@ async function main() {
saveUninitialized: false, saveUninitialized: false,
name: "sessionId", name: "sessionId",
cookie: { cookie: {
secure: false, secure: config.nodeEnv === "production",
httpOnly: false, httpOnly: true,
maxAge: config.session.maxAge, maxAge: config.session.maxAge,
sameSite: "lax", sameSite: "lax",
}, },

View File

@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from "express";
import { Prisma } from '@prisma/client'; import { Prisma } from "@prisma/client";
import { AppError } from "../errors/AppError";
export interface ApiError extends Error { export interface ApiError extends Error {
statusCode?: number; statusCode?: number;
@@ -7,48 +8,58 @@ export interface ApiError extends Error {
} }
export function errorHandler( export function errorHandler(
err: ApiError, err: ApiError | AppError,
_req: Request, _req: Request,
res: Response, res: Response,
_next: NextFunction _next: NextFunction,
): void { ): 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 // Prisma errors
if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err instanceof Prisma.PrismaClientKnownRequestError) {
switch (err.code) { switch (err.code) {
case 'P2002': case "P2002":
res.status(409).json({ error: 'Resource already exists' }); res.status(409).json({ error: "Resource already exists" });
return; return;
case 'P2025': case "P2025":
res.status(404).json({ error: 'Resource not found' }); res.status(404).json({ error: "Resource not found" });
return; return;
case 'P2003': case "P2003":
res.status(400).json({ error: 'Invalid reference to related resource' }); res
.status(400)
.json({ error: "Invalid reference to related resource" });
return; return;
default: default:
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: "Database error" });
return; return;
} }
} }
if (err instanceof Prisma.PrismaClientValidationError) { if (err instanceof Prisma.PrismaClientValidationError) {
res.status(400).json({ error: 'Invalid data format' }); res.status(400).json({ error: "Invalid data format" });
return; return;
} }
const statusCode = err.statusCode || 500; // Legacy support for errors with statusCode property
const message = err.message || 'Internal server error'; const statusCode = (err as ApiError).statusCode || 500;
res.status(statusCode).json({ res.status(statusCode).json({
error: statusCode === 500 ? 'Internal server error' : message error: statusCode === 500 ? "Internal server error" : err.message,
}); });
} }
export function notFoundHandler( export function notFoundHandler(
_req: Request, _req: Request,
res: Response, res: Response,
_next: NextFunction _next: NextFunction,
): void { ): void {
res.status(404).json({ error: 'Endpoint not found' }); res.status(404).json({ error: "Endpoint not found" });
} }

View File

@@ -1,11 +1,12 @@
import { prisma } from '../prisma/client'; import { prisma } from "../prisma/client";
import type { CreateClientInput, UpdateClientInput } from '../types'; import { NotFoundError } from "../errors/AppError";
import type { CreateClientInput, UpdateClientInput } from "../types";
export class ClientService { export class ClientService {
async findAll(userId: string) { async findAll(userId: string) {
return prisma.client.findMany({ return prisma.client.findMany({
where: { userId }, where: { userId },
orderBy: { name: 'asc' }, orderBy: { name: "asc" },
}); });
} }
@@ -27,9 +28,7 @@ export class ClientService {
async update(id: string, userId: string, data: UpdateClientInput) { async update(id: string, userId: string, data: UpdateClientInput) {
const client = await this.findById(id, userId); const client = await this.findById(id, userId);
if (!client) { if (!client) {
const error = new Error('Client not found') as Error & { statusCode: number }; throw new NotFoundError("Client not found");
error.statusCode = 404;
throw error;
} }
return prisma.client.update({ return prisma.client.update({
@@ -41,9 +40,7 @@ export class ClientService {
async delete(id: string, userId: string) { async delete(id: string, userId: string) {
const client = await this.findById(id, userId); const client = await this.findById(id, userId);
if (!client) { if (!client) {
const error = new Error('Client not found') as Error & { statusCode: number }; throw new NotFoundError("Client not found");
error.statusCode = 404;
throw error;
} }
await prisma.client.delete({ await prisma.client.delete({

View File

@@ -1,6 +1,6 @@
import { prisma } from '../prisma/client'; import { prisma } from "../prisma/client";
import { NotFoundError, BadRequestError } from "../errors/AppError";
import type { CreateProjectInput, UpdateProjectInput } from '../types'; import type { CreateProjectInput, UpdateProjectInput } from "../types";
export class ProjectService { export class ProjectService {
async findAll(userId: string, clientId?: string) { async findAll(userId: string, clientId?: string) {
@@ -9,7 +9,7 @@ export class ProjectService {
userId, userId,
...(clientId && { clientId }), ...(clientId && { clientId }),
}, },
orderBy: { name: 'asc' }, orderBy: { name: "asc" },
include: { include: {
client: { client: {
select: { select: {
@@ -42,9 +42,7 @@ export class ProjectService {
}); });
if (!client) { if (!client) {
const error = new Error('Client not found or does not belong to user') as Error & { statusCode: number }; throw new BadRequestError("Client not found or does not belong to user");
error.statusCode = 400;
throw error;
} }
return prisma.project.create({ return prisma.project.create({
@@ -69,9 +67,7 @@ export class ProjectService {
async update(id: string, userId: string, data: UpdateProjectInput) { async update(id: string, userId: string, data: UpdateProjectInput) {
const project = await this.findById(id, userId); const project = await this.findById(id, userId);
if (!project) { if (!project) {
const error = new Error('Project not found') as Error & { statusCode: number }; throw new NotFoundError("Project not found");
error.statusCode = 404;
throw error;
} }
// If clientId is being updated, verify it belongs to the user // If clientId is being updated, verify it belongs to the user
@@ -81,9 +77,9 @@ export class ProjectService {
}); });
if (!client) { if (!client) {
const error = new Error('Client not found or does not belong to user') as Error & { statusCode: number }; throw new BadRequestError(
error.statusCode = 400; "Client not found or does not belong to user",
throw error; );
} }
} }
@@ -109,9 +105,7 @@ export class ProjectService {
async delete(id: string, userId: string) { async delete(id: string, userId: string) {
const project = await this.findById(id, userId); const project = await this.findById(id, userId);
if (!project) { if (!project) {
const error = new Error('Project not found') as Error & { statusCode: number }; throw new NotFoundError("Project not found");
error.statusCode = 404;
throw error;
} }
await prisma.project.delete({ await prisma.project.delete({

View File

@@ -1,4 +1,9 @@
import { prisma } from "../prisma/client"; import { prisma } from "../prisma/client";
import {
NotFoundError,
BadRequestError,
ConflictError,
} from "../errors/AppError";
import type { import type {
CreateTimeEntryInput, CreateTimeEntryInput,
UpdateTimeEntryInput, UpdateTimeEntryInput,
@@ -238,11 +243,7 @@ export class TimeEntryService {
// Validate end time is after start time // Validate end time is after start time
if (endTime <= startTime) { if (endTime <= startTime) {
const error = new Error("End time must be after start time") as Error & { throw new BadRequestError("End time must be after start time");
statusCode: number;
};
error.statusCode = 400;
throw error;
} }
// Verify the project belongs to the user // Verify the project belongs to the user
@@ -251,11 +252,7 @@ export class TimeEntryService {
}); });
if (!project) { if (!project) {
const error = new Error("Project not found") as Error & { throw new NotFoundError("Project not found");
statusCode: number;
};
error.statusCode = 404;
throw error;
} }
// Check for overlapping entries // Check for overlapping entries
@@ -265,11 +262,9 @@ export class TimeEntryService {
endTime, endTime,
); );
if (hasOverlap) { if (hasOverlap) {
const error = new Error( throw new ConflictError(
"This time entry overlaps with an existing entry", "This time entry overlaps with an existing entry",
) as Error & { statusCode: number }; );
error.statusCode = 400;
throw error;
} }
return prisma.timeEntry.create({ return prisma.timeEntry.create({
@@ -301,11 +296,7 @@ export class TimeEntryService {
async update(id: string, userId: string, data: UpdateTimeEntryInput) { async update(id: string, userId: string, data: UpdateTimeEntryInput) {
const entry = await this.findById(id, userId); const entry = await this.findById(id, userId);
if (!entry) { if (!entry) {
const error = new Error("Time entry not found") as Error & { throw new NotFoundError("Time entry not found");
statusCode: number;
};
error.statusCode = 404;
throw error;
} }
const startTime = data.startTime const startTime = data.startTime
@@ -315,11 +306,7 @@ export class TimeEntryService {
// Validate end time is after start time // Validate end time is after start time
if (endTime <= startTime) { if (endTime <= startTime) {
const error = new Error("End time must be after start time") as Error & { throw new BadRequestError("End time must be after start time");
statusCode: number;
};
error.statusCode = 400;
throw error;
} }
// If project changed, verify it belongs to the user // If project changed, verify it belongs to the user
@@ -329,11 +316,7 @@ export class TimeEntryService {
}); });
if (!project) { if (!project) {
const error = new Error("Project not found") as Error & { throw new NotFoundError("Project not found");
statusCode: number;
};
error.statusCode = 404;
throw error;
} }
} }
@@ -345,11 +328,9 @@ export class TimeEntryService {
id, id,
); );
if (hasOverlap) { if (hasOverlap) {
const error = new Error( throw new ConflictError(
"This time entry overlaps with an existing entry", "This time entry overlaps with an existing entry",
) as Error & { statusCode: number }; );
error.statusCode = 400;
throw error;
} }
return prisma.timeEntry.update({ return prisma.timeEntry.update({
@@ -381,11 +362,7 @@ export class TimeEntryService {
async delete(id: string, userId: string) { async delete(id: string, userId: string) {
const entry = await this.findById(id, userId); const entry = await this.findById(id, userId);
if (!entry) { if (!entry) {
const error = new Error("Time entry not found") as Error & { throw new NotFoundError("Time entry not found");
statusCode: number;
};
error.statusCode = 404;
throw error;
} }
await prisma.timeEntry.delete({ await prisma.timeEntry.delete({

View File

@@ -1,5 +1,14 @@
import { prisma } from '../prisma/client'; import { prisma } from "../prisma/client";
import type { StartTimerInput, UpdateTimerInput, StopTimerInput } from '../types'; import {
NotFoundError,
BadRequestError,
ConflictError,
} from "../errors/AppError";
import type {
StartTimerInput,
UpdateTimerInput,
StopTimerInput,
} from "../types";
export class TimerService { export class TimerService {
async getOngoingTimer(userId: string) { async getOngoingTimer(userId: string) {
@@ -27,9 +36,7 @@ export class TimerService {
// Check if user already has an ongoing timer // Check if user already has an ongoing timer
const existingTimer = await this.getOngoingTimer(userId); const existingTimer = await this.getOngoingTimer(userId);
if (existingTimer) { if (existingTimer) {
const error = new Error('Timer is already running') as Error & { statusCode: number }; throw new BadRequestError("Timer is already running");
error.statusCode = 400;
throw error;
} }
// If projectId provided, verify it belongs to the user // If projectId provided, verify it belongs to the user
@@ -40,9 +47,7 @@ export class TimerService {
}); });
if (!project) { if (!project) {
const error = new Error('Project not found') as Error & { statusCode: number }; throw new NotFoundError("Project not found");
error.statusCode = 404;
throw error;
} }
projectId = data.projectId; projectId = data.projectId;
@@ -75,9 +80,7 @@ export class TimerService {
async update(userId: string, data: UpdateTimerInput) { async update(userId: string, data: UpdateTimerInput) {
const timer = await this.getOngoingTimer(userId); const timer = await this.getOngoingTimer(userId);
if (!timer) { if (!timer) {
const error = new Error('No timer is running') as Error & { statusCode: number }; throw new NotFoundError("No timer is running");
error.statusCode = 404;
throw error;
} }
// If projectId is explicitly null, clear the project // If projectId is explicitly null, clear the project
@@ -92,9 +95,7 @@ export class TimerService {
}); });
if (!project) { if (!project) {
const error = new Error('Project not found') as Error & { statusCode: number }; throw new NotFoundError("Project not found");
error.statusCode = 404;
throw error;
} }
projectId = data.projectId; projectId = data.projectId;
@@ -124,9 +125,7 @@ export class TimerService {
async stop(userId: string, data?: StopTimerInput) { async stop(userId: string, data?: StopTimerInput) {
const timer = await this.getOngoingTimer(userId); const timer = await this.getOngoingTimer(userId);
if (!timer) { if (!timer) {
const error = new Error('No timer is running') as Error & { statusCode: number }; throw new NotFoundError("No timer is running");
error.statusCode = 404;
throw error;
} }
// Determine which project to use // Determine which project to use
@@ -139,9 +138,7 @@ export class TimerService {
}); });
if (!project) { if (!project) {
const error = new Error('Project not found') as Error & { statusCode: number }; throw new NotFoundError("Project not found");
error.statusCode = 404;
throw error;
} }
projectId = data.projectId; projectId = data.projectId;
@@ -149,20 +146,24 @@ export class TimerService {
// If no project is selected, throw error requiring project selection // If no project is selected, throw error requiring project selection
if (!projectId) { if (!projectId) {
const error = new Error('Please select a project before stopping the timer') as Error & { statusCode: number }; throw new BadRequestError(
error.statusCode = 400; "Please select a project before stopping the timer",
throw error; );
} }
const endTime = new Date(); const endTime = new Date();
const startTime = timer.startTime; const startTime = timer.startTime;
// Check for overlapping entries // Check for overlapping entries
const hasOverlap = await this.hasOverlappingEntries(userId, startTime, endTime); const hasOverlap = await this.hasOverlappingEntries(
userId,
startTime,
endTime,
);
if (hasOverlap) { if (hasOverlap) {
const error = new Error('This time entry overlaps with an existing entry') as Error & { statusCode: number }; throw new ConflictError(
error.statusCode = 400; "This time entry overlaps with an existing entry",
throw error; );
} }
// Delete ongoing timer and create time entry in a transaction // Delete ongoing timer and create time entry in a transaction
@@ -201,14 +202,12 @@ export class TimerService {
private async hasOverlappingEntries( private async hasOverlappingEntries(
userId: string, userId: string,
startTime: Date, startTime: Date,
endTime: Date endTime: Date,
): Promise<boolean> { ): Promise<boolean> {
const count = await prisma.timeEntry.count({ const count = await prisma.timeEntry.count({
where: { where: {
userId, userId,
OR: [ OR: [{ startTime: { lt: endTime }, endTime: { gt: startTime } }],
{ startTime: { lt: endTime }, endTime: { gt: startTime } },
],
}, },
}); });
return count > 0; return count > 0;

View File

@@ -26,7 +26,7 @@ services:
OIDC_REDIRECT_URI: "${API_URL}/auth/callback" OIDC_REDIRECT_URI: "${API_URL}/auth/callback"
SESSION_SECRET: ${SESSION_SECRET} SESSION_SECRET: ${SESSION_SECRET}
PORT: 3001 PORT: 3001
NODE_ENV: production NODE_ENV: development
APP_URL: "${APP_URL}" APP_URL: "${APP_URL}"
ports: ports:
- "3001:3001" - "3001:3001"
@@ -47,3 +47,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -4,6 +4,7 @@ import {
useState, useState,
useEffect, useEffect,
useCallback, useCallback,
useRef,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -24,9 +25,8 @@ const TimerContext = createContext<TimerContextType | undefined>(undefined);
export function TimerProvider({ children }: { children: ReactNode }) { export function TimerProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [elapsedSeconds, setElapsedSeconds] = useState(0); const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [elapsedInterval, setElapsedInterval] = useState<ReturnType< // Use ref for interval ID to avoid stale closure issues
typeof setInterval const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
> | null>(null);
const { data: ongoingTimer, isLoading } = useQuery({ const { data: ongoingTimer, isLoading } = useQuery({
queryKey: ["ongoingTimer"], queryKey: ["ongoingTimer"],
@@ -42,22 +42,28 @@ export function TimerProvider({ children }: { children: ReactNode }) {
const initialElapsed = Math.floor((now - startTime) / 1000); const initialElapsed = Math.floor((now - startTime) / 1000);
setElapsedSeconds(initialElapsed); setElapsedSeconds(initialElapsed);
// Clear any existing interval first
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Start interval to update elapsed time // Start interval to update elapsed time
const interval = setInterval(() => { intervalRef.current = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000)); setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
}, 1000); }, 1000);
setElapsedInterval(interval);
} else { } else {
setElapsedSeconds(0); setElapsedSeconds(0);
if (elapsedInterval) { if (intervalRef.current) {
clearInterval(elapsedInterval); clearInterval(intervalRef.current);
setElapsedInterval(null); intervalRef.current = null;
} }
} }
// Cleanup on unmount or when ongoingTimer changes
return () => { return () => {
if (elapsedInterval) { if (intervalRef.current) {
clearInterval(elapsedInterval); clearInterval(intervalRef.current);
intervalRef.current = null;
} }
}; };
}, [ongoingTimer]); }, [ongoingTimer]);