creates application
This commit is contained in:
53
backend/src/services/client.service.ts
Normal file
53
backend/src/services/client.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { prisma } from '../prisma/client';
|
||||
import type { CreateClientInput, UpdateClientInput } from '../types';
|
||||
|
||||
export class ClientService {
|
||||
async findAll(userId: string) {
|
||||
return prisma.client.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string) {
|
||||
return prisma.client.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateClientInput) {
|
||||
return prisma.client.create({
|
||||
data: {
|
||||
...data,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return prisma.client.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await prisma.client.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
121
backend/src/services/project.service.ts
Normal file
121
backend/src/services/project.service.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { prisma } from '../prisma/client';
|
||||
|
||||
import type { CreateProjectInput, UpdateProjectInput } from '../types';
|
||||
|
||||
export class ProjectService {
|
||||
async findAll(userId: string, clientId?: string) {
|
||||
return prisma.project.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(clientId && { clientId }),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string) {
|
||||
return prisma.project.findFirst({
|
||||
where: { id, userId },
|
||||
include: {
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateProjectInput) {
|
||||
// Verify the client belongs to the user
|
||||
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;
|
||||
}
|
||||
|
||||
return prisma.project.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
color: data.color,
|
||||
userId,
|
||||
clientId: data.clientId,
|
||||
},
|
||||
include: {
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// If clientId is being updated, verify it belongs to the user
|
||||
if (data.clientId) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
color: data.color,
|
||||
clientId: data.clientId,
|
||||
},
|
||||
include: {
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await prisma.project.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
249
backend/src/services/timeEntry.service.ts
Normal file
249
backend/src/services/timeEntry.service.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { prisma } from '../prisma/client';
|
||||
import type { CreateTimeEntryInput, UpdateTimeEntryInput, TimeEntryFilters } from '../types';
|
||||
|
||||
export class TimeEntryService {
|
||||
async findAll(userId: string, filters: TimeEntryFilters = {}) {
|
||||
const { startDate, endDate, projectId, clientId, page = 1, limit = 50 } = filters;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
where.projectId = projectId;
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
where.project = { clientId };
|
||||
}
|
||||
|
||||
const [entries, total] = await Promise.all([
|
||||
prisma.timeEntry.findMany({
|
||||
where,
|
||||
orderBy: { startTime: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.timeEntry.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
entries,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string) {
|
||||
return prisma.timeEntry.findFirst({
|
||||
where: { id, userId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateTimeEntryInput) {
|
||||
const startTime = new Date(data.startTime);
|
||||
const endTime = new Date(data.endTime);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Verify the project belongs to the user
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: data.projectId, userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
const error = new Error('Project not found') as Error & { statusCode: number };
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check for overlapping entries
|
||||
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;
|
||||
}
|
||||
|
||||
return prisma.timeEntry.create({
|
||||
data: {
|
||||
startTime,
|
||||
endTime,
|
||||
description: data.description,
|
||||
userId,
|
||||
projectId: data.projectId,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const startTime = data.startTime ? new Date(data.startTime) : entry.startTime;
|
||||
const endTime = data.endTime ? new Date(data.endTime) : entry.endTime;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// If project changed, verify it belongs to the user
|
||||
if (data.projectId && data.projectId !== entry.projectId) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: data.projectId, userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
const error = new Error('Project not found') as Error & { statusCode: number };
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for overlapping entries (excluding this entry)
|
||||
const hasOverlap = await this.hasOverlappingEntries(userId, startTime, endTime, id);
|
||||
if (hasOverlap) {
|
||||
const error = new Error('This time entry overlaps with an existing entry') as Error & { statusCode: number };
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return prisma.timeEntry.update({
|
||||
where: { id },
|
||||
data: {
|
||||
startTime,
|
||||
endTime,
|
||||
description: data.description,
|
||||
projectId: data.projectId,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await prisma.timeEntry.delete({
|
||||
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;
|
||||
}
|
||||
}
|
||||
216
backend/src/services/timer.service.ts
Normal file
216
backend/src/services/timer.service.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { prisma } from '../prisma/client';
|
||||
import type { StartTimerInput, UpdateTimerInput, StopTimerInput } from '../types';
|
||||
|
||||
export class TimerService {
|
||||
async getOngoingTimer(userId: string) {
|
||||
return prisma.ongoingTimer.findUnique({
|
||||
where: { userId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async start(userId: string, data?: StartTimerInput) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// If projectId provided, verify it belongs to the user
|
||||
let projectId: string | null = null;
|
||||
if (data?.projectId) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: data.projectId, userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
const error = new Error('Project not found') as Error & { statusCode: number };
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
projectId = data.projectId;
|
||||
}
|
||||
|
||||
return prisma.ongoingTimer.create({
|
||||
data: {
|
||||
startTime: new Date(),
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// If projectId is explicitly null, clear the project
|
||||
// If projectId is a string, verify it belongs to the user
|
||||
let projectId: string | null | undefined = undefined;
|
||||
|
||||
if (data.projectId === null) {
|
||||
projectId = null;
|
||||
} else if (data.projectId) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: data.projectId, userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
const error = new Error('Project not found') as Error & { statusCode: number };
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
projectId = data.projectId;
|
||||
}
|
||||
|
||||
return prisma.ongoingTimer.update({
|
||||
where: { userId },
|
||||
data: projectId !== undefined ? { projectId } : {},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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({
|
||||
where: { id: data.projectId, userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
const error = new Error('Project not found') as Error & { statusCode: number };
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
projectId = data.projectId;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const startTime = timer.startTime;
|
||||
|
||||
// Check for overlapping entries
|
||||
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;
|
||||
}
|
||||
|
||||
// Delete ongoing timer and create time entry in a transaction
|
||||
const result = await prisma.$transaction([
|
||||
prisma.ongoingTimer.delete({
|
||||
where: { userId },
|
||||
}),
|
||||
prisma.timeEntry.create({
|
||||
data: {
|
||||
startTime,
|
||||
endTime,
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user