@bash-app/bash-common 30.225.0 → 30.226.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__/agePolicy.test.d.ts +2 -0
- package/dist/__tests__/agePolicy.test.d.ts.map +1 -0
- package/dist/__tests__/agePolicy.test.js +203 -0
- package/dist/__tests__/agePolicy.test.js.map +1 -0
- package/dist/__tests__/getDisplayGender.test.d.ts +2 -0
- package/dist/__tests__/getDisplayGender.test.d.ts.map +1 -0
- package/dist/__tests__/getDisplayGender.test.js +34 -0
- package/dist/__tests__/getDisplayGender.test.js.map +1 -0
- package/dist/__tests__/statusEnums.test.d.ts +2 -0
- package/dist/__tests__/statusEnums.test.d.ts.map +1 -0
- package/dist/__tests__/statusEnums.test.js +24 -0
- package/dist/__tests__/statusEnums.test.js.map +1 -0
- package/dist/aiApproval.d.ts +64 -0
- package/dist/aiApproval.d.ts.map +1 -0
- package/dist/aiApproval.js +54 -0
- package/dist/aiApproval.js.map +1 -0
- package/dist/definitions.d.ts +44 -2
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +106 -2
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +237 -104
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +12 -2
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/legalTemplates.d.ts +1 -1
- package/dist/legalTemplates.js +1 -1
- package/dist/utils/__tests__/badgeUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/badgeUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/badgeUtils.test.js +157 -0
- package/dist/utils/__tests__/badgeUtils.test.js.map +1 -0
- package/dist/utils/__tests__/bashPointsPaymentUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/bashPointsPaymentUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/bashPointsPaymentUtils.test.js +114 -0
- package/dist/utils/__tests__/bashPointsPaymentUtils.test.js.map +1 -0
- package/dist/utils/__tests__/contentFilterUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/contentFilterUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/contentFilterUtils.test.js +443 -0
- package/dist/utils/__tests__/contentFilterUtils.test.js.map +1 -0
- package/dist/utils/__tests__/displayDateUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/displayDateUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/displayDateUtils.test.js +105 -0
- package/dist/utils/__tests__/displayDateUtils.test.js.map +1 -0
- package/dist/utils/__tests__/getBackendHost.test.d.ts +2 -0
- package/dist/utils/__tests__/getBackendHost.test.d.ts.map +1 -0
- package/dist/utils/__tests__/getBackendHost.test.js +104 -0
- package/dist/utils/__tests__/getBackendHost.test.js.map +1 -0
- package/dist/utils/__tests__/getFrontendHost.test.d.ts +2 -0
- package/dist/utils/__tests__/getFrontendHost.test.d.ts.map +1 -0
- package/dist/utils/__tests__/getFrontendHost.test.js +207 -0
- package/dist/utils/__tests__/getFrontendHost.test.js.map +1 -0
- package/dist/utils/__tests__/promoCodesUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/promoCodesUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/promoCodesUtils.test.js +183 -0
- package/dist/utils/__tests__/promoCodesUtils.test.js.map +1 -0
- package/dist/utils/__tests__/qrCodeUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/qrCodeUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/qrCodeUtils.test.js +99 -0
- package/dist/utils/__tests__/qrCodeUtils.test.js.map +1 -0
- package/dist/utils/__tests__/stripeAccountUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/stripeAccountUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/stripeAccountUtils.test.js +75 -0
- package/dist/utils/__tests__/stripeAccountUtils.test.js.map +1 -0
- package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.d.ts +2 -0
- package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.d.ts.map +1 -0
- package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.js +233 -0
- package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.js.map +1 -0
- package/dist/utils/blog/blogCommentDbUtils.d.ts +161 -0
- package/dist/utils/blog/blogCommentDbUtils.d.ts.map +1 -0
- package/dist/utils/blog/blogCommentDbUtils.js +111 -0
- package/dist/utils/blog/blogCommentDbUtils.js.map +1 -0
- package/dist/utils/blog/blogDbUtils.d.ts +10 -0
- package/dist/utils/blog/blogDbUtils.d.ts.map +1 -1
- package/dist/utils/blog/blogDbUtils.js +71 -0
- package/dist/utils/blog/blogDbUtils.js.map +1 -1
- package/dist/utils/urlUtils.d.ts +10 -0
- package/dist/utils/urlUtils.d.ts.map +1 -1
- package/dist/utils/urlUtils.js +24 -5
- package/dist/utils/urlUtils.js.map +1 -1
- package/dist/utils/userUtils.d.ts +32 -0
- package/dist/utils/userUtils.d.ts.map +1 -1
- package/dist/utils/userUtils.js +27 -0
- package/dist/utils/userUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +927 -845
- package/src/__tests__/agePolicy.test.ts +257 -0
- package/src/__tests__/getDisplayGender.test.ts +50 -0
- package/src/__tests__/statusEnums.test.ts +36 -0
- package/src/definitions.ts +138 -1
- package/src/extendedSchemas.ts +41 -2
- package/src/index.ts +1 -0
- package/src/legalTemplates.ts +1 -1
- package/src/utils/__tests__/badgeUtils.test.ts +191 -0
- package/src/utils/__tests__/bashPointsPaymentUtils.test.ts +153 -0
- package/src/utils/__tests__/contentFilterUtils.test.ts +547 -0
- package/src/utils/__tests__/displayDateUtils.test.ts +113 -0
- package/src/utils/__tests__/getBackendHost.test.ts +128 -0
- package/src/utils/__tests__/getFrontendHost.test.ts +232 -0
- package/src/utils/__tests__/promoCodesUtils.test.ts +237 -0
- package/src/utils/__tests__/qrCodeUtils.test.ts +143 -0
- package/src/utils/__tests__/stripeAccountUtils.test.ts +93 -0
- package/src/utils/blog/__tests__/getRelatedBlogPosts.test.ts +271 -0
- package/src/utils/blog/blogCommentDbUtils.ts +161 -0
- package/src/utils/blog/blogDbUtils.ts +95 -1
- package/src/utils/urlUtils.ts +27 -5
- package/src/utils/userUtils.ts +40 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgePolicy helpers — unit tests.
|
|
3
|
+
*
|
|
4
|
+
* `getAgeFromDob` and `meetsAgePolicy` are the source of truth for both:
|
|
5
|
+
* - the `GET /me/id-status` endpoint (which returns `verifiedAge`)
|
|
6
|
+
* - the checkout `IdRequirementGate` (which gates buy-button on min age)
|
|
7
|
+
*
|
|
8
|
+
* They must handle: missing input, invalid strings, the day-before-birthday
|
|
9
|
+
* edge case, and leap-day birthdays without throwing or off-by-one errors.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
AgePolicy,
|
|
13
|
+
AgePolicyLabel,
|
|
14
|
+
AgePolicyMinimumAge,
|
|
15
|
+
agePolicyToFilterLabel,
|
|
16
|
+
eventMatchesAgeFilter,
|
|
17
|
+
getAgeFromDob,
|
|
18
|
+
legacyAgeRangeMatchesFilter,
|
|
19
|
+
meetsAgePolicy,
|
|
20
|
+
} from "../definitions";
|
|
21
|
+
|
|
22
|
+
describe("AgePolicyLabel", () => {
|
|
23
|
+
it("maps every enum value to a non-empty display label", () => {
|
|
24
|
+
for (const policy of Object.values(AgePolicy)) {
|
|
25
|
+
expect(typeof AgePolicyLabel[policy]).toBe("string");
|
|
26
|
+
expect(AgePolicyLabel[policy].length).toBeGreaterThan(0);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("uses the marketing-friendly +/All Ages copy", () => {
|
|
31
|
+
expect(AgePolicyLabel[AgePolicy.AllAges]).toBe("All Ages");
|
|
32
|
+
expect(AgePolicyLabel[AgePolicy.EighteenPlus]).toBe("18+");
|
|
33
|
+
expect(AgePolicyLabel[AgePolicy.TwentyOnePlus]).toBe("21+");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("AgePolicyMinimumAge", () => {
|
|
38
|
+
it("returns null for unrestricted events", () => {
|
|
39
|
+
expect(AgePolicyMinimumAge[AgePolicy.AllAges]).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns the explicit numeric minimum for restricted events", () => {
|
|
43
|
+
expect(AgePolicyMinimumAge[AgePolicy.EighteenPlus]).toBe(18);
|
|
44
|
+
expect(AgePolicyMinimumAge[AgePolicy.TwentyOnePlus]).toBe(21);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("getAgeFromDob", () => {
|
|
49
|
+
/** Build an ISO date string offset by a number of years, months, and days from now. */
|
|
50
|
+
const isoFromOffset = (years: number, months = 0, days = 0): string => {
|
|
51
|
+
const d = new Date();
|
|
52
|
+
d.setFullYear(d.getFullYear() - years);
|
|
53
|
+
d.setMonth(d.getMonth() - months);
|
|
54
|
+
d.setDate(d.getDate() - days);
|
|
55
|
+
return d.toISOString();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
it("returns null for null / undefined / empty input", () => {
|
|
59
|
+
expect(getAgeFromDob(null)).toBeNull();
|
|
60
|
+
expect(getAgeFromDob(undefined)).toBeNull();
|
|
61
|
+
expect(getAgeFromDob("")).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns null when the date string cannot be parsed", () => {
|
|
65
|
+
expect(getAgeFromDob("not-a-date")).toBeNull();
|
|
66
|
+
expect(getAgeFromDob("2025-13-99")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("computes whole-year age for a clearly-past birthday", () => {
|
|
70
|
+
// Born 25 years ago, 1 month ago — birthday already passed this year.
|
|
71
|
+
expect(getAgeFromDob(isoFromOffset(25, 1))).toBe(25);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("subtracts a year when birthday hasn't occurred yet this year", () => {
|
|
75
|
+
// Born 25 years ago + 1 month from now (birthday is next month).
|
|
76
|
+
const d = new Date();
|
|
77
|
+
d.setFullYear(d.getFullYear() - 25);
|
|
78
|
+
d.setMonth(d.getMonth() + 1);
|
|
79
|
+
expect(getAgeFromDob(d.toISOString())).toBe(24);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("treats today as 'birthday already happened' (not subtracting a year)", () => {
|
|
83
|
+
// Born exactly 30 years ago today.
|
|
84
|
+
const d = new Date();
|
|
85
|
+
d.setFullYear(d.getFullYear() - 30);
|
|
86
|
+
expect(getAgeFromDob(d.toISOString())).toBe(30);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("treats tomorrow's birthday as still being last year's age", () => {
|
|
90
|
+
// Born 30 years ago - 1 day (so birthday is tomorrow).
|
|
91
|
+
const d = new Date();
|
|
92
|
+
d.setFullYear(d.getFullYear() - 30);
|
|
93
|
+
d.setDate(d.getDate() + 1);
|
|
94
|
+
expect(getAgeFromDob(d.toISOString())).toBe(29);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("accepts a Date instance, not just a string", () => {
|
|
98
|
+
const d = new Date();
|
|
99
|
+
d.setFullYear(d.getFullYear() - 40);
|
|
100
|
+
d.setMonth(d.getMonth() - 2);
|
|
101
|
+
expect(getAgeFromDob(d)).toBe(40);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns 0 for a baby born earlier this year", () => {
|
|
105
|
+
// Born ~3 months ago — under 1 year old.
|
|
106
|
+
expect(getAgeFromDob(isoFromOffset(0, 3))).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("meetsAgePolicy", () => {
|
|
111
|
+
const yearsAgo = (n: number): string => {
|
|
112
|
+
const d = new Date();
|
|
113
|
+
d.setFullYear(d.getFullYear() - n);
|
|
114
|
+
// back off a day to guarantee the birthday has occurred this year
|
|
115
|
+
d.setDate(d.getDate() - 1);
|
|
116
|
+
return d.toISOString();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
it("All Ages always passes — even with no DOB", () => {
|
|
120
|
+
expect(meetsAgePolicy(AgePolicy.AllAges, null)).toBe(true);
|
|
121
|
+
expect(meetsAgePolicy(AgePolicy.AllAges, undefined)).toBe(true);
|
|
122
|
+
expect(meetsAgePolicy(AgePolicy.AllAges, yearsAgo(5))).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("denies restricted policies when DOB is missing", () => {
|
|
126
|
+
expect(meetsAgePolicy(AgePolicy.EighteenPlus, null)).toBe(false);
|
|
127
|
+
expect(meetsAgePolicy(AgePolicy.TwentyOnePlus, undefined)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("18+ allows ages >= 18 and denies < 18", () => {
|
|
131
|
+
expect(meetsAgePolicy(AgePolicy.EighteenPlus, yearsAgo(17))).toBe(false);
|
|
132
|
+
expect(meetsAgePolicy(AgePolicy.EighteenPlus, yearsAgo(18))).toBe(true);
|
|
133
|
+
expect(meetsAgePolicy(AgePolicy.EighteenPlus, yearsAgo(40))).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("21+ allows ages >= 21 and denies under-21 (including 18-, 20-year-olds)", () => {
|
|
137
|
+
expect(meetsAgePolicy(AgePolicy.TwentyOnePlus, yearsAgo(20))).toBe(false);
|
|
138
|
+
expect(meetsAgePolicy(AgePolicy.TwentyOnePlus, yearsAgo(21))).toBe(true);
|
|
139
|
+
expect(meetsAgePolicy(AgePolicy.TwentyOnePlus, yearsAgo(50))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("denies on invalid DOB strings (defensive fail-closed)", () => {
|
|
143
|
+
expect(meetsAgePolicy(AgePolicy.EighteenPlus, "not-a-date")).toBe(false);
|
|
144
|
+
expect(meetsAgePolicy(AgePolicy.TwentyOnePlus, "")).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("agePolicyToFilterLabel", () => {
|
|
149
|
+
it("maps each enum to its discover-filter chip label", () => {
|
|
150
|
+
expect(agePolicyToFilterLabel(AgePolicy.AllAges)).toBe("All Ages");
|
|
151
|
+
expect(agePolicyToFilterLabel(AgePolicy.EighteenPlus)).toBe("18+");
|
|
152
|
+
expect(agePolicyToFilterLabel(AgePolicy.TwentyOnePlus)).toBe("21+");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("treats null / undefined (legacy events) as All Ages so they don't disappear from filters", () => {
|
|
156
|
+
expect(agePolicyToFilterLabel(null)).toBe("All Ages");
|
|
157
|
+
expect(agePolicyToFilterLabel(undefined)).toBe("All Ages");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("eventMatchesAgeFilter", () => {
|
|
162
|
+
it("matches everything when no labels are selected (no-op filter)", () => {
|
|
163
|
+
expect(eventMatchesAgeFilter(AgePolicy.AllAges, [])).toBe(true);
|
|
164
|
+
expect(eventMatchesAgeFilter(AgePolicy.TwentyOnePlus, [])).toBe(true);
|
|
165
|
+
expect(eventMatchesAgeFilter(null, [])).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("matches when the event's policy is in the selected labels", () => {
|
|
169
|
+
expect(eventMatchesAgeFilter(AgePolicy.EighteenPlus, ["18+"])).toBe(true);
|
|
170
|
+
expect(
|
|
171
|
+
eventMatchesAgeFilter(AgePolicy.TwentyOnePlus, ["21+", "All Ages"])
|
|
172
|
+
).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("does not match when the event's policy is excluded by the selection", () => {
|
|
176
|
+
expect(eventMatchesAgeFilter(AgePolicy.EighteenPlus, ["21+"])).toBe(false);
|
|
177
|
+
expect(eventMatchesAgeFilter(AgePolicy.AllAges, ["18+", "21+"])).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("treats legacy 'Everyone' label as All Ages (back-compat with old saved filters)", () => {
|
|
181
|
+
expect(eventMatchesAgeFilter(AgePolicy.AllAges, ["Everyone"])).toBe(true);
|
|
182
|
+
expect(eventMatchesAgeFilter(AgePolicy.EighteenPlus, ["Everyone"])).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("treats legacy events without an agePolicy as All Ages", () => {
|
|
186
|
+
expect(eventMatchesAgeFilter(null, ["All Ages"])).toBe(true);
|
|
187
|
+
expect(eventMatchesAgeFilter(null, ["18+"])).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("legacyAgeRangeMatchesFilter", () => {
|
|
192
|
+
// The legacy `targetAudience.ageRange` field was schema-free, so we have to
|
|
193
|
+
// tolerate strings, arrays, objects, null, and even checkbox-style payloads.
|
|
194
|
+
|
|
195
|
+
it("matches everything when no filter labels are selected (no-op filter)", () => {
|
|
196
|
+
expect(legacyAgeRangeMatchesFilter(null, [])).toBe(true);
|
|
197
|
+
expect(legacyAgeRangeMatchesFilter(["18+"], [])).toBe(true);
|
|
198
|
+
expect(legacyAgeRangeMatchesFilter("anything", [])).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("matches '18+' filter when the legacy blob contains '18+'", () => {
|
|
202
|
+
expect(legacyAgeRangeMatchesFilter(["18+"], ["18+"])).toBe(true);
|
|
203
|
+
expect(legacyAgeRangeMatchesFilter("18+", ["18+"])).toBe(true);
|
|
204
|
+
expect(
|
|
205
|
+
legacyAgeRangeMatchesFilter({ adults: "18+" }, ["18+"])
|
|
206
|
+
).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("matches '21+' filter only when the legacy blob contains '21+'", () => {
|
|
210
|
+
expect(legacyAgeRangeMatchesFilter(["21+"], ["21+"])).toBe(true);
|
|
211
|
+
expect(legacyAgeRangeMatchesFilter("18+", ["21+"])).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("matches All Ages when blob is empty / missing (catches untagged legacy events)", () => {
|
|
215
|
+
expect(legacyAgeRangeMatchesFilter(null, ["All Ages"])).toBe(true);
|
|
216
|
+
expect(legacyAgeRangeMatchesFilter(undefined, ["All Ages"])).toBe(true);
|
|
217
|
+
expect(legacyAgeRangeMatchesFilter("", ["All Ages"])).toBe(true);
|
|
218
|
+
expect(legacyAgeRangeMatchesFilter([], ["All Ages"])).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("matches All Ages when blob explicitly mentions 'All Ages' or 'Family Friendly'", () => {
|
|
222
|
+
expect(
|
|
223
|
+
legacyAgeRangeMatchesFilter(["All Ages"], ["All Ages"])
|
|
224
|
+
).toBe(true);
|
|
225
|
+
expect(
|
|
226
|
+
legacyAgeRangeMatchesFilter(["Family Friendly"], ["All Ages"])
|
|
227
|
+
).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("does NOT match All Ages when blob carries a 21+ or 18+ marker", () => {
|
|
231
|
+
expect(legacyAgeRangeMatchesFilter(["18+"], ["All Ages"])).toBe(false);
|
|
232
|
+
expect(legacyAgeRangeMatchesFilter(["21+"], ["All Ages"])).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("treats legacy 'Everyone' filter label as a synonym for All Ages", () => {
|
|
236
|
+
expect(legacyAgeRangeMatchesFilter(null, ["Everyone"])).toBe(true);
|
|
237
|
+
expect(legacyAgeRangeMatchesFilter(["18+"], ["Everyone"])).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("returns true when ANY selected label matches (multi-select union semantics)", () => {
|
|
241
|
+
// Legacy blob says 21+; user selected both 18+ and 21+ — still a match.
|
|
242
|
+
expect(
|
|
243
|
+
legacyAgeRangeMatchesFilter(["21+"], ["18+", "21+"])
|
|
244
|
+
).toBe(true);
|
|
245
|
+
// Legacy blob says 21+; user selected only 18+ — no match.
|
|
246
|
+
expect(legacyAgeRangeMatchesFilter(["21+"], ["18+"])).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("does not throw on values that JSON.stringify can't handle (defensive)", () => {
|
|
250
|
+
const cyclic: Record<string, unknown> = {};
|
|
251
|
+
cyclic.self = cyclic;
|
|
252
|
+
// Cyclic objects throw inside JSON.stringify; the helper does NOT swallow
|
|
253
|
+
// that error today, so we document the contract: callers must pass
|
|
254
|
+
// serializable values. (The MyBashesPage code path always does.)
|
|
255
|
+
expect(() => legacyAgeRangeMatchesFilter(cyclic, ["18+"])).toThrow();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `getDisplayGender` precedence tests.
|
|
3
|
+
*
|
|
4
|
+
* Pins the contract that:
|
|
5
|
+
* - `genderIdentity` (free-text) **always wins** over the `gender` enum
|
|
6
|
+
* - whitespace-only `genderIdentity` is treated as absent
|
|
7
|
+
* - falsy `gender` (`null`, `undefined`) yields `null`, not the string "null"
|
|
8
|
+
* - the helper never throws on partial input
|
|
9
|
+
*/
|
|
10
|
+
import { Gender } from "@prisma/client";
|
|
11
|
+
import { getDisplayGender } from "../utils/userUtils.js";
|
|
12
|
+
|
|
13
|
+
describe("getDisplayGender", () => {
|
|
14
|
+
it("returns genderIdentity when present, even if gender is also set", () => {
|
|
15
|
+
expect(
|
|
16
|
+
getDisplayGender({
|
|
17
|
+
gender: Gender.Female,
|
|
18
|
+
genderIdentity: "Non-binary",
|
|
19
|
+
}),
|
|
20
|
+
).toBe("Non-binary");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns the gender enum value when genderIdentity is empty/null", () => {
|
|
24
|
+
expect(
|
|
25
|
+
getDisplayGender({ gender: Gender.Male, genderIdentity: null }),
|
|
26
|
+
).toBe("Male");
|
|
27
|
+
expect(
|
|
28
|
+
getDisplayGender({ gender: Gender.Male, genderIdentity: "" }),
|
|
29
|
+
).toBe("Male");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("trims whitespace-only genderIdentity and falls back to gender", () => {
|
|
33
|
+
expect(
|
|
34
|
+
getDisplayGender({ gender: Gender.NoPreference, genderIdentity: " " }),
|
|
35
|
+
).toBe("NoPreference");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null when both fields are missing", () => {
|
|
39
|
+
expect(getDisplayGender({})).toBeNull();
|
|
40
|
+
expect(
|
|
41
|
+
getDisplayGender({ gender: null, genderIdentity: null }),
|
|
42
|
+
).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns the trimmed identity (no surrounding whitespace) when used alone", () => {
|
|
46
|
+
expect(
|
|
47
|
+
getDisplayGender({ genderIdentity: " Genderfluid " }),
|
|
48
|
+
).toBe("Genderfluid");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin the value sets of the three status enums introduced as part of the
|
|
3
|
+
* "stringly-typed status" cleanup. These enums are the contract between
|
|
4
|
+
* - api endpoints (uploadIdApi, serviceApi, adminDashboardSummaryApi)
|
|
5
|
+
* - bash-app UI components (IdRequirementGate, IdVerificationSection,
|
|
6
|
+
* CustomScopeRequestsAdminPage, ServicesRatesAndPricing)
|
|
7
|
+
* - the database (Postgres ENUM columns)
|
|
8
|
+
*
|
|
9
|
+
* Adding/removing/renaming a member without intentionally updating the
|
|
10
|
+
* migration + all callers should fail this test loudly.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
CustomScopeStatus,
|
|
14
|
+
GovIdRetentionPolicy,
|
|
15
|
+
SchoolNameSource,
|
|
16
|
+
} from "@prisma/client";
|
|
17
|
+
|
|
18
|
+
describe("status enum value sets", () => {
|
|
19
|
+
test("GovIdRetentionPolicy", () => {
|
|
20
|
+
expect(Object.values(GovIdRetentionPolicy).sort()).toEqual(
|
|
21
|
+
["Indefinite", "ThirtyDays", "UntilIdExpires"].sort(),
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("SchoolNameSource", () => {
|
|
26
|
+
expect(Object.values(SchoolNameSource).sort()).toEqual(
|
|
27
|
+
["AiInferred", "UserConfirmed"].sort(),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("CustomScopeStatus", () => {
|
|
32
|
+
expect(Object.values(CustomScopeStatus).sort()).toEqual(
|
|
33
|
+
["Approved", "None", "Pending", "Rejected"].sort(),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/definitions.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AgePolicy,
|
|
2
3
|
BashEventType,
|
|
3
4
|
BashPassTier,
|
|
4
5
|
BracketType,
|
|
5
6
|
CompetitionType,
|
|
6
7
|
Contact,
|
|
8
|
+
CustomScopeStatus,
|
|
7
9
|
EntertainmentServiceType,
|
|
10
|
+
GovIdRetentionPolicy,
|
|
8
11
|
JudgingType,
|
|
9
12
|
MembershipTier,
|
|
10
13
|
MusicGenreType,
|
|
11
14
|
Prisma,
|
|
15
|
+
SchoolNameSource,
|
|
12
16
|
ServiceTypes,
|
|
13
17
|
Ticket,
|
|
14
18
|
TicketTier,
|
|
@@ -922,7 +926,140 @@ export const YearsOfExperienceToString: { [key in YearsOfExperience]: string } =
|
|
|
922
926
|
};
|
|
923
927
|
|
|
924
928
|
// Export Prisma enums for use in frontend
|
|
925
|
-
export {
|
|
929
|
+
export {
|
|
930
|
+
AgePolicy,
|
|
931
|
+
BracketType,
|
|
932
|
+
CompetitionType,
|
|
933
|
+
CustomScopeStatus,
|
|
934
|
+
EntertainmentServiceType,
|
|
935
|
+
GovIdRetentionPolicy,
|
|
936
|
+
JudgingType,
|
|
937
|
+
MusicGenreType,
|
|
938
|
+
SchoolNameSource,
|
|
939
|
+
ServiceTypes,
|
|
940
|
+
YearsOfExperience,
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Display labels for `User.govIdRetentionPolicy` (settings UI, ID verification gate).
|
|
945
|
+
*
|
|
946
|
+
* The enum is the source of truth - read sites should never branch on a string literal
|
|
947
|
+
* like `"30days"`; use `GovIdRetentionPolicy.ThirtyDays` instead so a typo can't quietly
|
|
948
|
+
* mismatch on either the API or DB side.
|
|
949
|
+
*/
|
|
950
|
+
export const GovIdRetentionPolicyLabel: Record<GovIdRetentionPolicy, string> = {
|
|
951
|
+
[GovIdRetentionPolicy.ThirtyDays]: "Delete after 30 days",
|
|
952
|
+
[GovIdRetentionPolicy.UntilIdExpires]: "Keep until ID expires",
|
|
953
|
+
[GovIdRetentionPolicy.Indefinite]: "Keep on file (indefinite)",
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Display labels for `Service.customScopeStatus` (admin dashboard, provider pricing UI).
|
|
958
|
+
*/
|
|
959
|
+
export const CustomScopeStatusLabel: Record<CustomScopeStatus, string> = {
|
|
960
|
+
[CustomScopeStatus.None]: "None",
|
|
961
|
+
[CustomScopeStatus.Pending]: "Pending Review",
|
|
962
|
+
[CustomScopeStatus.Approved]: "Approved",
|
|
963
|
+
[CustomScopeStatus.Rejected]: "Rejected",
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
/** Display label for an event's age policy (BashCard pill, filter UI, wizard). */
|
|
967
|
+
export const AgePolicyLabel: Record<AgePolicy, string> = {
|
|
968
|
+
[AgePolicy.AllAges]: "All Ages",
|
|
969
|
+
[AgePolicy.EighteenPlus]: "18+",
|
|
970
|
+
[AgePolicy.TwentyOnePlus]: "21+",
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
/** Minimum age (in years) implied by an event's age policy. `null` for All Ages. */
|
|
974
|
+
export const AgePolicyMinimumAge: Record<AgePolicy, number | null> = {
|
|
975
|
+
[AgePolicy.AllAges]: null,
|
|
976
|
+
[AgePolicy.EighteenPlus]: 18,
|
|
977
|
+
[AgePolicy.TwentyOnePlus]: 21,
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
/** Returns the user's age in whole years, or null if the DOB is missing/invalid. */
|
|
981
|
+
export function getAgeFromDob(dob: Date | string | null | undefined): number | null {
|
|
982
|
+
if (!dob) return null;
|
|
983
|
+
const birth = dob instanceof Date ? dob : new Date(dob);
|
|
984
|
+
if (Number.isNaN(birth.getTime())) return null;
|
|
985
|
+
const now = new Date();
|
|
986
|
+
let age = now.getFullYear() - birth.getFullYear();
|
|
987
|
+
const monthDelta = now.getMonth() - birth.getMonth();
|
|
988
|
+
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < birth.getDate())) {
|
|
989
|
+
age -= 1;
|
|
990
|
+
}
|
|
991
|
+
return age;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Whether the given DOB satisfies the policy's minimum age. AllAges is always true. */
|
|
995
|
+
export function meetsAgePolicy(
|
|
996
|
+
policy: AgePolicy,
|
|
997
|
+
dob: Date | string | null | undefined
|
|
998
|
+
): boolean {
|
|
999
|
+
const min = AgePolicyMinimumAge[policy];
|
|
1000
|
+
if (min === null) return true;
|
|
1001
|
+
const age = getAgeFromDob(dob);
|
|
1002
|
+
if (age === null) return false;
|
|
1003
|
+
return age >= min;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Discover-filter labels (kept human-friendly for chips and modal options). */
|
|
1007
|
+
export type AgePolicyFilterLabel = "All Ages" | "18+" | "21+";
|
|
1008
|
+
|
|
1009
|
+
/** Map an enum value to the discover-filter label. */
|
|
1010
|
+
export function agePolicyToFilterLabel(
|
|
1011
|
+
policy: AgePolicy | null | undefined
|
|
1012
|
+
): AgePolicyFilterLabel {
|
|
1013
|
+
if (policy === AgePolicy.EighteenPlus) return "18+";
|
|
1014
|
+
if (policy === AgePolicy.TwentyOnePlus) return "21+";
|
|
1015
|
+
// Untagged legacy events behave as All Ages so old data doesn't disappear from filters.
|
|
1016
|
+
return "All Ages";
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Whether an event's `agePolicy` matches a set of selected filter labels.
|
|
1021
|
+
* Tolerates legacy "Everyone" copy by treating it as a synonym for "All Ages".
|
|
1022
|
+
*/
|
|
1023
|
+
export function eventMatchesAgeFilter(
|
|
1024
|
+
eventAgePolicy: AgePolicy | null | undefined,
|
|
1025
|
+
selectedLabels: ReadonlyArray<string>
|
|
1026
|
+
): boolean {
|
|
1027
|
+
if (selectedLabels.length === 0) return true;
|
|
1028
|
+
const eventLabel = agePolicyToFilterLabel(eventAgePolicy);
|
|
1029
|
+
return selectedLabels.some(
|
|
1030
|
+
(raw) => (raw === "Everyone" ? "All Ages" : raw) === eventLabel
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Legacy fallback for events without `agePolicy`: scans the unstructured
|
|
1036
|
+
* `targetAudience.ageRange` blob (which historically held arrays of strings,
|
|
1037
|
+
* checkbox payloads, and free-text) for the canonical filter labels so that
|
|
1038
|
+
* old data still appears in the All Ages / 18+ / 21+ filters.
|
|
1039
|
+
*
|
|
1040
|
+
* Matching rules (in priority order):
|
|
1041
|
+
* - "21+" filter matches when the blob contains "21+".
|
|
1042
|
+
* - "18+" filter matches when the blob contains "18+".
|
|
1043
|
+
* - "All Ages" / "Everyone" matches when the blob contains "All Ages" or
|
|
1044
|
+
* "Family Friendly", OR when neither "18+" nor "21+" appears.
|
|
1045
|
+
*/
|
|
1046
|
+
export function legacyAgeRangeMatchesFilter(
|
|
1047
|
+
ageRange: unknown,
|
|
1048
|
+
selectedLabels: ReadonlyArray<string>
|
|
1049
|
+
): boolean {
|
|
1050
|
+
if (selectedLabels.length === 0) return true;
|
|
1051
|
+
const blob = JSON.stringify(ageRange ?? "");
|
|
1052
|
+
return selectedLabels.some((raw) => {
|
|
1053
|
+
const normalized = raw === "Everyone" ? "All Ages" : raw;
|
|
1054
|
+
if (normalized === "18+") return blob.includes("18+");
|
|
1055
|
+
if (normalized === "21+") return blob.includes("21+");
|
|
1056
|
+
return (
|
|
1057
|
+
blob.includes("All Ages") ||
|
|
1058
|
+
blob.includes("Family Friendly") ||
|
|
1059
|
+
(!blob.includes("18+") && !blob.includes("21+"))
|
|
1060
|
+
);
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
926
1063
|
|
|
927
1064
|
export type BashEventTypeToStringType = {
|
|
928
1065
|
[key in BashEventType]: string;
|
package/src/extendedSchemas.ts
CHANGED
|
@@ -143,8 +143,14 @@ export const FRONT_END_USER_DATA_TO_SELECT = {
|
|
|
143
143
|
state: true,
|
|
144
144
|
createdOn: true,
|
|
145
145
|
dob: true,
|
|
146
|
-
gender
|
|
147
|
-
|
|
146
|
+
// Raw `gender`, `genderIdentity`, `sex` are intentionally NOT in the public select.
|
|
147
|
+
// The public profile API exposes only the computed `genderDisplay` (gated by
|
|
148
|
+
// `showGenderOnProfile`). Self-account selects re-add the raw fields below
|
|
149
|
+
// (see PRIVATE_USER_ACCOUNT_TO_SELECT) so the user can edit their own value.
|
|
150
|
+
showAgeOnProfile: true,
|
|
151
|
+
showGenderOnProfile: true,
|
|
152
|
+
showVerifiedBadgesOnProfile: true,
|
|
153
|
+
schoolName: true,
|
|
148
154
|
documentIDId: true,
|
|
149
155
|
// documentID: true,
|
|
150
156
|
/** Set when Stripe Identity verification completes (hosted flow). */
|
|
@@ -186,6 +192,10 @@ export const FRONT_END_USER_DATA_TO_SELECT = {
|
|
|
186
192
|
|
|
187
193
|
export const PRIVATE_USER_ACCOUNT_TO_SELECT = {
|
|
188
194
|
...FRONT_END_USER_DATA_TO_SELECT,
|
|
195
|
+
// Self-only sensitive demographics — re-added so /me can populate edit forms.
|
|
196
|
+
gender: true,
|
|
197
|
+
genderIdentity: true,
|
|
198
|
+
sex: true,
|
|
189
199
|
street: true,
|
|
190
200
|
city: true,
|
|
191
201
|
state: true,
|
|
@@ -282,6 +292,8 @@ export interface BashEventExt extends Omit<
|
|
|
282
292
|
merchandiseItems: BashEventMerchCatalog | null;
|
|
283
293
|
// Prisma relation fields (not included in the flat BashEvent model type)
|
|
284
294
|
coordinates?: Coordinates[];
|
|
295
|
+
/** Linked venue (when bashEvent.venueId is set and `venue` is requested in include params) */
|
|
296
|
+
venue?: Venue | null;
|
|
285
297
|
targetAudience?: TargetAudience;
|
|
286
298
|
amountOfGuests?: AmountOfGuests;
|
|
287
299
|
recurrence?: Recurrence;
|
|
@@ -1125,6 +1137,8 @@ export const INVITATION_DATA_TO_INCLUDE = {
|
|
|
1125
1137
|
export interface InvitationExtraData extends Invitation {
|
|
1126
1138
|
isFreeGuest: boolean;
|
|
1127
1139
|
isOrganizer?: boolean;
|
|
1140
|
+
isCoHost?: boolean;
|
|
1141
|
+
organizerTitle?: string;
|
|
1128
1142
|
isOwnershipTransfer?: boolean;
|
|
1129
1143
|
includeQRCode?: boolean; // Controls whether QR code should be included in the invitation email
|
|
1130
1144
|
}
|
|
@@ -1230,6 +1244,31 @@ export interface UserExt extends User {
|
|
|
1230
1244
|
*/
|
|
1231
1245
|
birthdayDisplay?: string;
|
|
1232
1246
|
|
|
1247
|
+
/**
|
|
1248
|
+
* Computed by public user API when `showAgeOnProfile` is true and `dob` is set.
|
|
1249
|
+
* Example: "32".
|
|
1250
|
+
*/
|
|
1251
|
+
ageDisplay?: string;
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Computed by public user API when `showGenderOnProfile` is true.
|
|
1255
|
+
* Resolves `genderIdentity` (free-text self-describe) ahead of the enum `gender`.
|
|
1256
|
+
*/
|
|
1257
|
+
genderDisplay?: string;
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Computed trust badges. Server gates the entire object on
|
|
1261
|
+
* `showVerifiedBadgesOnProfile` and strips the underlying raw fields
|
|
1262
|
+
* (`paymentVerified`, `stripeAccountId`, `studentIdImageS3Key`, `schoolName`)
|
|
1263
|
+
* from the public response.
|
|
1264
|
+
*/
|
|
1265
|
+
badges?: {
|
|
1266
|
+
idVerified: boolean;
|
|
1267
|
+
paymentVerified: boolean;
|
|
1268
|
+
verifiedHost: boolean;
|
|
1269
|
+
studentAt: string | null;
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1233
1272
|
/** Included when the user query selects the `UserAvailability` relation (not on default Prisma `User`). */
|
|
1234
1273
|
bashAvailability?: BashAvailability | null;
|
|
1235
1274
|
|
package/src/index.ts
CHANGED
|
@@ -293,6 +293,7 @@ export * from "./utils/urlUtils.js";
|
|
|
293
293
|
export * from "./utils/userPromoCodeUtils.js";
|
|
294
294
|
export * from "./utils/userSubscriptionUtils.js";
|
|
295
295
|
export * from "./utils/blog/blogDbUtils.js";
|
|
296
|
+
export * from "./utils/blog/blogCommentDbUtils.js";
|
|
296
297
|
export * from "./utils/blogUtils.js";
|
|
297
298
|
export * from "./utils/entityUtils.js";
|
|
298
299
|
export * from "./utils/generalDateTimeUtils.js";
|
package/src/legalTemplates.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const DEFAULT_PHOTO_RELEASE_TEXT = `By attending this event, you understa
|
|
|
8
8
|
If you do not wish to appear in promotional materials, contact the event organizer before or during the event. For minors, a parent or guardian must agree to this consent.`;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Effective photo release text for display (wizard preview,
|
|
11
|
+
* Effective photo release text for display (wizard preview, bash detail page, checkout).
|
|
12
12
|
*/
|
|
13
13
|
export function getPhotoReleaseDisplayText(
|
|
14
14
|
photoReleaseText: string | null | undefined
|