🤝 Subunternehmer-API: Partnerschaften, Aufträge, Stundenzettel, Abrechnung

This commit is contained in:
2026-03-12 16:17:12 +00:00
parent 0be17882d5
commit b5aa228183
2 changed files with 676 additions and 0 deletions

View File

@@ -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) => {

673
src/routes/partnerships.ts Normal file
View File

@@ -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<any>(
`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<any>(
`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<any>(
`SELECT * FROM partnership_rates WHERE partnership_id = $1 ORDER BY is_default DESC, name`,
[partnershipId]
);
// Get recent shared orders
const recentOrders = await query<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(
`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
}))
};
});