feat: gender preference toggle, unified Gifts tab, Maxi bundles, browse similar, swipe timing - #61
Conversation
…delivery dates - Add recipient-side gender/preference selector (he/she/they) in invite flow - Prioritize swipe deck items based on selected gender preference - Rename 'Group Gifts' nav tab to 'Gifts' with solo/group sub-tabs - Show Maxi's gift bundle suggestions from completed challenges in Gifts tab - Add delivery date awareness (items marked 'Late' if won't ship by occasion) - One-click checkout button scaffolded for bundles - Store genderPref in soft profiles (localStorage + API backend) - Add GET /bundles API route for server-side bundle generation - Include .agents/DEPLOY.md with Terraform plan/apply instructions Co-Authored-By: Saksham <tarive22@gmail.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds gender preference capture in the invite flow, stores it on connections, introduces ChangesGender Preference + Bundle Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| return { | ||
| postId: item.postId, | ||
| title: item.title, | ||
| image: item.image, | ||
| price, | ||
| category: item.category, | ||
| deliveryDays, | ||
| canDeliverByDeadline, | ||
| }; | ||
| }); |
There was a problem hiding this comment.
🔴 Gift bundle items returned without title or image because the backend reads non-existent fields from stored posts
Bundle items are built by reading item.title and item.image (infra/src/handler.mjs:2358-2359) from DynamoDB post records, but ingested posts store those values as caption and product.image instead, so every bundle item is returned with undefined for both fields.
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 handler
Posts ingested via infra/ingest/ingest-pins.mjs:102 store the display name as caption: shortCaption(pin.title) and the image inside a nested object at product.image (infra/ingest/ingest-pins.mjs:129). There is no top-level title or image attribute on the DynamoDB item.
Other parts of the handler correctly handle this — e.g. infra/src/handler.mjs:874 uses String(p.product?.name || p.caption || p.title || p.name || "") and infra/src/handler.mjs:1282 uses a similar fallback chain. The /bundles handler should follow the same pattern.
| return { | |
| postId: item.postId, | |
| title: item.title, | |
| image: item.image, | |
| price, | |
| category: item.category, | |
| deliveryDays, | |
| canDeliverByDeadline, | |
| }; | |
| }); | |
| return { | |
| postId: item.postId, | |
| title: item.caption ?? item.product?.name ?? item.title ?? "", | |
| image: item.product?.image ?? item.image ?? "", | |
| price, | |
| category: item.category, | |
| deliveryDays, | |
| canDeliverByDeadline, | |
| }; |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 9bb6f77 — field resolution now uses item.caption ?? item.product?.name ?? item.title ?? "" and item.product?.image ?? item.image ?? "", matching the fallback chain used by other handler routes. Also added item.product?.price fallback for price.
| { label: "Events", href: "/feed/events", icon: "calendar" }, | ||
| { label: "Shop", href: "/feed/shop", icon: "gift" }, | ||
| { label: "Group Gifts", href: "/feed/pools", icon: "users" }, | ||
| { label: "Gifts", href: "/feed/pools", icon: "gift" }, |
There was a problem hiding this comment.
🚩 Sidebar now has two items with the same 'gift' icon — Shop and Gifts
The sidebar item for the pools/gifts page was changed from icon: "users" to icon: "gift" at web/components/app/sidebar.tsx:57, making it identical to the Shop item's icon at line 56. Both "Shop" and "Gifts" now render the same Icons.gift icon in the sidebar and mobile drawer. This isn't a crash but may cause user confusion since two nav items are visually indistinguishable by icon alone.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 9bb6f77 — reverted to icon: "users" for the Gifts tab.
| 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>" |
There was a problem hiding this comment.
🟥 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 ID 445056752928, region us-east-1, and the full live API Gateway endpoint URL https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com. While the account ID and endpoint are also mentioned in CLAUDE.md (which predates this PR), this PR adds new files containing curl command 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.
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.
| if (method === "GET" && path === "/bundles") { | ||
| const userId = qs.userId; | ||
| const connectionId = qs.connectionId; | ||
| if (!userId || !connectionId) return json(400, { error: "userId and connectionId required" }); | ||
| // 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)) { | ||
| try { | ||
| const out = await ddb.send(new GetCommand({ TableName: POSTS, Key: { postId: seed } })); | ||
| if (out.Item) bundleItems.push(out.Item); | ||
| } catch { /* skip missing */ } | ||
| } | ||
| } | ||
| // 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) || 50; | ||
| const deliveryDays = price > 200 ? 7 : price > 100 ? 5 : 3; | ||
| const canDeliverByDeadline = deadlineDays === null || deliveryDays <= deadlineDays; | ||
| return { | ||
| postId: item.postId, | ||
| title: item.title, | ||
| image: item.image, | ||
| price, | ||
| category: item.category, | ||
| deliveryDays, | ||
| canDeliverByDeadline, | ||
| }; | ||
| }); | ||
| return json(200, { | ||
| connectionId, | ||
| guestName: conn.guestName, | ||
| genderPref, | ||
| deadline, | ||
| deadlineDays, | ||
| bundle, | ||
| bundleTotal: bundle.reduce((sum, i) => sum + i.price, 0), | ||
| }); | ||
| } |
There was a problem hiding this comment.
🟨 Missing authorization check: /bundles endpoint allows any authenticated user to read another user's connection data
The GET /bundles endpoint (infra/src/handler.mjs:2319-2375) accepts userId and connectionId as query parameters and returns the connection's guest name, gender preference, birthday, and gift bundle. It only checks that the caller is authenticated (not in isPublicRoute), but does NOT verify that the authenticated user's identity (auth.sub) matches the requested userId. Any authenticated user can read any other user's connection data by guessing or enumerating connection IDs.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 9bb6f77 — added authorizeRequest call and auth.sub === userId check (with admin bypass). Returns 403 if mismatched.
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@infra/src/handler.mjs`:
- Around line 2336-2340: The seed lookup loop in handler.mjs is swallowing real
DynamoDB/runtime errors in the GetCommand path. Update the seed-fetch logic in
the bundle-building code to only skip when the response has no Item, and let
actual exceptions from ddb.send surface or be handled as a failure instead of
being ignored. Use the existing bundle/seeds loop and GetCommand call as the
place to distinguish “missing seed” from infrastructure failure.
- Around line 2352-2360: The bundle mapper in handler.mjs only reads top-level
item.title, item.image, and item.price, so post-shaped items with nested product
data can render blank fields and hit the $50 fallback. Update the
bundleItems.map callback to normalize each item first by preferring nested
product fields when present, then use those normalized values for title, image,
and price before computing deliveryDays and the returned bundle object.
In `@web/app/feed/pools/page.tsx`:
- Around line 326-328: The “One-click checkout bundle” CTA in the feed pools
scaffold is currently styled like a primary action but has no click handler, so
make it non-interactive until the checkout flow is implemented. Update the
button in the relevant render path to be disabled (and styled accordingly) or
wire it to the actual checkout handler once available, using the existing button
element as the anchor for the change.
In `@web/app/invite/`[code]/page.tsx:
- Around line 279-305: The gender preference selector in the invite page is
currently only visually indicating the active option. Update the button group in
the preference-mapping block to expose the selected state to assistive tech by
adding appropriate pressed/checked semantics on each option, using the existing
selected logic derived from genderPref and GENDER_PREF_META so screen readers
can announce the active choice.
In `@web/components/app/swipe-deck.tsx`:
- Around line 109-113: The deck reset path in startOver() is bypassing the
preference-aware ordering used in the effect, so the rebuilt deck loses the
current genderPref sort. Update startOver() to rebuild the deck through the same
logic as the useEffect for swipe-deck.tsx, applying sortByGenderPref to
buildDeck() whenever genderPref is set, and keep the stats/mounted reset
behavior unchanged.
In `@web/lib/api.ts`:
- Line 486: The transport types for `genderPref` in `api.ts` are using a plain
`string`, which breaks alignment with the shared gender preference contract.
Update the affected payload/response type definitions to use the shared
`GenderPref` union from `web/lib/gender-prefs.ts` at both locations mentioned,
so `POST /connections` and local-connection payloads stay type-safe and
exhaustive without casts or guards.
In `@web/lib/soft-profile.ts`:
- Line 19: The soft-profile state currently allows an unchecked string for
genderPref, so malformed persisted values can survive hydration in
loadSoftProfile(). Change the SoftProfile shape to use the shared GenderPref
union instead of string, and update the hydration/parsing logic to validate the
stored value against that union and reject unknown strings before returning the
profile.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 7800e319-70d6-4212-94eb-e2bb79b5f9b5
📒 Files selected for processing (9)
.agents/DEPLOY.mdinfra/src/handler.mjsweb/app/feed/pools/page.tsxweb/app/invite/[code]/page.tsxweb/components/app/sidebar.tsxweb/components/app/swipe-deck.tsxweb/lib/api.tsweb/lib/gender-prefs.tsweb/lib/soft-profile.ts
| name: string; | ||
| handle?: string; | ||
| birthday?: string; | ||
| genderPref?: string; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Use the shared GenderPref union at this transport boundary.
Typing these fields as string reintroduces schema drift (contract mismatch): invalid values can compile into POST /connections or local-connection payloads, and downstream code now needs casts/guards instead of getting exhaustiveness from the type system. Keep this in lockstep with web/lib/gender-prefs.ts.
🔧 Proposed fix
+import { type GenderPref } from "`@/lib/gender-prefs`";
+
export type GuestSoftProfile = {
name: string;
handle?: string;
birthday?: string;
- genderPref?: string;
+ genderPref?: GenderPref;
vibes?: string[];
seeds?: string[];
interests?: string[];
yesCount?: number;
totalSwipes?: number;
};
export type SoftConnection = {
userId: string;
connectionId: string;
soft?: boolean;
kind?: string;
guestName: string;
guestHandle?: string;
birthday?: string;
- genderPref?: string;
+ genderPref?: GenderPref;
vibes?: string[];
seeds?: string[];
interests?: string[];
yesCount?: number;
totalSwipes?: number;
seen?: boolean;
createdAt?: number;
};Also applies to: 503-503
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/lib/api.ts` at line 486, The transport types for `genderPref` in `api.ts`
are using a plain `string`, which breaks alignment with the shared gender
preference contract. Update the affected payload/response type definitions to
use the shared `GenderPref` union from `web/lib/gender-prefs.ts` at both
locations mentioned, so `POST /connections` and local-connection payloads stay
type-safe and exhaustive without casts or guards.
There was a problem hiding this comment.
Acknowledged as a type-safety improvement. Keeping string at the transport boundary is intentional — the API can return values the frontend doesn't yet know about without breaking deserialization. Runtime validation happens at the UI layer (genderPref in GENDER_PREF_META).
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
| vibes: string[]; | ||
| seeds: string[]; | ||
| birthday?: string; | ||
| genderPref?: string; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Harden genderPref at the localStorage boundary.
loadSoftProfile() trust-casts any object with a name, so genderPref?: string lets malformed values round-trip through persisted state unchecked. Use the shared GenderPref union here and reject unknown strings when hydrating.
🔧 Proposed fix
+import { type GenderPref, GENDER_PREF_META } from "`@/lib/gender-prefs`";
+
export type SoftProfile = {
name: string;
vibes: string[];
seeds: string[];
birthday?: string;
- genderPref?: string;
+ genderPref?: GenderPref;
inviterName: string;
completedAt: number;
};
export function loadSoftProfile(): SoftProfile | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as SoftProfile).name === "string"
) {
- return parsed as SoftProfile;
+ const candidate = parsed as Partial<SoftProfile> & { genderPref?: unknown };
+ if (
+ candidate.genderPref != null &&
+ (typeof candidate.genderPref !== "string" || !(candidate.genderPref in GENDER_PREF_META))
+ ) {
+ return null;
+ }
+ return candidate as SoftProfile;
}
return null;
} catch {
return null;
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| genderPref?: string; | |
| import { type GenderPref, GENDER_PREF_META } from "`@/lib/gender-prefs`"; | |
| export type SoftProfile = { | |
| name: string; | |
| vibes: string[]; | |
| seeds: string[]; | |
| birthday?: string; | |
| genderPref?: GenderPref; | |
| inviterName: string; | |
| completedAt: number; | |
| }; | |
| export function loadSoftProfile(): SoftProfile | null { | |
| if (typeof window === "undefined") return null; | |
| try { | |
| const raw = localStorage.getItem(KEY); | |
| if (!raw) return null; | |
| const parsed: unknown = JSON.parse(raw); | |
| if ( | |
| parsed && | |
| typeof parsed === "object" && | |
| typeof (parsed as SoftProfile).name === "string" | |
| ) { | |
| const candidate = parsed as Partial<SoftProfile> & { genderPref?: unknown }; | |
| if ( | |
| candidate.genderPref != null && | |
| (typeof candidate.genderPref !== "string" || !(candidate.genderPref in GENDER_PREF_META)) | |
| ) { | |
| return null; | |
| } | |
| return candidate as SoftProfile; | |
| } | |
| return null; | |
| } catch { | |
| return null; | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/lib/soft-profile.ts` at line 19, The soft-profile state currently allows
an unchecked string for genderPref, so malformed persisted values can survive
hydration in loadSoftProfile(). Change the SoftProfile shape to use the shared
GenderPref union instead of string, and update the hydration/parsing logic to
validate the stored value against that union and reject unknown strings before
returning the profile.
There was a problem hiding this comment.
Same rationale as api.ts — keeping string at the localStorage boundary is intentional so stale persisted data from older app versions doesn't cause loadSoftProfile() to return null. Invalid stored values are harmlessly ignored at the consumption point.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
- Fix duplicate gift icon: Gifts tab reverts to 'users' icon - Fix /bundles handler field normalization (caption/product.image/product.price) - Add auth check: verify auth.sub === userId on GET /bundles - Remove silent error swallowing in seed lookup loop - Add per-item 'Add to cart' button + 'Add entire bundle' on gift cards - Fix startOver() to preserve genderPref sort order - Boost category weights for guy-friendly items (10x for tech/sports) - Add aria-pressed to gender preference selector buttons Co-Authored-By: Saksham <tarive22@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@infra/src/handler.mjs`:
- Around line 2323-2326: The bundles authorization check is re-running request
auth inside the handler, bypassing the global AUTH_ENFORCE behavior and causing
unsigned callers to be rejected even when enforcement is off. Update the bundles
read path in infra/src/handler.mjs to reuse the already-computed top-level auth
result instead of calling authorizeRequest again, and keep the owner/admin check
against that shared auth object so /bundles follows the same toggle as the other
protected routes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: e9b88231-75e9-421c-b84b-ff9e6abdb2ef
📒 Files selected for processing (6)
infra/src/handler.mjsweb/app/feed/pools/page.tsxweb/app/invite/[code]/page.tsxweb/components/app/sidebar.tsxweb/components/app/swipe-deck.tsxweb/lib/gender-prefs.ts
✅ Files skipped from review due to trivial changes (1)
- web/components/app/sidebar.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
- web/components/app/swipe-deck.tsx
- web/lib/gender-prefs.ts
- web/app/invite/[code]/page.tsx
- web/app/feed/pools/page.tsx
| // 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" }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reuse the top-level auth result here.
Line 2324 re-runs the auth gate (request filter) unconditionally, so /bundles no longer follows the global AUTH_ENFORCE toggle. When enforcement is off, this endpoint still returns 403 for unsigned callers while sibling protected routes stay open, which creates environment drift.
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
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // 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" }); | |
| // Authorization: only the owner (or admin) can read their bundles | |
| if (auth && !(auth.via === "admin" || auth.sub === userId)) { | |
| return json(403, { error: "forbidden" }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@infra/src/handler.mjs` around lines 2323 - 2326, The bundles authorization
check is re-running request auth inside the handler, bypassing the global
AUTH_ENFORCE behavior and causing unsigned callers to be rejected even when
enforcement is off. Update the bundles read path in infra/src/handler.mjs to
reuse the already-computed top-level auth result instead of calling
authorizeRequest again, and keep the owner/admin check against that shared auth
object so /bundles follows the same toggle as the other protected routes.
There was a problem hiding this comment.
Intentional — /bundles (like /connections/claim at L2287) unconditionally enforces auth because it returns user-specific private data. The global AUTH_ENFORCE toggle is a development convenience for non-sensitive read routes; endpoints that expose personal data should never be unauthenticated regardless of the toggle state.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
…g signals - Add 'Browse similar' button next to 'Add all to cart' in Maxi bundles - Make each bundle item card clickable (opens item in new tab) - Shop page supports ?category= param to highlight/scroll to matching section - Track swipe dwell time (ms between card shown and swipe action) - Store dwellSignals in localStorage swipe records - Send dwellSignals to backend on connection creation (POST /connections) - Backend persists dwellSignals in DynamoDB for future recommendation enrichment Co-Authored-By: Saksham <tarive22@gmail.com>
Summary
Addresses the viral loop gap: sharing a swipe challenge with a guy showed no relevant items. This PR adds a recipient-side gender/preference selector before swiping begins, a unified "Gifts" tab (replacing "Group Gifts") that surfaces Maxi's bundle suggestions from completed challenges, and delivery date awareness that flags items unlikely to arrive by the occasion.
Gender preference flow (recipient-side toggle)
New
"preference"phase inserted between consent and swipe in/invite/[code]:Recipient picks
GenderPref = "he" | "she" | "they"— this reorders the swipe deck viasortByGenderPref()(category weight maps: tech/sports/fitness weighted high for "he", jewelry/vintage/wellness for "she", even mix for "they"). Stored in:SoftProfile.genderPref(localStorage)SoftConnection.genderPref(API + localStorage fallback)GuestSoftProfile.genderPref→POST /connectionsbodyUnified Gifts tab
"Group Gifts"→"Gifts"(both desktop + mobile drawer)/feed/poolspage now has solo/group sub-tabsSoloGiftCardfor each completed challenge connection, with:Backend (
infra/src/handler.mjs)POST /connections: now parses + validatesguest.genderPref(enum:["he","she","they"])GET /bundles?userId=&connectionId=: returns bundle items with delivery estimates relative to the connection's birthday/dateDeploy instructions
See
.agents/DEPLOY.mdfor Terraform plan/apply steps and post-deploy wiring. TL;DR: only the Lambda source hash changes — no new tables, no IAM changes, no new infra.Link to Devin session: https://calhacks-promptetheus.devinenterprise.com/sessions/3d8a7a08115f45c8938df8f9862f2f84
Requested by: @Tar-ive
Summary by CodeRabbit