Skip to content
Merged
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
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,70 @@ The backend provides interactive API docs:
- **Swagger UI** — `/api/docs`
- **ReDoc** — `/api/redoc`

## How It Works

1. **Email provider** — a background worker per watched folder uses IMAP IDLE (push notifications) with a polling fallback to detect new emails
2. **Processing queue** — new emails are added to a queue and processed asynchronously by a scheduled worker (every 5 seconds)
3. **LLM analysis** — the configured LLM extracts structured data (order number, tracking number, carrier, vendor, items, status, etc.) from the email
4. **Order matching** — the system matches the analysis to existing orders by order number, tracking number, or vendor + item similarity
5. **Order updates** — if a matching order is found it updates the status; otherwise it creates a new order. Every status change is recorded as a state entry for auditability.
6. **Notifications** — configured notifiers (email, webhook) are triggered for relevant events

## Order Statuses

Orders progress through these statuses as emails are processed:

`ordered` → `shipment_preparing` → `shipped` → `in_transit` → `out_for_delivery` → `delivered`

## Admin CLI

A built-in `pt-admin` command is available for user management tasks like listing users and resetting passwords.

### Docker (production)

```bash
docker compose -f docker-compose.prod.yaml exec package-tracker python -m app.cli list-users
docker compose -f docker-compose.prod.yaml exec package-tracker python -m app.cli reset-password <username>
```

### Docker (development)

```bash
docker compose exec backend python -m app.cli list-users
docker compose exec backend python -m app.cli reset-password <username>
```

### Without Docker

```bash
cd backend
python -m app.cli list-users
python -m app.cli reset-password <username>
```

The `reset-password` command prompts for the new password interactively. Pass `--password <pw>` to skip the prompt.

## Development Setup
Comment on lines +102 to +145
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says to "only analyze the backend" and to "exclude tests", but this PR includes substantial frontend changes (frontend/src/...) and multiple backend test updates (backend/tests/...). Please align the PR scope/description with the actual changes (or split frontend/test changes into separate PRs) so reviewers can apply the intended review criteria.

Copilot uses AI. Check for mistakes.

### With Docker (recommended)

```bash
docker compose up
```

This starts all three services:
- **Backend** at `http://localhost:8000` (with hot-reload)
- **Frontend** at `http://localhost:5173`
- **PostgreSQL** at `localhost:5432`

To rebuild after dependency changes:

```bash
docker compose up --build
```

### Without Docker

