Skip to content

Commit cb3735a

Browse files
committed
catalog enhancer; db update
1 parent 82a1819 commit cb3735a

7 files changed

Lines changed: 176 additions & 13 deletions

File tree

app/api/hiker_profile.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ def get_profile(profile_id: int, user: User = Depends(authenticate)):
5050
@route.post("", status_code=201)
5151
def create_profile(payload: HikerProfileType, user: User = Depends(authenticate)):
5252
existing_count = db.session.query(HikerProfile).filter_by(user_id=user.id).count()
53+
54+
if not user.is_subscribed and existing_count >= 1:
55+
raise HTTPException(403, "Upgrade to Full Access to create multiple hiker profiles.")
56+
5357
is_default = True if existing_count == 0 else payload.is_default
5458

5559
if is_default:

app/api/trip.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def create(payload: TripType, user: User = Depends(authenticate)):
130130
except Exception:
131131
raise HTTPException(400, "Unable to create trip.")
132132

133-
if payload.location and payload.location.strip():
133+
if payload.location and payload.location.strip() and user.is_subscribed:
134134
new_trip.enrich_status = "pending"
135135
db.session.commit()
136136
db.session.refresh(new_trip)
@@ -165,7 +165,7 @@ def update(payload: TripUpdate, user: User = Depends(authenticate)):
165165
(payload.start_date and payload.start_date != old_start_date) or
166166
(payload.end_date and payload.end_date != old_end_date)
167167
)
168-
if trip.location and (location_changed or dates_changed):
168+
if trip.location and (location_changed or dates_changed) and user.is_subscribed:
169169
trip.enrich_status = "pending"
170170

171171
db.session.commit()

app/api/webhook.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import logging
2+
3+
from fastapi import APIRouter, HTTPException, Request
4+
from fastapi_sqlalchemy import db
5+
6+
from models.base import User
7+
from utils.consts import REVENUECAT_WEBHOOK_SECRET
8+
9+
logger = logging.getLogger(__name__)
10+
11+
route = APIRouter()
12+
13+
SUBSCRIBE_EVENTS = {
14+
"INITIAL_PURCHASE",
15+
"RENEWAL",
16+
"UNCANCELLATION",
17+
"PRODUCT_CHANGE",
18+
"NON_RENEWING_PURCHASE",
19+
}
20+
21+
UNSUBSCRIBE_EVENTS = {
22+
"EXPIRATION",
23+
"BILLING_ISSUE",
24+
}
25+
26+
27+
@route.post("/revenuecat")
28+
async def revenuecat_webhook(request: Request):
29+
if REVENUECAT_WEBHOOK_SECRET:
30+
auth = request.headers.get("Authorization")
31+
expected = f"Bearer {REVENUECAT_WEBHOOK_SECRET}"
32+
if auth != expected:
33+
raise HTTPException(401, "Unauthorized")
34+
35+
body = await request.json()
36+
event = body.get("event", {})
37+
event_type = event.get("type")
38+
app_user_id = event.get("app_user_id")
39+
40+
if not event_type or not app_user_id:
41+
return {"ok": True}
42+
43+
try:
44+
user_id = int(app_user_id)
45+
except (ValueError, TypeError):
46+
logger.warning("RevenueCat webhook with non-integer app_user_id: %s", app_user_id)
47+
return {"ok": True}
48+
49+
if event_type in SUBSCRIBE_EVENTS:
50+
is_subscribed = True
51+
elif event_type in UNSUBSCRIBE_EVENTS:
52+
is_subscribed = False
53+
else:
54+
return {"ok": True}
55+
56+
user = db.session.query(User).filter_by(id=user_id).first()
57+
if not user:
58+
logger.warning("RevenueCat webhook for unknown user_id: %s", user_id)
59+
return {"ok": True}
60+
61+
user.is_subscribed = is_subscribed
62+
db.session.commit()
63+
64+
logger.info("RevenueCat %s: user %s is_subscribed=%s", event_type, user_id, is_subscribed)
65+
return {"ok": True}

app/main.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from sqlalchemy import create_engine
66

77
from utils.consts import DATABASE_URL, DEVELOPMENT, APP_HOST
8-
from api import user, resources, item, trip, category, pack, kit, hiker_profile
8+
from api import user, resources, item, trip, category, pack, kit, hiker_profile, webhook
99

