PickPal:为重要的人记住礼物与纪念日的 AI 笔记本
jm-fuster/pickpal
PickPal 是一个用西班牙语构建的个人项目,把人物笔记与 AI 礼物推荐结合:记录兴趣、尺码、过敏与送礼历史,日期临近时提醒并调用 Gemini 3.5 Flash 生成 6-9 条礼物建议。请求前先原子预留配额、失败即退还,后端对模型输出逐字段重新校验,模型错误被归类为 503/429/500 并返回西班牙语提示。
The perfect gift for the people who matter most.
Keep what you know about each person, decide when to be reminded, and let a model think with you when the date comes around.
What it is
A notebook for the people you care about, with an AI feature bolted on — in that order. You write down what you know about someone: their interests, their sizes, what they are allergic to, what you gave them last year and how it landed. When one of their dates approaches, PickPal reminds you and can turn everything you wrote down into gift ideas for that specific person, occasion and budget.
Built as a personal project, and used for real. The interface is in Spanish only — there is no i18n layer, the routes are Spanish words, and the model is prompted in Spanish. Everything below is the English description of a Spanish product.
What it does
An agenda that only shows the near future. /agenda lists the next four
months of events sorted by how soon they are, grouped under Hoy, Mañana or
the date itself, with urgency styling once something is inside seven days. On
wide screens, picking an event opens the gift panel beside it; on narrow ones it
navigates. A bell in the header lists whatever falls inside your notification
window — 30 days by default, and while the server accepts anything from 1 to 365,
no screen exposes that control yet.
A person is a notebook page. Name, relationship, up to 20 interests, up to 10
favourite brands, free notes, shoe and clothing sizes, allergies, dislikes, and a
generated avatar rather than an uploaded photo. The interest field autocompletes
against a local catalogue of roughly 130 interests in 17 categories
(src/lib/interests.ts) — deliberately offline, so suggestions cost no model
quota and nothing about the person leaves the device to power them. Editing is
inline autosave; /seres-queridos/[personId]/edit exists only as a redirect back
to the detail page.
Dates and history. Each date carries a label, a day and month, an optional year, a flag for annual or one-off, and an optional budget on a 0–500 € slider. Gift history records what you gave, for which occasion, in which year, how it was received, and any notes — and that history is fed back into the next generation.
Gift ideas on a short leash. Pick the occasion, pick one of four kinds of gift
(Producto físico, Experiencia, Tiempo juntos, Sorpréndeme), and press
Generar 9 ideas. The request asks Gemini 3.5 Flash for nine and accepts
between six and nine (z.array(...).min(6).max(9), src/lib/gifts.ts:88), then
drops duplicate titles. Save an idea with a thumbs-up, or discard it with a
thumbs-down — discarding offers an Undo, and once it sticks, that idea's
categories are recorded so the next batch steers away from them. A saved idea
becomes gift history through Lo regalé. Batches are cached per user, person,
occasion and gift type, so reopening a set you already generated costs no quota.
Store links. A physical-product idea deep-links into whichever of eleven
retailers you picked in settings — narrowed to the ones the model suggested for
that specific idea, falling back to all of your picks when none of them match:
Amazon, El Corte Inglés, AliExpress, Temu, Miravia, Decathlon, IKEA,
PcComponentes, MediaMarkt, Zalando and Druni (STORE_IDS, src/lib/stores.ts).
Experiences, time together and surprises get a single search link instead.
Email reminders, opt-in and off by default, because the declared legal basis
is consent. Lead times are a multi-select of 0, 2, 7 and 14 days, defaulting to
[14], and a user gets one grouped email per run rather than one per date.
Settings covers the theme, reminders, favourite stores, legal links, and
account deletion — type ELIMINAR, and it purges the Convex data before deleting
the Clerk user.
How a gift idea gets made
POST /api/recommendations is the part of this repo worth reading first.
POST /api/recommendations
│
├─ src/proxy.ts ─────────── default-deny matcher + Sec-Fetch-Site CSRF check
├─ auth() ───────────────── Clerk session, then a JWT for Convex
├─ env check ────────────── 503 unless GOOGLE_GENERATIVE_AI_API_KEY and
│ CONVEX_SERVER_SECRET are both present
├─ zod parse ────────────── the request body
│
├─ 4 × fetchQuery ───────── person · date and budget · gift history ·
│ (in parallel) the last batch's dislikedCategories
│
├─ reserve quota ──┐ ────── atomic, and before the model is called
│ │
├─ generateObject ─┤ ────── gemini-3.5-flash, Zod-typed output, maxRetries: 2
├─ dedupe titles ──┤
├─ enrich ─────────┤ ────── Pexels photo (4 s) · Brandfetch store (4 s / 2.5 s)
│ (best effort) │ both optional, both allowed to fail quietly
│ │
├─ upsert ─────────┤ ────── Convex re-validates every field server-side
│ │
└─ 200 JSON └─────── any throw above refunds the reserved unit
Three things in that diagram are the whole point:
- The quota is reserved before the model call and refunded if anything throws, because nothing has been persisted at that stage. This replaced a check-then-consume that could race.
- The route is not trusted by the backend.
api.recommendations.upsertre-validates every field the model produced, so a compromised route handler still cannot write nonsense into the database. - Model failures are translated rather than forwarded. Provider errors are classified into 503, 429-daily, 429-minute or 500 with user-safe Spanish copy, and the provider's response body never reaches the browser.
Authorisation, three times over
src/proxy.ts— Next 16 renamedmiddleware.tstoproxy.ts, andnpm run buildprints it asƒ Proxy (Middleware). It runsclerkMiddlewarewith a default-deny matcher: only/,/sign-in(.*),/sign-up(.*),/privacidadand/terminosare public, and anything else hitsauth.protect(). A new route is therefore private by accident rather than public by accident. It also 403s non-GET/apirequests whoseSec-Fetch-Siteheader says they came from another origin.auth()in the route handlers, withConvexProviderWithClerkin the browser.requireUser(ctx)in every Convex function, followed by a per-documentclerkUserIdownership check.
The browser reaches Convex with Clerk's own session token, which carries
aud: "convex" plus the email, name and given_name claims (Clerk's Convex
integration, configured under Sessions → Claims); the API route handlers mint
one from a JWT template named convex with the same claims. Both are signed by
the same issuer, which convex/auth.config.ts validates against
CLERK_JWT_ISSUER_DOMAIN with applicationID: "convex". requireUser returns identity.subject, denormalised
as clerkUserId on eight of the nine tables — importantDates is the exception,
since it is scoped through the person it belongs to. SessionGuard is a UX
redirect for a session that vanished, and is not a security boundary.
Rate limiting
Two independent systems, four buckets:
| Bucket | Limit | Where |
|---|---|---|
| AI generations | 10 per user per UTC day | recommendationUsage |
create_person |
50 per day | rateLimitBuckets |
create_date |
100 per day | rateLimitBuckets |
save_idea |
50 per day | rateLimitBuckets |
The generation counter is fail-closed and its reserve/refund mutations are
guarded by the shared CONVEX_SERVER_SECRET, so an authenticated browser client
cannot call refund and reset its own quota.
Data and jobs
Nine Convex tables: people, importantDates, userSettings,
emailNotifications, recommendationUsage, rateLimitBuckets,
recommendations, savedIdeas, giftHistory.
One cron — "send daily birthday email reminders", 0 8 * * *, running
internal.emails.runDailyEmailNotifications. It walks the users one at a time,
sends each of them batched HTML through the Resend REST API without the SDK, and
writes dedupe rows keyed by date, occurrence year and lead time, so a restart
cannot send the same reminder twice.
Stack
Next.js 16 (App Router) · React 19 · TypeScript 6 · Tailwind CSS 4 · Convex 1.46 · Clerk 7 · AI SDK 7 with Google Gemini 3.5 Flash · Zod 4 · react-hook-form · Base UI primitives following shadcn conventions · next-themes · Resend · Vitest.
Running it locally
Node 22 or newer, declared once in .nvmrc and mirrored by engines in
package.json. CI reads that same file rather than pinning a version of its own.
Do the accounts first, then let npx convex dev link the project before you set
anything on the Convex side — npx convex env set reads CONVEX_DEPLOYMENT out of
.env.local and exits with No CONVEX_DEPLOYMENT set without it. So the sequence
is: link, set the two Convex variables, run npx convex dev again.
- Create a free Clerk application and copy the publishable and secret keys.
- In Clerk, go to Configure → JWT Templates and create one from the
Convex preset, then copy its Issuer URL. Then activate the Convex
integration and, under Sessions → Claims, add the same user claims to the
session token:
"email": "{{user.primary_email_address}}","name": "{{user.full_name}}"and"given_name": "{{user.first_name}}"(addemailto the template too). Convex reads them from the token (identity.emailinconvex/settings.ts, the owner's name inconvex/lists.ts): withoutemailthe reminder cron has nobody to write to and stays silent. - Create a free Convex project.
- Get a Google AI Studio API key. Quota is counted per project, not per key, so use a project of your own.
Then:
git clone https://github.com/jm-fuster/PickPal.git cd PickPal npm install cp .env.example .env.local # fill it in — see Configuration below
Link the Convex project. This first push fails with "Environment variable CLERK_JWT_ISSUER_DOMAIN is used in auth config file but its value was not set" — that is expected, and the next two commands are what fix it:
npx convex dev --once # writes CONVEX_DEPLOYMENT and NEXT_PUBLIC_CONVEX_URL into .env.localGenerate the shared secret, and keep the value — it has to go in two places:
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"Put that 64-character string into CONVEX_SERVER_SECRET in .env.local, and then
give Convex the same one:
npx convex env set CLERK_JWT_ISSUER_DOMAIN https://your-issuer.clerk.accounts.dev npx convex env set CONVEX_SERVER_SECRET paste-the-same-value-here
Now two terminals, both long-running:
npx convex dev # keeps watching and pushing convex/ while you worknpm run dev # http://localhost:3000What works with only Clerk and Convex configured: the agenda, people, dates,
saved ideas and account deletion. POST /api/recommendations answers 503 until
the AI keys are in place, and emails silently never send. That is the designed
failure mode rather than a broken install.
Configuration
The variables live in three different places, and conflating them is the usual way to lose an afternoon.
Next.js — .env.local locally, project settings on Vercel:
| Variable | Required | Notes |
|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
yes | Clerk → API keys (pk_test_…) |
CLERK_SECRET_KEY |
yes | Clerk → API keys (sk_test_…) |
NEXT_PUBLIC_CLERK_SIGN_IN_URL |
yes | /sign-in |
NEXT_PUBLIC_CLERK_SIGN_UP_URL |
yes | /sign-up |
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL |
yes | /agenda |
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL |
yes | /agenda |
NEXT_PUBLIC_CONVEX_URL |
yes | written by npx convex dev |
CONVEX_DEPLOYMENT |
yes | written by npx convex dev |
CONVEX_SERVER_SECRET |
yes | The same value must also exist in Convex. Missing here, /api/recommendations answers 503; present but different from the Convex one, it answers 429 No autorizado. |
GOOGLE_GENERATIVE_AI_API_KEY |
yes | aistudio.google.com |
PEXELS_API_KEY |
no | stock photos on idea cards; degrades quietly |
BRANDFETCH_CLIENT_ID |
no | brand-store links; degrades quietly |
The Convex deployment — npx convex env set NAME value, plus --prod for
production:
| Variable | Required | Notes |
|---|---|---|
CLERK_JWT_ISSUER_DOMAIN |
yes | the Issuer of the convex JWT template |
CONVEX_SERVER_SECRET |
yes | the same value as in Next.js |
RESEND_API_KEY |
for email | convex/emails.ts throws without it |
EMAIL_FROM |
no | falls back to a PickPal <…> default |
Vercel only — CONVEX_DEPLOY_KEY. You generate it in the Convex dashboard,
not from the CLI.
Deploying to Vercel and Convex
- Import the repository into Vercel.
- Add the Next.js variables from the table above to Settings → Environment
Variables — all of them except
CONVEX_DEPLOYMENTandNEXT_PUBLIC_CONVEX_URL, which are local-only; the build derives the URL itself fromCONVEX_DEPLOY_KEY. - Override the build command with
npx convex deploy --cmd 'npm run build', and addCONVEX_DEPLOY_KEY. - Add the domain Vercel assigns you to Clerk → Domains, and point
CLERK_JWT_ISSUER_DOMAINat your production Clerk instance. - Before the first build, set the Convex-side variables against production
from your machine:
npx convex env set CLERK_JWT_ISSUER_DOMAIN <prod issuer> --prodandnpx convex env set CONVEX_SERVER_SECRET <the same value you gave Vercel> --prod. Until they exist, every build dies atnpx convex deployon the same auth-config check as the local setup. If you have already pushed, set them and redeploy.
CONVEX_DEPLOY_KEY decides which Convex deployment the build targets and
overwrites NEXT_PUBLIC_CONVEX_URL during the build, so changing only the URL
will not move you between deployments.
Every push to main redeploys. vercel.json skips Vercel builds for
dependabot/* branches, since CI already covers them.
The design system
The part I am most attached to, and the part you cannot see from the outside: the design system is checked against the code rather than merely described.
npm run token-map runs scripts/token-map.mjs, which joins a committed dump of
the Figma variables (design/figma-tokens.snapshot.json) with the real custom
properties in src/app/globals.css, writes the correspondence table to
docs/token-map.md, and exits non-zero only on
divergences, stale registry entries or conflicts that have not been declared in
design/token-divergences.json. A
check that goes permanently red teaches you to ignore it, so anything we chose to
live with has to be written down with a reason next to it. The current state is 71
variables mirrored and zero undeclared divergences.
Two other things that came out of doing it properly:
- Every colour token in
docs/design-system.mdcarries a measured contrast ratio rather than an opinion. --primarywas split into--primaryand--brand, and--secondaryinto--secondaryand--brand-secondary, because a single token was being asked to satisfy two contradictory WCAG requirements at once.
On accessibility, the honest version: the skip link is real
(src/app/(app)/layout.tsx, href="#contenido"), the Figma file meets AA and the
code has caught up with it, aria-live appears in three places — five live regions
if you count the bare role="status" ones — rather than everywhere it could, and
lint still flags two raw <img> uses for the generated avatars.
Quality gates
npm test # 176 tests across 13 files npm run lint npm run build
CI (.github/workflows/ci.yml) runs on every push to main and every pull
request, on Node 22: npm ci, npx tsc --noEmit,
npx tsc -p convex/tsconfig.json --noEmit, then npm test. That second
type-check exists because Convex has its own tsconfig — without it, Convex-only
type errors sail through CI and fail the Vercel deploy instead. A second job
auto-squash-merges Dependabot patch and minor bumps once the first one is green.
CI does not yet run the linter or next build, so those two are on you before you
push.
Documentation
| File | What it is |
|---|---|
docs/security.md |
The trust model, eight mandatory patterns, a PR checklist keyed to whichever path you touched, an explicit "not implemented yet, and why" section, and a Dependabot post-mortem |
docs/ia-regalos.md |
The AI pipeline and its three layers of defence. Stale on route paths and the store count — trust the code |
docs/email-notifications.md |
The daily cron end to end, and why the toggle ships off |
docs/tech-stack.md |
Why Convex over Prisma, Clerk over NextAuth, Resend without its SDK. Its intro still says "Next.js 14+" |
docs/figma-tokens.md |
The normative rulebook for the Figma file: the four-layer model, alias rules, and the requirement that every token is born with a description |
docs/token-map.md |
Generated by npm run token-map |
docs/design-system.md |
A decision log: 2,193 lines, no table of contents, and a dated work journal in its second half. Good for the reasoning, bad for looking up a token |
AGENTS.md and CLAUDE.md are this project's
contributor rulebook. They are short, and they exist to point at the three
documents you are expected to read before touching security, tokens or the design
system.
Some files under docs/ predate the current routes and survive only as history.
The routes in this README came out of npm run build.
Contributing
Issues and pull requests are welcome. For anything large, open an issue first so
we can agree on the shape before you write it. See
CONTRIBUTING.md — and note that it is currently out of date in
a few places that this README supersedes. It opens with an English summary; the
rest of it, like everything under docs/, is in Spanish.
Found a security bug? Do not open an issue — SECURITY.md says
where to send it, in English and Spanish.
Credits
The code is © Jorge Molina Fuster, all rights reserved. Some of what ships alongside it is not mine:
- Avatars. DiceBear's dylan style, a remix of a Figma Community file by
Natalia Spivak, used under
CC BY 4.0. PickPal renders it for
every person, so the credit is here and in the app's own credits, on
/terminos. - Store logos in
public/stores/are their owners' trademarks, used for identification. - Stock photography on idea cards comes from Pexels, linked from the
credits on
/terminos. - Type is Geist for the interface and Fraunces for headings.
License
© Jorge Molina Fuster. All rights reserved. See LICENSE.
来源:GitHub · github.com
