Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/customer/lib/services/profile_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,29 +35,25 @@ class ProfileService {
}

Future<void> logout() async {
final token = _client.auth.currentSession?.accessToken;
final userId = _client.auth.currentUser?.id;
final userId = SupabaseService.client.auth.currentUser?.id;

if (token == null || token.isEmpty || userId == null) {
await _client.auth.signOut();
if (userId == null) {
await SupabaseService.client.auth.signOut();
return;
}

try {
await _httpClient.post(
Uri.parse('$_apiBaseUrl/api/auth/logout'),
await _apiClient.post(
'/api/auth/logout',
headers: <String, String>{
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
'x-user-id': userId,
'x-user-role': 'customer',
},
).timeout(const Duration(seconds: 5));
);
} catch (e) {
// Log error but proceed to sign out locally
print('Backend logout failed: $e');
} finally {
await _client.auth.signOut();
await SupabaseService.client.auth.signOut();
Comment thread
ionfwsrijan marked this conversation as resolved.
}
}
}
1 change: 0 additions & 1 deletion apps/driver/lib/screens/profile_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import '../services/fcm_service.dart';
import '../../core/supabase_config.dart';
import 'package:truxify_shared/truxify_shared.dart' hide NotificationsScreen;
import 'notifications_screen.dart';
import 'package:http/http.dart' as http;