1010
ENGINE_KWARGS = dict(
1111
pool_size=5,
@@ -103,6 +103,14 @@
103103
)
104104

105105

106+
app.include_router(
107+
webhook.route,
108+
prefix="/webhook",
109+
tags=["webhook"],
110+
responses={404: {"description": "Not found"}}
111+
)
112+
113+
106114
@app.get("/health-check")
107115
def health_check():
108116
return "Packstack API is available"

app/tasks/enrich_product.py

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
import os
34
import re
@@ -117,31 +118,95 @@ def ai_complete(system: str, user: str, tools: list | None = None, max_retries:
117118
"Tools", "First Aid", "Safety", "Camera", "Climbing",
118119
]
119120

121+
SUBCATEGORIES = {
122+
"Clothing": [
123+
"Base Layers", "Mid Layers", "Insulation", "Rain Gear",
124+
"Wind Gear", "Socks", "Underwear", "Headwear", "Gloves",
125+
"Pants & Shorts", "Shirts & Tops",
126+
],
127+
"Cookware": [
128+
"Stoves", "Fuel", "Pots & Pans", "Utensils",
129+
"Drinkware", "Cleaning", "Coffee/Tea",
130+
],
131+
"Sleep System": [
132+
"Sleeping Bags", "Quilts", "Sleeping Pads", "Pillows", "Liners", "Bivys",
133+
],
134+
"Electronics": [
135+
"Power & Cables", "Lighting", "Navigation/Comm", "Batteries",
136+
"Solar", "Wearables", "Audio",
137+
],
138+
"Pack": [
139+
"Main Pack", "Daypacks", "Protection", "Organization", "Add-ons",
140+
],
141+
"Shelter": [
142+
"Tents", "Hammocks", "Tarps", "Hardware", "Structure",
143+
],
144+
"Toiletries": [
145+
"Hygiene", "Bathroom", "Sun & Bug", "Personal Care",
146+
],
147+
"Water System": [
148+
"Filtration", "Purification", "Bottles", "Hydration", "Storage",
149+
],
150+
"Food": [
151+
"Meals", "Snacks", "Beverages", "Storage", "Hanging",
152+
],
153+
"Footware": [
154+
"Primary", "Camp Shoes", "Gaiters", "Traction",
155+
],
156+
"Tools": [
157+
"Knives", "Repair", "Trekking Poles", "Processing",
158+
],
159+
"First Aid": [
160+
"Bandages", "Medication", "Blister Care", "Ointments",
161+
],
162+
"Safety": [
163+
"Survival", "Fire", "Protection",
164+
],
165+
"Camera": [
166+
"Body & Lens", "Support", "Media", "Power",
167+
],
168+
"Climbing": [
169+
"Personal", "Hardware", "Protection", "Soft Goods",
170+
],
171+
}
172+
173+
_subcategory_block = json.dumps(SUBCATEGORIES, indent=2)
174+
120175
SYSTEM_PROMPT = (
121176
"You are a backpacking and outdoor gear product database. You have expert knowledge "
122177
"of outdoor gear brands, product lines, and specifications. When given a brand and product name "
123178
"(which may be misspelled, abbreviated, or include variant info in the name), you research and "
124179
"return the canonical product information.\n\n"
125-
"You have access to web search. Use it to find the manufacturer's official product page. "
126-
'Prefer searching the manufacturer\'s site directly (e.g. "site:nemoequipment.com Tensor Insulated"). '
127-
"From the product page, extract:\n"
128-
"- The official product URL\n"
180+
"You have access to web search. Use it to find product pages from both the manufacturer's website "
181+
"and major retail sites (REI, Amazon, etc.), since many manufacturers do not sell directly. "
182+
"Try searching the manufacturer's site first "
183+
'(e.g. "site:nemoequipment.com Tensor Insulated"), then also search retail sites '
184+
'(e.g. "Nemo Tensor Insulated site:rei.com" or the product name on Amazon). '
185+
"From the best available product page(s), extract:\n"
186+
"- A product URL (prefer the manufacturer's page if available, otherwise use a retail page)\n"
129187
"- A product image URL (the main product photo, not a lifestyle/hero image)\n"
130-
"- The manufacturer's listed weight and any other specs (R-value, volume, packed size, temperature rating, etc.)\n\n"
188+
"- The listed weight and any other specs (R-value, volume, packed size, temperature rating, etc.)\n\n"
131189
"IMPORTANT: A variant is a meaningful product option like size (S/M/L/Regular/Long), color, gender, "
132190
"or volume capacity. Weight measurements (e.g. \"690g\", \"14oz\", \"2lb\"), dimensions, or other specs "
133191
"that appear in the variant field are NOT real variants. If the provided variant is just a weight or "
134192
"spec measurement, set variant_name to null and incorporate that data into weight_grams or "
135193
"additional_specs instead.\n\n"
136194
"If the input is not a real, identifiable outdoor/backpacking product (e.g. \"small bag\", \"misc item\", "
137195
"random text), mark it as invalid.\n\n"
138-
f"When assigning a category, you MUST use one of these exact values: {', '.join(CATEGORIES)}."
196+
f"When assigning a category, you MUST use one of these exact values: {', '.join(CATEGORIES)}.\n\n"
197+
"After assigning a category, also assign a subcategory. The valid subcategories for each category are:\n"
198+
f"{_subcategory_block}\n"
199+
"You MUST pick a subcategory from the list for the chosen category. If the product does not fit any "
200+
"subcategory, or the category is \"Miscellaneous\", set subcategory to null.\n\n"
201+
"FOOD ITEMS: If the category is \"Food\", look up the calories per serving (kcal) from the product "
202+
"page, nutrition label, or retailer listing. Report this value as kcal. For non-food items, set kcal "
203+
"to null."
139204
)
140205

