diff --git a/src/main.ts b/src/main.ts index 3818ddd..ddb2784 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { timesheetsRouter } from "./routes/timesheets.ts"; import { modulesRouter } from "./routes/modules.ts"; import { organizationsRouter } from "./routes/organizations.ts"; import { adminRouter } from "./routes/admin.ts"; +import { partnershipsRouter } from "./routes/partnerships.ts"; import { errorHandler } from "./middleware/error.ts"; import { requestLogger } from "./middleware/logger.ts"; import { initDB } from "./db/postgres.ts"; @@ -47,6 +48,8 @@ app.use(organizationsRouter.routes()); app.use(organizationsRouter.allowedMethods()); app.use(adminRouter.routes()); app.use(adminRouter.allowedMethods()); +app.use(partnershipsRouter.routes()); +app.use(partnershipsRouter.allowedMethods()); // Health check app.use((ctx) => { diff --git a/src/routes/partnerships.ts b/src/routes/partnerships.ts new file mode 100644 index 0000000..8ef7e74 --- /dev/null +++ b/src/routes/partnerships.ts @@ -0,0 +1,673 @@ +import { Router } from "@oak/oak"; +import { query, queryOne, execute } from "../db/postgres.ts"; +import { AppError } from "../middleware/error.ts"; +import { authMiddleware, requireChef } from "../middleware/auth.ts"; + +export const partnershipsRouter = new Router({ prefix: "/api/partnerships" }); + +// ============ PARTNERSHIP MANAGEMENT ============ + +// Get all partnerships (as contractor or subcontractor) +partnershipsRouter.get("/", authMiddleware, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + + const partnerships = await query( + `SELECT p.*, + c.name as contractor_name, c.slug as contractor_slug, + s.name as subcontractor_name, s.slug as subcontractor_slug, + (SELECT COUNT(*) FROM shared_orders WHERE partnership_id = p.id) as shared_orders_count, + (SELECT COUNT(*) FROM partnership_rates WHERE partnership_id = p.id) as rates_count + FROM org_partnerships p + JOIN organizations c ON p.contractor_org_id = c.id + JOIN organizations s ON p.subcontractor_org_id = s.id + WHERE p.contractor_org_id = $1 OR p.subcontractor_org_id = $1 + ORDER BY p.created_at DESC`, + [orgId] + ); + + // Mark role for each partnership + const result = partnerships.map(p => ({ + ...p, + my_role: p.contractor_org_id === orgId ? 'contractor' : 'subcontractor' + })); + + ctx.response.body = { partnerships: result }; +}); + +// Get single partnership with details +partnershipsRouter.get("/:id", authMiddleware, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + const partnershipId = ctx.params.id; + + const partnership = await queryOne( + `SELECT p.*, + c.name as contractor_name, c.slug as contractor_slug, + s.name as subcontractor_name, s.slug as subcontractor_slug + FROM org_partnerships p + JOIN organizations c ON p.contractor_org_id = c.id + JOIN organizations s ON p.subcontractor_org_id = s.id + WHERE p.id = $1 AND (p.contractor_org_id = $2 OR p.subcontractor_org_id = $2)`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Partnerschaft nicht gefunden", 404); + } + + // Get rates + const rates = await query( + `SELECT * FROM partnership_rates WHERE partnership_id = $1 ORDER BY is_default DESC, name`, + [partnershipId] + ); + + // Get recent shared orders + const recentOrders = await query( + `SELECT so.*, o.title as order_title, o.start_time, o.end_time + FROM shared_orders so + JOIN orders o ON so.original_order_id = o.id + WHERE so.partnership_id = $1 + ORDER BY so.created_at DESC + LIMIT 10`, + [partnershipId] + ); + + ctx.response.body = { + partnership: { + ...partnership, + my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor' + }, + rates, + recentOrders + }; +}); + +// Invite subcontractor (by org slug) +partnershipsRouter.post("/invite", requireChef, async (ctx) => { + const { id: userId, org_id: orgId } = ctx.state.auth.user; + const body = await ctx.request.body.json(); + const { subcontractor_slug, notes } = body; + + if (!subcontractor_slug) { + throw new AppError("Subunternehmer-Kürzel erforderlich", 400); + } + + // Find subcontractor org + const subOrg = await queryOne<{ id: string; name: string }>( + "SELECT id, name FROM organizations WHERE slug = $1", + [subcontractor_slug] + ); + + if (!subOrg) { + throw new AppError("Organisation nicht gefunden", 404); + } + + if (subOrg.id === orgId) { + throw new AppError("Sie können sich nicht selbst einladen", 400); + } + + // Check if partnership exists + const existing = await queryOne<{ id: string; status: string }>( + `SELECT id, status FROM org_partnerships + WHERE contractor_org_id = $1 AND subcontractor_org_id = $2`, + [orgId, subOrg.id] + ); + + if (existing) { + if (existing.status === 'active') { + throw new AppError("Partnerschaft existiert bereits", 409); + } + // Reactivate pending/paused/terminated + await execute( + `UPDATE org_partnerships SET status = 'pending', invited_by = $1, notes = $2, updated_at = NOW() + WHERE id = $3`, + [userId, notes || null, existing.id] + ); + ctx.response.body = { message: "Einladung erneut gesendet", partnershipId: existing.id }; + return; + } + + // Create partnership + const result = await queryOne<{ id: string }>( + `INSERT INTO org_partnerships (contractor_org_id, subcontractor_org_id, status, invited_by, notes) + VALUES ($1, $2, 'pending', $3, $4) + RETURNING id`, + [orgId, subOrg.id, userId, notes || null] + ); + + ctx.response.status = 201; + ctx.response.body = { + message: `Einladung an ${subOrg.name} gesendet`, + partnershipId: result?.id + }; +}); + +// Accept/Decline partnership invitation +partnershipsRouter.post("/:id/respond", requireChef, async (ctx) => { + const { id: userId, org_id: orgId } = ctx.state.auth.user; + const partnershipId = ctx.params.id; + const body = await ctx.request.body.json(); + const { accept } = body; + + const partnership = await queryOne( + `SELECT * FROM org_partnerships WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'pending'`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Einladung nicht gefunden oder bereits beantwortet", 404); + } + + if (accept) { + await execute( + `UPDATE org_partnerships SET status = 'active', accepted_by = $1, accepted_at = NOW(), updated_at = NOW() + WHERE id = $2`, + [userId, partnershipId] + ); + ctx.response.body = { message: "Partnerschaft aktiviert" }; + } else { + await execute( + `UPDATE org_partnerships SET status = 'terminated', updated_at = NOW() WHERE id = $1`, + [partnershipId] + ); + ctx.response.body = { message: "Einladung abgelehnt" }; + } +}); + +// ============ RATES ============ + +// Add rate to partnership +partnershipsRouter.post("/:id/rates", requireChef, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + const partnershipId = ctx.params.id; + const body = await ctx.request.body.json(); + + // Verify partnership and that user is contractor + const partnership = await queryOne( + `SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Partnerschaft nicht gefunden oder keine Berechtigung", 404); + } + + const { name, rate_type, amount, description, is_default, valid_from, valid_until } = body; + + if (!name || !amount) { + throw new AppError("Name und Betrag erforderlich", 400); + } + + // If setting as default, unset other defaults + if (is_default) { + await execute( + `UPDATE partnership_rates SET is_default = false WHERE partnership_id = $1`, + [partnershipId] + ); + } + + const result = await queryOne<{ id: string }>( + `INSERT INTO partnership_rates (partnership_id, name, rate_type, amount_cents, description, is_default, valid_from, valid_until) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [partnershipId, name, rate_type || 'hourly', Math.round(amount * 100), description || null, + is_default || false, valid_from || null, valid_until || null] + ); + + ctx.response.status = 201; + ctx.response.body = { message: "Satz hinzugefügt", rateId: result?.id }; +}); + +// Update rate +partnershipsRouter.put("/:id/rates/:rateId", requireChef, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + const { id: partnershipId, rateId } = ctx.params; + const body = await ctx.request.body.json(); + + // Verify + const partnership = await queryOne( + `SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Keine Berechtigung", 403); + } + + const { name, amount, description, is_default, valid_until } = body; + + if (is_default) { + await execute( + `UPDATE partnership_rates SET is_default = false WHERE partnership_id = $1 AND id != $2`, + [partnershipId, rateId] + ); + } + + await execute( + `UPDATE partnership_rates + SET name = COALESCE($1, name), + amount_cents = COALESCE($2, amount_cents), + description = COALESCE($3, description), + is_default = COALESCE($4, is_default), + valid_until = $5, + updated_at = NOW() + WHERE id = $6 AND partnership_id = $7`, + [name, amount ? Math.round(amount * 100) : null, description, is_default, valid_until || null, rateId, partnershipId] + ); + + ctx.response.body = { message: "Satz aktualisiert" }; +}); + +// ============ SHARED ORDERS ============ + +// Share order with subcontractor +partnershipsRouter.post("/:id/orders", requireChef, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + const partnershipId = ctx.params.id; + const body = await ctx.request.body.json(); + + const { order_id, rate_id, required_staff, notes } = body; + + if (!order_id) { + throw new AppError("Auftrags-ID erforderlich", 400); + } + + // Verify partnership + const partnership = await queryOne( + `SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2 AND status = 'active'`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Aktive Partnerschaft nicht gefunden", 404); + } + + // Verify order belongs to contractor + const order = await queryOne( + `SELECT * FROM orders WHERE id = $1 AND org_id = $2`, + [order_id, orgId] + ); + + if (!order) { + throw new AppError("Auftrag nicht gefunden", 404); + } + + // Check if already shared + const existing = await queryOne<{ id: string }>( + `SELECT id FROM shared_orders WHERE partnership_id = $1 AND original_order_id = $2`, + [partnershipId, order_id] + ); + + if (existing) { + throw new AppError("Auftrag bereits geteilt", 409); + } + + const result = await queryOne<{ id: string }>( + `INSERT INTO shared_orders (partnership_id, original_order_id, rate_id, required_staff, contractor_notes, status) + VALUES ($1, $2, $3, $4, $5, 'requested') + RETURNING id`, + [partnershipId, order_id, rate_id || null, required_staff || 1, notes || null] + ); + + ctx.response.status = 201; + ctx.response.body = { message: "Auftrag geteilt", sharedOrderId: result?.id }; +}); + +// Get shared orders for partnership +partnershipsRouter.get("/:id/orders", authMiddleware, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + const partnershipId = ctx.params.id; + const status = ctx.request.url.searchParams.get("status"); + + // Verify access + const partnership = await queryOne( + `SELECT * FROM org_partnerships WHERE id = $1 AND (contractor_org_id = $2 OR subcontractor_org_id = $2)`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Partnerschaft nicht gefunden", 404); + } + + let whereClause = "WHERE so.partnership_id = $1"; + const params: unknown[] = [partnershipId]; + + if (status) { + whereClause += " AND so.status = $2"; + params.push(status); + } + + const orders = await query( + `SELECT so.*, + o.title, o.description, o.location, o.address, o.start_time, o.end_time, o.status as order_status, + r.name as rate_name, r.amount_cents as rate_amount + FROM shared_orders so + JOIN orders o ON so.original_order_id = o.id + LEFT JOIN partnership_rates r ON so.rate_id = r.id + ${whereClause} + ORDER BY o.start_time DESC NULLS LAST`, + params + ); + + ctx.response.body = { + orders, + my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor' + }; +}); + +// Respond to shared order (subcontractor) +partnershipsRouter.post("/:id/orders/:orderId/respond", requireChef, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + const { id: partnershipId, orderId } = ctx.params; + const body = await ctx.request.body.json(); + const { accept, notes } = body; + + // Verify subcontractor + const partnership = await queryOne( + `SELECT * FROM org_partnerships WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'active'`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Keine Berechtigung", 403); + } + + const sharedOrder = await queryOne( + `SELECT * FROM shared_orders WHERE id = $1 AND partnership_id = $2 AND status = 'requested'`, + [orderId, partnershipId] + ); + + if (!sharedOrder) { + throw new AppError("Anfrage nicht gefunden oder bereits beantwortet", 404); + } + + const newStatus = accept ? 'accepted' : 'declined'; + + await execute( + `UPDATE shared_orders SET status = $1, subcontractor_notes = $2, responded_at = NOW(), updated_at = NOW() + WHERE id = $3`, + [newStatus, notes || null, orderId] + ); + + ctx.response.body = { message: accept ? "Auftrag angenommen" : "Auftrag abgelehnt" }; +}); + +// ============ TIMESHEETS ============ + +// Submit timesheet for shared order (subcontractor) +partnershipsRouter.post("/:id/orders/:orderId/timesheets", authMiddleware, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + const { id: partnershipId, orderId } = ctx.params; + const body = await ctx.request.body.json(); + const { timesheet_id } = body; + + // Verify subcontractor + const partnership = await queryOne( + `SELECT * FROM org_partnerships WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'active'`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Keine Berechtigung", 403); + } + + // Verify shared order + const sharedOrder = await queryOne( + `SELECT so.*, r.amount_cents as rate_amount + FROM shared_orders so + LEFT JOIN partnership_rates r ON so.rate_id = r.id + WHERE so.id = $1 AND so.partnership_id = $2 AND so.status = 'accepted'`, + [orderId, partnershipId] + ); + + if (!sharedOrder) { + throw new AppError("Geteilter Auftrag nicht gefunden oder nicht akzeptiert", 404); + } + + // Verify timesheet belongs to subcontractor org + const timesheet = await queryOne( + `SELECT t.* FROM timesheets t + JOIN users u ON t.user_id = u.id + WHERE t.id = $1 AND u.org_id = $2`, + [timesheet_id, orgId] + ); + + if (!timesheet) { + throw new AppError("Stundenzettel nicht gefunden", 404); + } + + // Calculate amount + const hours = timesheet.hours_worked || 0; + const calculatedAmount = sharedOrder.rate_amount + ? Math.round(hours * sharedOrder.rate_amount) + : null; + + const result = await queryOne<{ id: string }>( + `INSERT INTO partnership_timesheets (shared_order_id, timesheet_id, rate_id, calculated_amount_cents) + VALUES ($1, $2, $3, $4) + RETURNING id`, + [orderId, timesheet_id, sharedOrder.rate_id, calculatedAmount] + ); + + ctx.response.status = 201; + ctx.response.body = { + message: "Stundenzettel eingereicht", + partnershipTimesheetId: result?.id, + calculatedAmount: calculatedAmount ? calculatedAmount / 100 : null + }; +}); + +// Get timesheets for partnership +partnershipsRouter.get("/:id/timesheets", authMiddleware, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + const partnershipId = ctx.params.id; + const status = ctx.request.url.searchParams.get("status"); + const from = ctx.request.url.searchParams.get("from"); + const to = ctx.request.url.searchParams.get("to"); + + // Verify access + const partnership = await queryOne( + `SELECT * FROM org_partnerships WHERE id = $1 AND (contractor_org_id = $2 OR subcontractor_org_id = $2)`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Partnerschaft nicht gefunden", 404); + } + + let whereClause = "WHERE so.partnership_id = $1"; + const params: unknown[] = [partnershipId]; + let paramIndex = 2; + + if (status) { + whereClause += ` AND pt.approval_status = $${paramIndex}`; + params.push(status); + paramIndex++; + } + + if (from) { + whereClause += ` AND t.work_date >= $${paramIndex}`; + params.push(from); + paramIndex++; + } + + if (to) { + whereClause += ` AND t.work_date <= $${paramIndex}`; + params.push(to); + paramIndex++; + } + + const timesheets = await query( + `SELECT pt.*, + t.work_date, t.start_time, t.end_time, t.hours_worked, t.photo_url, + u.first_name || ' ' || u.last_name as worker_name, + o.title as order_title, + r.name as rate_name, r.amount_cents as rate_amount + FROM partnership_timesheets pt + JOIN shared_orders so ON pt.shared_order_id = so.id + JOIN timesheets t ON pt.timesheet_id = t.id + JOIN users u ON t.user_id = u.id + JOIN orders o ON so.original_order_id = o.id + LEFT JOIN partnership_rates r ON pt.rate_id = r.id + ${whereClause} + ORDER BY t.work_date DESC`, + params + ); + + // Calculate totals + const totals = { + pending: 0, + approved: 0, + pendingAmount: 0, + approvedAmount: 0 + }; + + for (const ts of timesheets) { + if (ts.approval_status === 'pending') { + totals.pending++; + totals.pendingAmount += ts.calculated_amount_cents || 0; + } else if (ts.approval_status === 'approved') { + totals.approved++; + totals.approvedAmount += ts.calculated_amount_cents || 0; + } + } + + ctx.response.body = { + timesheets, + totals: { + ...totals, + pendingAmount: totals.pendingAmount / 100, + approvedAmount: totals.approvedAmount / 100 + }, + my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor' + }; +}); + +// Approve/Reject partnership timesheet (contractor) +partnershipsRouter.post("/:id/timesheets/:tsId/review", requireChef, async (ctx) => { + const { id: userId, org_id: orgId } = ctx.state.auth.user; + const { id: partnershipId, tsId } = ctx.params; + const body = await ctx.request.body.json(); + const { approve, dispute_reason } = body; + + // Verify contractor + const partnership = await queryOne( + `SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2 AND status = 'active'`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Keine Berechtigung", 403); + } + + const newStatus = approve ? 'approved' : 'disputed'; + + await execute( + `UPDATE partnership_timesheets + SET approval_status = $1, approved_by = $2, approved_at = NOW(), dispute_reason = $3, updated_at = NOW() + WHERE id = $4`, + [newStatus, userId, approve ? null : (dispute_reason || 'Keine Angabe'), tsId] + ); + + ctx.response.body = { message: approve ? "Stundenzettel genehmigt" : "Stundenzettel beanstandet" }; +}); + +// ============ BILLING SUMMARY ============ + +// Get billing summary for partnership +partnershipsRouter.get("/:id/billing", authMiddleware, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + const partnershipId = ctx.params.id; + const from = ctx.request.url.searchParams.get("from"); + const to = ctx.request.url.searchParams.get("to"); + + // Verify access + const partnership = await queryOne( + `SELECT p.*, c.name as contractor_name, s.name as subcontractor_name + FROM org_partnerships p + JOIN organizations c ON p.contractor_org_id = c.id + JOIN organizations s ON p.subcontractor_org_id = s.id + WHERE p.id = $1 AND (p.contractor_org_id = $2 OR p.subcontractor_org_id = $2)`, + [partnershipId, orgId] + ); + + if (!partnership) { + throw new AppError("Partnerschaft nicht gefunden", 404); + } + + // Build date filter + let dateFilter = ""; + const params: unknown[] = [partnershipId]; + + if (from) { + dateFilter += ` AND t.work_date >= $${params.length + 1}`; + params.push(from); + } + if (to) { + dateFilter += ` AND t.work_date <= $${params.length + 1}`; + params.push(to); + } + + // Get summary by status + const summary = await queryOne( + `SELECT + COUNT(*) FILTER (WHERE pt.approval_status = 'pending') as pending_count, + COUNT(*) FILTER (WHERE pt.approval_status = 'approved') as approved_count, + COUNT(*) FILTER (WHERE pt.approval_status = 'disputed') as disputed_count, + COALESCE(SUM(pt.calculated_amount_cents) FILTER (WHERE pt.approval_status = 'pending'), 0) as pending_amount, + COALESCE(SUM(pt.calculated_amount_cents) FILTER (WHERE pt.approval_status = 'approved'), 0) as approved_amount, + COALESCE(SUM(t.hours_worked) FILTER (WHERE pt.approval_status = 'approved'), 0) as approved_hours + FROM partnership_timesheets pt + JOIN shared_orders so ON pt.shared_order_id = so.id + JOIN timesheets t ON pt.timesheet_id = t.id + WHERE so.partnership_id = $1 ${dateFilter}`, + params + ); + + // Get breakdown by rate + const byRate = await query( + `SELECT + r.name as rate_name, + r.amount_cents as rate_amount, + COUNT(*) as count, + COALESCE(SUM(t.hours_worked), 0) as total_hours, + COALESCE(SUM(pt.calculated_amount_cents), 0) as total_amount + FROM partnership_timesheets pt + JOIN shared_orders so ON pt.shared_order_id = so.id + JOIN timesheets t ON pt.timesheet_id = t.id + LEFT JOIN partnership_rates r ON pt.rate_id = r.id + WHERE so.partnership_id = $1 AND pt.approval_status = 'approved' ${dateFilter} + GROUP BY r.id, r.name, r.amount_cents + ORDER BY total_amount DESC`, + params + ); + + ctx.response.body = { + partnership: { + id: partnership.id, + contractor_name: partnership.contractor_name, + subcontractor_name: partnership.subcontractor_name, + my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor' + }, + period: { from, to }, + summary: { + pending: { + count: parseInt(summary?.pending_count || '0'), + amount: (summary?.pending_amount || 0) / 100 + }, + approved: { + count: parseInt(summary?.approved_count || '0'), + amount: (summary?.approved_amount || 0) / 100, + hours: parseFloat(summary?.approved_hours || '0') + }, + disputed: { + count: parseInt(summary?.disputed_count || '0') + } + }, + byRate: byRate.map(r => ({ + ...r, + rate_amount: r.rate_amount / 100, + total_hours: parseFloat(r.total_hours), + total_amount: r.total_amount / 100 + })) + }; +});