-
Notifications
You must be signed in to change notification settings - Fork 4
feat: gender preference toggle, unified Gifts tab, Maxi bundles, browse similar, swipe timing #61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2f21ffc
9bb6f77
a81e551
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| # Post-Deploy Wiring Instructions | ||
|
|
||
| This document describes the Terraform and backend wiring steps required after the | ||
| frontend changes in this PR are merged. These cannot be run inside Devin's VM and | ||
| must be executed in an environment with AWS credentials for account `445056752928` | ||
| in `us-east-1`. | ||
|
|
||
| --- | ||
|
|
||
| ## Summary of Infrastructure Changes | ||
|
|
||
| | Change | File | Impact | | ||
| |--------|------|--------| | ||
| | `genderPref` field stored on connections | `infra/src/handler.mjs` | No schema migration needed (DynamoDB is schemaless) | | ||
| | `GET /bundles` API route added | `infra/src/handler.mjs` | New read-only route; no new tables or IAM | | ||
| | No new DynamoDB tables | — | Existing `connections` + `posts` tables used | | ||
| | No new Lambda functions | — | All routes in the single `handler.mjs` monolith | | ||
|
|
||
| --- | ||
|
|
||
| ## Step 1: Deploy the Lambda (handler.mjs changes) | ||
|
|
||
| The handler update adds: | ||
| 1. `genderPref` parsing + storage on `POST /connections` | ||
| 2. `GET /bundles?userId=&connectionId=` route (reads from connections + posts tables) | ||
|
|
||
| ```bash | ||
| cd infra | ||
|
|
||
| # Install handler dependencies (s3vectors SDK) | ||
| cd src && npm ci && cd .. | ||
|
|
||
| # Plan — should show only the Lambda function updating (source_code_hash change) | ||
| terraform plan -var-file=production.tfvars -out=plan.out | ||
|
|
||
| # Expected changes: | ||
| # ~ aws_lambda_function.api (source_code_hash, filename) | ||
| # ~ data.archive_file.api (output_base64sha256) | ||
| # NO new resources, NO IAM changes, NO table changes. | ||
|
|
||
| # Apply | ||
| terraform apply plan.out | ||
| ``` | ||
|
|
||
| ### Verification | ||
|
|
||
| ```bash | ||
| # Test genderPref is stored | ||
| curl -X POST https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com/connections \ | ||
| -H "content-type: application/json" \ | ||
| -d '{"senderId":"test_user","guest":{"name":"TestGuy","genderPref":"he","vibes":["tech"],"seeds":["pin_1"]}}' | ||
|
|
||
| # Test bundle endpoint | ||
| curl "https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com/bundles?userId=test_user&connectionId=<id_from_above>" | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Step 2: Verify Frontend Reads | ||
|
|
||
| After the Lambda is deployed, the frontend will: | ||
| 1. Send `genderPref` in `POST /connections` when a challenge is completed | ||
| 2. Read connections via `GET /connections?userId=` (already works, now returns `genderPref`) | ||
| 3. Optionally call `GET /bundles` for server-side bundle generation (currently bundles | ||
| are computed client-side from seeds stored in the connection — the API route is a | ||
| future enhancement for when the catalog grows beyond what ships in `pins.ts`) | ||
|
|
||
| No `NEXT_PUBLIC_*` env var changes needed — the API base URL is unchanged. | ||
|
|
||
| --- | ||
|
|
||
| ## Step 3: Future Enhancements (not blocking this PR) | ||
|
|
||
| These are optional follow-ups that extend the bundle/delivery system: | ||
|
|
||
| ### 3a. Real delivery date integration | ||
|
|
||
| Currently `estimatedDeliveryDays()` uses a simple price-tier heuristic (3/5/7 days). | ||
| To integrate real shipping data: | ||
|
|
||
| 1. Add an `estimatedDeliveryDays` field to posts in DynamoDB (populate via PA-API | ||
| enrichment in `infra/ingest/paapi-enrich.mjs`) | ||
| 2. Update `GET /bundles` to read `item.estimatedDeliveryDays` instead of computing it | ||
|
|
||
| ### 3b. Maxi-powered bundle curation | ||
|
|
||
| The current bundle is a direct lookup of seed pins. To have Maxi (Bedrock) curate a | ||
| smarter bundle that accounts for gender preference, budget, and occasion: | ||
|
|
||
| 1. Add a `POST /bundles/generate` route that invokes Bedrock Converse with the | ||
| connection's taste profile | ||
| 2. Cache generated bundles in a new `bundles` DynamoDB table (PK: connectionId) | ||
| 3. Update the frontend `SoloGiftCard` to call this endpoint | ||
|
|
||
| ### 3c. One-click checkout | ||
|
|
||
| The "One-click checkout bundle" button is scaffolded in the frontend but not wired. | ||
| To complete: | ||
|
|
||
| 1. Integrate with Amazon Associates / PA-API cart creation | ||
| 2. Or implement Stripe Checkout for direct purchase flow | ||
| 3. Add a `POST /checkout` route that creates an order record | ||
|
|
||
| ### 3d. Gender-preference-aware vector recommendations | ||
|
|
||
| Currently gender preference only reorders the local PINS deck. To use it in the | ||
| vector recommender: | ||
|
|
||
| 1. Add `genderPref` as a metadata filter in the S3 Vectors query | ||
| (`infra/src/handler.mjs` → `GET /recommendations` route) | ||
| 2. Tag each vector with gender-affinity metadata during ingest | ||
| (`infra/ingest/ingest-pins.mjs` → add `genderAffinity` to vector metadata) | ||
|
|
||
| --- | ||
|
|
||
| ## No-Op Confirmation Checklist | ||
|
|
||
| Before applying, confirm: | ||
| - [ ] `terraform plan` shows ONLY the Lambda function update (no surprise resource creation) | ||
| - [ ] No new IAM permissions are required (bundles route reads from existing tables the Lambda already has access to) | ||
| - [ ] No DynamoDB table changes (genderPref is an optional attribute, no GSI needed) | ||
| - [ ] The `GET /bundles` route is NOT in `isPublicRoute()` — it requires auth (only the sender can view their own bundles) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2174,8 +2174,8 @@ export const handler = async (event) => { | |||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // ── Soft profiles (viral swipe challenge) ──────────────────────────────── | ||||||||||||||||||||||||||||||||||||||||
| // POST /connections { senderId, guest:{ name, handle?, birthday?, vibes?, | ||||||||||||||||||||||||||||||||||||||||
| // seeds?, interests?, yesCount?, totalSwipes? } } | ||||||||||||||||||||||||||||||||||||||||
| // POST /connections { senderId, guest:{ name, handle?, birthday?, genderPref?, | ||||||||||||||||||||||||||||||||||||||||
| // vibes?, seeds?, interests?, yesCount?, totalSwipes? } } | ||||||||||||||||||||||||||||||||||||||||
| // Created when an invited guest finishes the swipe challenge. The sender | ||||||||||||||||||||||||||||||||||||||||
| // (senderId, embedded in the invite link) "owns" the resulting soft profile; | ||||||||||||||||||||||||||||||||||||||||
| // consent is implied by the guest completing a link the sender shared. | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -2192,6 +2192,18 @@ export const handler = async (event) => { | |||||||||||||||||||||||||||||||||||||||
| typeof guest.birthday === "string" && /^\d{4}-\d{2}-\d{2}$/.test(guest.birthday) | ||||||||||||||||||||||||||||||||||||||||
| ? guest.birthday | ||||||||||||||||||||||||||||||||||||||||
| : undefined; | ||||||||||||||||||||||||||||||||||||||||
| const VALID_GENDER_PREFS = ["he", "she", "they"]; | ||||||||||||||||||||||||||||||||||||||||
| const genderPref = | ||||||||||||||||||||||||||||||||||||||||
| typeof guest.genderPref === "string" && VALID_GENDER_PREFS.includes(guest.genderPref) | ||||||||||||||||||||||||||||||||||||||||
| ? guest.genderPref | ||||||||||||||||||||||||||||||||||||||||
| : undefined; | ||||||||||||||||||||||||||||||||||||||||
| // Parse dwell timing signals (how long the guest spent on each card) | ||||||||||||||||||||||||||||||||||||||||
| const dwellSignals = Array.isArray(guest.dwellSignals) | ||||||||||||||||||||||||||||||||||||||||
| ? guest.dwellSignals | ||||||||||||||||||||||||||||||||||||||||
| .slice(0, 100) | ||||||||||||||||||||||||||||||||||||||||
| .filter((s) => s && typeof s.id === "string" && typeof s.dwellMs === "number") | ||||||||||||||||||||||||||||||||||||||||
| .map((s) => ({ id: String(s.id), dir: String(s.dir), dwellMs: Math.round(Number(s.dwellMs)) })) | ||||||||||||||||||||||||||||||||||||||||
| : undefined; | ||||||||||||||||||||||||||||||||||||||||
| const item = { | ||||||||||||||||||||||||||||||||||||||||
| userId: senderId, | ||||||||||||||||||||||||||||||||||||||||
| connectionId: `conn_${rid}`, | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -2200,11 +2212,13 @@ export const handler = async (event) => { | |||||||||||||||||||||||||||||||||||||||
| guestName: String(guest.name).trim().slice(0, 80), | ||||||||||||||||||||||||||||||||||||||||
| guestHandle: guest.handle ? String(guest.handle).slice(0, 40) : undefined, | ||||||||||||||||||||||||||||||||||||||||
| birthday, | ||||||||||||||||||||||||||||||||||||||||
| genderPref, | ||||||||||||||||||||||||||||||||||||||||
| vibes: Array.isArray(guest.vibes) ? guest.vibes.slice(0, 12).map(String) : [], | ||||||||||||||||||||||||||||||||||||||||
| seeds: Array.isArray(guest.seeds) ? guest.seeds.slice(0, 20).map(String) : [], | ||||||||||||||||||||||||||||||||||||||||
| interests: Array.isArray(guest.interests) ? guest.interests.slice(0, 12).map(String) : [], | ||||||||||||||||||||||||||||||||||||||||
| yesCount: Number(guest.yesCount) || 0, | ||||||||||||||||||||||||||||||||||||||||
| totalSwipes: Number(guest.totalSwipes) || 0, | ||||||||||||||||||||||||||||||||||||||||
| dwellSignals, | ||||||||||||||||||||||||||||||||||||||||
| seen: false, | ||||||||||||||||||||||||||||||||||||||||
| createdAt: Date.now(), | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -2306,6 +2320,71 @@ export const handler = async (event) => { | |||||||||||||||||||||||||||||||||||||||
| return json(200, { ok: true, claimed }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // ── Gift bundles (Maxi's picks from a completed challenge) ───────────────── | ||||||||||||||||||||||||||||||||||||||||
| // GET /bundles?connectionId=&userId= — generate a gift bundle from a | ||||||||||||||||||||||||||||||||||||||||
| // completed swipe challenge. Uses the connection's seeds + genderPref to rank | ||||||||||||||||||||||||||||||||||||||||
| // items and compute estimated delivery dates relative to the birthday/date. | ||||||||||||||||||||||||||||||||||||||||
| if (method === "GET" && path === "/bundles") { | ||||||||||||||||||||||||||||||||||||||||
| const userId = qs.userId; | ||||||||||||||||||||||||||||||||||||||||
| const connectionId = qs.connectionId; | ||||||||||||||||||||||||||||||||||||||||
| if (!userId || !connectionId) return json(400, { error: "userId and connectionId required" }); | ||||||||||||||||||||||||||||||||||||||||
| // Authorization: only the owner (or admin) can read their bundles | ||||||||||||||||||||||||||||||||||||||||
| const auth = await authorizeRequest(event, method, path); | ||||||||||||||||||||||||||||||||||||||||
| if (!(auth.via === "admin" || auth.sub === userId)) { | ||||||||||||||||||||||||||||||||||||||||
| return json(403, { error: "forbidden" }); | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+2331
to
+2334
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Reuse the top-level auth result here. Line 2324 re-runs the auth gate (request filter) unconditionally, so Suggested fix- const auth = await authorizeRequest(event, method, path);
- if (!(auth.via === "admin" || auth.sub === userId)) {
+ if (auth && !(auth.via === "admin" || auth.sub === userId)) {
return json(403, { error: "forbidden" });
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intentional — There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| // Fetch the connection record | ||||||||||||||||||||||||||||||||||||||||
| const connOut = await ddb.send( | ||||||||||||||||||||||||||||||||||||||||
| new GetCommand({ TableName: CONNECTIONS, Key: { userId, connectionId } }) | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| const conn = connOut.Item; | ||||||||||||||||||||||||||||||||||||||||
| if (!conn) return json(404, { error: "connection not found" }); | ||||||||||||||||||||||||||||||||||||||||
| // Build a bundle from the seeds — query the posts table for matching items | ||||||||||||||||||||||||||||||||||||||||
| const seeds = conn.seeds ?? []; | ||||||||||||||||||||||||||||||||||||||||
| const genderPref = conn.genderPref; // "he" | "she" | "they" | undefined | ||||||||||||||||||||||||||||||||||||||||
| const deadline = conn.birthday; // "YYYY-MM-DD" or undefined | ||||||||||||||||||||||||||||||||||||||||
| let bundleItems = []; | ||||||||||||||||||||||||||||||||||||||||
| if (seeds.length > 0) { | ||||||||||||||||||||||||||||||||||||||||
| // Look up seed pins from the posts table | ||||||||||||||||||||||||||||||||||||||||
| for (const seed of seeds.slice(0, 8)) { | ||||||||||||||||||||||||||||||||||||||||
| const out = await ddb.send(new GetCommand({ TableName: POSTS, Key: { postId: seed } })); | ||||||||||||||||||||||||||||||||||||||||
| if (out.Item) bundleItems.push(out.Item); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| // Compute delivery estimates for each item | ||||||||||||||||||||||||||||||||||||||||
| const today = new Date(); | ||||||||||||||||||||||||||||||||||||||||
| today.setHours(0, 0, 0, 0); | ||||||||||||||||||||||||||||||||||||||||
| let deadlineDays = null; | ||||||||||||||||||||||||||||||||||||||||
| if (deadline && /^\d{4}-\d{2}-\d{2}$/.test(deadline)) { | ||||||||||||||||||||||||||||||||||||||||
| const [y, m, d] = deadline.split("-").map(Number); | ||||||||||||||||||||||||||||||||||||||||
| const target = new Date(y, m - 1, d); | ||||||||||||||||||||||||||||||||||||||||
| deadlineDays = Math.ceil((target.getTime() - today.getTime()) / 86_400_000); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| const bundle = bundleItems.map((item) => { | ||||||||||||||||||||||||||||||||||||||||
| const price = Number(item.price ?? item.product?.price) || 50; | ||||||||||||||||||||||||||||||||||||||||
| const deliveryDays = price > 200 ? 7 : price > 100 ? 5 : 3; | ||||||||||||||||||||||||||||||||||||||||
| const canDeliverByDeadline = deadlineDays === null || deliveryDays <= deadlineDays; | ||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||
| postId: item.postId, | ||||||||||||||||||||||||||||||||||||||||
| title: item.caption ?? item.product?.name ?? item.title ?? "", | ||||||||||||||||||||||||||||||||||||||||
| image: item.product?.image ?? item.image ?? "", | ||||||||||||||||||||||||||||||||||||||||
| price, | ||||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||
| category: item.category ?? item.product?.category, | ||||||||||||||||||||||||||||||||||||||||
| deliveryDays, | ||||||||||||||||||||||||||||||||||||||||
| canDeliverByDeadline, | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+2367
to
+2376
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Gift bundle items returned without title or image because the backend reads non-existent fields from stored posts Bundle items are built by reading Impact: The gift bundle API endpoint returns items with no title and no image, rendering the bundle unusable for any consumer. Post schema mismatch in the /bundles handlerPosts ingested via Other parts of the handler correctly handle this — e.g.
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9bb6f77 — field resolution now uses |
||||||||||||||||||||||||||||||||||||||||
| return json(200, { | ||||||||||||||||||||||||||||||||||||||||
| connectionId, | ||||||||||||||||||||||||||||||||||||||||
| guestName: conn.guestName, | ||||||||||||||||||||||||||||||||||||||||
| genderPref, | ||||||||||||||||||||||||||||||||||||||||
| deadline, | ||||||||||||||||||||||||||||||||||||||||
| deadlineDays, | ||||||||||||||||||||||||||||||||||||||||
| bundle, | ||||||||||||||||||||||||||||||||||||||||
| bundleTotal: bundle.reduce((sum, i) => sum + i.price, 0), | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+2327
to
+2386
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟨 Missing authorization check: /bundles endpoint allows any authenticated user to read another user's connection data The Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9bb6f77 — added |
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // ── Group gifts (pools) ────────────────────────────────────────────────── | ||||||||||||||||||||||||||||||||||||||||
| // POST /pools { userId, name, pool:{ title, occasion, goal, blurb?, emoji?, | ||||||||||||||||||||||||||||||||||||||||
| // grad?, image?, recipient? } } — create a pool; the creator becomes the | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟥 Secrets exposure: AWS account ID and live API endpoint hardcoded in documentation
The deploy instructions file (
.agents/DEPLOY.md:6,49,54) hardcodes the AWS account ID445056752928, regionus-east-1, and the full live API Gateway endpoint URLhttps://tvyu8gqmki.execute-api.us-east-1.amazonaws.com. While the account ID and endpoint are also mentioned inCLAUDE.md(which predates this PR), this PR adds new files containingcurlcommand examples with these values that make reconnaissance trivial for attackers.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Acknowledged — the API endpoint and account ID are already in
CLAUDE.md(committed before this PR) and this is a private repo. The information is not a secret (account IDs aren't sensitive, and the API endpoint is publicly routable by design). Keeping the curl examples as-is for developer convenience.