141206
WEB_SEARCH_TOOL = {
142207
"type": "web_search_20250305",
143208
"name": "web_search",
144-
"max_uses": 3,
209+
"max_uses": 5,
145210
}
146211

147212
TOOL_SCHEMA = {
@@ -172,11 +237,14 @@ def ai_complete(system: str, user: str, tools: list | None = None, max_retries:
172237
},
173238
"product_url": {
174239
"type": ["string", "null"],
175-
"description": "Official manufacturer product page URL. Null if unknown.",
240+
"description": (
241+
"Product page URL (prefer manufacturer's page; use a retail page "
242+
"if manufacturer doesn't sell direct). Null if unknown."
243+
),
176244
},
177245
"image_url": {
178246
"type": ["string", "null"],
179-
"description": "URL of the main product photo from the manufacturer's site. Null if not found.",
247+
"description": "URL of the main product photo. Null if not found.",
180248
},
181249
"description": {
182250
"type": ["string", "null"],
@@ -187,6 +255,20 @@ def ai_complete(system: str, user: str, tools: list | None = None, max_retries:
187255
"enum": CATEGORIES + [None],
188256
"description": "Gear category. Must be one of the predefined values.",
189257
},
258+
"subcategory": {
259+
"type": ["string", "null"],
260+
"description": (
261+
"Subcategory within the assigned category. Must be one of the valid "
262+
"subcategories for the chosen category, or null."
263+
),
264+
},
265+
"kcal": {
266+
"type": ["integer", "null"],
267+
"description": (
268+
"Calories per serving for Food category items. Null for non-food "
269+
"items or if unknown."
270+
),
271+
},
190272
"additional_specs": {
191273
"type": ["object", "null"],
192274
"description": (
@@ -489,7 +571,9 @@ def enrich_product(self, brand_id: int, product_id: int, product_variant_id: int
489571
image_url=result.get("image_url"),
490572
description=result.get("description"),
491573
category_suggestion=result.get("category"),
574+
subcategory=result.get("subcategory"),
492575
additional_specs=result.get("additional_specs"),
576+
kcal=result.get("kcal"),
493577
brand_id=brand.id,
494578
product_id=product.id,
495579
product_variant_id=variant.id if variant else None,

app/utils/consts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@
3232

3333
REVIEW_EMAIL = os.getenv('REVIEW_EMAIL')
3434
REVIEW_OTP = os.getenv('REVIEW_OTP')
35+
36+
REVENUECAT_WEBHOOK_SECRET = os.getenv('REVENUECAT_WEBHOOK_SECRET')

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ uvicorn[standard]
1919
sentry-sdk
2020
celery[redis]
2121
anthropic
22-
git+https://github.com/Packstack-Tech/models.git@v2.0.1#egg=models
22+
git+https://github.com/Packstack-Tech/models.git@v2.0.2#egg=models

0 commit comments

Comments
 (0)