@bash-app/bash-common 30.224.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.
Files changed (109) hide show
  1. package/dist/__tests__/agePolicy.test.d.ts +2 -0
  2. package/dist/__tests__/agePolicy.test.d.ts.map +1 -0
  3. package/dist/__tests__/agePolicy.test.js +203 -0
  4. package/dist/__tests__/agePolicy.test.js.map +1 -0
  5. package/dist/__tests__/getDisplayGender.test.d.ts +2 -0
  6. package/dist/__tests__/getDisplayGender.test.d.ts.map +1 -0
  7. package/dist/__tests__/getDisplayGender.test.js +34 -0
  8. package/dist/__tests__/getDisplayGender.test.js.map +1 -0
  9. package/dist/__tests__/statusEnums.test.d.ts +2 -0
  10. package/dist/__tests__/statusEnums.test.d.ts.map +1 -0
  11. package/dist/__tests__/statusEnums.test.js +24 -0
  12. package/dist/__tests__/statusEnums.test.js.map +1 -0
  13. package/dist/aiApproval.d.ts +64 -0
  14. package/dist/aiApproval.d.ts.map +1 -0
  15. package/dist/aiApproval.js +54 -0
  16. package/dist/aiApproval.js.map +1 -0
  17. package/dist/definitions.d.ts +44 -2
  18. package/dist/definitions.d.ts.map +1 -1
  19. package/dist/definitions.js +106 -2
  20. package/dist/definitions.js.map +1 -1
  21. package/dist/extendedSchemas.d.ts +237 -104
  22. package/dist/extendedSchemas.d.ts.map +1 -1
  23. package/dist/extendedSchemas.js +12 -2
  24. package/dist/extendedSchemas.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/legalTemplates.d.ts +1 -1
  30. package/dist/legalTemplates.js +1 -1
  31. package/dist/utils/__tests__/badgeUtils.test.d.ts +2 -0
  32. package/dist/utils/__tests__/badgeUtils.test.d.ts.map +1 -0
  33. package/dist/utils/__tests__/badgeUtils.test.js +157 -0
  34. package/dist/utils/__tests__/badgeUtils.test.js.map +1 -0
  35. package/dist/utils/__tests__/bashPointsPaymentUtils.test.d.ts +2 -0
  36. package/dist/utils/__tests__/bashPointsPaymentUtils.test.d.ts.map +1 -0
  37. package/dist/utils/__tests__/bashPointsPaymentUtils.test.js +114 -0
  38. package/dist/utils/__tests__/bashPointsPaymentUtils.test.js.map +1 -0
  39. package/dist/utils/__tests__/contentFilterUtils.test.d.ts +2 -0
  40. package/dist/utils/__tests__/contentFilterUtils.test.d.ts.map +1 -0
  41. package/dist/utils/__tests__/contentFilterUtils.test.js +443 -0
  42. package/dist/utils/__tests__/contentFilterUtils.test.js.map +1 -0
  43. package/dist/utils/__tests__/displayDateUtils.test.d.ts +2 -0
  44. package/dist/utils/__tests__/displayDateUtils.test.d.ts.map +1 -0
  45. package/dist/utils/__tests__/displayDateUtils.test.js +105 -0
  46. package/dist/utils/__tests__/displayDateUtils.test.js.map +1 -0
  47. package/dist/utils/__tests__/getBackendHost.test.d.ts +2 -0
  48. package/dist/utils/__tests__/getBackendHost.test.d.ts.map +1 -0
  49. package/dist/utils/__tests__/getBackendHost.test.js +104 -0
  50. package/dist/utils/__tests__/getBackendHost.test.js.map +1 -0
  51. package/dist/utils/__tests__/getFrontendHost.test.d.ts +2 -0
  52. package/dist/utils/__tests__/getFrontendHost.test.d.ts.map +1 -0
  53. package/dist/utils/__tests__/getFrontendHost.test.js +207 -0
  54. package/dist/utils/__tests__/getFrontendHost.test.js.map +1 -0
  55. package/dist/utils/__tests__/promoCodesUtils.test.d.ts +2 -0
  56. package/dist/utils/__tests__/promoCodesUtils.test.d.ts.map +1 -0
  57. package/dist/utils/__tests__/promoCodesUtils.test.js +183 -0
  58. package/dist/utils/__tests__/promoCodesUtils.test.js.map +1 -0
  59. package/dist/utils/__tests__/qrCodeUtils.test.d.ts +2 -0
  60. package/dist/utils/__tests__/qrCodeUtils.test.d.ts.map +1 -0
  61. package/dist/utils/__tests__/qrCodeUtils.test.js +99 -0
  62. package/dist/utils/__tests__/qrCodeUtils.test.js.map +1 -0
  63. package/dist/utils/__tests__/stripeAccountUtils.test.d.ts +2 -0
  64. package/dist/utils/__tests__/stripeAccountUtils.test.d.ts.map +1 -0
  65. package/dist/utils/__tests__/stripeAccountUtils.test.js +75 -0
  66. package/dist/utils/__tests__/stripeAccountUtils.test.js.map +1 -0
  67. package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.d.ts +2 -0
  68. package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.d.ts.map +1 -0
  69. package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.js +233 -0
  70. package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.js.map +1 -0
  71. package/dist/utils/blog/blogCommentDbUtils.d.ts +161 -0
  72. package/dist/utils/blog/blogCommentDbUtils.d.ts.map +1 -0
  73. package/dist/utils/blog/blogCommentDbUtils.js +111 -0
  74. package/dist/utils/blog/blogCommentDbUtils.js.map +1 -0
  75. package/dist/utils/blog/blogDbUtils.d.ts +10 -0
  76. package/dist/utils/blog/blogDbUtils.d.ts.map +1 -1
  77. package/dist/utils/blog/blogDbUtils.js +71 -0
  78. package/dist/utils/blog/blogDbUtils.js.map +1 -1
  79. package/dist/utils/urlUtils.d.ts +10 -0
  80. package/dist/utils/urlUtils.d.ts.map +1 -1
  81. package/dist/utils/urlUtils.js +24 -5
  82. package/dist/utils/urlUtils.js.map +1 -1
  83. package/dist/utils/userUtils.d.ts +32 -0
  84. package/dist/utils/userUtils.d.ts.map +1 -1
  85. package/dist/utils/userUtils.js +27 -0
  86. package/dist/utils/userUtils.js.map +1 -1
  87. package/package.json +1 -1
  88. package/prisma/schema.prisma +951 -845
  89. package/src/__tests__/agePolicy.test.ts +257 -0
  90. package/src/__tests__/getDisplayGender.test.ts +50 -0
  91. package/src/__tests__/statusEnums.test.ts +36 -0
  92. package/src/definitions.ts +138 -1
  93. package/src/extendedSchemas.ts +41 -2
  94. package/src/index.ts +1 -0
  95. package/src/legalTemplates.ts +1 -1
  96. package/src/utils/__tests__/badgeUtils.test.ts +191 -0
  97. package/src/utils/__tests__/bashPointsPaymentUtils.test.ts +153 -0
  98. package/src/utils/__tests__/contentFilterUtils.test.ts +547 -0
  99. package/src/utils/__tests__/displayDateUtils.test.ts +113 -0
  100. package/src/utils/__tests__/getBackendHost.test.ts +128 -0
  101. package/src/utils/__tests__/getFrontendHost.test.ts +232 -0
  102. package/src/utils/__tests__/promoCodesUtils.test.ts +237 -0
  103. package/src/utils/__tests__/qrCodeUtils.test.ts +143 -0
  104. package/src/utils/__tests__/stripeAccountUtils.test.ts +93 -0
  105. package/src/utils/blog/__tests__/getRelatedBlogPosts.test.ts +271 -0
  106. package/src/utils/blog/blogCommentDbUtils.ts +161 -0
  107. package/src/utils/blog/blogDbUtils.ts +95 -1
  108. package/src/utils/urlUtils.ts +27 -5
  109. 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
+ });
@@ -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 { YearsOfExperience, EntertainmentServiceType, MusicGenreType, ServiceTypes, CompetitionType, JudgingType, BracketType };
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;
@@ -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: true,
147
- sex: true,
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";
@@ -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, event page, checkout).
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