<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[DrawSage]]></title><description><![CDATA[DrawSage]]></description><link>https://drawsage.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 01:39:05 GMT</lastBuildDate><atom:link href="https://drawsage.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I built a Secret Santa draw where even the organizer can't peek]]></title><description><![CDATA[Every December someone in a group chat volunteers to "just do the Secret Santa in a spreadsheet". By doing that they've signed up to know every pairing…]]></description><link>https://drawsage.hashnode.dev/how-i-built-a-secret-santa-draw-where-even-the-organizer-can-t-peek</link><guid isPermaLink="true">https://drawsage.hashnode.dev/how-i-built-a-secret-santa-draw-where-even-the-organizer-can-t-peek</guid><category><![CDATA[Next.js]]></category><category><![CDATA[webdev]]></category><category><![CDATA[privacy]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[DrawSage]]></dc:creator><pubDate>Sun, 18 Oct 2026 15:00:27 GMT</pubDate><content:encoded><![CDATA[<p>Every December someone in a group chat volunteers to "just do the Secret Santa in a spreadsheet". By doing that they've signed up to know every pairing, and the whole surprise depends on them never looking at it.</p>
<p>I wanted a draw where:</p>
<ol>
<li>nobody needs an account, not even the organizer;</li>
<li>everyone joins from <strong>one link</strong> with just a name;</li>
<li>couples (or a manager and their reports) can be kept apart;</li>
<li><strong>the organizer's screen never shows who has whom</strong>.</li>
</ol>
<p>Here's how the Next.js (App Router) + Prisma/Postgres app behind it works, including the trade-offs I'm not claiming to have solved.</p>
<h2>1. Three kinds of links, three kinds of power</h2>
<p>There are no users and no sessions. Access comes entirely from which URL you hold:</p>
<table>
<thead>
<tr>
<th>Link</th>
<th>Who has it</th>
<th>What it can do</th>
</tr>
</thead>
<tbody><tr>
<td><code>/draw/{slug}</code></td>
<td>the whole group</td>
<td>see the title and budget, join with a name</td>
</tr>
<tr>
<td><code>/draw/{slug}/manage?token={organizerToken}</code></td>
<td>organizer only</td>
<td>see the participant names, add exclusions, run the draw, delete it</td>
</tr>
<tr>
<td><code>/draw/{slug}/reveal/{revealToken}</code></td>
<td>one participant</td>
<td>edit your own wishlist; after the draw, see <strong>your</strong> match and their wishlist</td>
</tr>
</tbody></table>
<p>The IDs come from <code>nanoid</code>'s <code>customAlphabet</code>, using alphabets without look-alike characters:</p>
<pre><code class="language-ts">// Public join slug: short, URL-safe
export const makeSlug = customAlphabet("23456789abcdefghjkmnpqrstuvwxyz", 10);

// Long secret tokens (organizer / reveal)
export const makeSecret = customAlphabet(
  "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz", 24);
</code></pre>
<p>That's 24 characters from a 55-symbol alphabet, about 138 bits, so the tokens can't be guessed. The slug is only about 49 bits. That's fine, because all a slug lets you do is join.</p>
<p>The "organizer can't peek" property comes from <strong>what each page queries and renders</strong>:</p>
<ul>
<li>The manage page loads participants and exclusions but only passes <code>{ id, name }</code> to the client. Once the draw has run, the page just says assignments live on each person's private link.</li>
<li>The reveal page looks up a participant <strong>by <code>revealToken</code></strong>, checks that the token belongs to that slug, and only then loads the one person in <code>givesToId</code>.</li>
<li>The private pages set <code>robots: { index: false }</code> and <code>referrer: "no-referrer"</code>, so the tokens don't leak through search results or the Referer header when someone clicks a wishlist link.</li>
<li>When you join, your reveal token is saved in <code>localStorage</code> for that slug. The public join page can then show a "See who I'm buying for" button on that device without the token ever appearing in the group chat.</li>
</ul>
<p><strong>The honest caveat:</strong> the server does store <code>givesToId</code>, because it has to show you your match. So this is "the organizer can't see it", not "nobody can". The operator, which is me, could query the database. A stronger design would encrypt each assignment so only the matching reveal token can decrypt it. That's on the list; I'd rather say what isn't done than oversell it. The other trade-off is losing your link. The organizer can't look up your match, because that's the point. They can only start a fresh draw.</p>
<h2>2. Exclusions: a derangement with constraints</h2>
<p>A Secret Santa assignment is a permutation with no fixed points (nobody draws themselves), which is a <strong>derangement</strong>. Exclusions add more forbidden edges. I store them as directed pairs (<code>fromId</code> must not give to <code>toId</code>), so a couple is simply two rules, and "A can't draw her boss, but the boss can draw A" can also be expressed.</p>
<p>The algorithm is deliberately simple:</p>
<pre><code class="language-ts">// forbidden: Map&lt;giverId, Set&lt;receiverId&gt;&gt;, always containing self
for (const id of participantIds) {
  const blocked = forbidden.get(id)!;
  if (participantIds.every((other) =&gt; blocked.has(other))) {
    throw new Error("Could not find a valid draw with the current exclusions...");
  }
}

