2026-03-06 09:05:31 -05:00

239 lines
7.8 KiB
TypeScript

import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { BreakEvenService } from './breakeven.service';
@Injectable()
export class DriversService {
constructor(
private readonly db: DatabaseService,
private readonly breakEven: BreakEvenService,
) {}
// ============================================================
// DRIVER SESSION - Daily Login Flow
// ============================================================
async startSession(driverId: string): Promise<any> {
// All registered drivers can start — no approval gate for MVP
const driver = await this.db.queryOne(
`SELECT d.*, u.first_name, u.last_name
FROM drivers d JOIN users u ON u.id = d.user_id
WHERE d.id = $1`,
[driverId],
);
if (!driver) throw new NotFoundException('Driver not found');
// Check if session already exists today
const existing = await this.db.queryOne(
`SELECT * FROM driver_sessions
WHERE driver_id = $1 AND session_date = CURRENT_DATE`,
[driverId],
);
if (existing) return { session: existing, breakEven: this.breakEven.calculate(existing) };
// $20 daily fee tracked as debt — deducted from first payouts, no upfront card charge
const session = await this.db.queryOne(
`INSERT INTO driver_sessions (driver_id, daily_fee, fee_paid)
VALUES ($1, $2, FALSE)
RETURNING *`,
[driverId, 20.00],
);
// Mark driver as online and available
await this.db.query(
`UPDATE driver_locations SET is_online = TRUE, is_available = TRUE, updated_at = NOW()
WHERE driver_id = $1`,
[driverId],
);
return { session, breakEven: this.breakEven.calculate(session) };
}
async endSession(driverId: string): Promise<any> {
const session = await this.db.queryOne(
`UPDATE driver_sessions
SET logout_at = NOW(), status = 'inactive'
WHERE driver_id = $1 AND session_date = CURRENT_DATE AND status = 'active'
RETURNING *`,
[driverId],
);
await this.db.query(
`UPDATE driver_locations SET is_online = FALSE, is_available = FALSE WHERE driver_id = $1`,
[driverId],
);
return { session, breakEven: session ? this.breakEven.calculate(session) : null };
}
async getSession(driverId: string): Promise<any> {
const session = await this.db.queryOne(
`SELECT * FROM driver_sessions
WHERE driver_id = $1 AND session_date = CURRENT_DATE`,
[driverId],
);
if (!session) return { session: null, breakEven: null };
const earnings = await this.db.queryMany(
`SELECT de.*, o.order_number, o.delivered_at
FROM driver_earnings de
JOIN orders o ON o.id = de.order_id
WHERE de.session_id = $1
ORDER BY de.created_at DESC`,
[session.id],
);
return {
session,
breakEven: this.breakEven.calculate(session),
earnings,
};
}
// ============================================================
// DRIVER PROFILE & EARNINGS
// ============================================================
async getProfile(driverId: string) {
return this.db.queryOne(
`SELECT d.*, u.first_name, u.last_name, u.email, u.phone, u.avatar_url,
z.name AS zone_name
FROM drivers d
JOIN users u ON u.id = d.user_id
LEFT JOIN zones z ON z.id = d.zone_id
WHERE d.id = $1`,
[driverId],
);
}
async getEarningsHistory(driverId: string, days: number = 30) {
return this.db.queryMany(
`SELECT
ds.session_date,
ds.deliveries_count,
ds.delivery_revenue,
ds.tips_earned,
ds.net_earnings,
ds.daily_fee,
ds.fee_paid
FROM driver_sessions ds
WHERE ds.driver_id = $1
AND ds.session_date >= CURRENT_DATE - INTERVAL '${days} days'
ORDER BY ds.session_date DESC`,
[driverId],
);
}
async getAnalytics(driverId: string) {
const [summary, weekly, hourly] = await Promise.all([
// All-time + period summaries
this.db.queryOne(
`SELECT
COUNT(*) FILTER (WHERE session_date >= CURRENT_DATE - 6) AS sessions_7d,
COUNT(*) FILTER (WHERE session_date >= CURRENT_DATE - 29) AS sessions_30d,
SUM(deliveries_count) FILTER (WHERE session_date >= CURRENT_DATE - 6) AS trips_7d,
SUM(deliveries_count) FILTER (WHERE session_date >= CURRENT_DATE - 29) AS trips_30d,
SUM(deliveries_count) AS trips_total,
SUM(net_earnings) FILTER (WHERE session_date >= CURRENT_DATE - 6) AS net_7d,
SUM(net_earnings) FILTER (WHERE session_date >= CURRENT_DATE - 29) AS net_30d,
SUM(net_earnings) AS net_total,
SUM(tips_earned) FILTER (WHERE session_date >= CURRENT_DATE - 29) AS tips_30d,
SUM(tips_earned) AS tips_total,
AVG(CASE WHEN deliveries_count > 0 THEN net_earnings END) AS avg_daily_net
FROM driver_sessions
WHERE driver_id = $1`,
[driverId],
),
// Day-of-week breakdown (0=Sun, 6=Sat)
this.db.queryMany(
`SELECT
EXTRACT(DOW FROM session_date) AS dow,
TO_CHAR(session_date, 'Dy') AS day_name,
AVG(deliveries_count) AS avg_trips,
AVG(net_earnings) AS avg_net
FROM driver_sessions
WHERE driver_id = $1 AND session_date >= CURRENT_DATE - 90
GROUP BY dow, day_name
ORDER BY dow`,
[driverId],
),
// Hour-of-day delivery distribution from driver_earnings
this.db.queryMany(
`SELECT
EXTRACT(HOUR FROM de.created_at) AS hour,
COUNT(*) AS deliveries,
SUM(de.total) AS revenue
FROM driver_earnings de
WHERE de.driver_id = $1
AND de.created_at >= NOW() - INTERVAL '90 days'
GROUP BY hour
ORDER BY hour`,
[driverId],
),
]);
// Find peak day and peak hour
const peakDay = weekly.reduce(
(best: any, d: any) => (Number(d.avg_trips) > Number(best?.avg_trips || 0) ? d : best),
null,
);
const peakHour = hourly.reduce(
(best: any, h: any) => (Number(h.deliveries) > Number(best?.deliveries || 0) ? h : best),
null,
);
return { summary, weekly, hourly, peakDay, peakHour };
}
async updateLocation(
driverId: string,
lng: number,
lat: number,
heading?: number,
speedKmh?: number,
) {
return this.db.query(
`INSERT INTO driver_locations (driver_id, location, heading, speed_kmh, updated_at)
VALUES ($1, ST_GeographyFromText($2), $3, $4, NOW())
ON CONFLICT (driver_id) DO UPDATE
SET location = EXCLUDED.location,
heading = EXCLUDED.heading,
speed_kmh = EXCLUDED.speed_kmh,
updated_at = NOW()`,
[driverId, `POINT(${lng} ${lat})`, heading, speedKmh],
);
}
async setAvailability(driverId: string, isAvailable: boolean) {
return this.db.query(
`UPDATE driver_locations SET is_available = $2 WHERE driver_id = $1`,
[driverId, isAvailable],
);
}
async getNearbyDrivers(lng: number, lat: number, radiusKm: number = 5) {
return this.db.queryMany(
`SELECT
dl.driver_id,
u.first_name,
d.vehicle_type,
d.rating,
ST_Distance(dl.location, ST_GeographyFromText($1)) / 1000 AS distance_km,
ST_X(dl.location::geometry) AS lng,
ST_Y(dl.location::geometry) AS lat
FROM driver_locations dl
JOIN drivers d ON d.id = dl.driver_id
JOIN users u ON u.id = d.user_id
WHERE dl.is_online = TRUE
AND dl.is_available = TRUE
AND ST_DWithin(dl.location, ST_GeographyFromText($1), $2 * 1000)
ORDER BY distance_km ASC
LIMIT 20`,
[`POINT(${lng} ${lat})`, radiusKm],
);
}
}