The first real design decision I made for my invite-code board wasn't about the UI or the database — it was a refusal to let a single yen ever change hands on the platform. This post is part of my "automation foundation for mass-producing personal projects" series, continuing from Splitting Claude Code's Memory Into Four Layers. Here I'll walk through the policy I locked in first when designing InviteLoop (live in production: inviteloop.vercel.app) — the choice to reward users only with official perks and cut cash out of the loop entirely — why I made it, and how I translated that decision into code. What InviteLoop Is InviteLoop is a board where users post and share invite codes for various services. It targets services that have an official referral program — the "refer a friend and get X" kind, like Dropbox or Uber — and gathers their invite codes in one place so they're easy to find. The tech stack is as follows. Layer Technology Framework Next.js 16.2.9 (App Router) Database Turso (libsql / SQLite-compatible) ORM Drizzle ORM Auth NextAuth.js v5 (Google OAuth) Styling Tailwind CSS v4 Testing Vitest + Playwright Deploy Vercel It looks simple at a glance, but the thing I spent the most time on in the design wasn't the UI or the features — it was the single question of "where do I draw the line on the reward model?" Why I Chose "No Cash Rewards" When you set out to build an invite-code board, an idea comes to mind almost automatically: "let's award points when a code gets used, and let users cash those points out." There are in fact services abroad that run on exactly this model. Even so, I ruled out cash rewards, for three main reasons. Reason 1: The terms-of-service risk of each service Services that offer a referral program usually have clauses in their terms like "resale or cash conversion of referral perks is prohibited" or "redistribution of perks through third-party platforms is forbidden." If InviteLoop had a "get ¥X when your code is used" mechanism, it would become the vehicle through which users indirectly break the terms of those services. The platform side carries the risk of being held responsible for encouraging users' terms violations. This can be the kind of situation where "I didn't know" isn't a valid excuse, so I decided at the design stage to sever the cash flow completely. Reason 2: It invites spam and inflated posts If there's a cash reward, you get a structure where "revenue scales with the number of times a code is used." That creates an incentive for a single user to report usage of their own code from multiple accounts, or to mass-post with bots. Blocking spam is far easier to design into the incentives up front than to bolt on afterward. By removing the monetary motive, I brought the payoff of cheating to zero. Reason 3: Community health What people expect from an invite-code board is "being able to quickly find a code that actually works." Once cash gets involved, posters start prioritizing "codes that get used as many times as possible" and chase quantity over quality. By "quality" here I mean "the code is actually valid" and "the note is thorough enough that you understand how to use it." By keeping rewards to official perks only, I designed the system so that little other than "I want to share a code that genuinely works" can creep in. Don't Let "Reward" Exist in the Schema in the First Place The first move in translating this design decision into code was not writing the concept of a reward into the DB schema at all. // src/db/schema.ts(抜粋) export const services = sqliteTable('service', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), name: text('name').notNull(), slug: text('slug').notNull().unique(), category: text('category').notNull(), perkSummary: text('perk_summary').notNull().default(''), perkJa: text('perk_ja').notNull().default(''), perkEn: text('perk_en').notNull().default(''), officialUrl: text('official_url').notNull().default(''), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull().$defaultFn(() => new Date()), }) Enter fullscreen mode Exit fullscreen mode perkJa / perkEn are description fields that say "this is the official perk for this service." They may contain an amount, but only to display, as-is, information the official service has already published. InviteLoop's own point balance or cash-out table exists nowhere in the schema. Look at the table for the invite codes themselves, and there are zero columns related to rewards. export const inviteCodes = sqliteTable('invite_code', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), serviceId: text('service_id').notNull().references(() => services.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), code: text('code').notNull(), url: text('url').notNull().default(''), note: text('note').notNull().default(''), status: text('status', { enum: ['active', 'hidden', 'removed'] }).notNull().default('active'), clickCount: integer('click_count').notNull().default(0), upvotes: integer('upvotes').notNull().default(0), usageCount: integer('usage_count').notNull().default(0), reportCount: integer('report_count').notNull().default(0), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull().$defaultFn(() => new Date()), }) Enter fullscreen mode Exit fullscreen mode clickCount, upvotes, usageCount, and reportCount are all "metrics that indicate the trust and popularity of a post on the board" — none of them are grounds for a cash payout. As long as the schema is the single source of truth, you can't pay anything out from a column that doesn't exist. This is the strongest guarantee in the whole design. Post Limits and URL Domain Validation If you ask, "won't it get abused without rewards?" — honestly, the answer isn't zero. People wanting to push their own code to the top, or simple pranksters mass-posting, are conceivable. What blocks that is a post rate limit and URL validation. // src/lib/rate-limit.ts export const MAX_POSTS_PER_DAY = 5 export function checkRateLimit(i: RateLimitInput): RateLimitResult { if (i.alreadyPostedThisService) return { ok: false, reason: 'duplicate_service' } if (i.postsToday >= i.maxPerDay) return { ok: false, reason: 'daily_limit' } return { ok: true } } Enter fullscreen mode Exit fullscreen mode A single user is forbidden from posting multiple codes to the same service (duplicate_service). The daily post cap is also 5 (MAX_POSTS_PER_DAY = 5). The post form has a field where you can attach an invite URL alongside the code, but if an arbitrary URL could go in there, it would be used to funnel people to some other service. So I built it to only allow the official domain of the service in question. // src/lib/allowed-hosts.ts export function allowedHostsFor(officialUrl: string): string[] { if (!officialUrl) return [] try { const h = new URL(officialUrl).host return h.startsWith('www.') ? [h, h.slice(4)] : [h, `www.${h}`] } catch { return [] } } Enter fullscreen mode Exit fullscreen mode From officialUrl, it generates both the www and non-www variants as allowed hosts, and validates the URL inside validateSubmission. // src/lib/validation.ts(抜粋) if (parsed.protocol !== 'https:' || !allowedHosts.includes(parsed.host)) { return { ok: false, error: 'url_not_allowed' } } Enter fullscreen mode Exit fullscreen mode https: only, and the official domain only. That alone almost entirely shuts down phishing-style use via external links. Automatic Moderation: Auto-Hide at 3 Reports Codes that accumulate a certain number of user reports are automatically hidden. // src/lib/scoring.ts export const REPORT_AUTOHIDE_THRESHOLD = 3 Enter fullscreen mode Exit fullscreen mode // src/repos/engagement.ts(reportCode 関数の末尾) const reportCount = rows[0].reportCount const hidden = reportCount >= REPORT_AUTOHIDE_THRESHOLD if (hidden) { await db.update(inviteCodes) .set({ status: 'hidden' }) .where(eq(inviteCodes.id, codeId)) } return { ok: true as const, reportCount, hidden } Enter fullscreen mode Exit fullscreen mode At 3 reports, status becomes 'hidden'. Since the listing screen excludes codes with this status, problematic posts get hidden without any manual intervention. Raising the bar blindly would sweep up legitimate codes too, so for now the threshold is kept conservative at 3. To stop the same user from reporting the same code multiple times, the report table has a composite unique constraint on (code_id, reporter_user_id). // src/db/schema.ts(抜粋) export const reports = sqliteTable('report', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), codeId: text('code_id').notNull().references(() => inviteCodes.id, { onDelete: 'cascade' }), reporterUserId: text('reporter_user_id'), reason: text('reason').notNull().default(''), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull().$defaultFn(() => new Date()), }, (t) => ({ uniq: uniqueIndex('report_code_user_uq').on(t.codeId, t.reporterUserId) })) Enter fullscreen mode Exit fullscreen mode Fair Ordering: Score × Weighted Shuffle As posts pile up, "what order do you list the codes in?" becomes the crux of the UX. Ordering purely by score (popularity) gives too much of an advantage to codes that have been near the top for a long time. Order by newest instead, and a code sinks out of sight immediately and loses its chance to receive votes. To solve this problem, I adopted a "weighted-probability shuffle." // src/lib/scoring.ts export const SCORE_CLICK_WEIGHT = 1 export const SCORE_USAGE_WEIGHT = 5 export const SCORE_UPVOTE_WEIGHT = 3 export function computeScore(input: { clickCount: number usageCount: number upvoteCount: number }): number { return ( input.clickCount * SCORE_CLICK_WEIGHT + input.usageCount * SCORE_USAGE_WEIGHT + input.upvoteCount * SCORE_UPVOTE_WEIGHT ) } Enter fullscreen mode Exit fullscreen mode The weighting evaluates "actually used it (usageCount)" the most heavily (×5), followed by "liked it (upvotes)" (×3), with "merely clicked (clickCount)" being the lightest (×1). The ordering computation is handled by computeFairOrder. // src/lib/ordering.ts(抜粋) export function computeFairOrder( codes: T[], opts: { newQuota: number; seed: number; now: number; freshWindowMs: number }, ): T[] { const fresh = codes .filter((x) => opts.now - x.createdAt a.createdAt - b.createdAt) const reserved = fresh.slice(0, Math.max(0, opts.newQuota)) const reservedIds = new Set(reserved.map((x) => x.id)) const rest = codes.filter((x) => !reservedIds.has(x.id)) const weight = (x: T) => 1 + Math.min(x.score * ORDER_SCORE_WEIGHT, ORDER_MAX_BOOST) return [...reserved, ...weightedSeededShuffle(rest, weight, opts.seed)] } Enter fullscreen mode Exit fullscreen mode It reserves a fixed slot at the top for codes posted within the last 7 days (FRESH_WINDOW_MS), and orders the rest with a probability proportional to their score. The randomness of that probability is fixed with a mulberry32 seed, so giving the same seed produces the same order (it doesn't scramble on every page reload). Designing Contact and Notifications Board-style services see inquiry handling creep up over time. I designed it to store inquiries in the existing Turso DB, without adding any external service. // src/db/schema.ts(抜粋) export const contactMessages = sqliteTable('contact_message', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), name: text('name').notNull(), email: text('email').notNull(), body: text('body').notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull().$defaultFn(() => new Date()), // Discord通知済みか(0=未通知)。着信時に通知を試み、失敗分はcronが回収する。 notified: integer('notified').notNull().default(0), }) Enter fullscreen mode Exit fullscreen mode When an inquiry arrives, it's saved to the DB first, and then a notification is fired to a Discord webhook. // src/app/api/contact/route.ts(抜粋) const id = await createContactMessage(db, v.value) // 着信を即 Discord 通知。失敗しても保存は守る(cron が後で回収)。 const ok = await notifyContact({ project: 'InviteLoop', id, name: v.value.name, email: v.value.email, message: v.value.body, createdAt: Date.now(), }) if (ok) await markContactNotified(db, id) Enter fullscreen mode Exit fullscreen mode The important part is the ordering: "even if the Discord notification fails, the save to the DB must always succeed." A failed notification is left as notified = 0, and a Cron that runs at 0:00 (UTC) the next day picks it back up. // vercel.json { "crons": [ { "path": "/api/cron/notify-contacts", "schedule": "0 0 * * *" } ] } Enter fullscreen mode Exit fullscreen mode // src/app/api/cron/notify-contacts/route.ts(抜粋) // 自己修復: 着信時の Discord 通知が落ちた問い合わせ(notified=0)を毎日回収する。 const pending = await listPendingContacts(db, BATCH) for (const c of pending) { const ok = await notifyContact({ ... }) if (ok) { await markContactNotified(db, c.id) sent++ } } Enter fullscreen mode Exit fullscreen mode The guarantee that "the notification will arrive by the next day at the latest" is achieved with no external service, using Turso alone. This self-healing pattern is one I've also rolled out horizontally across other projects. Privacy Protection via IP Hashing When recording click counts and usage reports, I want to avoid storing the raw IP address, from a personal-data standpoint. // src/lib/ip.ts export function hashIp(ip: string, salt: string): string { if (!ip) return '' return createHash('sha256').update(`${salt}:${ip}`).digest('hex') } Enter fullscreen mode Exit fullscreen mode It hashes with SHA-256, using AUTH_SECRET as the salt. The original IP address never remains in the DB, and only duplicate operations from the same IP can be detected. The mechanism to exclude duplicate clicks using the IP hash plus a time window works the same way. // src/lib/scoring.ts export const CLICK_DEDUP_WINDOW_MS = 6 * 60 * 60 * 1000 // 6時間 Enter fullscreen mode Exit fullscreen mode Clicks from the same IP hash within 6 hours are counted as one. Places I Got Stuck in the Design (Pitfalls) Confusing "displaying the official perk amount" with "InviteLoop's own reward" If perkJa / perkEn contain official info like "you get ¥X for a referral," there's a foreseeable case where a user misreads it as "InviteLoop gives me money." When designing the frontend display, I ended up clearly separating, via a label, that this is "the official perk of this service." It's not just the DB column names — it cascades all the way into the wording design of the UI. The status enum design At first I thought just 'active' and 'hidden' would be enough, but I added 'removed' for cases where an admin wants to intentionally delete something permanently (an obviously fraudulent code, for instance). I'm glad I made it three states from the start. 'hidden' transitions automatically via user reports, while 'removed' is something an admin operates explicitly — that's the division of labor. Vercel Cron's minimum interval On Vercel's Hobby plan, Cron's minimum interval is one day. "Near-real-time notification recovery" isn't possible, so I designed it purely as a fallback. Production notifications are a best effort that arrives the moment an inquiry comes in, and Cron is the insurance. Preventing self-votes and self-usage reports Both the vote and recordUsage functions reject operations on your own code. // src/repos/engagement.ts(vote 関数の抜粋) if (owner === userId) return { ok: false as const, reason: 'self' as const } Enter fullscreen mode Exit fullscreen mode Forget this constraint, and a poster can pump up their own code's score at will. Even under a "rewards are official perks only" policy, the motive to aim for the top of the ranking remains, so you still need a separate defensive line against score manipulation. Summary Why I ruled out cash rewards: three points — terms-of-service risk, inviting spam, and community health. How I implemented the removal: the first move was to make sure no reward column exists in the schema. Abuse deterrence: a combination of MAX_POSTS_PER_DAY = 5, one code per service, URL domain validation, and auto-hide at 3 reports. Fair ordering: achieved with a score of click×1, usage×5, upvote×3, plus a weighted-probability shuffle. Contact notifications: a self-healing pattern of "save to DB → notify Discord → Cron recovers failures the next day." IP addresses: hashed with SHA-256 + salt, with the original value never left in the DB. The "no cash" constraint looks like it strips features away, but it actually makes "what do I need to prevent?" clear, which keeps the design simple. Given the nature of invite codes, I've concluded that shutting down spam and terms-of-service risk from the very start is the condition for a service you can operate over the long haul. Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio · X · GitHub*
Why I Designed an Invite-Code Board With No Cash Rewards
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.