Skip to content

Implement product catalogue and order management features#67

Merged
stenwire merged 4 commits into
mainfrom
catalogue-feature
Jul 4, 2026
Merged

Implement product catalogue and order management features#67
stenwire merged 4 commits into
mainfrom
catalogue-feature

Conversation

@stenwire

@stenwire stenwire commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Description

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Refactoring (no functional changes)
  • Documentation update
  • Infrastructure / CI / tooling

Checklist

  • My code follows the existing code style of this project
  • I have tested my changes locally
  • Lint passes (make lint-be / make lint-fe)
  • Tests pass (make test-be / make test-fe)
  • I have added tests for new functionality (if applicable)
  • I have updated documentation (if applicable)

Screenshots / Demo

Related Issues

stenwire added 4 commits May 10, 2026 18:09
- Added product model and schema for CRUD operations.
- Integrated product routes into the API.
- Developed search functionality for products within the agent system.
- Created frontend components for product listing, adding, editing, and bulk uploading.
- Implemented notifications for user actions related to products.
- Added CSV import functionality for bulk product uploads.
- Updated dashboard layout to include a link to the product catalogue.
- Implemented API functions for listing orders, retrieving a single order, and updating order status in api.ts.
- Defined order-related types including Order, OrderItem, and OrdersPage in types.ts.
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

🤖 Hi @stenwire, I've received your request, and I'm working on it now! You can track my progress in the logs for more details.

@stenwire
stenwire merged commit 6ea4265 into main Jul 4, 2026
3 of 4 checks passed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a comprehensive WhatsApp broadcast campaign system, a product catalogue, and order management capabilities, alongside pagination and search enhancements across multiple API endpoints and frontend views. The code review identifies several critical issues, including a runtime NameError due to missing imports in the WhatsApp API, database portability concerns from raw PostgreSQL queries in the campaign worker, and an anti-pattern in database session retrieval. Additionally, the feedback recommends using Decimal instead of float for monetary values to prevent precision errors and adding a transaction rollback on bulk upload failures.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +10 to +14
from app.models.whatsapp_broadcast import (
CampaignMessageStatus,
WhatsAppCampaign,
WhatsAppCampaignMessage,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The _process_status_updates function uses datetime and timezone (e.g., datetime.now(timezone.utc)), but these are not imported in this file. This will raise a NameError at runtime when a status update webhook is received. Please import datetime and timezone from the datetime module.

Suggested change
from app.models.whatsapp_broadcast import (
CampaignMessageStatus,
WhatsAppCampaign,
WhatsAppCampaignMessage,
)
from datetime import datetime, timezone
from app.models.whatsapp_broadcast import (
CampaignMessageStatus,
WhatsAppCampaign,
WhatsAppCampaignMessage,
)

Comment on lines +97 to +134
result = db.execute(
text(
"""
SELECT id FROM whatsapp_campaigns
WHERE status = :scheduled
AND scheduled_at <= :now
ORDER BY scheduled_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
"""
),
{
"scheduled": CampaignStatus.SCHEDULED.value,
"now": now,
"limit": CLAIM_LIMIT,
},
)
ids = [row[0] for row in result]
if not ids:
db.commit()
return []

# Mark as SENDING inside the same transaction while the lock is held.
db.execute(
text(
"""
UPDATE whatsapp_campaigns
SET status = :sending, started_at = :now, updated_at = :now
WHERE id = ANY(:ids)
"""
),
{
"sending": CampaignStatus.SENDING.value,
"now": now,
"ids": ids,
},
)
db.commit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The raw SQL query uses PostgreSQL-specific syntax WHERE id = ANY(:ids) and FOR UPDATE SKIP LOCKED which will fail when running unit tests or local development against SQLite. To ensure database portability and maintainability, rewrite these queries using SQLAlchemy's database-agnostic ORM methods.

        # Use SQLAlchemy ORM for database-agnostic query execution (works on both PostgreSQL and SQLite)
        result = (
            db.query(WhatsAppCampaign.id)
            .filter(
                WhatsAppCampaign.status == CampaignStatus.SCHEDULED.value,
                WhatsAppCampaign.scheduled_at <= now,
            )
            .order_by(WhatsAppCampaign.scheduled_at.asc())
            .limit(CLAIM_LIMIT)
            .with_for_update(skip_locked=True)
            .all()
        )
        ids = [row[0] for row in result]
        if not ids:
            db.commit()
            return []

        # Mark as SENDING inside the same transaction while the lock is held.
        db.query(WhatsAppCampaign).filter(WhatsAppCampaign.id.in_(ids)).update(
            {
                WhatsAppCampaign.status: CampaignStatus.SENDING.value,
                WhatsAppCampaign.started_at: now,
                WhatsAppCampaign.updated_at: now,
            },
            synchronize_session=False,
        )
        db.commit()

# Delivery / read status callbacks from outbound campaigns.
statuses = value.get("statuses")
if statuses:
db = next(get_db())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using next(get_db()) to manually retrieve a database session is an anti-pattern and can lead to generator lifecycle issues or unclosed sessions. Since this is a webhook handler running outside of FastAPI's dependency injection context, it is safer and cleaner to use SessionLocal() directly with a try...finally block.

        from app.db.session import SessionLocal
        db = SessionLocal()

Comment on lines +149 to +150
price_str = row.get('price', '0').replace(',', '')
price = float(price_str) if price_str else 0.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Parsing currency/price values as float can introduce floating-point precision and rounding errors. Since the database column Product.price is defined as Numeric(10, 2) and the Pydantic schema expects a Decimal, use Python's Decimal type to parse and store monetary values accurately.

Suggested change
price_str = row.get('price', '0').replace(',', '')
price = float(price_str) if price_str else 0.0
from decimal import Decimal
price_str = row.get('price', '0').replace(',', '')
price = Decimal(price_str) if price_str else Decimal("0.00")

Comment on lines +187 to +188
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to process CSV file: {str(e)}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If an exception occurs during database operations or db.commit(), the transaction is left open and un-rolled-back, which can leave the database session in an invalid state. Ensure db.rollback() is called in the except block before raising the HTTPException.

Suggested change
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to process CSV file: {str(e)}")
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=f"Failed to process CSV file: {str(e)}")

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

