Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:
REDIS_PORT: 6379
SECRET_KEY: test-secret-key
DEBUG: True
STELLAR_ESCROW_PUBLIC: "GDUMMY"
ESCROW_CONTRACT_ID: "CDUMMY"
run: |
pytest ${{ env.WORKING_DIR }}/app/tests/ -v --cov=${{ env.WORKING_DIR }}/app --cov-report=xml

Expand Down Expand Up @@ -142,7 +142,7 @@ jobs:
-e REDIS_HOST=test-redis \
-e REDIS_PORT=6379 \
-e SECRET_KEY=test-secret-key \
-e STELLAR_ESCROW_PUBLIC=GDUMMY \
-e ESCROW_CONTRACT_ID=CDUMMY \
-e DEBUG=True \
-p 8000:8000 \
stellarts-api:test
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.90.0"
toolchain: "stable"
targets: wasm32-unknown-unknown
components: clippy, rustfmt

Expand Down Expand Up @@ -62,7 +62,7 @@ jobs:

- name: Run tests
working-directory: contracts
run: cargo test --verbose
run: cargo test --verbose --features testutils

- name: Build contracts
working-directory: contracts
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/frontend-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ on:
pull_request:
paths:
- 'frontend/**'
push:
branches:
- main
paths:
- 'frontend/**'

jobs:
build:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
.kiro
cover/

