@bash-app/bash-common 30.433.0 → 30.435.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.
Files changed (111) hide show
  1. package/dist/__tests__/appleIapProducts.test.js +1 -0
  2. package/dist/__tests__/appleIapProducts.test.js.map +1 -1
  3. package/dist/__tests__/promoCodeScope.test.js +9 -2
  4. package/dist/__tests__/promoCodeScope.test.js.map +1 -1
  5. package/dist/__tests__/statusEnums.test.js +1 -0
  6. package/dist/__tests__/statusEnums.test.js.map +1 -1
  7. package/dist/appleIapProducts.d.ts +12 -2
  8. package/dist/appleIapProducts.d.ts.map +1 -1
  9. package/dist/appleIapProducts.js +13 -0
  10. package/dist/appleIapProducts.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/intros/__tests__/introsReleaseGates.test.d.ts +2 -0
  16. package/dist/intros/__tests__/introsReleaseGates.test.d.ts.map +1 -0
  17. package/dist/intros/__tests__/introsReleaseGates.test.js +140 -0
  18. package/dist/intros/__tests__/introsReleaseGates.test.js.map +1 -0
  19. package/dist/intros/__tests__/psychExperiment.test.d.ts +2 -0
  20. package/dist/intros/__tests__/psychExperiment.test.d.ts.map +1 -0
  21. package/dist/intros/__tests__/psychExperiment.test.js +63 -0
  22. package/dist/intros/__tests__/psychExperiment.test.js.map +1 -0
  23. package/dist/intros/__tests__/reasons.test.d.ts +2 -0
  24. package/dist/intros/__tests__/reasons.test.d.ts.map +1 -0
  25. package/dist/intros/__tests__/reasons.test.js +72 -0
  26. package/dist/intros/__tests__/reasons.test.js.map +1 -0
  27. package/dist/intros/__tests__/sharedNotes.test.d.ts +2 -0
  28. package/dist/intros/__tests__/sharedNotes.test.d.ts.map +1 -0
  29. package/dist/intros/__tests__/sharedNotes.test.js +19 -0
  30. package/dist/intros/__tests__/sharedNotes.test.js.map +1 -0
  31. package/dist/intros/__tests__/transitions.test.d.ts +2 -0
  32. package/dist/intros/__tests__/transitions.test.d.ts.map +1 -0
  33. package/dist/intros/__tests__/transitions.test.js +43 -0
  34. package/dist/intros/__tests__/transitions.test.js.map +1 -0
  35. package/dist/intros/__tests__/warmDisclosure.test.d.ts +2 -0
  36. package/dist/intros/__tests__/warmDisclosure.test.d.ts.map +1 -0
  37. package/dist/intros/__tests__/warmDisclosure.test.js +10 -0
  38. package/dist/intros/__tests__/warmDisclosure.test.js.map +1 -0
  39. package/dist/intros/age.d.ts +4 -0
  40. package/dist/intros/age.d.ts.map +1 -0
  41. package/dist/intros/age.js +14 -0
  42. package/dist/intros/age.js.map +1 -0
  43. package/dist/intros/attendance.d.ts +11 -0
  44. package/dist/intros/attendance.d.ts.map +1 -0
  45. package/dist/intros/attendance.js +27 -0
  46. package/dist/intros/attendance.js.map +1 -0
  47. package/dist/intros/attributeCatalog.d.ts +17 -0
  48. package/dist/intros/attributeCatalog.d.ts.map +1 -0
  49. package/dist/intros/attributeCatalog.js +96 -0
  50. package/dist/intros/attributeCatalog.js.map +1 -0
  51. package/dist/intros/index.d.ts +10 -0
  52. package/dist/intros/index.d.ts.map +1 -0
  53. package/dist/intros/index.js +10 -0
  54. package/dist/intros/index.js.map +1 -0
  55. package/dist/intros/intents.d.ts +8 -0
  56. package/dist/intros/intents.d.ts.map +1 -0
  57. package/dist/intros/intents.js +44 -0
  58. package/dist/intros/intents.js.map +1 -0
  59. package/dist/intros/psychExperiment.d.ts +29 -0
  60. package/dist/intros/psychExperiment.d.ts.map +1 -0
  61. package/dist/intros/psychExperiment.js +82 -0
  62. package/dist/intros/psychExperiment.js.map +1 -0
  63. package/dist/intros/reasons.d.ts +38 -0
  64. package/dist/intros/reasons.d.ts.map +1 -0
  65. package/dist/intros/reasons.js +162 -0
  66. package/dist/intros/reasons.js.map +1 -0
  67. package/dist/intros/sharedNotes.d.ts +10 -0
  68. package/dist/intros/sharedNotes.d.ts.map +1 -0
  69. package/dist/intros/sharedNotes.js +56 -0
  70. package/dist/intros/sharedNotes.js.map +1 -0
  71. package/dist/intros/transitions.d.ts +29 -0
  72. package/dist/intros/transitions.d.ts.map +1 -0
  73. package/dist/intros/transitions.js +128 -0
  74. package/dist/intros/transitions.js.map +1 -0
  75. package/dist/intros/validation.d.ts +50 -0
  76. package/dist/intros/validation.d.ts.map +1 -0
  77. package/dist/intros/validation.js +212 -0
  78. package/dist/intros/validation.js.map +1 -0
  79. package/dist/mirroredPrismaEnums.d.ts +1 -0
  80. package/dist/mirroredPrismaEnums.d.ts.map +1 -1
  81. package/dist/mirroredPrismaEnums.js +1 -0
  82. package/dist/mirroredPrismaEnums.js.map +1 -1
  83. package/dist/promoCodeScope.d.ts +7 -1
  84. package/dist/promoCodeScope.d.ts.map +1 -1
  85. package/dist/promoCodeScope.js +13 -4
  86. package/dist/promoCodeScope.js.map +1 -1
  87. package/package.json +1 -1
  88. package/prisma/schema.prisma +321 -2
  89. package/src/__tests__/appleIapProducts.test.ts +3 -0
  90. package/src/__tests__/promoCodeScope.test.ts +16 -3
  91. package/src/__tests__/statusEnums.test.ts +1 -0
  92. package/src/appleIapProducts.ts +29 -2
  93. package/src/index.ts +1 -0
  94. package/src/intros/__tests__/introsReleaseGates.test.ts +181 -0
  95. package/src/intros/__tests__/psychExperiment.test.ts +99 -0
  96. package/src/intros/__tests__/reasons.test.ts +122 -0
  97. package/src/intros/__tests__/sharedNotes.test.ts +20 -0
  98. package/src/intros/__tests__/transitions.test.ts +61 -0
  99. package/src/intros/__tests__/warmDisclosure.test.ts +10 -0
  100. package/src/intros/age.ts +13 -0
  101. package/src/intros/attendance.ts +33 -0
  102. package/src/intros/attributeCatalog.ts +113 -0
  103. package/src/intros/index.ts +9 -0
  104. package/src/intros/intents.ts +49 -0
  105. package/src/intros/psychExperiment.ts +130 -0
  106. package/src/intros/reasons.ts +187 -0
  107. package/src/intros/sharedNotes.ts +59 -0
  108. package/src/intros/transitions.ts +172 -0
  109. package/src/intros/validation.ts +248 -0
  110. package/src/mirroredPrismaEnums.ts +1 -0
  111. package/src/promoCodeScope.ts +20 -4
