creates application
This commit is contained in:
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Start the application
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && npm start"]
|
||||
1791
backend/package-lock.json
generated
Normal file
1791
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
backend/package.json
Normal file
30
backend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "timetracker-backend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:generate": "prisma generate",
|
||||
"db:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"openid-client": "^5.6.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.17.10",
|
||||
"@types/node": "^20.10.5",
|
||||
"prisma": "^5.7.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
93
backend/prisma/schema.prisma
Normal file
93
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,93 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @db.VarChar(255)
|
||||
username String @db.VarChar(255)
|
||||
email String @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
clients Client[]
|
||||
projects Project[]
|
||||
timeEntries TimeEntry[]
|
||||
ongoingTimer OngoingTimer?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Client {
|
||||
id String @id @default(uuid())
|
||||
name String @db.VarChar(255)
|
||||
description String? @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
userId String @map("user_id") @db.VarChar(255)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
projects Project[]
|
||||
|
||||
@@index([userId])
|
||||
@@map("clients")
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(uuid())
|
||||
name String @db.VarChar(255)
|
||||
description String? @db.Text
|
||||
color String? @db.VarChar(7) // Hex color code
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
userId String @map("user_id") @db.VarChar(255)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
clientId String @map("client_id")
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
timeEntries TimeEntry[]
|
||||
ongoingTimer OngoingTimer?
|
||||
|
||||
@@index([userId])
|
||||
@@index([clientId])
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
model TimeEntry {
|
||||
id String @id @default(uuid())
|
||||
startTime DateTime @map("start_time") @db.Timestamptz()
|
||||
endTime DateTime @map("end_time") @db.Timestamptz()
|
||||
description String? @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
userId String @map("user_id") @db.VarChar(255)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, startTime])
|
||||
@@index([projectId])
|
||||
@@map("time_entries")
|
||||
}
|
||||
|
||||
model OngoingTimer {
|
||||
id String @id @default(uuid())
|
||||
startTime DateTime @map("start_time") @db.Timestamptz()
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
userId String @map("user_id") @db.VarChar(255) @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
projectId String? @map("project_id")
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@map("ongoing_timers")
|
||||
}
|
||||
115
backend/src/auth/oidc.ts
Normal file
115
backend/src/auth/oidc.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Issuer, generators, Client, TokenSet } from 'openid-client';
|
||||
import { config } from '../config';
|
||||
import type { AuthenticatedUser } from '../types';
|
||||
|
||||
let oidcClient: Client | null = null;
|
||||
|
||||
export async function initializeOIDC(): Promise<void> {
|
||||
try {
|
||||
const issuer = await Issuer.discover(config.oidc.issuerUrl);
|
||||
|
||||
oidcClient = new issuer.Client({
|
||||
client_id: config.oidc.clientId,
|
||||
redirect_uris: [config.oidc.redirectUri],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none', // PKCE flow - no client secret
|
||||
});
|
||||
|
||||
console.log('OIDC client initialized');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize OIDC client:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOIDCClient(): Client {
|
||||
if (!oidcClient) {
|
||||
throw new Error('OIDC client not initialized');
|
||||
}
|
||||
return oidcClient;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
codeVerifier: string;
|
||||
state: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
export function createAuthSession(): AuthSession {
|
||||
return {
|
||||
codeVerifier: generators.codeVerifier(),
|
||||
state: generators.state(),
|
||||
nonce: generators.nonce(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthorizationUrl(session: AuthSession): string {
|
||||
const client = getOIDCClient();
|
||||
const codeChallenge = generators.codeChallenge(session.codeVerifier);
|
||||
|
||||
return client.authorizationUrl({
|
||||
scope: 'openid profile email',
|
||||
state: session.state,
|
||||
nonce: session.nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCallback(
|
||||
params: Record<string, string>,
|
||||
session: AuthSession
|
||||
): Promise<TokenSet> {
|
||||
const client = getOIDCClient();
|
||||
|
||||
const tokenSet = await client.callback(
|
||||
config.oidc.redirectUri,
|
||||
params,
|
||||
{
|
||||
code_verifier: session.codeVerifier,
|
||||
state: session.state,
|
||||
nonce: session.nonce,
|
||||
}
|
||||
);
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
export async function getUserInfo(tokenSet: TokenSet): Promise<AuthenticatedUser> {
|
||||
const client = getOIDCClient();
|
||||
|
||||
const claims = tokenSet.claims();
|
||||
|
||||
// Try to get more detailed userinfo if available
|
||||
let userInfo: Record<string, unknown> = {};
|
||||
try {
|
||||
userInfo = await client.userinfo(tokenSet);
|
||||
} catch {
|
||||
// Some providers don't support userinfo endpoint
|
||||
// We'll use the claims from the ID token
|
||||
}
|
||||
|
||||
const id = String(claims.sub);
|
||||
const username = String(userInfo.preferred_username || claims.preferred_username || claims.name || id);
|
||||
const email = String(userInfo.email || claims.email || '');
|
||||
|
||||
if (!email) {
|
||||
throw new Error('Email not provided by OIDC provider');
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyToken(tokenSet: TokenSet): Promise<boolean> {
|
||||
try {
|
||||
const client = getOIDCClient();
|
||||
await client.userinfo(tokenSet);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
47
backend/src/config/index.ts
Normal file
47
backend/src/config/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || '',
|
||||
},
|
||||
|
||||
oidc: {
|
||||
issuerUrl: process.env.OIDC_ISSUER_URL || '',
|
||||
clientId: process.env.OIDC_CLIENT_ID || '',
|
||||
redirectUri: process.env.OIDC_REDIRECT_URI || 'http://localhost:3001/auth/callback',
|
||||
},
|
||||
|
||||
session: {
|
||||
secret: process.env.SESSION_SECRET || 'default-secret-change-in-production',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function validateConfig(): void {
|
||||
const required = [
|
||||
'DATABASE_URL',
|
||||
'OIDC_ISSUER_URL',
|
||||
'OIDC_CLIENT_ID',
|
||||
];
|
||||
|
||||
for (const key of required) {
|
||||
if (!process.env[key]) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.session.secret.length < 32) {
|
||||
console.warn('Warning: SESSION_SECRET should be at least 32 characters for security');
|
||||
}
|
||||
}
|
||||
74
backend/src/index.ts
Normal file
74
backend/src/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import session from 'express-session';
|
||||
import { config, validateConfig } from './config';
|
||||
import { connectDatabase } from './prisma/client';
|
||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||
|
||||
// Import routes
|
||||
import authRoutes from './routes/auth.routes';
|
||||
import clientRoutes from './routes/client.routes';
|
||||
import projectRoutes from './routes/project.routes';
|
||||
import timeEntryRoutes from './routes/timeEntry.routes';
|
||||
import timerRoutes from './routes/timer.routes';
|
||||
|
||||
async function main() {
|
||||
// Validate configuration
|
||||
validateConfig();
|
||||
|
||||
// Connect to database
|
||||
await connectDatabase();
|
||||
|
||||
const app = express();
|
||||
|
||||
// CORS
|
||||
app.use(cors({
|
||||
origin: config.cors.origin,
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Session
|
||||
app.use(session({
|
||||
secret: config.session.secret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
name: 'sessionId',
|
||||
cookie: {
|
||||
secure: config.nodeEnv === 'production',
|
||||
httpOnly: true,
|
||||
maxAge: config.session.maxAge,
|
||||
sameSite: config.nodeEnv === 'production' ? 'strict' : 'lax',
|
||||
},
|
||||
}));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/api/clients', clientRoutes);
|
||||
app.use('/api/projects', projectRoutes);
|
||||
app.use('/api/time-entries', timeEntryRoutes);
|
||||
app.use('/api/timer', timerRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
// Start server
|
||||
app.listen(config.port, () => {
|
||||
console.log(`Server running on port ${config.port}`);
|
||||
console.log(`Environment: ${config.nodeEnv}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
43
backend/src/middleware/auth.ts
Normal file
43
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../prisma/client';
|
||||
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
||||
|
||||
export function requireAuth(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
if (!req.session?.user) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = req.session.user as AuthenticatedUser;
|
||||
next();
|
||||
}
|
||||
|
||||
export function optionalAuth(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
if (req.session?.user) {
|
||||
req.user = req.session.user as AuthenticatedUser;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export async function syncUser(user: AuthenticatedUser): Promise<void> {
|
||||
await prisma.user.upsert({
|
||||
where: { id: user.id },
|
||||
update: {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
create: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
54
backend/src/middleware/errorHandler.ts
Normal file
54
backend/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export interface ApiError extends Error {
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
err: ApiError,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
): void {
|
||||
console.error('Error:', err);
|
||||
|
||||
// Prisma errors
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
switch (err.code) {
|
||||
case 'P2002':
|
||||
res.status(409).json({ error: 'Resource already exists' });
|
||||
return;
|
||||
case 'P2025':
|
||||
res.status(404).json({ error: 'Resource not found' });
|
||||
return;
|
||||
case 'P2003':
|
||||
res.status(400).json({ error: 'Invalid reference to related resource' });
|
||||
return;
|
||||
default:
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (err instanceof Prisma.PrismaClientValidationError) {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
export function notFoundHandler(
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
): void {
|
||||
res.status(404).json({ error: 'Endpoint not found' });
|
||||
}
|
||||
51
backend/src/middleware/validation.ts
Normal file
51
backend/src/middleware/validation.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodSchema, ZodError } from 'zod';
|
||||
|
||||
export function validateBody<T>(schema: ZodSchema<T>) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
req.body = schema.parse(req.body);
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const errors = error.errors.map(e => ({
|
||||
path: e.path.join('.'),
|
||||
message: e.message,
|
||||
}));
|
||||
res.status(400).json({ error: 'Validation failed', details: errors });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function validateParams<T>(schema: ZodSchema<T>) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
req.params = schema.parse(req.params) as typeof req.params;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
res.status(400).json({ error: 'Invalid parameters', details: error.errors });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function validateQuery<T>(schema: ZodSchema<T>) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
req.query = schema.parse(req.query) as typeof req.query;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
res.status(400).json({ error: 'Invalid query parameters', details: error.errors });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
25
backend/src/prisma/client.ts
Normal file
25
backend/src/prisma/client.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
export async function connectDatabase(): Promise<void> {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
console.log('Connected to database');
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDatabase(): Promise<void> {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
86
backend/src/routes/auth.routes.ts
Normal file
86
backend/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Router } from 'express';
|
||||
import { initializeOIDC, createAuthSession, getAuthorizationUrl, handleCallback, getUserInfo } from '../auth/oidc';
|
||||
import { syncUser } from '../middleware/auth';
|
||||
import type { AuthenticatedRequest } from '../types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Initialize OIDC on first request
|
||||
let oidcInitialized = false;
|
||||
|
||||
async function ensureOIDC() {
|
||||
if (!oidcInitialized) {
|
||||
await initializeOIDC();
|
||||
oidcInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/login - Initiate OIDC login flow
|
||||
router.get('/login', async (req, res) => {
|
||||
try {
|
||||
await ensureOIDC();
|
||||
|
||||
const session = createAuthSession();
|
||||
req.session.oidc = session;
|
||||
|
||||
const authorizationUrl = getAuthorizationUrl(session);
|
||||
res.redirect(authorizationUrl);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Failed to initiate login' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /auth/callback - OIDC callback handler
|
||||
router.get('/callback', async (req, res) => {
|
||||
try {
|
||||
await ensureOIDC();
|
||||
|
||||
const oidcSession = req.session.oidc;
|
||||
if (!oidcSession) {
|
||||
res.status(400).json({ error: 'Invalid session' });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenSet = await handleCallback(req.query as Record<string, string>, oidcSession);
|
||||
const user = await getUserInfo(tokenSet);
|
||||
|
||||
// Sync user with database
|
||||
await syncUser(user);
|
||||
|
||||
// Store user in session
|
||||
req.session.user = user;
|
||||
delete req.session.oidc;
|
||||
|
||||
// Redirect to frontend
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
res.redirect(`${frontendUrl}/auth/callback?success=true`);
|
||||
} catch (error) {
|
||||
console.error('Callback error:', error);
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
res.redirect(`${frontendUrl}/auth/callback?error=authentication_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /auth/logout - End session
|
||||
router.post('/logout', (req: AuthenticatedRequest, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: 'Failed to logout' });
|
||||
return;
|
||||
}
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
export default router;
|
||||
67
backend/src/routes/client.routes.ts
Normal file
67
backend/src/routes/client.routes.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { validateBody, validateParams } from '../middleware/validation';
|
||||
import { ClientService } from '../services/client.service';
|
||||
import { CreateClientSchema, UpdateClientSchema, IdSchema } from '../schemas';
|
||||
import type { AuthenticatedRequest } from '../types';
|
||||
|
||||
const router = Router();
|
||||
const clientService = new ClientService();
|
||||
|
||||
// GET /api/clients - List user's clients
|
||||
router.get('/', requireAuth, async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const clients = await clientService.findAll(req.user!.id);
|
||||
res.json(clients);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/clients - Create client
|
||||
router.post(
|
||||
'/',
|
||||
requireAuth,
|
||||
validateBody(CreateClientSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const client = await clientService.create(req.user!.id, req.body);
|
||||
res.status(201).json(client);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/clients/:id - Update client
|
||||
router.put(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
validateBody(UpdateClientSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const client = await clientService.update(req.params.id, req.user!.id, req.body);
|
||||
res.json(client);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/clients/:id - Delete client
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
await clientService.delete(req.params.id, req.user!.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
78
backend/src/routes/project.routes.ts
Normal file
78
backend/src/routes/project.routes.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { validateBody, validateParams, validateQuery } from '../middleware/validation';
|
||||
import { ProjectService } from '../services/project.service';
|
||||
import { CreateProjectSchema, UpdateProjectSchema, IdSchema } from '../schemas';
|
||||
import { z } from 'zod';
|
||||
import type { AuthenticatedRequest } from '../types';
|
||||
|
||||
const router = Router();
|
||||
const projectService = new ProjectService();
|
||||
|
||||
const QuerySchema = z.object({
|
||||
clientId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
// GET /api/projects - List user's projects
|
||||
router.get(
|
||||
'/',
|
||||
requireAuth,
|
||||
validateQuery(QuerySchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const { clientId } = req.query as { clientId?: string };
|
||||
const projects = await projectService.findAll(req.user!.id, clientId);
|
||||
res.json(projects);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/projects - Create project
|
||||
router.post(
|
||||
'/',
|
||||
requireAuth,
|
||||
validateBody(CreateProjectSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const project = await projectService.create(req.user!.id, req.body);
|
||||
res.status(201).json(project);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/projects/:id - Update project
|
||||
router.put(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
validateBody(UpdateProjectSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const project = await projectService.update(req.params.id, req.user!.id, req.body);
|
||||
res.json(project);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/projects/:id - Delete project
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
await projectService.delete(req.params.id, req.user!.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
72
backend/src/routes/timeEntry.routes.ts
Normal file
72
backend/src/routes/timeEntry.routes.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { validateBody, validateParams, validateQuery } from '../middleware/validation';
|
||||
import { TimeEntryService } from '../services/timeEntry.service';
|
||||
import { CreateTimeEntrySchema, UpdateTimeEntrySchema, IdSchema, TimeEntryFiltersSchema } from '../schemas';
|
||||
import type { AuthenticatedRequest } from '../types';
|
||||
|
||||
const router = Router();
|
||||
const timeEntryService = new TimeEntryService();
|
||||
|
||||
// GET /api/time-entries - List user's entries
|
||||
router.get(
|
||||
'/',
|
||||
requireAuth,
|
||||
validateQuery(TimeEntryFiltersSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const result = await timeEntryService.findAll(req.user!.id, req.query);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/time-entries - Create entry manually
|
||||
router.post(
|
||||
'/',
|
||||
requireAuth,
|
||||
validateBody(CreateTimeEntrySchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const entry = await timeEntryService.create(req.user!.id, req.body);
|
||||
res.status(201).json(entry);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/time-entries/:id - Update entry
|
||||
router.put(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
validateBody(UpdateTimeEntrySchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const entry = await timeEntryService.update(req.params.id, req.user!.id, req.body);
|
||||
res.json(entry);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/time-entries/:id - Delete entry
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
await timeEntryService.delete(req.params.id, req.user!.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
66
backend/src/routes/timer.routes.ts
Normal file
66
backend/src/routes/timer.routes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { validateBody } from '../middleware/validation';
|
||||
import { TimerService } from '../services/timer.service';
|
||||
import { StartTimerSchema, UpdateTimerSchema, StopTimerSchema } from '../schemas';
|
||||
import type { AuthenticatedRequest } from '../types';
|
||||
|
||||
const router = Router();
|
||||
const timerService = new TimerService();
|
||||
|
||||
// GET /api/timer - Get current ongoing timer
|
||||
router.get('/', requireAuth, async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const timer = await timerService.getOngoingTimer(req.user!.id);
|
||||
res.json(timer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/timer/start - Start timer
|
||||
router.post(
|
||||
'/start',
|
||||
requireAuth,
|
||||
validateBody(StartTimerSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const timer = await timerService.start(req.user!.id, req.body);
|
||||
res.status(201).json(timer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/timer - Update ongoing timer
|
||||
router.put(
|
||||
'/',
|
||||
requireAuth,
|
||||
validateBody(UpdateTimerSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const timer = await timerService.update(req.user!.id, req.body);
|
||||
res.json(timer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/timer/stop - Stop timer
|
||||
router.post(
|
||||
'/stop',
|
||||
requireAuth,
|
||||
validateBody(StopTimerSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const entry = await timerService.stop(req.user!.id, req.body);
|
||||
res.json(entry);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
64
backend/src/schemas/index.ts
Normal file
64
backend/src/schemas/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const IdSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const CreateClientSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const UpdateClientSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const CreateProjectSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().max(1000).optional(),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
|
||||
clientId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const UpdateProjectSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().nullable(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const CreateTimeEntrySchema = z.object({
|
||||
startTime: z.string().datetime(),
|
||||
endTime: z.string().datetime(),
|
||||
description: z.string().max(1000).optional(),
|
||||
projectId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const UpdateTimeEntrySchema = z.object({
|
||||
startTime: z.string().datetime().optional(),
|
||||
endTime: z.string().datetime().optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const TimeEntryFiltersSchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
});
|
||||
|
||||
export const StartTimerSchema = z.object({
|
||||
projectId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const UpdateTimerSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
export const StopTimerSchema = z.object({
|
||||
projectId: z.string().uuid().optional(),
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
70
backend/src/types/index.ts
Normal file
70
backend/src/types/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
export interface CreateClientInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateClientInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateProjectInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export interface UpdateProjectInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface CreateTimeEntryInput {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
description?: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface UpdateTimeEntryInput {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface TimeEntryFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
projectId?: string;
|
||||
clientId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface StartTimerInput {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTimerInput {
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
export interface StopTimerInput {
|
||||
projectId?: string;
|
||||
}
|
||||
10
backend/src/types/session.d.ts
vendored
Normal file
10
backend/src/types/session.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'express-session';
|
||||
import type { AuthenticatedUser } from './index';
|
||||
import type { AuthSession } from '../auth/oidc';
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
user?: AuthenticatedUser;
|
||||
oidc?: AuthSession;
|
||||
}
|
||||
}
|
||||
18
backend/tsconfig.json
Normal file
18
backend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user