import { AppError } from '../lib/errors.js'; import { query } from './db.js'; function parseLimit(value, fallback = 20, max = 100) { const parsed = Number.parseInt(`${value || fallback}`, 10); if (!Number.isFinite(parsed) || parsed <= 0) return fallback; return Math.min(parsed, max); } function parseOffset(value) { const parsed = Number.parseInt(`${value || 0}`, 10); if (!Number.isFinite(parsed) || parsed < 0) return 0; return parsed; } export async function listOrders({ tenantId, businessId, status, limit, offset }) { const result = await query( ` SELECT o.id, o.order_number AS "orderNumber", o.title, o.status, o.service_type AS "serviceType", o.starts_at AS "startsAt", o.ends_at AS "endsAt", o.location_name AS "locationName", o.location_address AS "locationAddress", o.created_at AS "createdAt", b.id AS "businessId", b.business_name AS "businessName", v.id AS "vendorId", v.company_name AS "vendorName", COALESCE(COUNT(s.id), 0)::INTEGER AS "shiftCount", COALESCE(SUM(s.required_workers), 0)::INTEGER AS "requiredWorkers", COALESCE(SUM(s.assigned_workers), 0)::INTEGER AS "assignedWorkers" FROM orders o JOIN businesses b ON b.id = o.business_id LEFT JOIN vendors v ON v.id = o.vendor_id LEFT JOIN shifts s ON s.order_id = o.id WHERE o.tenant_id = $1 AND ($2::uuid IS NULL OR o.business_id = $2::uuid) AND ($3::text IS NULL OR o.status = $3::text) GROUP BY o.id, b.id, v.id ORDER BY o.created_at DESC LIMIT $4 OFFSET $5 `, [ tenantId, businessId || null, status || null, parseLimit(limit), parseOffset(offset), ] ); return result.rows; } export async function getOrderDetail({ tenantId, orderId }) { const orderResult = await query( ` SELECT o.id, o.order_number AS "orderNumber", o.title, o.description, o.status, o.service_type AS "serviceType", o.starts_at AS "startsAt", o.ends_at AS "endsAt", o.location_name AS "locationName", o.location_address AS "locationAddress", o.latitude, o.longitude, o.notes, o.created_at AS "createdAt", b.id AS "businessId", b.business_name AS "businessName", v.id AS "vendorId", v.company_name AS "vendorName" FROM orders o JOIN businesses b ON b.id = o.business_id LEFT JOIN vendors v ON v.id = o.vendor_id WHERE o.tenant_id = $1 AND o.id = $2 `, [tenantId, orderId] ); if (orderResult.rowCount === 0) { throw new AppError('NOT_FOUND', 'Order not found', 404, { tenantId, orderId }); } const shiftsResult = await query( ` SELECT s.id, s.shift_code AS "shiftCode", s.title, s.status, s.starts_at AS "startsAt", s.ends_at AS "endsAt", s.timezone, s.location_name AS "locationName", s.location_address AS "locationAddress", s.required_workers AS "requiredWorkers", s.assigned_workers AS "assignedWorkers", cp.id AS "clockPointId", cp.label AS "clockPointLabel" FROM shifts s LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE s.tenant_id = $1 AND s.order_id = $2 ORDER BY s.starts_at ASC `, [tenantId, orderId] ); const shiftIds = shiftsResult.rows.map((row) => row.id); let rolesByShiftId = new Map(); if (shiftIds.length > 0) { const rolesResult = await query( ` SELECT sr.id, sr.shift_id AS "shiftId", sr.role_code AS "roleCode", sr.role_name AS "roleName", sr.workers_needed AS "workersNeeded", sr.assigned_count AS "assignedCount", sr.pay_rate_cents AS "payRateCents", sr.bill_rate_cents AS "billRateCents" FROM shift_roles sr WHERE sr.shift_id = ANY($1::uuid[]) ORDER BY sr.role_name ASC `, [shiftIds] ); rolesByShiftId = rolesResult.rows.reduce((map, row) => { const list = map.get(row.shiftId) || []; list.push(row); map.set(row.shiftId, list); return map; }, new Map()); } return { ...orderResult.rows[0], shifts: shiftsResult.rows.map((shift) => ({ ...shift, roles: rolesByShiftId.get(shift.id) || [], })), }; } export async function listFavoriteStaff({ tenantId, businessId, limit, offset }) { const result = await query( ` SELECT sf.id AS "favoriteId", sf.created_at AS "favoritedAt", s.id AS "staffId", s.full_name AS "fullName", s.primary_role AS "primaryRole", s.average_rating AS "averageRating", s.rating_count AS "ratingCount", s.status FROM staff_favorites sf JOIN staffs s ON s.id = sf.staff_id WHERE sf.tenant_id = $1 AND sf.business_id = $2 ORDER BY sf.created_at DESC LIMIT $3 OFFSET $4 `, [tenantId, businessId, parseLimit(limit), parseOffset(offset)] ); return result.rows; } export async function getStaffReviewSummary({ tenantId, staffId, limit }) { const staffResult = await query( ` SELECT id AS "staffId", full_name AS "fullName", average_rating AS "averageRating", rating_count AS "ratingCount", primary_role AS "primaryRole", status FROM staffs WHERE tenant_id = $1 AND id = $2 `, [tenantId, staffId] ); if (staffResult.rowCount === 0) { throw new AppError('NOT_FOUND', 'Staff not found', 404, { tenantId, staffId }); } const reviewsResult = await query( ` SELECT sr.id AS "reviewId", sr.rating, sr.review_text AS "reviewText", sr.tags, sr.created_at AS "createdAt", b.id AS "businessId", b.business_name AS "businessName", sr.assignment_id AS "assignmentId" FROM staff_reviews sr JOIN businesses b ON b.id = sr.business_id WHERE sr.tenant_id = $1 AND sr.staff_id = $2 ORDER BY sr.created_at DESC LIMIT $3 `, [tenantId, staffId, parseLimit(limit, 10, 50)] ); return { ...staffResult.rows[0], reviews: reviewsResult.rows, }; } export async function getAssignmentAttendance({ tenantId, assignmentId }) { const assignmentResult = await query( ` SELECT a.id AS "assignmentId", a.status, a.shift_id AS "shiftId", a.staff_id AS "staffId", s.title AS "shiftTitle", s.starts_at AS "shiftStartsAt", s.ends_at AS "shiftEndsAt", attendance_sessions.id AS "sessionId", attendance_sessions.status AS "sessionStatus", attendance_sessions.check_in_at AS "checkInAt", attendance_sessions.check_out_at AS "checkOutAt", attendance_sessions.worked_minutes AS "workedMinutes" FROM assignments a JOIN shifts s ON s.id = a.shift_id LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id WHERE a.id = $1 AND a.tenant_id = $2 `, [assignmentId, tenantId] ); if (assignmentResult.rowCount === 0) { throw new AppError('NOT_FOUND', 'Assignment not found', 404, { tenantId, assignmentId }); } const eventsResult = await query( ` SELECT id AS "attendanceEventId", event_type AS "eventType", source_type AS "sourceType", source_reference AS "sourceReference", nfc_tag_uid AS "nfcTagUid", latitude, longitude, distance_to_clock_point_meters AS "distanceToClockPointMeters", within_geofence AS "withinGeofence", validation_status AS "validationStatus", validation_reason AS "validationReason", captured_at AS "capturedAt" FROM attendance_events WHERE assignment_id = $1 ORDER BY captured_at ASC `, [assignmentId] ); return { ...assignmentResult.rows[0], events: eventsResult.rows, }; }