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
14 changes: 14 additions & 0 deletions drizzle/0005_add_referral_reward_allocations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE "referral_reward_allocations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"referral_id" uuid NOT NULL,
"idempotency_key" text NOT NULL,
"amount" text NOT NULL,
"asset" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "referral_reward_allocations_referral_id_unique" UNIQUE("referral_id"),
CONSTRAINT "referral_reward_allocations_idempotency_key_unique" UNIQUE("idempotency_key")
);
--> statement-breakpoint
ALTER TABLE "referral_reward_allocations" ADD CONSTRAINT "referral_reward_allocations_referral_id_referrals_id_fk" FOREIGN KEY ("referral_id") REFERENCES "public"."referrals"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "referral_reward_allocations_referral_id_idx" ON "referral_reward_allocations" USING btree ("referral_id");
18 changes: 0 additions & 18 deletions package-lock.json

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

33 changes: 33 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,3 +754,36 @@ export const referrals = pgTable(

export type Referral = typeof referrals.$inferSelect;
export type NewReferral = typeof referrals.$inferInsert;

// ---------------------------------------------------------------------------
// Referral reward allocations
// ---------------------------------------------------------------------------
/**
* One immutable reward allocation per referral. Both unique constraints are
* required: the referral constraint prevents double payment, while the key
* constraint makes retried requests return the original allocation.
*/
export const referralRewardAllocations = pgTable(
"referral_reward_allocations",
{
id: uuid("id").primaryKey().defaultRandom(),
referralId: uuid("referral_id")
.notNull()
.references(() => referrals.id, { onDelete: "cascade" })
.unique(),
idempotencyKey: text("idempotency_key").notNull().unique(),
amount: text("amount").notNull(),
asset: text("asset").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
referralRewardAllocationsReferralIdIdx: index(
"referral_reward_allocations_referral_id_idx",
).on(t.referralId),
}),
);

export type ReferralRewardAllocation = typeof referralRewardAllocations.$inferSelect;
export type NewReferralRewardAllocation = typeof referralRewardAllocations.$inferInsert;
87 changes: 86 additions & 1 deletion src/services/referralService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@

import { eq, desc } from "drizzle-orm";
import { db } from "../db/client";
import { referrals, type Referral, type NewReferral } from "../db/schema";
import {
referrals,
referralRewardAllocations,
type Referral,
type NewReferral,
type ReferralRewardAllocation,
} from "../db/schema";

// ---------------------------------------------------------------------------
// Public interface
Expand All @@ -30,6 +36,21 @@ export interface ReferralResult {
message: string;
}

export interface AllocateReferralRewardInput {
referralId: string;
idempotencyKey: string;
amount: string;
asset: string;
}

export class ReferralRewardValidationError extends Error {
readonly code = "referral_reward_validation_error";
}

export class ReferralRewardConflictError extends Error {
readonly code = "referral_reward_conflict";
}

// ---------------------------------------------------------------------------
// Default (production) implementations
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -78,3 +99,67 @@ export async function listUserReferrals(userId: string): Promise<Referral[]> {
.where(eq(referrals.userId, userId))
.orderBy(desc(referrals.createdAt));
}

function validateRewardInput(input: AllocateReferralRewardInput): void {
if (!input.referralId.trim()) {
throw new ReferralRewardValidationError("referralId is required");
}
if (!input.idempotencyKey.trim() || input.idempotencyKey.length > 128) {
throw new ReferralRewardValidationError("idempotencyKey must be 1-128 characters");
}
if (!/^(0|[1-9]\d*)(\.\d{1,18})?$/.test(input.amount) || /^0(?:\.0{1,18})?$/.test(input.amount)) {
throw new ReferralRewardValidationError("amount must be a positive decimal with up to 18 places");
}
if (!/^[A-Z0-9]{1,12}$/.test(input.asset)) {
throw new ReferralRewardValidationError("asset must be 1-12 uppercase alphanumeric characters");
}
}

function matchesAllocation(
allocation: ReferralRewardAllocation,
input: AllocateReferralRewardInput,
): boolean {
return allocation.referralId === input.referralId &&
allocation.amount === input.amount &&
allocation.asset === input.asset;
}

/**
* Allocates a referral reward exactly once. The insert is the serialization
* point, so concurrent callers cannot both create an allocation. A conflict
* is safe to retry only when all business fields match the stored row.
*/
export async function allocateReferralReward(
input: AllocateReferralRewardInput,
): Promise<ReferralRewardAllocation> {
validateRewardInput(input);

const inserted = await db
.insert(referralRewardAllocations)
.values({
referralId: input.referralId,
idempotencyKey: input.idempotencyKey,
amount: input.amount,
asset: input.asset,
})
.onConflictDoNothing()
.returning();
if (inserted[0]) return inserted[0];

const byKey = await db
.select()
.from(referralRewardAllocations)
.where(eq(referralRewardAllocations.idempotencyKey, input.idempotencyKey));
const byReferral = byKey[0] ?? (await db
.select()
.from(referralRewardAllocations)
.where(eq(referralRewardAllocations.referralId, input.referralId)))[0];

if (!byReferral) {
throw new Error("referral reward allocation conflict could not be resolved");
}
if (!matchesAllocation(byReferral, input)) {
throw new ReferralRewardConflictError("referral reward allocation does not match the existing allocation");
}
return byReferral;
}
Loading