Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
]
},
"dependencies": {
"@profullstack/affiliates": "^0.2.0",
"@profullstack/affiliates": "^0.3.0",
"@profullstack/footer": "latest",
"@profullstack/profile": "0.1.6",
"@profullstack/redeem-credits": "^0.2.0",
Expand Down
29 changes: 29 additions & 0 deletions src/lib/__tests__/rewards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,35 @@ describe("invites", () => {
expect((await acceptInvite(fresh("bob"), inv.code, true, deps())).accepted).toBe(true);
});

it("two simultaneous sign-ins with different invites: one is accepted and paid, the other refused unused", async () => {
const a1 = await createInvite("alice", "x", "", aff);
const a2 = await createInvite("dan", "x", "", aff);
if (!a1.ok || !a2.ok) throw new Error("no invite");
const [r1, r2] = await Promise.all([
acceptInvite(fresh("bob"), a1.code, true, deps()),
acceptInvite(fresh("bob"), a2.code, true, deps()),
]);
const outcomes = [r1, r2];
expect(outcomes.filter((r) => r.accepted)).toHaveLength(1);
expect(outcomes.find((r) => !r.accepted)).toEqual({ accepted: false, reason: "already_invited" });
// Bob and exactly one inviter are paid once; the losing invite is still unused and spendable.
expect(await credits.balance("bob", CREDIT_UNIT)).toBe(INVITE_BONUS_CENTS);
const paid = [await credits.balance("alice", CREDIT_UNIT), await credits.balance("dan", CREDIT_UNIT)].sort();
expect(paid).toEqual([0, INVITE_BONUS_CENTS]);
const loser = r1.accepted ? a2 : a1;
expect((await acceptInvite(fresh("erin"), loser.code, true, deps())).accepted).toBe(true);
});

it("an account that already took an invite is refused a second one later, which stays unused", async () => {
const a1 = await createInvite("alice", "x", "", aff);
const a2 = await createInvite("dan", "x", "", aff);
if (!a1.ok || !a2.ok) throw new Error("no invite");
expect((await acceptInvite(fresh("bob"), a1.code, true, deps())).accepted).toBe(true);
expect(await acceptInvite(fresh("bob"), a2.code, true, deps())).toEqual({ accepted: false, reason: "already_invited" });
expect(await credits.balance("dan", CREDIT_UNIT)).toBe(0);
expect((await invitesSummary("dan", "x", aff)).invites[0].used).toBe(false);
});

it("takes the invite from the cookie, else from signup metadata", () => {
expect(inviteCodeFor("INV-AAAA-BBBB-CCCC", fresh("b"))).toBe("INV-AAAA-BBBB-CCCC");
expect(inviteCodeFor(null, fresh("b", { user_metadata: { invite_code: "INV-1111-2222-3333" } }))).toBe("INV-1111-2222-3333");
Expand Down
9 changes: 6 additions & 3 deletions src/lib/rewards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,12 @@ export type InviteOutcome =

/**
* Spend an invite for a new account and credit both sides. Only called once the
* account passes the signup checks. The invite itself is single use, so the
* bonus can only be paid once per invite; an account redeems one invite.
* account passes the signup checks. The bonus rests on redeemInvite's answer
* alone: since @profullstack/affiliates 0.3 the database (aff_claim_invite plus
* the aff_invites_one_per_user unique index) lets an account take ONE invite,
* atomically, so two sign-ins racing with different invite cookies get one
* `ok` and one `already_invited` (no bonus, the second invite stays unused).
* A separate invitedBy() check first would only be a racy read, so there is none.
*/
export async function acceptInvite(
user: SignupUser,
Expand All @@ -189,7 +193,6 @@ export async function acceptInvite(
if (!passesSignupChecks(user, hasProfile, deps.now)) return { accepted: false, reason: "checks" };
const a = deps.affiliates ?? getAffiliates();
const c = deps.credits ?? getCredits();
if (await a.invitedBy(user.id)) return { accepted: false, reason: "already_invited" };
const r = await a.redeemInvite(code, user.id);
if (!r.ok) return { accepted: false, reason: r.reason };
const grant = { unit: CREDIT_UNIT, amount: INVITE_BONUS_CENTS };
Expand Down
33 changes: 33 additions & 0 deletions supabase/migrations/019_affiliates_0_3.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- @profullstack/affiliates 0.3.0 upgrade for a database on 0.2's migration (prefix aff_)
-- Generated by supabaseUpgradeSql('aff_'). Idempotent; the full migration includes the same statements.
-- 0.3.0: one invite per invitee. Upgrades a 0.2 database in place: earlier duplicate redemptions
-- are kept and marked legacy_dup = 1, then a unique index allows one redeemed invite per user.

ALTER TABLE public.aff_invites ADD COLUMN IF NOT EXISTS legacy_dup INTEGER NOT NULL DEFAULT 0;
UPDATE public.aff_invites SET legacy_dup = 1
WHERE used_by IS NOT NULL AND legacy_dup = 0 AND EXISTS (
SELECT 1 FROM public.aff_invites o WHERE o.used_by = public.aff_invites.used_by
AND (o.used_at < public.aff_invites.used_at OR (o.used_at = public.aff_invites.used_at AND o.id < public.aff_invites.id)));
CREATE UNIQUE INDEX IF NOT EXISTS aff_invites_one_per_user ON public.aff_invites (used_by)
WHERE used_by IS NOT NULL AND legacy_dup = 0;

-- CREATE OR REPLACE keeps the grants 0.2 set (service_role only).
CREATE OR REPLACE FUNCTION public.aff_claim_invite(p_id TEXT, p_user TEXT, p_at BIGINT)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $fn$
BEGIN
-- One invite per invitee: NOT EXISTS refuses a repeat, and the unique index aff_invites_one_per_user
-- turns a concurrent repeat by the same user into a unique_violation, answered as FALSE.
UPDATE public.aff_invites SET used_by = p_user, used_at = p_at
WHERE id = p_id AND used_by IS NULL AND revoked_at IS NULL
AND NOT EXISTS (SELECT 1 FROM public.aff_invites prior WHERE prior.used_by = p_user);
RETURN FOUND;
EXCEPTION WHEN unique_violation THEN
RETURN FALSE;
END
$fn$;

NOTIFY pgrst, 'reload schema';
Loading