@bash-app/bash-common 30.432.0 → 30.434.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/appleIapProducts.test.js +1 -0
- package/dist/__tests__/appleIapProducts.test.js.map +1 -1
- package/dist/__tests__/bashSectionTitleForBashType.test.js +2 -0
- package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
- package/dist/__tests__/discoveryScope.test.js +9 -1
- package/dist/__tests__/discoveryScope.test.js.map +1 -1
- package/dist/__tests__/promoCartEngine.test.d.ts +2 -0
- package/dist/__tests__/promoCartEngine.test.d.ts.map +1 -0
- package/dist/__tests__/promoCartEngine.test.js +174 -0
- package/dist/__tests__/promoCartEngine.test.js.map +1 -0
- package/dist/__tests__/promoCodeRedemption.test.d.ts +2 -0
- package/dist/__tests__/promoCodeRedemption.test.d.ts.map +1 -0
- package/dist/__tests__/promoCodeRedemption.test.js +103 -0
- package/dist/__tests__/promoCodeRedemption.test.js.map +1 -0
- package/dist/__tests__/promoCodeScope.test.d.ts +2 -0
- package/dist/__tests__/promoCodeScope.test.d.ts.map +1 -0
- package/dist/__tests__/promoCodeScope.test.js +84 -0
- package/dist/__tests__/promoCodeScope.test.js.map +1 -0
- package/dist/__tests__/statusEnums.test.js +1 -0
- package/dist/__tests__/statusEnums.test.js.map +1 -1
- package/dist/appleIapProducts.d.ts +12 -2
- package/dist/appleIapProducts.d.ts.map +1 -1
- package/dist/appleIapProducts.js +13 -0
- package/dist/appleIapProducts.js.map +1 -1
- package/dist/definitions.d.ts +3 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +4 -2
- package/dist/definitions.js.map +1 -1
- package/dist/discoveryScope.d.ts +6 -6
- package/dist/discoveryScope.d.ts.map +1 -1
- package/dist/discoveryScope.js +25 -16
- package/dist/discoveryScope.js.map +1 -1
- package/dist/extendedSchemas.d.ts +1 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/intros/__tests__/introsReleaseGates.test.d.ts +2 -0
- package/dist/intros/__tests__/introsReleaseGates.test.d.ts.map +1 -0
- package/dist/intros/__tests__/introsReleaseGates.test.js +140 -0
- package/dist/intros/__tests__/introsReleaseGates.test.js.map +1 -0
- package/dist/intros/__tests__/psychExperiment.test.d.ts +2 -0
- package/dist/intros/__tests__/psychExperiment.test.d.ts.map +1 -0
- package/dist/intros/__tests__/psychExperiment.test.js +63 -0
- package/dist/intros/__tests__/psychExperiment.test.js.map +1 -0
- package/dist/intros/__tests__/reasons.test.d.ts +2 -0
- package/dist/intros/__tests__/reasons.test.d.ts.map +1 -0
- package/dist/intros/__tests__/reasons.test.js +72 -0
- package/dist/intros/__tests__/reasons.test.js.map +1 -0
- package/dist/intros/__tests__/sharedNotes.test.d.ts +2 -0
- package/dist/intros/__tests__/sharedNotes.test.d.ts.map +1 -0
- package/dist/intros/__tests__/sharedNotes.test.js +19 -0
- package/dist/intros/__tests__/sharedNotes.test.js.map +1 -0
- package/dist/intros/__tests__/transitions.test.d.ts +2 -0
- package/dist/intros/__tests__/transitions.test.d.ts.map +1 -0
- package/dist/intros/__tests__/transitions.test.js +43 -0
- package/dist/intros/__tests__/transitions.test.js.map +1 -0
- package/dist/intros/__tests__/warmDisclosure.test.d.ts +2 -0
- package/dist/intros/__tests__/warmDisclosure.test.d.ts.map +1 -0
- package/dist/intros/__tests__/warmDisclosure.test.js +10 -0
- package/dist/intros/__tests__/warmDisclosure.test.js.map +1 -0
- package/dist/intros/age.d.ts +4 -0
- package/dist/intros/age.d.ts.map +1 -0
- package/dist/intros/age.js +14 -0
- package/dist/intros/age.js.map +1 -0
- package/dist/intros/attendance.d.ts +11 -0
- package/dist/intros/attendance.d.ts.map +1 -0
- package/dist/intros/attendance.js +27 -0
- package/dist/intros/attendance.js.map +1 -0
- package/dist/intros/attributeCatalog.d.ts +17 -0
- package/dist/intros/attributeCatalog.d.ts.map +1 -0
- package/dist/intros/attributeCatalog.js +96 -0
- package/dist/intros/attributeCatalog.js.map +1 -0
- package/dist/intros/index.d.ts +10 -0
- package/dist/intros/index.d.ts.map +1 -0
- package/dist/intros/index.js +10 -0
- package/dist/intros/index.js.map +1 -0
- package/dist/intros/intents.d.ts +8 -0
- package/dist/intros/intents.d.ts.map +1 -0
- package/dist/intros/intents.js +44 -0
- package/dist/intros/intents.js.map +1 -0
- package/dist/intros/psychExperiment.d.ts +29 -0
- package/dist/intros/psychExperiment.d.ts.map +1 -0
- package/dist/intros/psychExperiment.js +82 -0
- package/dist/intros/psychExperiment.js.map +1 -0
- package/dist/intros/reasons.d.ts +38 -0
- package/dist/intros/reasons.d.ts.map +1 -0
- package/dist/intros/reasons.js +162 -0
- package/dist/intros/reasons.js.map +1 -0
- package/dist/intros/sharedNotes.d.ts +10 -0
- package/dist/intros/sharedNotes.d.ts.map +1 -0
- package/dist/intros/sharedNotes.js +56 -0
- package/dist/intros/sharedNotes.js.map +1 -0
- package/dist/intros/transitions.d.ts +29 -0
- package/dist/intros/transitions.d.ts.map +1 -0
- package/dist/intros/transitions.js +128 -0
- package/dist/intros/transitions.js.map +1 -0
- package/dist/intros/validation.d.ts +50 -0
- package/dist/intros/validation.d.ts.map +1 -0
- package/dist/intros/validation.js +212 -0
- package/dist/intros/validation.js.map +1 -0
- package/dist/mirroredPrismaEnums.d.ts +1 -0
- package/dist/mirroredPrismaEnums.d.ts.map +1 -1
- package/dist/mirroredPrismaEnums.js +1 -0
- package/dist/mirroredPrismaEnums.js.map +1 -1
- package/dist/promoCartEngine.d.ts +55 -0
- package/dist/promoCartEngine.d.ts.map +1 -0
- package/dist/promoCartEngine.js +135 -0
- package/dist/promoCartEngine.js.map +1 -0
- package/dist/promoCodeRedemption.d.ts +44 -0
- package/dist/promoCodeRedemption.d.ts.map +1 -0
- package/dist/promoCodeRedemption.js +81 -0
- package/dist/promoCodeRedemption.js.map +1 -0
- package/dist/promoCodeScope.d.ts +91 -0
- package/dist/promoCodeScope.d.ts.map +1 -0
- package/dist/promoCodeScope.js +102 -0
- package/dist/promoCodeScope.js.map +1 -0
- package/dist/utils/__tests__/discountEngine/discountCalculator.test.js +8 -1
- package/dist/utils/__tests__/discountEngine/discountCalculator.test.js.map +1 -1
- package/dist/utils/__tests__/paymentUtils.test.js +87 -4
- package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
- package/dist/utils/paymentUtils.d.ts +30 -5
- package/dist/utils/paymentUtils.d.ts.map +1 -1
- package/dist/utils/paymentUtils.js +43 -11
- package/dist/utils/paymentUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +427 -6
- package/src/__tests__/appleIapProducts.test.ts +3 -0
- package/src/__tests__/bashSectionTitleForBashType.test.ts +4 -0
- package/src/__tests__/discoveryScope.test.ts +22 -1
- package/src/__tests__/promoCartEngine.test.ts +201 -0
- package/src/__tests__/promoCodeRedemption.test.ts +134 -0
- package/src/__tests__/promoCodeScope.test.ts +119 -0
- package/src/__tests__/statusEnums.test.ts +1 -0
- package/src/appleIapProducts.ts +29 -2
- package/src/definitions.ts +4 -2
- package/src/discoveryScope.ts +30 -20
- package/src/extendedSchemas.ts +1 -0
- package/src/index.ts +4 -0
- package/src/intros/__tests__/introsReleaseGates.test.ts +181 -0
- package/src/intros/__tests__/psychExperiment.test.ts +99 -0
- package/src/intros/__tests__/reasons.test.ts +122 -0
- package/src/intros/__tests__/sharedNotes.test.ts +20 -0
- package/src/intros/__tests__/transitions.test.ts +61 -0
- package/src/intros/__tests__/warmDisclosure.test.ts +10 -0
- package/src/intros/age.ts +13 -0
- package/src/intros/attendance.ts +33 -0
- package/src/intros/attributeCatalog.ts +113 -0
- package/src/intros/index.ts +9 -0
- package/src/intros/intents.ts +49 -0
- package/src/intros/psychExperiment.ts +130 -0
- package/src/intros/reasons.ts +187 -0
- package/src/intros/sharedNotes.ts +59 -0
- package/src/intros/transitions.ts +172 -0
- package/src/intros/validation.ts +248 -0
- package/src/mirroredPrismaEnums.ts +1 -0
- package/src/promoCartEngine.ts +193 -0
- package/src/promoCodeRedemption.ts +123 -0
- package/src/promoCodeScope.ts +180 -0
- package/src/utils/__tests__/discountEngine/discountCalculator.test.ts +8 -0
- package/src/utils/__tests__/paymentUtils.test.ts +100 -6
- package/src/utils/paymentUtils.ts +56 -13
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attributeMayBeUsedInExperiments,
|
|
3
|
+
isIntrosAttributeKind,
|
|
4
|
+
validateAttributeValue,
|
|
5
|
+
type IntrosAttributeKind,
|
|
6
|
+
} from "./attributeCatalog.js";
|
|
7
|
+
import { isIntrosIntent, type IntrosIntent } from "./intents.js";
|
|
8
|
+
|
|
9
|
+
export const WARM_INVITE_PREFS = ["Off", "Anonymous", "Named"] as const;
|
|
10
|
+
export type WarmIntroInvitePref = (typeof WARM_INVITE_PREFS)[number];
|
|
11
|
+
|
|
12
|
+
export const INTROS_EVENT_MODES = ["Off", "Platonic", "All"] as const;
|
|
13
|
+
export type IntrosEventMode = (typeof INTROS_EVENT_MODES)[number];
|
|
14
|
+
|
|
15
|
+
const GENDERS = ["women", "men", "nonbinary", "everyone"] as const;
|
|
16
|
+
|
|
17
|
+
export type AttributePatch = {
|
|
18
|
+
value: unknown;
|
|
19
|
+
visibility: "OnlyMe" | "RevealedIntros";
|
|
20
|
+
allowExperimentalMatching: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ProfilePatch = {
|
|
24
|
+
shortIntro?: string | null;
|
|
25
|
+
conversationTopics?: string[];
|
|
26
|
+
professionalInterests?: string[];
|
|
27
|
+
travelRadiusMiles?: number | null;
|
|
28
|
+
intents?: IntrosIntent[];
|
|
29
|
+
warmIntroInvites?: WarmIntroInvitePref | null;
|
|
30
|
+
shareRegistrationStatus?: boolean;
|
|
31
|
+
revealOnlyAfterCheckIn?: boolean;
|
|
32
|
+
notifyIntros?: boolean;
|
|
33
|
+
warmSuggestionAudience?: "None" | "Connections" | "ConnectionsAndHosts";
|
|
34
|
+
romance?: { seekingGenders: string[]; ageMin: number; ageMax: number } | null;
|
|
35
|
+
attributes?: Partial<Record<IntrosAttributeKind, AttributePatch | null>>;
|
|
36
|
+
/** Unknown attribute keys sent as null, so a retired note can be removed. */
|
|
37
|
+
discardAttributes?: string[];
|
|
38
|
+
activate?: boolean;
|
|
39
|
+
globalMatching?: boolean;
|
|
40
|
+
romanceConsent?: boolean;
|
|
41
|
+
psychologyExperiment?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ValidationOk<T> = { ok: true; value: T };
|
|
45
|
+
export type ValidationErr = { ok: false; error: string };
|
|
46
|
+
|
|
47
|
+
function cleanList(raw: unknown, label: string): ValidationOk<string[]> | ValidationErr {
|
|
48
|
+
if (!Array.isArray(raw)) return { ok: false, error: `${label} must be a list` };
|
|
49
|
+
if (raw.length > 10) return { ok: false, error: `${label} can have at most 10 items` };
|
|
50
|
+
const next: string[] = [];
|
|
51
|
+
for (const item of raw) {
|
|
52
|
+
if (typeof item !== "string") return { ok: false, error: `${label} must be text` };
|
|
53
|
+
const trimmed = item.trim().slice(0, 40);
|
|
54
|
+
if (trimmed) next.push(trimmed);
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, value: next };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseWarmIntroInvites(
|
|
60
|
+
raw: unknown
|
|
61
|
+
): ValidationOk<WarmIntroInvitePref | null> | ValidationErr {
|
|
62
|
+
if (raw == null) return { ok: true, value: null };
|
|
63
|
+
if (typeof raw !== "string" || !(WARM_INVITE_PREFS as readonly string[]).includes(raw)) {
|
|
64
|
+
return { ok: false, error: "Choose Off, Anonymous, or Named" };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, value: raw as WarmIntroInvitePref };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseIntrosEventMode(raw: unknown): ValidationOk<IntrosEventMode> | ValidationErr {
|
|
70
|
+
if (typeof raw !== "string" || !(INTROS_EVENT_MODES as readonly string[]).includes(raw)) {
|
|
71
|
+
return { ok: false, error: "Choose Off, Platonic, or All" };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, value: raw as IntrosEventMode };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseProfilePatch(body: unknown): ValidationOk<ProfilePatch> | ValidationErr {
|
|
77
|
+
if (body == null || typeof body !== "object") {
|
|
78
|
+
return { ok: false, error: "Request body is required" };
|
|
79
|
+
}
|
|
80
|
+
const record = body as Record<string, unknown>;
|
|
81
|
+
const patch: ProfilePatch = {};
|
|
82
|
+
|
|
83
|
+
if ("shortIntro" in record) {
|
|
84
|
+
if (record.shortIntro == null) patch.shortIntro = null;
|
|
85
|
+
else if (typeof record.shortIntro !== "string") {
|
|
86
|
+
return { ok: false, error: "Introduction must be text" };
|
|
87
|
+
} else {
|
|
88
|
+
const text = record.shortIntro.replace(/https?:\/\/\S+/gi, "").trim();
|
|
89
|
+
if (text.length > 140) {
|
|
90
|
+
return { ok: false, error: "Introduction must be 140 characters or fewer" };
|
|
91
|
+
}
|
|
92
|
+
patch.shortIntro = text;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if ("conversationTopics" in record) {
|
|
96
|
+
const parsed = cleanList(record.conversationTopics, "Conversation topics");
|
|
97
|
+
if (!parsed.ok) return parsed;
|
|
98
|
+
patch.conversationTopics = parsed.value;
|
|
99
|
+
}
|
|
100
|
+
if ("professionalInterests" in record) {
|
|
101
|
+
const parsed = cleanList(record.professionalInterests, "Interests");
|
|
102
|
+
if (!parsed.ok) return parsed;
|
|
103
|
+
patch.professionalInterests = parsed.value;
|
|
104
|
+
}
|
|
105
|
+
if ("travelRadiusMiles" in record) {
|
|
106
|
+
if (record.travelRadiusMiles == null) patch.travelRadiusMiles = null;
|
|
107
|
+
else if (
|
|
108
|
+
typeof record.travelRadiusMiles !== "number" ||
|
|
109
|
+
record.travelRadiusMiles < 1 ||
|
|
110
|
+
record.travelRadiusMiles > 200
|
|
111
|
+
) {
|
|
112
|
+
return { ok: false, error: "Travel distance must be between 1 and 200 miles" };
|
|
113
|
+
} else patch.travelRadiusMiles = Math.round(record.travelRadiusMiles);
|
|
114
|
+
}
|
|
115
|
+
if ("intents" in record) {
|
|
116
|
+
if (!Array.isArray(record.intents)) return { ok: false, error: "Intents must be a list" };
|
|
117
|
+
const intents: IntrosIntent[] = [];
|
|
118
|
+
for (const item of record.intents) {
|
|
119
|
+
if (typeof item !== "string" || !isIntrosIntent(item)) {
|
|
120
|
+
return { ok: false, error: "Unknown connection intention" };
|
|
121
|
+
}
|
|
122
|
+
if (!intents.includes(item)) intents.push(item);
|
|
123
|
+
}
|
|
124
|
+
patch.intents = intents;
|
|
125
|
+
}
|
|
126
|
+
if ("warmIntroInvites" in record) {
|
|
127
|
+
const parsed = parseWarmIntroInvites(record.warmIntroInvites);
|
|
128
|
+
if (!parsed.ok) return parsed;
|
|
129
|
+
patch.warmIntroInvites = parsed.value;
|
|
130
|
+
}
|
|
131
|
+
for (const key of [
|
|
132
|
+
"shareRegistrationStatus",
|
|
133
|
+
"revealOnlyAfterCheckIn",
|
|
134
|
+
"notifyIntros",
|
|
135
|
+
"activate",
|
|
136
|
+
"globalMatching",
|
|
137
|
+
"romanceConsent",
|
|
138
|
+
"psychologyExperiment",
|
|
139
|
+
] as const) {
|
|
140
|
+
if (key in record) {
|
|
141
|
+
if (typeof record[key] !== "boolean") return { ok: false, error: `${key} must be on or off` };
|
|
142
|
+
patch[key] = record[key] as boolean;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if ("warmSuggestionAudience" in record) {
|
|
146
|
+
const value = record.warmSuggestionAudience;
|
|
147
|
+
if (value !== "None" && value !== "Connections" && value !== "ConnectionsAndHosts") {
|
|
148
|
+
return { ok: false, error: "Choose who may be asked to introduce you" };
|
|
149
|
+
}
|
|
150
|
+
patch.warmSuggestionAudience = value;
|
|
151
|
+
}
|
|
152
|
+
if ("romance" in record) {
|
|
153
|
+
if (record.romance == null) patch.romance = null;
|
|
154
|
+
else if (typeof record.romance !== "object") {
|
|
155
|
+
return { ok: false, error: "Romance preferences are invalid" };
|
|
156
|
+
} else {
|
|
157
|
+
const romance = record.romance as Record<string, unknown>;
|
|
158
|
+
const ageMin = romance.ageMin;
|
|
159
|
+
const ageMax = romance.ageMax;
|
|
160
|
+
if (
|
|
161
|
+
typeof ageMin !== "number" ||
|
|
162
|
+
typeof ageMax !== "number" ||
|
|
163
|
+
ageMin < 18 ||
|
|
164
|
+
ageMax > 99 ||
|
|
165
|
+
ageMin > ageMax
|
|
166
|
+
) {
|
|
167
|
+
return { ok: false, error: "Romance ages must be between 18 and 99" };
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(romance.seekingGenders)) {
|
|
170
|
+
return { ok: false, error: "Choose who you want to meet" };
|
|
171
|
+
}
|
|
172
|
+
const seeking = romance.seekingGenders.filter((g): g is string => typeof g === "string");
|
|
173
|
+
if (seeking.some((g) => !(GENDERS as readonly string[]).includes(g))) {
|
|
174
|
+
return { ok: false, error: "Choose who you want to meet" };
|
|
175
|
+
}
|
|
176
|
+
patch.romance = { seekingGenders: seeking, ageMin, ageMax };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (record.attributes != null && typeof record.attributes === "object") {
|
|
180
|
+
const attributes: NonNullable<ProfilePatch["attributes"]> = {};
|
|
181
|
+
for (const [key, raw] of Object.entries(record.attributes as Record<string, unknown>)) {
|
|
182
|
+
if (!isIntrosAttributeKind(key)) {
|
|
183
|
+
if (raw == null) {
|
|
184
|
+
patch.discardAttributes = [...(patch.discardAttributes ?? []), key];
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
return { ok: false, error: "Unknown profile attribute" };
|
|
188
|
+
}
|
|
189
|
+
if (raw == null) {
|
|
190
|
+
attributes[key] = null;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (typeof raw !== "object") return { ok: false, error: "Attributes are invalid" };
|
|
194
|
+
const attr = raw as Record<string, unknown>;
|
|
195
|
+
const parsed = validateAttributeValue(key, attr.value);
|
|
196
|
+
if (!parsed.ok) return parsed;
|
|
197
|
+
if (attr.visibility !== "OnlyMe" && attr.visibility !== "RevealedIntros") {
|
|
198
|
+
return { ok: false, error: "Choose who can see this attribute" };
|
|
199
|
+
}
|
|
200
|
+
if (attr.allowExperimentalMatching === true && !attributeMayBeUsedInExperiments(key)) {
|
|
201
|
+
return { ok: false, error: "This attribute cannot be used for matching" };
|
|
202
|
+
}
|
|
203
|
+
attributes[key] = {
|
|
204
|
+
value: parsed.value,
|
|
205
|
+
visibility: attr.visibility,
|
|
206
|
+
allowExperimentalMatching: attr.allowExperimentalMatching === true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
patch.attributes = attributes;
|
|
210
|
+
}
|
|
211
|
+
return { ok: true, value: patch };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function validateLegacyIntroPreference(key: string, value: unknown): string | null {
|
|
215
|
+
if (key === "introIntents") {
|
|
216
|
+
if (!Array.isArray(value)) return "Intro intents must be a list";
|
|
217
|
+
const allowed = new Set(["friends", "professional", "mentorship"]);
|
|
218
|
+
if (value.some((item) => typeof item !== "string" || !allowed.has(item))) {
|
|
219
|
+
return "Unknown intro intent";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (key === "personalityType" && value != null) {
|
|
223
|
+
if (typeof value !== "string" || !/^[A-Za-z]{1,4}$/.test(value)) {
|
|
224
|
+
return "Personality type must be 1 to 4 letters";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
key === "locusOfControl" &&
|
|
229
|
+
value != null &&
|
|
230
|
+
value !== "internal" &&
|
|
231
|
+
value !== "external" &&
|
|
232
|
+
value !== "mixed"
|
|
233
|
+
) {
|
|
234
|
+
return "Locus of control must be internal, external, or mixed";
|
|
235
|
+
}
|
|
236
|
+
if (key === "openToIntros" && typeof value !== "boolean") {
|
|
237
|
+
return "Open to intros must be on or off";
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Warm invite names the other person only when both recipients chose Named. */
|
|
243
|
+
export function warmInviteNamesOtherPerson(
|
|
244
|
+
a: WarmIntroInvitePref | null | undefined,
|
|
245
|
+
b: WarmIntroInvitePref | null | undefined
|
|
246
|
+
): boolean {
|
|
247
|
+
return a === "Named" && b === "Named";
|
|
248
|
+
}
|
|
@@ -202,6 +202,7 @@ export const CreditSourceType = {
|
|
|
202
202
|
CompetitionFinalist: "CompetitionFinalist",
|
|
203
203
|
CompetitionWin: "CompetitionWin",
|
|
204
204
|
HornClear: "HornClear",
|
|
205
|
+
IntrosEarlyReveal: "IntrosEarlyReveal",
|
|
205
206
|
} as const satisfies Record<CreditSourceTypeEnum, CreditSourceTypeEnum>;
|
|
206
207
|
|
|
207
208
|
export type CreditSourceType = CreditSourceTypeEnum;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-tier promo engine (pure). Decides, per cart line, whether a code applies
|
|
3
|
+
* and how much cash and BashPoints it takes off, with a deterministic fixed-cash
|
|
4
|
+
* allocation that the API persists so refunds and reports reproduce exactly.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
promoCodeAppliesToTier,
|
|
8
|
+
promoCodeDiscountKind,
|
|
9
|
+
type PromoDiscountKind,
|
|
10
|
+
type PromoScopeFields,
|
|
11
|
+
} from "./promoCodeScope.js";
|
|
12
|
+
|
|
13
|
+
export type PromoCartLinePricing = "USD" | "BASHPOINTS" | "FREE";
|
|
14
|
+
|
|
15
|
+
export type PromoCartLine = {
|
|
16
|
+
ticketTierId: string;
|
|
17
|
+
pricing: PromoCartLinePricing;
|
|
18
|
+
quantity: number;
|
|
19
|
+
/** Cash subtotal for the line after special offers, in cents. */
|
|
20
|
+
cashSubtotalCents: number;
|
|
21
|
+
/** BashPoints subtotal for the line. */
|
|
22
|
+
pointsSubtotal: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PromoForCart = PromoScopeFields & {
|
|
26
|
+
discountAppliesToBashPoints?: boolean | null;
|
|
27
|
+
redeemBy?: Date | null;
|
|
28
|
+
disabledAt?: Date | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PromoCartLineResult = {
|
|
32
|
+
ticketTierId: string;
|
|
33
|
+
eligible: boolean;
|
|
34
|
+
cashDiscountCents: number;
|
|
35
|
+
pointsDiscount: number;
|
|
36
|
+
finalCashCents: number;
|
|
37
|
+
finalPoints: number;
|
|
38
|
+
attributedTicketCount: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type PromoCartResult = {
|
|
42
|
+
status: "applied" | "not_applicable" | "expired" | "disabled";
|
|
43
|
+
discountKind: PromoDiscountKind;
|
|
44
|
+
lines: PromoCartLineResult[];
|
|
45
|
+
eligibleTierIds: string[];
|
|
46
|
+
cashDiscountTotalCents: number;
|
|
47
|
+
pointsDiscountTotal: number;
|
|
48
|
+
attributedTicketCount: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function clampPercent(value: number | null | undefined): number {
|
|
52
|
+
const pct = Math.floor(Number(value) || 0);
|
|
53
|
+
return Math.max(0, Math.min(100, pct));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Split `amount` across `weights` in proportion, flooring each share, then give
|
|
58
|
+
* leftover units one at a time to the largest weight first, ties by key ascending.
|
|
59
|
+
*/
|
|
60
|
+
export function allocateProportionally(
|
|
61
|
+
amount: number,
|
|
62
|
+
items: ReadonlyArray<{ key: string; weight: number }>
|
|
63
|
+
): Map<string, number> {
|
|
64
|
+
const result = new Map<string, number>();
|
|
65
|
+
const total = items.reduce((s, i) => s + Math.max(0, i.weight), 0);
|
|
66
|
+
const whole = Math.max(0, Math.floor(amount));
|
|
67
|
+
if (total <= 0 || whole <= 0) {
|
|
68
|
+
for (const i of items) result.set(i.key, 0);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
const capped = Math.min(whole, total);
|
|
72
|
+
let assigned = 0;
|
|
73
|
+
for (const i of items) {
|
|
74
|
+
const share = Math.floor((capped * Math.max(0, i.weight)) / total);
|
|
75
|
+
result.set(i.key, share);
|
|
76
|
+
assigned += share;
|
|
77
|
+
}
|
|
78
|
+
// Fractional parts sum to fewer units than there are lines, so one pass suffices.
|
|
79
|
+
let leftover = capped - assigned;
|
|
80
|
+
const order = [...items]
|
|
81
|
+
.filter((i) => i.weight > 0)
|
|
82
|
+
.sort((a, b) => b.weight - a.weight || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
83
|
+
for (const i of order) {
|
|
84
|
+
if (leftover <= 0) break;
|
|
85
|
+
result.set(i.key, (result.get(i.key) ?? 0) + 1);
|
|
86
|
+
leftover--;
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Per-ticket split of a line amount: equal shares, leftover cents to tickets in id order. */
|
|
92
|
+
export function allocateAcrossTickets(
|
|
93
|
+
lineAmount: number,
|
|
94
|
+
ticketIds: readonly string[]
|
|
95
|
+
): Map<string, number> {
|
|
96
|
+
const sorted = [...ticketIds].sort();
|
|
97
|
+
const result = new Map<string, number>();
|
|
98
|
+
const amount = Math.max(0, Math.floor(lineAmount));
|
|
99
|
+
if (sorted.length === 0) return result;
|
|
100
|
+
const base = Math.floor(amount / sorted.length);
|
|
101
|
+
let leftover = amount - base * sorted.length;
|
|
102
|
+
for (const id of sorted) {
|
|
103
|
+
result.set(id, base + (leftover > 0 ? 1 : 0));
|
|
104
|
+
if (leftover > 0) leftover--;
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function resolvePromoForCart(args: {
|
|
110
|
+
promo: PromoForCart;
|
|
111
|
+
cartLines: readonly PromoCartLine[];
|
|
112
|
+
now: Date;
|
|
113
|
+
}): PromoCartResult {
|
|
114
|
+
const { promo, cartLines, now } = args;
|
|
115
|
+
const kind = promoCodeDiscountKind(promo);
|
|
116
|
+
const empty = (status: PromoCartResult["status"]): PromoCartResult => ({
|
|
117
|
+
status,
|
|
118
|
+
discountKind: kind,
|
|
119
|
+
lines: cartLines.map((l) => ({
|
|
120
|
+
ticketTierId: l.ticketTierId,
|
|
121
|
+
eligible: false,
|
|
122
|
+
cashDiscountCents: 0,
|
|
123
|
+
pointsDiscount: 0,
|
|
124
|
+
finalCashCents: Math.max(0, l.cashSubtotalCents),
|
|
125
|
+
finalPoints: Math.max(0, l.pointsSubtotal),
|
|
126
|
+
attributedTicketCount: 0,
|
|
127
|
+
})),
|
|
128
|
+
eligibleTierIds: [],
|
|
129
|
+
cashDiscountTotalCents: 0,
|
|
130
|
+
pointsDiscountTotal: 0,
|
|
131
|
+
attributedTicketCount: 0,
|
|
132
|
+
});
|
|
133
|
+
if (promo.disabledAt) return empty("disabled");
|
|
134
|
+
if (promo.redeemBy && promo.redeemBy.getTime() < now.getTime()) return empty("expired");
|
|
135
|
+
|
|
136
|
+
const eligibleSet = new Set(
|
|
137
|
+
cartLines.filter((l) => promoCodeAppliesToTier(promo, l.ticketTierId)).map((l) => l.ticketTierId)
|
|
138
|
+
);
|
|
139
|
+
if (eligibleSet.size === 0) return empty("not_applicable");
|
|
140
|
+
|
|
141
|
+
const pct = clampPercent(promo.discountAmountPercentage);
|
|
142
|
+
const fixed = Math.max(0, Math.floor(promo.discountAmountInCents ?? 0));
|
|
143
|
+
const pointsAllowed = promo.discountAppliesToBashPoints === true;
|
|
144
|
+
|
|
145
|
+
const fixedAllocation =
|
|
146
|
+
kind === "FIXED_CASH"
|
|
147
|
+
? allocateProportionally(
|
|
148
|
+
fixed,
|
|
149
|
+
cartLines
|
|
150
|
+
.filter((l) => eligibleSet.has(l.ticketTierId) && l.pricing === "USD")
|
|
151
|
+
.map((l) => ({ key: l.ticketTierId, weight: Math.max(0, l.cashSubtotalCents) }))
|
|
152
|
+
)
|
|
153
|
+
: new Map<string, number>();
|
|
154
|
+
|
|
155
|
+
const lines = cartLines.map((l): PromoCartLineResult => {
|
|
156
|
+
const eligible = eligibleSet.has(l.ticketTierId);
|
|
157
|
+
const cash = Math.max(0, l.cashSubtotalCents);
|
|
158
|
+
const points = Math.max(0, l.pointsSubtotal);
|
|
159
|
+
let cashDiscount = 0;
|
|
160
|
+
let pointsDiscount = 0;
|
|
161
|
+
if (eligible) {
|
|
162
|
+
if (kind === "PERCENT") {
|
|
163
|
+
if (l.pricing === "USD") cashDiscount = Math.floor((cash * pct) / 100);
|
|
164
|
+
if (l.pricing === "BASHPOINTS" && pointsAllowed) {
|
|
165
|
+
pointsDiscount = Math.floor((points * pct) / 100);
|
|
166
|
+
}
|
|
167
|
+
} else if (kind === "FIXED_CASH" && l.pricing === "USD") {
|
|
168
|
+
cashDiscount = fixedAllocation.get(l.ticketTierId) ?? 0;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
cashDiscount = Math.min(cashDiscount, cash);
|
|
172
|
+
pointsDiscount = Math.min(pointsDiscount, points);
|
|
173
|
+
return {
|
|
174
|
+
ticketTierId: l.ticketTierId,
|
|
175
|
+
eligible,
|
|
176
|
+
cashDiscountCents: cashDiscount,
|
|
177
|
+
pointsDiscount,
|
|
178
|
+
finalCashCents: cash - cashDiscount,
|
|
179
|
+
finalPoints: points - pointsDiscount,
|
|
180
|
+
attributedTicketCount: eligible ? Math.max(0, l.quantity) : 0,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
status: "applied",
|
|
186
|
+
discountKind: kind,
|
|
187
|
+
lines,
|
|
188
|
+
eligibleTierIds: [...eligibleSet],
|
|
189
|
+
cashDiscountTotalCents: lines.reduce((s, l) => s + l.cashDiscountCents, 0),
|
|
190
|
+
pointsDiscountTotal: lines.reduce((s, l) => s + l.pointsDiscount, 0),
|
|
191
|
+
attributedTicketCount: lines.reduce((s, l) => s + l.attributedTicketCount, 0),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promo code redemption rules shared by the API and the app.
|
|
3
|
+
* The API owns the ledger; these helpers are pure.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const PROMO_CODE_REJECT_REASONS = [
|
|
7
|
+
"expired",
|
|
8
|
+
"exhausted",
|
|
9
|
+
"already_used",
|
|
10
|
+
"disabled",
|
|
11
|
+
"invalid",
|
|
12
|
+
"host_review_required",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type PromoCodeRejectReason = (typeof PROMO_CODE_REJECT_REASONS)[number];
|
|
16
|
+
|
|
17
|
+
/** Guest-facing copy. Plain and specific: this is about money. */
|
|
18
|
+
export const PROMO_CODE_REJECT_MESSAGES: Record<PromoCodeRejectReason, string> = {
|
|
19
|
+
expired: "This code has expired.",
|
|
20
|
+
exhausted: "This code has reached its order limit.",
|
|
21
|
+
already_used: "You've already used this code as many times as it allows.",
|
|
22
|
+
disabled: "This code is no longer active.",
|
|
23
|
+
invalid: "We couldn't find that code for this bash.",
|
|
24
|
+
host_review_required: "This code needs the host to review it.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function isPromoCodeRejectReason(value: unknown): value is PromoCodeRejectReason {
|
|
28
|
+
return (
|
|
29
|
+
typeof value === "string" &&
|
|
30
|
+
(PROMO_CODE_REJECT_REASONS as readonly string[]).includes(value)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type PromoRedeemabilityInput = {
|
|
35
|
+
redeemBy: Date | null | undefined;
|
|
36
|
+
disabledAt?: Date | null;
|
|
37
|
+
maxRedemptions: number | null | undefined;
|
|
38
|
+
maxUsesPerAccount?: number | null;
|
|
39
|
+
/** Consumed plus unexpired Reserved orders for the code. */
|
|
40
|
+
activeOrderCount: number;
|
|
41
|
+
/** Same count for the buyer's account (user id, else normalized email). */
|
|
42
|
+
accountOrderCount: number;
|
|
43
|
+
now: Date;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** First failing rule, or null when the code can be used for one more order. */
|
|
47
|
+
export function evaluatePromoRedeemability(
|
|
48
|
+
input: PromoRedeemabilityInput
|
|
49
|
+
): PromoCodeRejectReason | null {
|
|
50
|
+
if (input.disabledAt) return "disabled";
|
|
51
|
+
if (input.redeemBy && input.redeemBy.getTime() < input.now.getTime()) {
|
|
52
|
+
return "expired";
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
input.maxRedemptions != null &&
|
|
56
|
+
input.maxRedemptions > 0 &&
|
|
57
|
+
input.activeOrderCount >= input.maxRedemptions
|
|
58
|
+
) {
|
|
59
|
+
return "exhausted";
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
input.maxUsesPerAccount != null &&
|
|
63
|
+
input.maxUsesPerAccount > 0 &&
|
|
64
|
+
input.accountOrderCount >= input.maxUsesPerAccount
|
|
65
|
+
) {
|
|
66
|
+
return "already_used";
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type PromoCodeDuplicateCandidate = {
|
|
72
|
+
id: string;
|
|
73
|
+
ticketTierId: string | null;
|
|
74
|
+
promoterId: string | null;
|
|
75
|
+
discountAmountInCents: number | null;
|
|
76
|
+
discountAmountPercentage: number | null;
|
|
77
|
+
redeemBy: Date | null;
|
|
78
|
+
maxRedemptions: number | null;
|
|
79
|
+
bashPointsRewardPerTicket: number | null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function duplicateSignature(row: PromoCodeDuplicateCandidate): string {
|
|
83
|
+
return JSON.stringify([
|
|
84
|
+
row.promoterId ?? null,
|
|
85
|
+
row.discountAmountInCents ?? null,
|
|
86
|
+
row.discountAmountPercentage ?? null,
|
|
87
|
+
row.redeemBy ? new Date(row.redeemBy).getTime() : null,
|
|
88
|
+
row.maxRedemptions ?? null,
|
|
89
|
+
row.bashPointsRewardPerTicket ?? null,
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Legacy codes are unique per tier, so one bash can hold several rows with the
|
|
95
|
+
* same code. Identical rows resolve to the oldest id, preferring a row whose tier
|
|
96
|
+
* is in the cart. Rows that differ need the host to review them.
|
|
97
|
+
*/
|
|
98
|
+
export function pickPromoCodeAmongDuplicates<T extends PromoCodeDuplicateCandidate>(
|
|
99
|
+
rows: readonly T[],
|
|
100
|
+
cartTierIds?: readonly string[]
|
|
101
|
+
): { row: T } | { reason: "host_review_required" } {
|
|
102
|
+
if (rows.length === 0) {
|
|
103
|
+
throw new Error("pickPromoCodeAmongDuplicates needs at least one row");
|
|
104
|
+
}
|
|
105
|
+
if (rows.length === 1) return { row: rows[0] };
|
|
106
|
+
const signature = duplicateSignature(rows[0]);
|
|
107
|
+
if (rows.some((r) => duplicateSignature(r) !== signature)) {
|
|
108
|
+
return { reason: "host_review_required" };
|
|
109
|
+
}
|
|
110
|
+
const inCart = cartTierIds?.length
|
|
111
|
+
? rows.filter((r) => r.ticketTierId && cartTierIds.includes(r.ticketTierId))
|
|
112
|
+
: [];
|
|
113
|
+
const pool = inCart.length ? inCart : rows;
|
|
114
|
+
const oldest = [...pool].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0];
|
|
115
|
+
return { row: oldest };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function normalizePromoRedemptionEmail(
|
|
119
|
+
email: string | null | undefined
|
|
120
|
+
): string | null {
|
|
121
|
+
const trimmed = email?.trim().toLowerCase() ?? "";
|
|
122
|
+
return trimmed ? trimmed : null;
|
|
123
|
+
}
|