## Development Setup
### Without Docker
#### Backend
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async def list_modules(
configured = True
if info and info.is_configured:
try:
configured = await info.is_configured()
configured = await info.is_configured(db)
except Exception:
configured = False
response.append(ModuleResponse(
Expand Down
211 changes: 59 additions & 152 deletions backend/app/api/orders.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import asc, desc, func, nullslast, select
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from typing import Optional

from app.database import get_db
from app.models.user import User
from app.models.order import Order
from app.models.order_state import OrderState
from app.schemas.order import OrderResponse, OrderDetailResponse, UpdateOrderRequest, LinkOrderRequest, CreateOrderRequest, OrderListResponse, OrderCountsResponse
from app.schemas.order import (
OrderResponse,
OrderDetailResponse,
UpdateOrderRequest,
LinkOrderRequest,
CreateOrderRequest,
OrderListResponse,
OrderCountsResponse,
)
from app.api.deps import get_current_user
from app.services.orders.order_service import (
create_order as svc_create_order,
list_orders as svc_list_orders,
get_order_counts as svc_get_order_counts,
get_order_detail as svc_get_order_detail,
update_order as svc_update_order,
link_orders as svc_link_orders,
delete_order as svc_delete_order,
)

router = APIRouter(prefix="/api/v1/orders", tags=["orders"])

SORTABLE_COLUMNS = {
"order_number": Order.order_number,
"vendor_name": Order.vendor_name,
"carrier": Order.carrier,
"status": Order.status,
"order_date": Order.order_date,
"total_amount": Order.total_amount,
"updated_at": Order.updated_at,
}


@router.post("", response_model=OrderResponse, status_code=201)
async def create_order(
req: CreateOrderRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
order = Order(
user_id=user.id,
vendor_name=req.vendor_name,
order_number=req.order_number,
tracking_number=req.tracking_number,
carrier=req.carrier,
vendor_domain=req.vendor_domain,
status=req.status,
order_date=req.order_date,
total_amount=req.total_amount,
currency=req.currency,
estimated_delivery=req.estimated_delivery,
items=[item.model_dump() for item in req.items] if req.items else None,
)
db.add(order)
await db.flush()

state = OrderState(
order_id=order.id,
status=req.status,
source_type="manual",
)
db.add(state)
await db.commit()
await db.refresh(order)
return order
return await svc_create_order(db, user.id, req)


@router.get("", response_model=OrderListResponse)
Expand All @@ -69,39 +47,18 @@ async def list_orders(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Order).where(Order.user_id == user.id)

if status:
statuses = [s.strip() for s in status.split(",")]
query = query.where(Order.status.in_(statuses))
if search:
search_filter = f"%{search}%"
query = query.where(
(Order.order_number.ilike(search_filter))
| (Order.vendor_name.ilike(search_filter))
| (Order.tracking_number.ilike(search_filter))
| (Order.carrier.ilike(search_filter))
| (Order.vendor_domain.ilike(search_filter))
)

count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar() or 0

if sort_by not in SORTABLE_COLUMNS:
raise HTTPException(status_code=422, detail=f"Invalid sort_by. Must be one of: {', '.join(sorted(SORTABLE_COLUMNS))}")
if sort_dir not in ("asc", "desc"):
raise HTTPException(status_code=422, detail="Invalid sort_dir. Must be 'asc' or 'desc'")

column = SORTABLE_COLUMNS[sort_by]
direction = asc if sort_dir == "asc" else desc
query = query.order_by(nullslast(direction(column)))
query = query.offset((page - 1) * per_page).limit(per_page)
result = await db.execute(query)
items = result.scalars().all()

result = await svc_list_orders(
db, user.id,
page=page,
per_page=per_page,
status=status,
search=search,
sort_by=sort_by,
sort_dir=sort_dir,
)
return OrderListResponse(
items=[OrderResponse.model_validate(i) for i in items],
total=total,
items=[OrderResponse.model_validate(i) for i in result.items],
total=result.total,
page=page,
per_page=per_page,
)
Expand All @@ -113,94 +70,44 @@ async def order_counts(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Order.status, func.count()).where(Order.user_id == user.id)

if search:
search_filter = f"%{search}%"
query = query.where(
(Order.order_number.ilike(search_filter))
| (Order.vendor_name.ilike(search_filter))
| (Order.tracking_number.ilike(search_filter))
| (Order.carrier.ilike(search_filter))
| (Order.vendor_domain.ilike(search_filter))
)

query = query.group_by(Order.status)
result = await db.execute(query)
counts = dict(result.all())

total = sum(counts.values())
return OrderCountsResponse(
total=total,
ordered=counts.get("ordered", 0),
shipment_preparing=counts.get("shipment_preparing", 0),
shipped=counts.get("shipped", 0),
in_transit=counts.get("in_transit", 0),
out_for_delivery=counts.get("out_for_delivery", 0),
delivered=counts.get("delivered", 0),
)
counts = await svc_get_order_counts(db, user.id, search)
return OrderCountsResponse(**counts)


@router.get("/{order_id}", response_model=OrderDetailResponse)
async def get_order(order_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Order).where(Order.id == order_id, Order.user_id == user.id).options(selectinload(Order.states))
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
async def get_order(
order_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await svc_get_order_detail(db, user.id, order_id)


@router.patch("/{order_id}", response_model=OrderResponse)
async def update_order(order_id: int, req: UpdateOrderRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
order = await db.get(Order, order_id)
if not order or order.user_id != user.id:
raise HTTPException(status_code=404, detail="Order not found")
old_status = order.status
for field, value in req.model_dump(exclude_unset=True).items():
setattr(order, field, value)

# Create OrderState if status changed
if req.status and req.status != old_status:
state = OrderState(
order_id=order.id,
status=req.status,
source_type="manual",
)
db.add(state)

await db.commit()
await db.refresh(order)
return order
async def update_order(
order_id: int,
req: UpdateOrderRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await svc_update_order(db, user.id, order_id, req)


@router.post("/{order_id}/link")
async def link_orders(order_id: int, req: LinkOrderRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
source = await db.get(Order, order_id)
target = await db.get(Order, req.target_order_id)
if not source or source.user_id != user.id or not target or target.user_id != user.id:
raise HTTPException(status_code=404, detail="Order not found")
if target.tracking_number and not source.tracking_number:
source.tracking_number = target.tracking_number
if target.carrier and not source.carrier:
source.carrier = target.carrier
if target.status and target.status != "ordered":
source.status = target.status
# Move states from target to source
result = await db.execute(select(OrderState).where(OrderState.order_id == target.id))
for state in result.scalars().all():
state.order_id = source.id
await db.delete(target)
await db.commit()
await db.refresh(source)
async def link_orders(
order_id: int,
req: LinkOrderRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
source = await svc_link_orders(db, user.id, order_id, req.target_order_id)
return {"merged_into": source.id}


@router.delete("/{order_id}", status_code=204)
async def delete_order(order_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
order = await db.get(Order, order_id)
if not order or order.user_id != user.id:
raise HTTPException(status_code=404, detail="Order not found")
await db.delete(order)
await db.commit()
async def delete_order(
order_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await svc_delete_order(db, user.id, order_id)
7 changes: 6 additions & 1 deletion backend/app/api/smtp.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
Expand All @@ -9,6 +11,8 @@
from app.schemas.smtp import SmtpConfigRequest, SmtpConfigResponse, SmtpTestRequest
from app.services.email_service import send_email

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/v1/admin/smtp", tags=["smtp"], dependencies=[Depends(get_admin_user)])


Expand Down Expand Up @@ -61,4 +65,5 @@ async def test_smtp(req: SmtpTestRequest, db: AsyncSession = Depends(get_db)):
)
return {"status": "ok"}
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
logger.warning("SMTP test failed for recipient %s: %s", req.recipient, e)
raise HTTPException(status_code=502, detail="Failed to send test email")
2 changes: 1 addition & 1 deletion backend/app/api/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ async def system_status(db: AsyncSession = Depends(get_db)):
configured = True
if info.is_configured:
try:
configured = await info.is_configured()
configured = await info.is_configured(db)
except Exception:
configured = False

Expand Down
Loading
Loading