# Translations
Expand Down
3 changes: 3 additions & 0 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
from app.models.payment import Payment
from app.models.review import Review
from app.models.portfolio import PortfolioItem
from app.models.bom import BOMItem
from app.models.inventory import InventoryCheckResult
from app.models.notification import NotificationEvent

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
Expand Down
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"])
8 changes: 7 additions & 1 deletion backend/app/api/v1/endpoints/booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from decimal import Decimal
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -33,6 +33,7 @@
from app.services.completion_verification import assess_booking_completion
from app.services.geolocation import geolocation_service
from app.services.soroban import transition_to_in_progress
from app.services.inventory.tasks import run_inventory_check

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -203,6 +204,7 @@ def get_all_bookings(
def update_booking_status(
booking_id: UUID,
status_payload: BookingStatusUpdate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
Expand Down Expand Up @@ -338,6 +340,10 @@ def update_booking_status(
db.commit()
db.refresh(booking)

# Trigger background inventory check when booking goes IN_PROGRESS
if new_status == BookingStatus.IN_PROGRESS:
background_tasks.add_task(run_inventory_check, booking.id, db)

return {
"message": f"Booking {booking_id} status updated",
"updated_by": current_user.id,
Expand Down
91 changes: 91 additions & 0 deletions backend/app/api/v1/endpoints/inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from datetime import datetime, timedelta, timezone
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.core.auth import require_client, get_current_active_user
from app.db.session import get_db
from app.models.booking import Booking, BookingStatus
from app.models.client import Client
from app.models.inventory import InventoryCheckResult
from app.models.user import User
from app.schemas.booking import BookingResponse
from app.schemas.inventory import ClientSupplyOverrideRequest, InventoryCheckResultResponse

router = APIRouter()

_STALE_THRESHOLD = timedelta(hours=24)


@router.get("/inventory/{booking_id}/results", response_model=list[InventoryCheckResultResponse])
def get_inventory_results(
booking_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""Return inventory check results for a booking. Staleness is computed at query time."""
rows = (
db.query(InventoryCheckResult)
.filter(InventoryCheckResult.booking_id == booking_id)
.all()
)

now = datetime.now(timezone.utc)
results = []
for row in rows:
checked_at = row.checked_at
# Ensure timezone-aware comparison
if checked_at is not None and checked_at.tzinfo is None:
checked_at = checked_at.replace(tzinfo=timezone.utc)

computed_status = row.status
if checked_at is not None and checked_at < now - _STALE_THRESHOLD:
computed_status = "stale"

results.append(
InventoryCheckResultResponse(
id=row.id,
booking_id=row.booking_id,
bom_item_id=row.bom_item_id,
store_id=row.store_id,
store_name=row.store_name,
store_address=row.store_address,
available=row.available,
pre_pay_url=row.pre_pay_url,
status=computed_status,
checked_at=row.checked_at,
)
)

return results


@router.post("/bookings/{booking_id}/supply-override", response_model=BookingResponse)
def set_supply_override(
booking_id: UUID,
payload: ClientSupplyOverrideRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_client),
):
"""Set client_supply_override on a booking. Caller must be the booking owner."""
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Booking not found")

# Verify caller is the booking owner via their client profile
client = db.query(Client).filter(Client.user_id == current_user.id).first()
if not client or booking.client_id != client.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not the booking owner")

# Only allow override on PENDING or CONFIRMED bookings
if booking.status not in (BookingStatus.PENDING, BookingStatus.CONFIRMED):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Supply override only allowed on pending or confirmed bookings",
)

booking.client_supply_override = payload.client_supply_override
db.commit()
db.refresh(booking)
return booking
6 changes: 6 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class Settings(BaseSettings):
REDIS_PORT: int = 6380
REDIS_DB: int = 0
NEARBY_CACHE_TTL: int = 60 # Seconds to cache nearby-artisan search results
INVENTORY_CACHE_TTL: int = 300 # Seconds to cache store inventory responses
STORE_API_TIMEOUT_S: int = 5 # Per-adapter timeout in seconds

# CORS
BACKEND_CORS_ORIGINS: list[AnyHttpUrl] = []
Expand Down Expand Up @@ -55,6 +57,10 @@ def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str:
STRIPE_SECRET_KEY: str | None = None
STRIPE_PUBLISHABLE_KEY: str | None = None

# Firebase Cloud Messaging
FCM_PROJECT_ID: str | None = None
FCM_SERVICE_ACCOUNT_JSON: str | None = None

# Soroban Configuration
# Optional vision model configuration for completion verification
VISION_API_URL: str | None = None
Expand Down
8 changes: 8 additions & 0 deletions backend/app/db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,11 @@
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()

# Import all models here so they are registered with Base.metadata
# (used by Alembic autogenerate and app startup)
def _register_models():
from app.models import user, artisan, client, booking, payment, review, portfolio # noqa: F401
from app.models import bom, inventory, notification # noqa: F401

_register_models()
16 changes: 16 additions & 0 deletions backend/app/models/bom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Uuid
from sqlalchemy.sql import func

from app.db.base import Base


class BOMItem(Base):
__tablename__ = "bom_items"

id = Column(Integer, primary_key=True)
booking_id = Column(Uuid, ForeignKey("bookings.id"), nullable=False)
sku = Column(String(100), nullable=False)
name = Column(String(300), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
unit = Column(String(50))
created_at = Column(DateTime(timezone=True), server_default=func.now())
3 changes: 3 additions & 0 deletions backend/app/models/booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from sqlalchemy import (
DECIMAL,
Boolean,
Column,
DateTime,
Enum,
Expand Down Expand Up @@ -44,6 +45,8 @@ class Booking(Base):
date = Column(DateTime(timezone=True))
location = Column(String(500))
notes = Column(Text)
client_supply_override = Column(Boolean, default=False, nullable=False)
artisan_device_token = Column(String(512), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
Expand Down
19 changes: 19 additions & 0 deletions backend/app/models/inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Uuid
from sqlalchemy.sql import func

from app.db.base import Base


class InventoryCheckResult(Base):
__tablename__ = "inventory_check_results"

id = Column(Integer, primary_key=True)
booking_id = Column(Uuid, ForeignKey("bookings.id"), nullable=False)
bom_item_id = Column(Integer, ForeignKey("bom_items.id"), nullable=False)
store_id = Column(String(100), nullable=False)
store_name = Column(String(300), nullable=False)
store_address = Column(String(500))
available = Column(Boolean, nullable=False)
pre_pay_url = Column(String(2048))
status = Column(String(20), default="fresh")
checked_at = Column(DateTime(timezone=True), server_default=func.now())
16 changes: 16 additions & 0 deletions backend/app/models/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Uuid
from sqlalchemy.sql import func

from app.db.base import Base


class NotificationEvent(Base):
__tablename__ = "notification_events"

id = Column(Integer, primary_key=True)
artisan_id = Column(Integer, ForeignKey("artisans.id"), nullable=False)
booking_id = Column(Uuid, ForeignKey("bookings.id"), nullable=False)
store_id = Column(String(100), nullable=False)
item_sku = Column(String(100), nullable=False)
sent_at = Column(DateTime(timezone=True), server_default=func.now())
fcm_success = Column(Boolean, nullable=False)
4 changes: 4 additions & 0 deletions backend/app/schemas/booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from pydantic import BaseModel, ConfigDict, Field

from app.schemas.inventory import InventoryCheckResultResponse


class BookingCreate(BaseModel):
"""Schema for creating a new booking"""
Expand Down Expand Up @@ -122,3 +124,5 @@ class BookingResponse(BaseModel):
notes: str | None
created_at: datetime
updated_at: datetime | None
client_supply_override: bool = False
inventory_results: list[InventoryCheckResultResponse] = []
44 changes: 44 additions & 0 deletions backend/app/schemas/inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field


class BOMItemCreate(BaseModel):
sku: str
name: str
quantity: int = Field(ge=1)
unit: str | None = None


class BOMItemResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: int
booking_id: UUID
sku: str
name: str
quantity: int
unit: str | None
created_at: datetime


class InventoryCheckResultResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: int
booking_id: UUID
bom_item_id: int
store_id: str
store_name: str
store_address: str | None
available: bool
pre_pay_url: str | None
status: str # "fresh" | "stale" | "unavailable"
checked_at: datetime


class ClientSupplyOverrideRequest(BaseModel):
client_supply_override: bool
2 changes: 1 addition & 1 deletion backend/app/schemas/user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import re
from enum import StrEnum
from enum import Enum, StrEnum

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator

Expand Down
3 changes: 3 additions & 0 deletions backend/app/services/inventory/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .base import StoreAdapterProtocol, StoreItemResult

__all__ = ["StoreAdapterProtocol", "StoreItemResult"]
Loading
Loading