Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions backend/app/api/v1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
auth,
booking,
health,
inventory,
payments,
stats,
user,
Expand All @@ -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"])
10 changes: 3 additions & 7 deletions backend/app/api/v1/endpoints/booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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}")
Expand Down
223 changes: 223 additions & 0 deletions backend/app/api/v1/endpoints/inventory.py
Original file line number Diff line number Diff line change
@@ -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
]
22 changes: 21 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
25 changes: 25 additions & 0 deletions backend/app/models/inventory_check_result.py
Original file line number Diff line number Diff line change
@@ -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
)
23 changes: 23 additions & 0 deletions backend/app/models/store.py
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 3 additions & 18 deletions backend/app/services/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading