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.`, }; } }