class ProfileScreen extends StatefulWidget {
const ProfileScreen({
Expand Down
2 changes: 1 addition & 1 deletion apps/driver/lib/services/marketplace_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ class MarketplaceRepository {
callback: (payload) {
try {
final newRecord = payload.newRecord;
if (newRecord != null && newRecord.isNotEmpty) {
if (newRecord.isNotEmpty) {
final offer = _mapLoadOffer(newRecord);
controller.add(offer);
}
Expand Down
82 changes: 35 additions & 47 deletions backend/api/src/routes/orderRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import { changeDropSchema, cancelOrderSchema } from '../validation/requestSchemas.js';
import { awardReputationPoints } from '../services/reputation.js';
import { escrowDeposit, escrowRelease, escrowRefund } from '../services/escrow.js';
import { sendDeliveryOtpNotification } from '../services/notificationService.js';
import { sendDeliveryOtpNotification, storeDeliveryOtp, getActiveDeliveryOtp, verifyDeliveryOtp, expireDeliveryOtps } from '../services/notificationService.js';
import { predictDemand, predictPrice } from '../services/ml.js';
import rateLimit from 'express-rate-limit';
import logger from '../middleware/logger.js';
Expand Down Expand Up @@ -376,10 +376,6 @@ router.get('/:id', authenticate, validateParams(paramIdSchema), async (req, res)
}

const responseOrder = { ...order };
// Strip delivery OTP for drivers to prevent security bypass
if (req.user.role === 'driver' && responseOrder.delivery_otp) {
delete responseOrder.delivery_otp;
}

const { data: timeline } = await supabase.from('order_timeline').select('milestone, milestone_time, completed, sort_order').eq('order_display_id', order.order_display_id).order('sort_order', { ascending: true });

Expand Down Expand Up @@ -766,11 +762,15 @@ router.put('/:id/milestones', authenticate, requireRole(['driver']), validatePar
const updates = { status, updated_at: new Date().toISOString() };
let generatedOtp = null;

if (milestone === 'In Transit' && (!order.delivery_otp || isOtpExpired(order.otp_generated_at))) {
generatedOtp = crypto.randomInt(100000, 1000000).toString();
updates.delivery_otp = generatedOtp;
updates.otp_generated_at = new Date().toISOString();
await clearOtpState(orderId);
if (milestone === 'In Transit') {
const activeOtp = await getActiveDeliveryOtp(orderId);
if (!activeOtp) {
generatedOtp = crypto.randomInt(100000, 1000000).toString();
const stored = await storeDeliveryOtp(orderId, generatedOtp, OTP_TTL_MINUTES);
if (stored) {
await clearOtpState(orderId);
}
}
}

const { data: updatedOrder, error: updateErr } = await supabase.from('orders').update(updates).eq('id', orderId).select('*').single();
Expand All @@ -783,13 +783,7 @@ router.put('/:id/milestones', authenticate, requireRole(['driver']), validatePar
await sendDeliveryOtpNotification(order.customer_id, order.order_display_id, generatedOtp);
}

// Strip delivery_otp from updatedOrder to prevent exposure to drivers
const responseOrder = { ...updatedOrder };
if (responseOrder.delivery_otp) {
delete responseOrder.delivery_otp;
}

const response = { message: 'Milestone updated successfully.', order: responseOrder, milestone, status };
const response = { message: 'Milestone updated successfully.', order: updatedOrder, milestone, status };

res.json(response);
} catch (err) {
Expand All @@ -814,19 +808,18 @@ router.post('/:id/verify-delivery', authenticate, requireRole(['driver']), verif
}

try {
const { data: order, error: orderErr } = await supabase.from('orders').select('*').eq('id', orderId).maybeSingle();
const { data: order, error: orderErr } = await supabase.from('orders').select('id, order_display_id, driver_id, customer_id, escrow_status, status').eq('id', orderId).maybeSingle();
if (orderErr || !order) return res.status(404).json({ error: 'Order not found.' });
if (order.driver_id !== req.user.id) return res.status(403).json({ error: 'Access Denied: You are not assigned to this order.' });
if (!order.delivery_otp || order.otp_verified) return res.status(400).json({ error: 'OTP not available or already verified.' });

// Check OTP expiry
if (isOtpExpired(order.otp_generated_at)) {
const otpRecord = await getActiveDeliveryOtp(orderId);
if (!otpRecord) {
return res.status(400).json({
error: 'OTP has expired. Please request a new delivery OTP.',
error: 'OTP not available or has expired. Please request a new delivery OTP.',
});
}

if (order.delivery_otp !== String(otp)) {
if (otpRecord.otp !== String(otp)) {
const count = await recordOtpFailure(orderId);
const remaining = Math.max(0, OTP_MAX_FAILED_ATTEMPTS - count);
const message = remaining > 0
Expand All @@ -838,15 +831,15 @@ router.post('/:id/verify-delivery', authenticate, requireRole(['driver']), verif

// Successful verification — clear failure state
await clearOtpState(orderId);
await verifyDeliveryOtp(orderId);

const { data: preUpdatedOrder, error: updateErr } = await supabase.from('orders').update({
otp_verified: true, updated_at: new Date().toISOString()
updated_at: new Date().toISOString()
})
.eq('id', orderId)
.not('otp_verified', 'eq', true)
.not('status', 'eq', 'cancelled')
.not('status', 'eq', 'payment_released')
.select('*')
.select('id, order_display_id, status')
.single();

if (updateErr) {
Expand All @@ -863,20 +856,9 @@ router.post('/:id/verify-delivery', authenticate, requireRole(['driver']), verif
return res.status(500).json({ error: 'Failed to complete trip and release payment.', details: rpcErr.message });
}

// Fetch the updated order directly from the database as the single source of truth
const { data: updatedOrder, error: fetchErr } = await supabase
.from('orders')
.select('*')
.eq('id', orderId)
.single();

if (fetchErr) {
logger.error('Failed to fetch updated order:', fetchErr.message);
return res.status(500).json({ error: 'Failed to retrieve completed order details.', details: fetchErr.message });
}

// Escrow: release funds to driver after successful delivery verification
if (updatedOrder.escrow_status === 'funded') {
if (order.escrow_status === 'funded') {
try {
const { txHash } = await escrowRelease(order.order_display_id);
if (txHash) {
Expand All @@ -890,16 +872,10 @@ router.post('/:id/verify-delivery', authenticate, requireRole(['driver']), verif
logger.error('[escrow] Release failed for order', orderId, ':', releaseErr.message);
}
} else {
logger.info(`[escrow] Escrow not funded (status: ${updatedOrder.escrow_status}) — skipping on-chain release.`);
}

// Strip delivery_otp from updatedOrder to prevent exposure
const responseOrder = { ...updatedOrder };
if (responseOrder.delivery_otp) {
delete responseOrder.delivery_otp;
logger.info(`[escrow] Escrow not funded (status: ${order.escrow_status}) — skipping on-chain release.`);
}

res.json({ message: 'Delivery verified successfully! Payment released to driver.', order: responseOrder });
res.json({ message: 'Delivery verified successfully! Payment released to driver.' });
} catch (err) {
res.status(500).json({ error: 'Internal Server Error' });
}
Expand Down Expand Up @@ -992,10 +968,22 @@ router.post('/:id/cancel', authenticate, requireRole(['customer']), validatePara
if (!order) return res.status(404).json({ error: 'Order not found.' });
if (order.customer_id !== req.user.id) return res.status(403).json({ error: 'Access Denied: You do not own this order.' });

// Prevent cancellation if delivery OTP was already verified
const { data: otpCheck, error: otpCheckErr } = await supabase
.from('delivery_otps')
.select('id')
.eq('order_id', order.id)
.eq('verified', true)
.limit(1)
.maybeSingle();

if (!otpCheckErr && otpCheck) {
return res.status(409).json({ error: 'Cannot cancel: delivery OTP has already been verified.' });
}

const { data: updatedOrder, error: updateErr } = await supabase.from('orders')
.update({ status: 'cancelled', cancellation_reason: reason, updated_at: new Date().toISOString() })
.eq('order_display_id', orderId)
.not('otp_verified', 'eq', true)
.not('status', 'in', '("delivered","payment_released","cancelled")')
.select('cancellation_fee, order_display_id, status, cancellation_reason, escrow_status')
.single();
Expand Down
103 changes: 100 additions & 3 deletions backend/api/src/services/notificationService.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,106 @@ export async function sendFcmNotification(userId, notification, data = {}) {
}

/**
* Deliver the delivery OTP to the customer through a secure out-of-band channel.
* Persist a delivery OTP in the isolated delivery_otps table.
* Called when the order transitions to 'In Transit' and a fresh OTP is needed.
*
* @param {string} orderId - The order UUID.
* @param {string} otp - The 6-digit delivery OTP.
* @param {number} ttlMinutes - Time-to-live for the OTP (defaults to 15).
* @returns {Promise<{id: string} | null>}
*/
export async function storeDeliveryOtp(orderId, otp, ttlMinutes = 15) {
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();

const { data, error } = await supabase
.from('delivery_otps')
.insert({
order_id: orderId,
otp,
expires_at: expiresAt,
verified: false,
})
.select('id')
.single();

if (error) {
console.error('[NotificationService] Failed to store OTP:', error.message);
return null;
}

console.log(`[NotificationService] OTP stored for order ${orderId}, expires at ${expiresAt}`);
return data;
}

/**
* Retrieve the latest active (unexpired, unverified) OTP for an order.
*
* @param {string} orderId
* @returns {Promise<{id: string, otp: string, expires_at: string} | null>}
*/
export async function getActiveDeliveryOtp(orderId) {
const { data, error } = await supabase
.from('delivery_otps')
.select('id, otp, expires_at')
.eq('order_id', orderId)
.eq('verified', false)
.gte('expires_at', new Date().toISOString())
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();

if (error) {
console.error('[NotificationService] Failed to fetch active OTP:', error.message);
return null;
}

return data;
}

/**
* Mark a delivery OTP as verified.
*
* @param {string} orderId
* @returns {Promise<boolean>}
*/
export async function verifyDeliveryOtp(orderId) {
const { error } = await supabase
.from('delivery_otps')
.update({
verified: true,
verified_at: new Date().toISOString(),
})
.eq('order_id', orderId)
.eq('verified', false);

if (error) {
console.error('[NotificationService] Failed to verify OTP:', error.message);
return false;
}

return true;
}

/**
* Invalidate (expire) all active OTPs for an order.
*
* @param {string} orderId
* @returns {Promise<void>}
*/
export async function expireDeliveryOtps(orderId) {
const { error } = await supabase
.from('delivery_otps')
.update({ expires_at: new Date().toISOString() })
.eq('order_id', orderId)
.eq('verified', false);

if (error) {
console.error('[NotificationService] Failed to expire OTPs:', error.message);
}
}

/**
* Deliver the delivery OTP to the customer through out-of-band channels.
*
* @param {string} customerId - The customer's profile UUID.
* @param {string} orderDisplayId - The display identifier of the order (e.g. #FFYYYYMMDDXXXX).
Expand Down Expand Up @@ -163,7 +262,6 @@ export async function sendDeliveryOtpNotification(customerId, orderDisplayId, ot
* @param {object} [metadata={}] - Optional metadata to persist.
*/
export async function sendPushNotification(userId, title, body, notifType, metadata = {}) {
// 1. Persist notification record
if (supabase) {
try {
const { error } = await supabase
Expand All @@ -178,6 +276,5 @@ export async function sendPushNotification(userId, title, body, notifType, metad
}
}

// 2. FCM delivery (fire-and-forget)
void sendFcmNotification(userId, { title, body }, { notifType, ...metadata });
}
16 changes: 12 additions & 4 deletions backend/api/test/integration/bids.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -623,8 +623,12 @@ describe('Bid Routes', () => {
status: 'pending',
});

m.store.profiles.push({ id: 'driver-1', full_name: 'Driver One' });
m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null });
m.store.profiles.push(
{ id: 'customer-1', full_name: 'Customer One', polygon_wallet_address: '0xCustomerWallet' },
{ id: 'driver-1', full_name: 'Driver One' },
);
m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null, polygon_wallet_address: '0xDriverWallet' });
mockEscrowDeposit.mockResolvedValue({ txHash: '0xescrowtest123' });

m.programRpcError('Load offer is no longer available');

Expand Down Expand Up @@ -660,8 +664,12 @@ describe('Bid Routes', () => {
status: 'pending',
});

m.store.profiles.push({ id: 'driver-1', full_name: 'Driver One' });
m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null });
m.store.profiles.push(
{ id: 'customer-1', full_name: 'Customer One', polygon_wallet_address: '0xCustomerWallet' },
{ id: 'driver-1', full_name: 'Driver One' },
);
m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null, polygon_wallet_address: '0xDriverWallet' });
mockEscrowDeposit.mockResolvedValue({ txHash: '0xescrowtest123' });

m.programRpcError('Order is no longer pending');

Expand Down
Loading