creates application

This commit is contained in:
simon.franken
2026-02-16 10:15:27 +01:00
parent 791c661395
commit 7d678c1c4d
65 changed files with 10389 additions and 0 deletions

25
backend/Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

30
backend/package.json Normal file
View 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"
}
}

View 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"

View 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
View 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;
}
}

View 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
View 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);
});

View 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,
},
});
}

View 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' });
}

View 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);
}
};
}

View 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();
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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(),
});

View 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 },
});
}
}

View 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 },
});
}
}

View 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;
}
}

View 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;
}
}

View 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
View 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
View 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"]
}