improvements
This commit is contained in:
41
backend/src/errors/AppError.ts
Normal file
41
backend/src/errors/AppError.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
res.status(404).json({ error: "Endpoint not found" });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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<TimerContextType | undefined>(undefined);
|
||||
export function TimerProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [elapsedInterval, setElapsedInterval] = useState<ReturnType<
|
||||
typeof setInterval
|
||||
> | null>(null);
|
||||
// Use ref for interval ID to avoid stale closure issues
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | 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]);
|
||||
|
||||
Reference in New Issue
Block a user