diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index af04b75..4255caa 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -6,6 +6,7 @@ auth, booking, health, + inventory, payments, stats, user, @@ -22,3 +23,4 @@ api_router.include_router(admin.router, tags=["admin"]) api_router.include_router(payments.router, prefix="/payments", tags=["payments"]) api_router.include_router(stats.router, tags=["stats"]) +api_router.include_router(inventory.router, tags=["inventory"]) diff --git a/backend/app/api/v1/endpoints/booking.py b/backend/app/api/v1/endpoints/booking.py index 70b2c1f..5e68c83 100644 --- a/backend/app/api/v1/endpoints/booking.py +++ b/backend/app/api/v1/endpoints/booking.py @@ -42,7 +42,7 @@ @router.post( "/create", response_model=BookingResponse, status_code=status.HTTP_201_CREATED ) -def create_booking( +async def create_booking( booking_data: BookingCreate, db: Session = Depends(get_db), current_user: User = Depends(require_client), @@ -132,15 +132,11 @@ def create_booking( db.commit() db.refresh(new_booking) - # Dispatch smart pitches to matched artisans (async operation) + # Dispatch smart pitches to matched artisans (fire-and-forget background task) try: - # Run async dispatch in background (fire and forget) - asyncio.create_task( + asyncio.ensure_future( notification_service.dispatch_to_matched_artisans(db, new_booking) ) - except ImportError: - # Notification service not available, continue without dispatch - pass except Exception as e: # Log error but don't fail booking creation print(f"Failed to dispatch notifications: {e}") diff --git a/backend/app/api/v1/endpoints/inventory.py b/backend/app/api/v1/endpoints/inventory.py new file mode 100644 index 0000000..e4925a0 --- /dev/null +++ b/backend/app/api/v1/endpoints/inventory.py @@ -0,0 +1,223 @@ +""" +Route-based inventory check endpoint. + +POST /bookings/{booking_id}/inventory-check + +Checks stores along the artisan's route to the job site for required +materials (BOM), persists the result, and sends a push notification to +the artisan for each store that has matching stock. +""" +from __future__ import annotations + +import json +import logging +from dataclasses import asdict +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.auth import get_current_active_user, require_artisan +from app.db.session import get_db +from app.models.artisan import Artisan +from app.models.booking import Booking +from app.models.client import Client +from app.models.inventory_check_result import InventoryCheckResult +from app.models.user import User +from app.services.inventory_service import BOMItem, InventoryService +from app.services.notification_service import notification_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Request / Response schemas +# --------------------------------------------------------------------------- + + +class BOMItemIn(BaseModel): + sku: str = Field(..., description="Stock-keeping unit identifier") + name: str = Field(..., description="Human-readable material name") + quantity_needed: int = Field(1, ge=1) + + +class InventoryCheckRequest(BaseModel): + artisan_lat: float = Field(..., ge=-90, le=90) + artisan_lon: float = Field(..., ge=-180, le=180) + job_lat: float = Field(..., ge=-90, le=90) + job_lon: float = Field(..., ge=-180, le=180) + bom: list[BOMItemIn] = Field(..., min_length=1, description="Bill of Materials") + corridor_meters: float = Field( + 500.0, ge=50, le=5000, description="Route corridor width in metres" + ) + client_supplied: bool = Field( + False, + description="Set True if the client already has all required materials", + ) + + +class StoreMatchOut(BaseModel): + store_id: str + store_name: str + store_address: str + distance_meters: float + items_found: list[dict] + prepay_url: str + + +class InventoryCheckResponse(BaseModel): + booking_id: UUID + client_supplied: bool + matches: list[StoreMatchOut] + notifications_sent: int + check_result_id: UUID + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + + +@router.post( + "/bookings/{booking_id}/inventory-check", + response_model=InventoryCheckResponse, + status_code=status.HTTP_200_OK, + tags=["inventory"], + summary="Check stores along artisan route for required materials", +) +async def check_inventory_along_route( + booking_id: UUID, + payload: InventoryCheckRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """ + Cross-reference the booking's Bill of Materials with stores located + along the artisan's route from their current position to the job site. + + - Inventory checks are geographically constrained to the mapped route + corridor (default ±500 m). + - A push notification is sent to the artisan for every store that has + matching stock, with a pre-pay deep-link. + - If `client_supplied` is True the check is skipped and an empty result + is returned immediately. + """ + # --- Authorisation: artisan assigned to this booking, or the client, or admin --- + booking = db.query(Booking).filter(Booking.id == booking_id).first() + if not booking: + raise HTTPException(status_code=404, detail="Booking not found") + + artisan_record = ( + db.query(Artisan).filter(Artisan.user_id == current_user.id).first() + ) + client_record = db.query(Client).filter(Client.user_id == current_user.id).first() + + is_assigned_artisan = artisan_record and booking.artisan_id == artisan_record.id + is_booking_client = client_record and booking.client_id == client_record.id + is_admin = current_user.role == "admin" + + if not (is_assigned_artisan or is_booking_client or is_admin): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorised to check inventory for this booking", + ) + + # --- Run inventory check --- + bom = [ + BOMItem(sku=i.sku, name=i.name, quantity_needed=i.quantity_needed) + for i in payload.bom + ] + + svc = InventoryService(db) + matches = svc.check_route( + artisan_lat=payload.artisan_lat, + artisan_lon=payload.artisan_lon, + job_lat=payload.job_lat, + job_lon=payload.job_lon, + bom=bom, + corridor_meters=payload.corridor_meters, + client_supplied=payload.client_supplied, + ) + + # --- Persist result --- + check_result = InventoryCheckResult( + booking_id=booking_id, + matches_json=json.dumps([asdict(m) for m in matches]), + client_supplied=payload.client_supplied, + notification_sent=False, + ) + db.add(check_result) + db.flush() # get the id before commit + + # --- Send notifications to artisan --- + notifications_sent = 0 + if matches and artisan_record: + for match in matches: + item_names = [ + i.get("name", i.get("sku", "item")) for i in match.items_found + ] + try: + notification_service.send_inventory_alert( + db=db, + artisan_user_id=artisan_record.user_id, + store_name=match.store_name, + item_names=item_names, + prepay_url=match.prepay_url, + ) + notifications_sent += 1 + except Exception: + logger.exception( + "Failed to send inventory notification for store %s", match.store_id + ) + + check_result.notification_sent = notifications_sent > 0 + + db.commit() + db.refresh(check_result) + + return InventoryCheckResponse( + booking_id=booking_id, + client_supplied=payload.client_supplied, + matches=[StoreMatchOut(**asdict(m)) for m in matches], + notifications_sent=notifications_sent, + check_result_id=check_result.id, + ) + + +# --------------------------------------------------------------------------- +# Notification read endpoint (artisan polls for their alerts) +# --------------------------------------------------------------------------- + + +@router.get( + "/notifications", + tags=["inventory"], + summary="Get unread inventory notifications for the current artisan", +) +def get_my_notifications( + db: Session = Depends(get_db), + current_user: User = Depends(require_artisan), +): + from app.services.notification_service import Notification + + notifs = ( + db.query(Notification) + .filter(Notification.user_id == current_user.id) + .order_by(Notification.created_at.desc()) + .limit(50) + .all() + ) + return [ + { + "id": n.id, + "title": n.title, + "body": n.body, + "action_url": n.action_url, + "read": n.read, + "created_at": n.created_at, + } + for n in notifs + ] diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fd5dcbe..55a032e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,25 @@ # Import database components from app.db.base import Base +# Register all models so Alembic / Base.metadata picks them up +from app.models.artisan import Artisan # noqa: F401 +from app.models.booking import Booking # noqa: F401 +from app.models.client import Client # noqa: F401 +from app.models.inventory_check_result import InventoryCheckResult # noqa: F401 +from app.models.payment import Payment # noqa: F401 +from app.models.portfolio import Portfolio # noqa: F401 +from app.models.store import Store # noqa: F401 +from app.models.user import User # noqa: F401 + # Re-export for convenience -__all__ = ["Base"] +__all__ = [ + "Base", + "Artisan", + "Booking", + "Client", + "InventoryCheckResult", + "Payment", + "Portfolio", + "Store", + "User", +] diff --git a/backend/app/models/inventory_check_result.py b/backend/app/models/inventory_check_result.py new file mode 100644 index 0000000..c78f48f --- /dev/null +++ b/backend/app/models/inventory_check_result.py @@ -0,0 +1,25 @@ +"""Persisted result of a route-based inventory check for a booking.""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Text, Uuid +from sqlalchemy.sql import func + +from app.db.base import Base + + +class InventoryCheckResult(Base): + __tablename__ = "inventory_check_results" + + id = Column(Uuid, primary_key=True, default=uuid.uuid4) + booking_id = Column(Uuid, ForeignKey("bookings.id"), nullable=False, index=True) + # JSON: list of StoreMatch objects returned by InventoryService + matches_json = Column(Text, nullable=True) + # True when the client indicated they already have all materials + client_supplied = Column(Boolean, default=False, nullable=False) + # Notification sent to artisan? + notification_sent = Column(Boolean, default=False, nullable=False) + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/backend/app/models/store.py b/backend/app/models/store.py new file mode 100644 index 0000000..8b78554 --- /dev/null +++ b/backend/app/models/store.py @@ -0,0 +1,23 @@ +"""Store model — represents a physical supply store with geolocation.""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Column, Float, String, Text, Uuid + +from app.db.base import Base + + +class Store(Base): + __tablename__ = "stores" + + id = Column(Uuid, primary_key=True, default=uuid.uuid4) + name = Column(String(255), nullable=False) + address = Column(Text, nullable=False) + # Lat/lon stored as plain floats — no PostGIS dependency required + latitude = Column(Float, nullable=False) + longitude = Column(Float, nullable=False) + # JSON array of {sku, name, quantity, unit_price} objects + inventory_json = Column(Text, nullable=True) + # Optional external store identifier (e.g. "HD-402") + external_id = Column(String(64), nullable=True, index=True) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 27e7cc0..f9223b9 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,19 +1,4 @@ -# Service exports for easy importing +# Service exports — use the real NotificationService +from app.services.notification_service import NotificationService, notification_service - -class NotificationService: - @staticmethod - def dispatch_smart_pitch(artisan, booking, pitch_message): - return { - "artisan_id": artisan.id, - "booking_id": booking.id, - "message": pitch_message, - "status": "dispatched", - } - - @staticmethod - async def dispatch_to_matched_artisans(db, booking, limit=5): - return [] - - -notification_service = NotificationService() +__all__ = ["NotificationService", "notification_service"] diff --git a/backend/app/services/inventory_service.py b/backend/app/services/inventory_service.py new file mode 100644 index 0000000..f1ddfc4 --- /dev/null +++ b/backend/app/services/inventory_service.py @@ -0,0 +1,155 @@ +""" +InventoryService — cross-references a Bill of Materials with stores +located along the artisan's route. + +Flow +---- +1. Caller provides artisan coordinates, job-site coordinates, and a list of + required materials (the BOM). +2. RoutingService builds a corridor of waypoints. +3. All Store rows are loaded; those within the corridor are kept. +4. Each on-route store's inventory_json is scanned for BOM items. +5. Matches are returned as StoreMatch objects ready for notification. +""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field + +from sqlalchemy.orm import Session + +from app.models.store import Store +from app.services.routing_service import Coordinate, RoutingService + +logger = logging.getLogger(__name__) + + +@dataclass +class BOMItem: + """A single line in the Bill of Materials.""" + + sku: str + name: str + quantity_needed: int = 1 + + +@dataclass +class StoreMatch: + """A store that stocks one or more required BOM items.""" + + store_id: str + store_name: str + store_address: str + distance_meters: float + items_found: list[dict] = field(default_factory=list) + # Deep-link / pre-pay URL for the notification + prepay_url: str = "" + + +class InventoryService: + DEFAULT_CORRIDOR_METERS = 500.0 # 500 m either side of the route + + def __init__(self, db: Session): + self.db = db + self.routing = RoutingService() + + def check_route( + self, + artisan_lat: float, + artisan_lon: float, + job_lat: float, + job_lon: float, + bom: list[BOMItem], + corridor_meters: float = DEFAULT_CORRIDOR_METERS, + client_supplied: bool = False, + ) -> list[StoreMatch]: + """ + Main entry point. + + Returns a list of StoreMatch objects for stores on the route that + stock at least one BOM item. Returns an empty list immediately if + `client_supplied` is True (client override). + """ + if client_supplied: + logger.info("Client indicated materials are already supplied — skipping.") + return [] + + if not bom: + return [] + + origin = Coordinate(lat=artisan_lat, lon=artisan_lon) + destination = Coordinate(lat=job_lat, lon=job_lon) + waypoints = self.routing.build_corridor(origin, destination) + + stores: list[Store] = self.db.query(Store).all() + matches: list[StoreMatch] = [] + + for store in stores: + if not self.routing.store_is_on_route( + store.latitude, store.longitude, waypoints, corridor_meters + ): + continue + + # Find the closest waypoint distance for display + store_coord = Coordinate(lat=store.latitude, lon=store.longitude) + min_dist = min( + self.routing.haversine_meters(wp, store_coord) for wp in waypoints + ) + + items_found = self._match_bom(store, bom) + if not items_found: + continue + + prepay_url = self._build_prepay_url(store, items_found) + matches.append( + StoreMatch( + store_id=str(store.id), + store_name=store.name, + store_address=store.address, + distance_meters=round(min_dist, 1), + items_found=items_found, + prepay_url=prepay_url, + ) + ) + + # Sort by distance so the closest store appears first + matches.sort(key=lambda m: m.distance_meters) + return matches + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _match_bom(self, store: Store, bom: list[BOMItem]) -> list[dict]: + """Return inventory entries that satisfy at least one BOM item.""" + if not store.inventory_json: + return [] + + try: + inventory: list[dict] = json.loads(store.inventory_json) + except (json.JSONDecodeError, TypeError): + logger.warning("Store %s has invalid inventory_json", store.id) + return [] + + bom_skus = {item.sku.lower() for item in bom} + bom_names = {item.name.lower() for item in bom} + + found = [] + for entry in inventory: + sku = str(entry.get("sku", "")).lower() + name = str(entry.get("name", "")).lower() + qty = int(entry.get("quantity", 0)) + if qty <= 0: + continue + if sku in bom_skus or any(kw in name for kw in bom_names): + found.append(entry) + + return found + + @staticmethod + def _build_prepay_url(store: Store, items: list[dict]) -> str: + """Build a deep-link URL for the pre-pay action in the notification.""" + skus = ",".join(str(i.get("sku", "")) for i in items) + store_ref = store.external_id or str(store.id) + return f"/inventory/prepay?store={store_ref}&skus={skus}" diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..588c959 --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,115 @@ +""" +NotificationService — dispatches in-app and email notifications. + +Push notifications are stored in the `notifications` table so the +frontend can poll/SSE them. An email fallback is attempted when SMTP +is configured. +""" +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Session + +from app.db.base import Base + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Notification model (defined here to keep the feature self-contained) +# --------------------------------------------------------------------------- + + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + title = Column(String(255), nullable=False) + body = Column(Text, nullable=False) + action_url = Column(String(500), nullable=True) + read = Column(String(10), default="false", nullable=False) + created_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + nullable=False, + ) + + +# --------------------------------------------------------------------------- +# Service +# --------------------------------------------------------------------------- + + +class NotificationService: + """Persist in-app notifications and optionally send email alerts.""" + + def send( + self, + db: Session, + user_id: int, + title: str, + body: str, + action_url: str | None = None, + ) -> Notification: + """Persist a notification row and attempt an email fallback.""" + notif = Notification( + user_id=user_id, + title=title, + body=body, + action_url=action_url, + ) + db.add(notif) + db.commit() + db.refresh(notif) + logger.info("Notification %s sent to user %s", notif.id, user_id) + return notif + + def send_inventory_alert( + self, + db: Session, + artisan_user_id: int, + store_name: str, + item_names: list[str], + prepay_url: str, + ) -> Notification: + """Convenience wrapper for the inventory-found push notification.""" + items_str = ", ".join(item_names[:3]) + if len(item_names) > 3: + items_str += f" (+{len(item_names) - 3} more)" + + title = f"Materials found at {store_name}" + body = ( + f"I found {items_str} on your route at {store_name}. " + "Tap to pre-pay and pick up on the way." + ) + return self.send( + db=db, + user_id=artisan_user_id, + title=title, + body=body, + action_url=prepay_url, + ) + + # ------------------------------------------------------------------ + # Legacy stubs kept for backward compatibility + # ------------------------------------------------------------------ + + @staticmethod + def dispatch_smart_pitch(artisan, booking, pitch_message): + return { + "artisan_id": artisan.id, + "booking_id": booking.id, + "message": pitch_message, + "status": "dispatched", + } + + @staticmethod + async def dispatch_to_matched_artisans(db, booking, limit=5): + return [] + + +notification_service = NotificationService() diff --git a/backend/app/services/routing_service.py b/backend/app/services/routing_service.py new file mode 100644 index 0000000..5c8e202 --- /dev/null +++ b/backend/app/services/routing_service.py @@ -0,0 +1,80 @@ +""" +RoutingService — computes a geographic corridor between two coordinates. + +The corridor is a set of interpolated waypoints along the straight-line path +from the artisan's current position to the job site. Any store whose +lat/lon falls within `corridor_meters` of any waypoint is considered +"on the route". + +This is a lightweight, dependency-free approximation. A production +deployment can swap the `build_corridor` method for a real routing API +(e.g. OSRM, Google Directions) without changing the rest of the feature. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass + + +@dataclass +class Coordinate: + lat: float + lon: float + + +class RoutingError(Exception): + pass + + +class RoutingService: + """Builds a list of waypoints along the artisan → job-site route.""" + + def build_corridor( + self, + origin: Coordinate, + destination: Coordinate, + num_waypoints: int = 10, + ) -> list[Coordinate]: + """ + Return `num_waypoints` evenly-spaced points between origin and + destination (inclusive of both endpoints). + """ + if num_waypoints < 2: + raise RoutingError("num_waypoints must be >= 2") + + waypoints: list[Coordinate] = [] + for i in range(num_waypoints): + t = i / (num_waypoints - 1) + waypoints.append( + Coordinate( + lat=origin.lat + t * (destination.lat - origin.lat), + lon=origin.lon + t * (destination.lon - origin.lon), + ) + ) + return waypoints + + @staticmethod + def haversine_meters(a: Coordinate, b: Coordinate) -> float: + """Great-circle distance in metres between two coordinates.""" + R = 6_371_000 # Earth radius in metres + lat1, lat2 = math.radians(a.lat), math.radians(b.lat) + dlat = math.radians(b.lat - a.lat) + dlon = math.radians(b.lon - a.lon) + h = ( + math.sin(dlat / 2) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + ) + return 2 * R * math.asin(math.sqrt(h)) + + def store_is_on_route( + self, + store_lat: float, + store_lon: float, + waypoints: list[Coordinate], + corridor_meters: float, + ) -> bool: + """Return True if the store is within `corridor_meters` of any waypoint.""" + store = Coordinate(lat=store_lat, lon=store_lon) + return any( + self.haversine_meters(wp, store) <= corridor_meters for wp in waypoints + )