239 lines
7.8 KiB
TypeScript
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],
|
|
);
|
|
}
|
|
}
|