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,
|
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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user