The_Vibe/packages/backend/src/modules/restaurants/restaurants.service.ts
2026-03-15 14:54:36 -04:00

313 lines
11 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
const UBER_COMMISSION_RATE = 0.30;
const PLATFORM_PER_ORDER_FEE = 0.10;
const MONTHLY_SUBSCRIPTION = 500;
@Injectable()
export class RestaurantsService {
constructor(private readonly db: DatabaseService) {}
// ============================================================
// DISCOVERY (Customer-facing)
// ============================================================
async findNearby(lng: number, lat: number, radiusKm: number = 5, cuisine?: string) {
const cuisineFilter = cuisine
? `AND $4 = ANY(r.cuisine_type)`
: '';
return this.db.queryMany(
`SELECT
r.id, r.name, r.slug, r.description, r.cuisine_type,
r.logo_url, r.rating, r.total_reviews, r.avg_prep_time_minutes,
r.min_order_amount, r.is_open,
ST_Distance(r.location, ST_GeographyFromText($1)) / 1000 AS distance_km,
ST_X(r.location::geometry) AS lng,
ST_Y(r.location::geometry) AS lat,
z.name AS zone_name
FROM restaurants r
LEFT JOIN zones z ON z.id = r.zone_id
WHERE r.is_active = TRUE
AND ST_DWithin(r.location, ST_GeographyFromText($1), $2 * 1000)
${cuisineFilter}
ORDER BY r.is_open DESC, distance_km ASC
LIMIT 50`,
cuisine
? [`POINT(${lng} ${lat})`, radiusKm, null, cuisine]
: [`POINT(${lng} ${lat})`, radiusKm],
);
}
async findBySlug(slug: string) {
const restaurant = await this.db.queryOne(
`SELECT r.*, u.email AS owner_email, z.name AS zone_name
FROM restaurants r
JOIN users u ON u.id = r.owner_id
LEFT JOIN zones z ON z.id = r.zone_id
WHERE r.slug = $1 AND r.is_active = TRUE`,
[slug],
);
if (!restaurant) throw new NotFoundException('Restaurant not found');
const categories = await this.db.queryMany(
`SELECT mc.*, json_agg(
json_build_object(
'id', mi.id, 'name', mi.name, 'description', mi.description,
'price', mi.price, 'image_url', mi.image_url,
'dietary_tags', mi.dietary_tags, 'is_available', mi.is_available,
'is_featured', mi.is_featured
) ORDER BY mi.sort_order
) FILTER (WHERE mi.id IS NOT NULL) AS items
FROM menu_categories mc
LEFT JOIN menu_items mi ON mi.category_id = mc.id AND mi.is_available = TRUE
WHERE mc.restaurant_id = $1 AND mc.is_active = TRUE
GROUP BY mc.id
ORDER BY mc.sort_order`,
[restaurant.id],
);
return { ...restaurant, menu: categories };
}
// ============================================================
// RESTAURANT DASHBOARD
// ============================================================
async getSavingsDashboard(restaurantId: string) {
const restaurant = await this.db.queryOne(
`SELECT * FROM v_restaurant_savings WHERE restaurant_id = $1`,
[restaurantId],
);
if (!restaurant) throw new NotFoundException('Restaurant not found');
// Recent orders with savings breakdown
const recentOrders = await this.db.queryMany(
`SELECT
o.id, o.order_number, o.subtotal, o.status, o.created_at,
o.platform_fee, o.cc_processing_fee,
o.uber_equivalent_fee, o.restaurant_savings,
o.restaurant_receives,
u.first_name AS customer_first_name
FROM orders o
JOIN users u ON u.id = o.customer_id
WHERE o.restaurant_id = $1
ORDER BY o.created_at DESC
LIMIT 20`,
[restaurantId],
);
// Monthly summary
const monthly = await this.db.queryOne(
`SELECT
COUNT(*) AS total_orders,
SUM(subtotal) AS gross_sales,
SUM(platform_fee) AS platform_fees_paid,
SUM(uber_equivalent_fee) AS uber_would_have_charged,
SUM(restaurant_savings) AS total_saved,
$2::DECIMAL AS monthly_subscription
FROM orders
WHERE restaurant_id = $1
AND status = 'delivered'
AND created_at >= date_trunc('month', NOW())`,
[restaurantId, MONTHLY_SUBSCRIPTION],
);
return {
restaurant,
monthly: {
...monthly,
totalCostOnPlatform: Number(monthly?.platform_fees_paid || 0) + MONTHLY_SUBSCRIPTION,
message: monthly?.total_saved
? `You saved $${Number(monthly.total_saved).toFixed(2)} vs UberEats this month`
: 'Start taking orders to see your savings',
},
recentOrders,
};
}
async getRestaurantByOwner(ownerId: string) {
return this.db.queryOne(
`SELECT * FROM restaurants WHERE owner_id = $1`,
[ownerId],
);
}
async updateHours(restaurantId: string, isOpen: boolean) {
return this.db.queryOne(
`UPDATE restaurants SET is_open = $2, updated_at = NOW()
WHERE id = $1 RETURNING id, name, is_open`,
[restaurantId, isOpen],
);
}
async create(ownerId: string, dto: any) {
const slug = dto.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
// Resolve coordinates: use provided values or geocode the address via Nominatim
let { lat, lng } = dto;
if (!lat || !lng || isNaN(Number(lat)) || isNaN(Number(lng))) {
const coords = await this.geocodeAddress(
`${dto.address || ''}, ${dto.postalCode || ''}, Toronto, ON, Canada`,
);
lat = coords.lat;
lng = coords.lng;
}
// Resolve zone_id: accept zone slug or UUID
let zoneId = dto.zoneId;
if (zoneId && !/^[0-9a-f-]{36}$/i.test(zoneId)) {
const zone = await this.db.queryOne(`SELECT id FROM zones WHERE slug = $1`, [zoneId]);
zoneId = zone?.id ?? zoneId;
}
return this.db.queryOne(
`INSERT INTO restaurants (owner_id, name, slug, description, cuisine_type, phone, email, address, postal_code, location, zone_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, ST_GeographyFromText($10), $11)
RETURNING *`,
[
ownerId, dto.name, slug, dto.description, dto.cuisineType,
dto.phone, dto.email, dto.address, dto.postalCode,
`POINT(${lng} ${lat})`, zoneId,
],
);
}
// Free geocoding via OpenStreetMap Nominatim — no API key required
private async geocodeAddress(address: string): Promise<{ lat: number; lng: number }> {
try {
const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(address)}`;
const res = await fetch(url, { headers: { 'User-Agent': 'TheVibe/1.0 (thevibe.ca)' } });
const data: any[] = await res.json();
if (data.length > 0) return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
} catch {}
return { lat: 43.6389, lng: -79.4196 }; // fallback: Liberty Village centre
}
// ============================================================
// MENU MANAGEMENT (restaurant owner)
// ============================================================
async getMenu(restaurantId: string) {
const categories = await this.db.queryMany(
`SELECT mc.id, mc.name, mc.sort_order, mc.is_active
FROM menu_categories mc
WHERE mc.restaurant_id = $1
ORDER BY mc.sort_order, mc.name`,
[restaurantId],
);
const items = await this.db.queryMany(
`SELECT mi.id, mi.category_id, mi.name, mi.description, mi.price,
mi.is_available, mi.is_featured, mi.dietary_tags, mi.sort_order
FROM menu_items mi
JOIN menu_categories mc ON mc.id = mi.category_id
WHERE mc.restaurant_id = $1
ORDER BY mi.sort_order, mi.name`,
[restaurantId],
);
return categories.map((cat) => ({
...cat,
items: items.filter((i) => i.category_id === cat.id),
}));
}
async addCategory(restaurantId: string, name: string) {
const existing = await this.db.queryMany(
`SELECT sort_order FROM menu_categories WHERE restaurant_id = $1`,
[restaurantId],
);
const nextSort = existing.length + 1;
return this.db.queryOne(
`INSERT INTO menu_categories (restaurant_id, name, sort_order)
VALUES ($1, $2, $3) RETURNING *`,
[restaurantId, name.trim(), nextSort],
);
}
async addMenuItem(restaurantId: string, dto: {
categoryId: string;
name: string;
description?: string;
price: number;
dietaryTags?: string[];
}) {
// Verify category belongs to this restaurant
const cat = await this.db.queryOne(
`SELECT id FROM menu_categories WHERE id = $1 AND restaurant_id = $2`,
[dto.categoryId, restaurantId],
);
if (!cat) throw new Error('Category not found');
const count = await this.db.queryOne(
`SELECT COUNT(*) AS n FROM menu_items WHERE category_id = $1`,
[dto.categoryId],
);
const nextSort = Number(count?.n || 0) + 1;
return this.db.queryOne(
`INSERT INTO menu_items (restaurant_id, category_id, name, description, price, dietary_tags, sort_order, is_available)
VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE) RETURNING *`,
[restaurantId, dto.categoryId, dto.name.trim(), dto.description || null, dto.price, dto.dietaryTags || [], nextSort],
);
}
async updateMenuItem(restaurantId: string, itemId: string, dto: {
name?: string;
description?: string;
price?: number;
isAvailable?: boolean;
isFeatured?: boolean;
}) {
const sets: string[] = [];
const params: any[] = [itemId, restaurantId];
let idx = 3;
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name.trim()); }
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
if (dto.price !== undefined) { sets.push(`price = $${idx++}`); params.push(dto.price); }
if (dto.isAvailable !== undefined) { sets.push(`is_available = $${idx++}`); params.push(dto.isAvailable); }
if (dto.isFeatured !== undefined) { sets.push(`is_featured = $${idx++}`); params.push(dto.isFeatured); }
if (sets.length === 0) return this.db.queryOne(`SELECT * FROM menu_items WHERE id = $1`, [itemId]);
sets.push(`updated_at = NOW()`);
return this.db.queryOne(
`UPDATE menu_items SET ${sets.join(', ')}
WHERE id = $1 AND restaurant_id = $2 RETURNING *`,
params,
);
}
async deleteMenuItem(restaurantId: string, itemId: string) {
return this.db.queryOne(
`DELETE FROM menu_items WHERE id = $1 AND restaurant_id = $2 RETURNING id`,
[itemId, restaurantId],
);
}
// ============================================================
// SAVINGS CALCULATOR (standalone - for marketing page)
// ============================================================
calculateSavings(monthlyOrders: number, avgOrderValue: number) {
const uberTotalFees = monthlyOrders * avgOrderValue * UBER_COMMISSION_RATE;
const platformTotalFees =
MONTHLY_SUBSCRIPTION + monthlyOrders * PLATFORM_PER_ORDER_FEE;
const monthlySavings = uberTotalFees - platformTotalFees;
const annualSavings = monthlySavings * 12;
return {
monthlyOrders,
avgOrderValue,
uberEatsFees: uberTotalFees,
platformFees: platformTotalFees,
monthlySavings,
annualSavings,
savingsPercent: Math.round((monthlySavings / uberTotalFees) * 100),
breakdownMessage: `UberEats would charge $${uberTotalFees.toFixed(2)}/month. We charge $${platformTotalFees.toFixed(2)}/month.`,
};
}
}