for (let attempt = 0; attempt &lt; maxAttempts /* 800 */; attempt++) {
  const receivers = shuffle([...participantIds]); // Fisher–Yates
  const mapping = new Map&lt;string, string&gt;();
  let ok = true;
  for (let i = 0; i &lt; participantIds.length; i++) {
    if (forbidden.get(participantIds[i])?.has(receivers[i])) { ok = false; break; }
    mapping.set(participantIds[i], receivers[i]);
  }
  if (ok) return mapping;
}
throw new Error("Could not find a valid draw with the current exclusions...");
</code></pre>
<p>Why rejection sampling instead of a clever constructive algorithm?</p>
<ul>
<li><strong>It's uniform.</strong> Each shuffle is a uniformly random permutation, and we keep the first one that passes. So every valid assignment is equally likely. The common "everyone in one big circle" approach can't produce assignments made of several smaller loops. Naive greedy backtracking that takes the first receiver that works is also biased.</li>
<li><strong>It's fast where it matters.</strong> With no exclusions, about 1/e ≈ 37% of random permutations are derangements, so 800 attempts basically never fail. Typical family or office exclusions (a few couples in a group of 8–30) barely change that.</li>
<li><strong>It fails loudly.</strong> The pre-check catches the obvious impossible case (someone has excluded everyone). If 800 shuffles all fail, the API returns a readable error ("loosen exclusions or add participants") <strong>before anything is written</strong>.</li>
</ul>
<p>That pre-check is necessary but not sufficient. Three people who can each only give to the same one person pass it, and still have no solution (in matching terms, Hall's condition fails). For dense constraint sets, the right upgrade is backtracking: shuffle the candidates, assign givers with the fewest options first, and undo when you hit a dead end. Or check feasibility with a bipartite matching first, and only sample once you know a solution exists. Small groups haven't needed that yet, so I haven't shipped it.</p>
<p>Running the draw is one Prisma <code>$transaction</code>: every <code>givesToId</code> update plus <code>status: "DRAWN"</code>. Either the whole draw is saved or none of it is. Once a draw is <code>DRAWN</code>, both the run endpoint and the exclusions endpoint return 409.</p>
<h2>3. A unique OG image for every draw</h2>
<p>Most people meet DrawSage as a link pasted into WhatsApp or iMessage, so the preview card does most of the selling. Next's file convention makes a preview for each draw almost free: put an <code>opengraph-image.tsx</code> next to <code>app/draw/[slug]/page.tsx</code>.</p>
<pre><code class="language-tsx">export const size = { width: 1200, height: 630 };
export default async function Image({ params }) {
  const { slug } = await params;
  const draw = await prisma.draw.findUnique({
    where: { slug },
    select: { title: true, budgetMaxCents: true, _count: { select: { participants: true } } },
  });
  // title (truncated to 70 chars, font size stepped by length),
  // "Budget up to $X", "N people already in", and a "Tap to add your name →" pill
  return new ImageResponse(/* JSX */, size);
}
</code></pre>
<p>Two details matter here. First, the card shows <strong>counts, never names</strong>. Second, join pages are <code>noindex</code> but not blocked in <code>robots.txt</code>, so link-preview crawlers can still fetch them while search engines leave them out.</p>
<h2>4. AI gift ideas with a deliberately boring fallback</h2>
<p>After the draw, your reveal page shows gift ideas for your recipient, based on their wishlist and the group's budget. The implementation is:</p>
<ul>
<li><strong>One call</strong> to an OpenAI-compatible <code>/chat/completions</code> endpoint. The base URL and model come from env vars; I point it at DeepSeek. The call uses <code>response_format: json_object</code>, a 12-second <code>AbortSignal.timeout</code>, and a prompt that says to stay at or under the budget maximum.</li>
<li><strong>Caching:</strong> the cache key is a SHA-256 of the input JSON plus the model name. Results go in a <code>GiftSuggestionCache</code> table for 7 days. Identical requests, like the same budget and the same empty wishlist, are common in December, so the cache saves real money.</li>
<li><strong>Fallback:</strong> if there's no key, the request times out, the JSON doesn't parse, or the list comes back empty, a hand-curated catalog of about 35 items takes over. Items are scored by interest tags and wishlist words, and anything over 115% of the budget is dropped. The user always gets 8 ideas and never sees an error.</li>
<li><strong>Rate limiting:</strong> 30 requests a minute per IP, kept in memory. That's crude, but enough for an MVP.</li>
</ul>
<p>Gift links are Amazon search links. When an Associates tag is configured, they're affiliate links, and the UI says so next to the ideas.</p>
<h2>What I'd do differently or next</h2>
<ul>
<li>Encrypt each assignment under the matching reveal token, so the database alone can't reveal the pairings.</li>
<li>A feasibility check (bipartite matching) when exclusions are added, rather than only at draw time.</li>
<li>Use <code>crypto.getRandomValues</code> for the shuffle instead of <code>Math.random</code>. For a gift exchange this is more about appearances than real security, but it's cheap.</li>
</ul>
<p>If you've built something similar, or know a neat sampling method for heavily constrained derangements, I'd love to hear about it in the comments.</p>
<hr />
<p><em>Disclosure: I built DrawSage, the free, no-signup Secret Santa tool this post describes. You can try it at <a href="https://drawsage.com">drawsage.com</a>.</em></p>
]]></content:encoded></item></channel></rss>