refactoring
This commit is contained in:
@@ -40,8 +40,8 @@ export function validateConfig(): void {
|
||||
}
|
||||
|
||||
if (config.session.secret.length < 32) {
|
||||
console.warn(
|
||||
"Warning: SESSION_SECRET should be at least 32 characters for security",
|
||||
throw new Error(
|
||||
"SESSION_SECRET must be at least 32 characters. Set a strong secret in your environment.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
handleCallback,
|
||||
getUserInfo,
|
||||
} from "../auth/oidc";
|
||||
import { syncUser } from "../middleware/auth";
|
||||
import { requireAuth, syncUser } from "../middleware/auth";
|
||||
import type { AuthenticatedRequest } from "../types";
|
||||
|
||||
const router = Router();
|
||||
@@ -84,12 +84,8 @@ router.post("/logout", (req: AuthenticatedRequest, res) => {
|
||||
});
|
||||
|
||||
// GET /auth/me - Get current user
|
||||
router.get("/me", (req: AuthenticatedRequest, res) => {
|
||||
if (!req.session?.user) {
|
||||
res.status(401).json({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
res.json(req.session.user);
|
||||
router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => {
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { prisma } from "../prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import {
|
||||
NotFoundError,
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
} from "../errors/AppError";
|
||||
import { hasOverlappingEntries } from "../utils/timeUtils";
|
||||
import type {
|
||||
CreateTimeEntryInput,
|
||||
UpdateTimeEntryInput,
|
||||
@@ -14,131 +16,93 @@ import type {
|
||||
export class TimeEntryService {
|
||||
async getStatistics(userId: string, filters: StatisticsFilters = {}) {
|
||||
const { startDate, endDate, projectId, clientId } = filters;
|
||||
const where: {
|
||||
userId: string;
|
||||
startTime?: { gte?: Date; lte?: Date };
|
||||
projectId?: string;
|
||||
project?: { clientId?: string };
|
||||
} = { userId };
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.startTime = {};
|
||||
if (startDate) where.startTime.gte = new Date(startDate);
|
||||
if (endDate) where.startTime.lte = new Date(endDate);
|
||||
}
|
||||
// Build an array of safe Prisma SQL filter fragments to append as AND clauses.
|
||||
const extraFilters: Prisma.Sql[] = [];
|
||||
if (startDate) extraFilters.push(Prisma.sql`AND te.start_time >= ${new Date(startDate)}`);
|
||||
if (endDate) extraFilters.push(Prisma.sql`AND te.start_time <= ${new Date(endDate)}`);
|
||||
if (projectId) extraFilters.push(Prisma.sql`AND te.project_id = ${projectId}`);
|
||||
if (clientId) extraFilters.push(Prisma.sql`AND p.client_id = ${clientId}`);
|
||||
|
||||
if (projectId) {
|
||||
where.projectId = projectId;
|
||||
}
|
||||
const filterClause = extraFilters.length
|
||||
? Prisma.join(extraFilters, " ")
|
||||
: Prisma.empty;
|
||||
|
||||
if (clientId) {
|
||||
where.project = { clientId };
|
||||
}
|
||||
|
||||
const entries = await prisma.timeEntry.findMany({
|
||||
where,
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate total duration in seconds
|
||||
const totalSeconds = entries.reduce((total, entry) => {
|
||||
const startTime = new Date(entry.startTime);
|
||||
const endTime = new Date(entry.endTime);
|
||||
return (
|
||||
total + Math.floor((endTime.getTime() - startTime.getTime()) / 1000)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
// Calculate by project
|
||||
const byProject = entries.reduce(
|
||||
(acc, entry) => {
|
||||
const projectId = entry.project.id;
|
||||
const projectName = entry.project.name;
|
||||
const projectColor = entry.project.color;
|
||||
const startTime = new Date(entry.startTime);
|
||||
const endTime = new Date(entry.endTime);
|
||||
const duration = Math.floor(
|
||||
(endTime.getTime() - startTime.getTime()) / 1000,
|
||||
);
|
||||
|
||||
if (!acc[projectId]) {
|
||||
acc[projectId] = {
|
||||
projectId,
|
||||
projectName,
|
||||
projectColor,
|
||||
totalSeconds: 0,
|
||||
entryCount: 0,
|
||||
};
|
||||
}
|
||||
acc[projectId].totalSeconds += duration;
|
||||
acc[projectId].entryCount += 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
const [projectGroups, clientGroups, totalAgg] = await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
{
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectColor: string | null;
|
||||
totalSeconds: number;
|
||||
entryCount: number;
|
||||
}
|
||||
>,
|
||||
);
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
project_color: string | null;
|
||||
total_seconds: bigint;
|
||||
entry_count: bigint;
|
||||
}[]
|
||||
>(Prisma.sql`
|
||||
SELECT
|
||||
p.id AS project_id,
|
||||
p.name AS project_name,
|
||||
p.color AS project_color,
|
||||
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds,
|
||||
COUNT(te.id)::bigint AS entry_count
|
||||
FROM time_entries te
|
||||
JOIN projects p ON p.id = te.project_id
|
||||
WHERE te.user_id = ${userId}
|
||||
${filterClause}
|
||||
GROUP BY p.id, p.name, p.color
|
||||
ORDER BY total_seconds DESC
|
||||
`),
|
||||
|
||||
// Calculate by client
|
||||
const byClient = entries.reduce(
|
||||
(acc, entry) => {
|
||||
const clientId = entry.project.client.id;
|
||||
const clientName = entry.project.client.name;
|
||||
const startTime = new Date(entry.startTime);
|
||||
const endTime = new Date(entry.endTime);
|
||||
const duration = Math.floor(
|
||||
(endTime.getTime() - startTime.getTime()) / 1000,
|
||||
);
|
||||
|
||||
if (!acc[clientId]) {
|
||||
acc[clientId] = {
|
||||
clientId,
|
||||
clientName,
|
||||
totalSeconds: 0,
|
||||
entryCount: 0,
|
||||
};
|
||||
}
|
||||
acc[clientId].totalSeconds += duration;
|
||||
acc[clientId].entryCount += 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
prisma.$queryRaw<
|
||||
{
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
totalSeconds: number;
|
||||
entryCount: number;
|
||||
}
|
||||
>,
|
||||
);
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
total_seconds: bigint;
|
||||
entry_count: bigint;
|
||||
}[]
|
||||
>(Prisma.sql`
|
||||
SELECT
|
||||
c.id AS client_id,
|
||||
c.name AS client_name,
|
||||
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds,
|
||||
COUNT(te.id)::bigint AS entry_count
|
||||
FROM time_entries te
|
||||
JOIN projects p ON p.id = te.project_id
|
||||
JOIN clients c ON c.id = p.client_id
|
||||
WHERE te.user_id = ${userId}
|
||||
${filterClause}
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY total_seconds DESC
|
||||
`),
|
||||
|
||||
prisma.$queryRaw<{ total_seconds: bigint; entry_count: bigint }[]>(
|
||||
Prisma.sql`
|
||||
SELECT
|
||||
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds,
|
||||
COUNT(te.id)::bigint AS entry_count
|
||||
FROM time_entries te
|
||||
JOIN projects p ON p.id = te.project_id
|
||||
WHERE te.user_id = ${userId}
|
||||
${filterClause}
|
||||
`,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalSeconds,
|
||||
entryCount: entries.length,
|
||||
byProject: Object.values(byProject),
|
||||
byClient: Object.values(byClient),
|
||||
totalSeconds: Number(totalAgg[0]?.total_seconds ?? 0),
|
||||
entryCount: Number(totalAgg[0]?.entry_count ?? 0),
|
||||
byProject: projectGroups.map((r) => ({
|
||||
projectId: r.project_id,
|
||||
projectName: r.project_name,
|
||||
projectColor: r.project_color,
|
||||
totalSeconds: Number(r.total_seconds),
|
||||
entryCount: Number(r.entry_count),
|
||||
})),
|
||||
byClient: clientGroups.map((r) => ({
|
||||
clientId: r.client_id,
|
||||
clientName: r.client_name,
|
||||
totalSeconds: Number(r.total_seconds),
|
||||
entryCount: Number(r.entry_count),
|
||||
})),
|
||||
filters: {
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
@@ -256,7 +220,7 @@ export class TimeEntryService {
|
||||
}
|
||||
|
||||
// Check for overlapping entries
|
||||
const hasOverlap = await this.hasOverlappingEntries(
|
||||
const hasOverlap = await hasOverlappingEntries(
|
||||
userId,
|
||||
startTime,
|
||||
endTime,
|
||||
@@ -321,7 +285,7 @@ export class TimeEntryService {
|
||||
}
|
||||
|
||||
// Check for overlapping entries (excluding this entry)
|
||||
const hasOverlap = await this.hasOverlappingEntries(
|
||||
const hasOverlap = await hasOverlappingEntries(
|
||||
userId,
|
||||
startTime,
|
||||
endTime,
|
||||
@@ -369,33 +333,4 @@ export class TimeEntryService {
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
private async hasOverlappingEntries(
|
||||
userId: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
excludeId?: string,
|
||||
): Promise<boolean> {
|
||||
const where: {
|
||||
userId: string;
|
||||
id?: { not: string };
|
||||
OR: Array<{
|
||||
startTime?: { lt: Date };
|
||||
endTime?: { gt: Date };
|
||||
}>;
|
||||
} = {
|
||||
userId,
|
||||
OR: [
|
||||
// Entry starts during the new entry
|
||||
{ startTime: { lt: endTime }, endTime: { gt: startTime } },
|
||||
],
|
||||
};
|
||||
|
||||
if (excludeId) {
|
||||
where.id = { not: excludeId };
|
||||
}
|
||||
|
||||
const count = await prisma.timeEntry.count({ where });
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
} from "../errors/AppError";
|
||||
import { hasOverlappingEntries } from "../utils/timeUtils";
|
||||
import type {
|
||||
StartTimerInput,
|
||||
UpdateTimerInput,
|
||||
@@ -155,7 +156,7 @@ export class TimerService {
|
||||
const startTime = timer.startTime;
|
||||
|
||||
// Check for overlapping entries
|
||||
const hasOverlap = await this.hasOverlappingEntries(
|
||||
const hasOverlap = await hasOverlappingEntries(
|
||||
userId,
|
||||
startTime,
|
||||
endTime,
|
||||
@@ -198,18 +199,4 @@ export class TimerService {
|
||||
|
||||
return result[1]; // Return the created time entry
|
||||
}
|
||||
|
||||
private async hasOverlappingEntries(
|
||||
userId: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
): Promise<boolean> {
|
||||
const count = await prisma.timeEntry.count({
|
||||
where: {
|
||||
userId,
|
||||
OR: [{ startTime: { lt: endTime }, endTime: { gt: startTime } }],
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
29
backend/src/utils/timeUtils.ts
Normal file
29
backend/src/utils/timeUtils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { prisma } from "../prisma/client";
|
||||
|
||||
/**
|
||||
* Checks whether a user already has a time entry that overlaps with the given
|
||||
* [startTime, endTime] interval.
|
||||
*
|
||||
* @param userId - The user whose entries are checked.
|
||||
* @param startTime - Start of the interval to check.
|
||||
* @param endTime - End of the interval to check.
|
||||
* @param excludeId - Optional time-entry id to exclude (used when updating an
|
||||
* existing entry so it does not collide with itself).
|
||||
*/
|
||||
export async function hasOverlappingEntries(
|
||||
userId: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
excludeId?: string,
|
||||
): Promise<boolean> {
|
||||
const count = await prisma.timeEntry.count({
|
||||
where: {
|
||||
userId,
|
||||
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||
// An entry overlaps when it starts before our end AND ends after our start.
|
||||
startTime: { lt: endTime },
|
||||
endTime: { gt: startTime },
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user