@@ -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;
@@ -9,6 +9,17 @@ import { getPublicBashDetailPath } from "./utils/slugUtils.js";
9
9
  export type PromoDiscountKind = "NONE" | "PERCENT" | "FIXED_CASH";
10
10
  export type PromoEligibilityMode = "ALL_TIERS" | "SELECTED_TIERS";
11
11
 
12
+ /** How the host pays a promoter for a code. BashPoints is credited here; the others are paid outside Bash. */
13
+ export const PROMO_COMPENSATION_METHODS = ["Venmo", "Zelle", "CashApp", "BashPoints"] as const;
14
+ export type PromoCompensationMethod = (typeof PROMO_COMPENSATION_METHODS)[number];
15
+
16
+ export function isPromoCompensationMethod(value: unknown): value is PromoCompensationMethod {
17
+ return (
18
+ typeof value === "string" &&
19
+ (PROMO_COMPENSATION_METHODS as readonly string[]).includes(value)
20
+ );
21
+ }
22
+
12
23
  export type PromoScopeFields = {
13
24
  ticketTierId?: string | null;
14
25
  bashEventId?: string | null;
@@ -98,7 +109,7 @@ export type PromoterCodeInput = {
98
109
  promoterUserId?: string | null;
99
110
  promoterEmail?: string | null;
100
111
  discountType: PromoDiscountKind;
101
- /** Percent (1-100) for PERCENT, cents for FIXED_CASH, ignored for NONE. */
112
+ /** Percent (0-100) for PERCENT, cents (0 or more) for FIXED_CASH, ignored for NONE. */
102
113
  discountValue?: number | null;
103
114
  eligibilityMode: PromoEligibilityMode;
104
115
  eligibleTierIds?: string[];
@@ -112,6 +123,8 @@ export type PromoterCodeInput = {
112
123
  promoterCommissionBps?: number | null;
113
124
  /** The host opened and confirmed the Promoter reward section. */
114
125
  promoterRewardReviewed?: boolean;
126
+ /** Venmo, Zelle, Cash App, or BashPoints. Null clears a saved choice. */
127
+ compensationMethod?: PromoCompensationMethod | null;
115
128
  };
116
129
 
117
130
  export type PromoterCodeStatusLabel = "active" | "expired" | "exhausted" | "disabled";
@@ -129,12 +142,12 @@ export function promoterCodeInputErrors(
129
142
  }
130
143
  if (input.discountType === "PERCENT") {
131
144
  const pct = Number(input.discountValue);
132
- if (!Number.isInteger(pct) || pct < 1 || pct > 100) {
133
- errors.push("Guest discount must be between 1% and 100%.");
145
+ if (!Number.isInteger(pct) || pct < 0 || pct > 100) {
146
+ errors.push("Guest discount must be between 0% and 100%.");
134
147
  }
135
148
  } else if (input.discountType === "FIXED_CASH") {
136
149
  const cents = Number(input.discountValue);
137
- if (!Number.isInteger(cents) || cents <= 0) errors.push("Enter a fixed discount above $0.");
150
+ if (!Number.isInteger(cents) || cents < 0) errors.push("Enter a fixed discount of $0 or more.");
138
151
  }
139
152
  if (input.eligibilityMode === "SELECTED_TIERS") {
140
153
  const ids = input.eligibleTierIds ?? [];
@@ -160,5 +173,8 @@ export function promoterCodeInputErrors(
160
173
  if (hasPromoter && !input.promoterRewardReviewed) {
161
174
  errors.push("Review the promoter reward before saving.");
162
175
  }
176
+ if (input.compensationMethod != null && !isPromoCompensationMethod(input.compensationMethod)) {
177
+ errors.push("Choose Venmo, Zelle, Cash App, or BashPoints.");
178
+ }
163
179
  return errors;
164
180
  }