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,
    };
  },
});