## 📋 Review Summary

This is a substantial pull request that introduces significant new functionality for product catalogues, order management, and WhatsApp broadcasting. The backend implementation is generally robust and well-structured. The new database models, APIs, and background worker are well-designed.

I've added a few inline comments with suggestions for performance improvements and to enhance code clarity and robustness, but these are minor.

🔍 General Feedback

  • Good Job: The separation of concerns between services, API routers, and the new background worker is clean. The use of FOR UPDATE SKIP LOCKED for the campaign worker is a great choice for building a scalable job queue.
  • Testing: Given the size and complexity of this feature, it would be beneficial to add more integration tests, especially for the CSV import and the campaign execution logic, to cover various edge cases.
  • Frontend: The new frontend pages look good and seem to correctly integrate with the new APIs. The addition of pagination to existing pages is also a welcome improvement.

Overall, this is a great contribution that adds a lot of value.

Comment on lines +162 to +163
else:
product = Product(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The parsing of `stock_quantity` from the CSV can be made more robust. If the `stock_quantity` column is present but empty, `int('')` will raise a `ValueError`. It's safer to handle this case explicitly.
Suggested change
else:
product = Product(
price_str = row.get('price', '0').replace(',', '')
price = float(price_str) if price_str else 0.0
stock_str = row.get('stock_quantity', '0').strip()
stock = int(stock_str) if stock_str else 0
is_active = row.get('is_active', 'true').lower() == 'true'

from_phone,
ai_text,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 This function commits to the database inside a loop, which can lead to performance issues if a webhook payload contains a large number of status updates. Additionally, the `WhatsAppCampaign` is fetched from the database for each status, even if multiple statuses belong to the same campaign.

To improve performance, you can move the db.commit() outside the loop and cache campaign objects.

Suggested change
def _process_status_updates(db: Session, statuses: list[dict]) -> None:
"""Update campaign messages from Meta `statuses[]` webhook entries."""
campaign_cache = {}
for entry in statuses:
meta_id = entry.get("id")
raw_status = (entry.get("status") or "").lower()
if not meta_id or raw_status not in _STATUS_TO_ENUM:
continue
new_status = _STATUS_TO_ENUM[raw_status]
msg = (
db.query(WhatsAppCampaignMessage)
.filter(WhatsAppCampaignMessage.meta_message_id == meta_id)
.first()
)
if not msg:
continue
# Idempotency / out-of-order protection: only move forward.
current_rank = _STATUS_RANK.get(msg.status, 0)
new_rank = _STATUS_RANK[new_status.value]
if new_rank <= current_rank and new_status != CampaignMessageStatus.FAILED:
continue
previous_status = msg.status
msg.status = new_status.value
now = datetime.now(timezone.utc)
if new_status == CampaignMessageStatus.DELIVERED and not msg.delivered_at:
msg.delivered_at = now
elif new_status == CampaignMessageStatus.READ and not msg.read_at:
msg.read_at = now
elif new_status == CampaignMessageStatus.FAILED:
errors = entry.get("errors") or []
if errors:
err = errors[0]
msg.error_code = str(err.get("code", ""))[:100]
msg.error_message = str(err.get("title") or err.get("message") or "")[:500]
# Aggregate into campaign counts (only on first transition into each state).
campaign = campaign_cache.get(msg.campaign_id)
if campaign is None:
campaign = (
db.query(WhatsAppCampaign)
.filter(WhatsAppCampaign.id == msg.campaign_id)
.first()
)
campaign_cache[msg.campaign_id] = campaign
if campaign and previous_status != new_status.value:
if new_status == CampaignMessageStatus.DELIVERED:
campaign.delivered_count += 1
elif new_status == CampaignMessageStatus.READ:
campaign.read_count += 1
elif new_status == CampaignMessageStatus.FAILED:
campaign.failed_count += 1
if previous_status == CampaignMessageStatus.SENT.value:
campaign.sent_count = max(0, campaign.sent_count - 1)
db.commit()

Comment on lines +238 to +241
f"ROUTING RULES — you MUST delegate every request, NEVER respond directly:\n"
f"- Greetings (hello, hi, hey, good morning, etc.): ALWAYS delegate to 'greeting_agent'\n"
f"- Farewells (bye, goodbye, see you, thanks and bye, etc.): ALWAYS delegate to 'farewell_agent'\n"
f"- Products, prices, stock, purchasing, or order questions: ALWAYS delegate to 'sales_agent'\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 There's a contradiction in the chief agent's instructions. The prompt says "you MUST delegate every request, NEVER respond directly" and "You have NO tools of your own," but then it instructs the agent to "respond with..." for out-of-scope topics. This will likely confuse the model.

The rag_agent is already designed to handle out-of-scope questions gracefully. It's better to make the rag_agent the default fallback for any request that doesn't fit a more specific category. This makes the chief agent's routing logic simpler and more robust.

Suggested change
f"ROUTING RULES — you MUST delegate every request, NEVER respond directly:\n"
f"- Greetings (hello, hi, hey, good morning, etc.): ALWAYS delegate to 'greeting_agent'\n"
f"- Farewells (bye, goodbye, see you, thanks and bye, etc.): ALWAYS delegate to 'farewell_agent'\n"
f"- Products, prices, stock, purchasing, or order questions: ALWAYS delegate to 'sales_agent'\n"
f"ROUTING RULES — you MUST delegate every request, NEVER respond directly:\n"
f"- Greetings (hello, hi, hey, good morning, etc.): ALWAYS delegate to 'greeting_agent'\n"
f"- Farewells (bye, goodbye, see you, thanks and bye, etc.): ALWAYS delegate to 'farewell_agent'\n"
f"- Products, prices, stock, purchasing, or order questions: ALWAYS delegate to 'sales_agent'\n"
f"- General info, FAQ, how-to, support, or any other questions about {business_name}: ALWAYS delegate to 'rag_agent'\n"
f"- User frustrated, requests a human, or asks to speak to someone: ALWAYS delegate to 'escalation_agent'\n"
f"- Anything outside {business_name} topics: ALWAYS delegate to 'rag_agent'.\n\n"
f"IMPORTANT: You have NO tools of your own. You MUST delegate to the appropriate agent for every request. Do NOT compose a reply yourself.\n\n"

Comment on lines +345 to +347

user_id = tool_context.state.get("user_id")
if not user_id:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 It's generally a good practice to have all imports at the top of the file. This improves readability and avoids potential issues with circular dependencies or delayed import errors.
Suggested change
user_id = tool_context.state.get("user_id")
if not user_id:
import json
from decimal import Decimal
import asyncio
import threading
from app.core.subscription import TIER_LIMITS
from app.core.config import settings
from app.services.email_service import EmailSchema

(This would be at the top of the file)



def import_csv(db: Session, business_id: str, file_bytes: bytes) -> CsvImportResult:
"""Import contacts from a CSV with headers: phone, name (optional), tags (optional, comma-sep)."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 This function has a potential N+1 query problem. It queries the database for each row in the CSV to check if a contact exists. For large files, this can be very slow.

You can significantly improve performance by fetching all potentially existing contacts in a single query before the loop.

Suggested change
"""Import contacts from a CSV with headers: phone, name (optional), tags (optional, comma-sep)."""
def import_csv(db: Session, business_id: str, file_bytes: bytes) -> CsvImportResult:
"""Import contacts from a CSV with headers: phone, name (optional), tags (optional, comma-sep)."""
result = CsvImportResult()
try:
text = file_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
result.errors.append("File is not valid UTF-8")
return result
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames or "phone" not in [h.lower() for h in reader.fieldnames]:
result.errors.append("CSV must have a 'phone' header column")
return result
rows = list(reader)
phones_in_csv = {
normalize_phone(row.get(key) or "")
for row in rows
for key in row
if key and key.lower() == "phone" and row[key]
}
phones_in_csv.discard(None)
existing_contacts = {
c.phone_e164: c
for c in db.query(WhatsAppContact).filter(
WhatsAppContact.business_id == business_id,
WhatsAppContact.phone_e164.in_(phones_in_csv),
)
}
# Normalize headers to lowercase for lookup
for row_num, row in enumerate(rows, start=2):
row_lower = {(k or "").lower(): v for k, v in row.items()}
raw_phone = (row_lower.get("phone") or "").strip()
if not raw_phone:
result.skipped += 1
continue
normalized = normalize_phone(raw_phone)
if not normalized:
result.errors.append(f"Row {row_num}: invalid phone '{raw_phone}'")
result.skipped += 1
continue
name = (row_lower.get("name") or "").strip() or None
tags_raw = (row_lower.get("tags") or "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None
if normalized in existing_contacts:
existing = existing_contacts[normalized]
if name and not existing.name:
existing.name = name
if tags:
existing.tags = tags
result.skipped += 1
continue
contact = WhatsAppContact(
business_id=business_id,
phone_e164=normalized,
name=name,
tags=tags,
source=ContactSource.CSV.value,
opted_in=True,
)
db.add(contact)
result.imported += 1
db.commit()
return result

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant