313 lines
11 KiB
TypeScript
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.`,
|
|
};
|
|
}
|
|
}
|