Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 | 26x 2x 2x 2x 373x 36x 36x 9x 9x 143x 143x 51x 51x 51x 51x 1x 1x 50x 42x 5x 2x 10x 10x | import { eq } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "../../db/schema";
import { generateId } from "../../lib/ids";
const PARTNERS = schema.partners;
export type Partner = typeof schema.partners.$inferSelect;
/**
* The phone is already a partner.
*
* Raised from the UNIQUE index rather than from a check-then-insert, because
* the check-then-insert has a window: two OTP verifications for the same
* number, arriving together, both read "no partner" and both insert. D1 has no
* transactions to close that window, so the index is the only place the
* guarantee can actually live.
*/
export class PartnerAlreadyExistsError extends Error {
constructor(public phoneNorm: string) {
super(`A partner already exists for ${phoneNorm}`);
this.name = "PartnerAlreadyExistsError";
}
}
export type CreatePartnerInput = {
phoneNorm: string;
name: string;
firmName?: string | null;
/** Set when this number was already an RRM recruitment target. */
prospectId?: string | null;
/**
* What they agreed to, and when. Copied from the prospect for a recruited
* partner, collected on the registration screen for a self-registered one —
* the caller decides which, this just records it. Optional in the type only
* because the column is nullable for rows that predate it.
*/
consentVersion?: string | null;
consentAt?: Date | null;
source?: (typeof schema.partners.$inferInsert)["source"];
language?: (typeof schema.partners.$inferInsert)["language"];
closingsBand?: (typeof schema.partners.$inferInsert)["closingsBand"];
};
export class PartnersDal {
constructor(private db: DrizzleD1Database<typeof schema>) {}
async findByPhone(phoneNorm: string): Promise<Partner | null> {
const rows = await this.db
.select()
.from(PARTNERS)
.where(eq(PARTNERS.phoneNorm, phoneNorm))
.limit(1);
return rows[0] ?? null;
}
/**
* The `/r/<code>` lookup. Partial-unique-indexed, so at most one partner
* can own a given share code.
*/
async findByShareCode(shareCode: string): Promise<Partner | null> {
const rows = await this.db
.select()
.from(PARTNERS)
.where(eq(PARTNERS.shareCode, shareCode))
.limit(1);
return rows[0] ?? null;
}
async findById(id: string): Promise<Partner | null> {
const rows = await this.db
.select()
.from(PARTNERS)
.where(eq(PARTNERS.id, id))
.limit(1);
return rows[0] ?? null;
}
/**
* Enrol a partner.
*
* @throws {PartnerAlreadyExistsError} when the number is already enrolled.
* The caller should re-read and continue rather than surface an error: a
* partner racing their own second OTP tap has done nothing wrong, and the
* correct outcome is that they are signed in.
*/
async create(input: CreatePartnerInput): Promise<Partner> {
const now = new Date();
const row: typeof schema.partners.$inferInsert = {
id: generateId(),
phoneNorm: input.phoneNorm,
name: input.name,
firmName: input.firmName ?? null,
prospectId: input.prospectId ?? null,
consentVersion: input.consentVersion ?? null,
consentAt: input.consentAt ?? null,
source: input.source ?? "self_registered",
language: input.language ?? "en",
closingsBand: input.closingsBand ?? null,
dateCreated: now,
dateUpdated: now,
};
try {
await this.db.insert(PARTNERS).values(row);
} catch (error) {
Eif (isUniqueViolation(error)) {
throw new PartnerAlreadyExistsError(input.phoneNorm);
}
throw error;
}
return row as Partner;
}
async update(
id: string,
patch: Partial<
Pick<
typeof schema.partners.$inferInsert,
| "name"
| "firmName"
| "language"
| "closingsBand"
| "upiId"
| "upiName"
| "panLast4"
| "notifyWhatsapp"
| "capPerDay"
| "capPerMonth"
| "prospectId"
| "shareCode"
>
>,
): Promise<void> {
await this.db
.update(PARTNERS)
.set({ ...patch, dateUpdated: new Date() })
.where(eq(PARTNERS.id, id));
}
/** FR-F-7: never silent — the reason is shown to the partner. */
async suspend(id: string, reason: string): Promise<void> {
await this.db
.update(PARTNERS)
.set({
suspendedAt: new Date(),
suspendedReason: reason,
dateUpdated: new Date(),
})
.where(eq(PARTNERS.id, id));
}
async unsuspend(id: string): Promise<void> {
await this.db
.update(PARTNERS)
.set({
suspendedAt: null,
suspendedReason: null,
dateUpdated: new Date(),
})
.where(eq(PARTNERS.id, id));
}
}
/**
* D1 surfaces a constraint violation as a message, not a code, so this matches
* on text. Kept in one place so the fragility is visible rather than repeated.
*/
export function isUniqueViolation(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /UNIQUE constraint failed/i.test(message);
}
|