File size: 7,186 Bytes
97ee7cb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | import { internalMutation, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { DatabaseReader, DatabaseWriter } from "./_generated/server";
function hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
async function generateUniqueReferralCode(
db: DatabaseReader,
email: string,
): Promise<string> {
for (let attempt = 0; attempt < 10; attempt++) {
const input = attempt === 0 ? email : `${email}:${attempt}`;
const code = hashCode(input).toString(36).padStart(6, "0").slice(0, 8);
const existing = await db
.query("registrations")
.withIndex("by_referral_code", (q) => q.eq("referralCode", code))
.first();
if (!existing) return code;
}
// Fallback: timestamp-based code (extremely unlikely path)
return Date.now().toString(36).slice(-8);
}
async function getCounter(db: DatabaseReader, name: string): Promise<number> {
const counter = await db
.query("counters")
.withIndex("by_name", (q) => q.eq("name", name))
.first();
return counter?.value ?? 0;
}
async function incrementCounter(db: DatabaseWriter, name: string): Promise<number> {
const counter = await db
.query("counters")
.withIndex("by_name", (q) => q.eq("name", name))
.first();
const newVal = (counter?.value ?? 0) + 1;
if (counter) {
await db.patch(counter._id, { value: newVal });
} else {
await db.insert("counters", { name, value: newVal });
}
return newVal;
}
export const register = mutation({
args: {
email: v.string(),
source: v.optional(v.string()),
appVersion: v.optional(v.string()),
referredBy: v.optional(v.string()),
},
handler: async (ctx, args) => {
const normalizedEmail = args.email.trim().toLowerCase();
const existing = await ctx.db
.query("registrations")
.withIndex("by_normalized_email", (q) => q.eq("normalizedEmail", normalizedEmail))
.first();
if (existing) {
let code = existing.referralCode;
if (!code) {
code = await generateUniqueReferralCode(ctx.db, normalizedEmail);
await ctx.db.patch(existing._id, { referralCode: code });
}
return {
status: "already_registered" as const,
referralCode: code,
referralCount: existing.referralCount ?? 0,
};
}
const referralCode = await generateUniqueReferralCode(ctx.db, normalizedEmail);
// Credit the referrer. Two code spaces can match:
// 1. registrations.referralCode — 6-char email-derived codes
// assigned to waitlist entries before signup. Credit lives
// on `registrations.referralCount` for the matching row.
// 2. userReferralCodes.code — 8-char HMAC codes assigned to
// signed-in Clerk users via the share-button feature. The
// referrer has no registrations row, so credit lives on
// the separate `userReferralCredits` table.
//
// Try (1) first for backwards-compatibility with existing
// waitlist referrals, then fall through to (2). Never credits
// both — the two namespaces are disjoint by design (6-char vs
// 8-char hex).
if (args.referredBy) {
const registrationReferrer = await ctx.db
.query("registrations")
.withIndex("by_referral_code", (q) => q.eq("referralCode", args.referredBy))
.first();
if (registrationReferrer) {
await ctx.db.patch(registrationReferrer._id, {
referralCount: (registrationReferrer.referralCount ?? 0) + 1,
});
} else {
const clerkReferrer = await ctx.db
.query("userReferralCodes")
.withIndex("by_code", (q) => q.eq("code", args.referredBy as string))
.first();
if (clerkReferrer) {
// Dedupe by (referrer, email). Returning visitors who
// re-submit the waitlist form must not double-credit.
const existingCredit = await ctx.db
.query("userReferralCredits")
.withIndex("by_referrer_email", (q) =>
q.eq("referrerUserId", clerkReferrer.userId).eq("refereeEmail", normalizedEmail),
)
.first();
if (!existingCredit) {
await ctx.db.insert("userReferralCredits", {
referrerUserId: clerkReferrer.userId,
refereeEmail: normalizedEmail,
createdAt: Date.now(),
});
}
}
}
}
const position = await incrementCounter(ctx.db, "registrations_total");
await ctx.db.insert("registrations", {
email: args.email.trim(),
normalizedEmail,
registeredAt: Date.now(),
source: args.source ?? "unknown",
appVersion: args.appVersion ?? "unknown",
referralCode,
referredBy: args.referredBy,
referralCount: 0,
});
const suppressed = await ctx.db
.query("emailSuppressions")
.withIndex("by_normalized_email", (q) => q.eq("normalizedEmail", normalizedEmail))
.first();
return {
status: "registered" as const,
referralCode,
referralCount: 0,
position,
emailSuppressed: !!suppressed,
};
},
});
/**
* Phase 9 / Todo #223: bind a Clerk-derived 8-char share code to a
* Clerk userId so future /pro?ref=<code> visitors can be credited
* back to the sharer. Idempotent — the same (userId, code) pair is
* inserted at most once. Called by /api/referral/me on every call
* so the mapping is live by the time anyone clicks a shared link.
*
* Code collisions: the Clerk HMAC space is 4B slots; at our scale
* collisions are a rounding error. If the same code somehow maps
* to two userIds we keep the first write and ignore the second —
* the later-registered user will simply not be creditable through
* the share flow. An alert on this log is appropriate operational
* signal that it's time to rotate the signing secret.
*/
export const registerUserReferralCode = internalMutation({
args: { userId: v.string(), code: v.string() },
handler: async (ctx, args) => {
const existing = await ctx.db
.query("userReferralCodes")
.withIndex("by_code", (q) => q.eq("code", args.code))
.first();
if (existing) {
if (existing.userId !== args.userId) {
console.warn(
`[referral] code collision: ${args.code} first bound to ${existing.userId}, ignoring request from ${args.userId}`,
);
}
return { isNew: false };
}
await ctx.db.insert("userReferralCodes", {
userId: args.userId,
code: args.code,
createdAt: Date.now(),
});
return { isNew: true };
},
});
export const getPosition = query({
args: { referralCode: v.string() },
handler: async (ctx, args) => {
const reg = await ctx.db
.query("registrations")
.withIndex("by_referral_code", (q) => q.eq("referralCode", args.referralCode))
.first();
if (!reg) return null;
const total = await getCounter(ctx.db, "registrations_total");
return {
referralCount: reg.referralCount ?? 0,
total,
};
},
});
|