@bash-app/bash-common 30.429.0 → 30.431.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 (36) hide show
  1. package/dist/__tests__/definitionsHelpers.test.js +1 -0
  2. package/dist/__tests__/definitionsHelpers.test.js.map +1 -1
  3. package/dist/bashFeedTypes.d.ts +3 -0
  4. package/dist/bashFeedTypes.d.ts.map +1 -1
  5. package/dist/definitions.d.ts +1 -0
  6. package/dist/definitions.d.ts.map +1 -1
  7. package/dist/definitions.js +2 -0
  8. package/dist/definitions.js.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/utils/__tests__/feedPostSocialLinks.test.d.ts +2 -0
  14. package/dist/utils/__tests__/feedPostSocialLinks.test.d.ts.map +1 -0
  15. package/dist/utils/__tests__/feedPostSocialLinks.test.js +46 -0
  16. package/dist/utils/__tests__/feedPostSocialLinks.test.js.map +1 -0
  17. package/dist/utils/__tests__/paymentUtils.test.js +12 -1
  18. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  19. package/dist/utils/feedPostSocialLinks.d.ts +26 -0
  20. package/dist/utils/feedPostSocialLinks.d.ts.map +1 -0
  21. package/dist/utils/feedPostSocialLinks.js +159 -0
  22. package/dist/utils/feedPostSocialLinks.js.map +1 -0
  23. package/dist/utils/paymentUtils.d.ts +2 -0
  24. package/dist/utils/paymentUtils.d.ts.map +1 -1
  25. package/dist/utils/paymentUtils.js +16 -3
  26. package/dist/utils/paymentUtils.js.map +1 -1
  27. package/package.json +1 -1
  28. package/prisma/schema.prisma +30 -2
  29. package/src/__tests__/definitionsHelpers.test.ts +1 -0
  30. package/src/bashFeedTypes.ts +4 -0
  31. package/src/definitions.ts +3 -0
  32. package/src/index.ts +1 -0
  33. package/src/utils/__tests__/feedPostSocialLinks.test.ts +62 -0
  34. package/src/utils/__tests__/paymentUtils.test.ts +13 -0
  35. package/src/utils/feedPostSocialLinks.ts +174 -0
  36. package/src/utils/paymentUtils.ts +21 -3
@@ -4,6 +4,8 @@ import type {
4
4
  Privacy,
5
5
  } from "@prisma/client";
6
6
 
7
+ import type { FeedPostSocialLink } from "./utils/feedPostSocialLinks.js";
8
+
7
9
  /**
8
10
  * Bash snippet on a feed post (matches typical `linkedBashInclude` / shaping output).
9
11
  * `activeOffers` replaces raw `ticketTiers` after API media-tree shaping.
@@ -122,6 +124,7 @@ export interface BashFeedDraftView {
122
124
  mediaIds: string[];
123
125
  mediaUrls: string[];
124
126
  isAnonymous: boolean;
127
+ socialLinks?: FeedPostSocialLink[] | null;
125
128
  bash: { id: string; title: string | null; coverPhoto: string | null } | null;
126
129
  service: { id: string; serviceName: string | null; coverPhoto: string | null } | null;
127
130
  }
@@ -213,6 +216,7 @@ export interface BashFeedPostData {
213
216
  content: string | null;
214
217
  mediaIds: string[];
215
218
  mediaUrls?: string[];
219
+ socialLinks?: FeedPostSocialLink[] | null;
216
220
  isFeatured?: boolean;
217
221
  isAnonymous?: boolean;
218
222
  createdAt: string;
@@ -835,6 +835,7 @@ export type FilterFields = {
835
835
  incomeRange: string[];
836
836
  hasOpenTasks: boolean; // Boolean filter for events with open volunteer tasks
837
837
  acceptsBashPoints: boolean; // Boolean filter for events with BashPoints-priced tickets
838
+ acceptsBashPass: boolean; // Boolean filter for events with a BashPass-eligible ticket
838
839
  ambashadorOnly: boolean; // Boolean filter for events created by Ambashadors
839
840
  };
840
841
 
@@ -854,6 +855,7 @@ export const EMPTY_FILTER_FIELDS: FilterFields = {
854
855
  incomeRange: [],
855
856
  hasOpenTasks: false,
856
857
  acceptsBashPoints: false,
858
+ acceptsBashPass: false,
857
859
  ambashadorOnly: false,
858
860
  };
859
861
 
@@ -874,6 +876,7 @@ export function createEmptyFilterFields(): FilterFields {
874
876
  incomeRange: [],
875
877
  hasOpenTasks: false,
876
878
  acceptsBashPoints: false,
879
+ acceptsBashPass: false,
877
880
  ambashadorOnly: false,
878
881
  };
879
882
  }
package/src/index.ts CHANGED
@@ -394,6 +394,7 @@ export * from "./utils/discountEngine/bestPriceResolver.js";
394
394
  export * from "./utils/discountEngine/eligibilityValidator.js";
395
395
  export * from "./utils/nonprofitUtils.js";
396
396
  export * from "./utils/objUtils.js";
397
+ export * from "./utils/feedPostSocialLinks.js";
397
398
  export * from "./utils/paymentUtils.js";
398
399
  export * from "./utils/promoCodesUtils.js";
399
400
  export * from "./utils/qrCodeUtils.js";
@@ -0,0 +1,62 @@
1
+ import {
2
+ parseFeedPostSocialLinks,
3
+ providerLinksFromOrganization,
4
+ withoutPosterSocialLinks,
5
+ } from "../feedPostSocialLinks.js";
6
+
7
+ describe("parseFeedPostSocialLinks", () => {
8
+ it("accepts https links and upgrades a bare host", () => {
9
+ const result = parseFeedPostSocialLinks([
10
+ { platform: "Website", url: "example.com/book" },
11
+ { platform: "Instagram", url: "@nightowl" },
12
+ ]);
13
+ expect("links" in result).toBe(true);
14
+ if (!("links" in result)) return;
15
+ expect(result.links[0]?.url).toBe("https://example.com/book");
16
+ expect(result.links[1]?.url).toBe("https://instagram.com/nightowl");
17
+ });
18
+
19
+ it("rejects scripts and too many links", () => {
20
+ expect(parseFeedPostSocialLinks([{ platform: "Website", url: "javascript:alert(1)" }])).toEqual({
21
+ error: "Links need a normal https address.",
22
+ });
23
+ expect(
24
+ parseFeedPostSocialLinks(
25
+ Array.from({ length: 6 }, () => ({
26
+ platform: "Website",
27
+ url: "https://example.com",
28
+ }))
29
+ )
30
+ ).toEqual({ error: "Add up to 5 links." });
31
+ });
32
+ });
33
+
34
+ describe("withoutPosterSocialLinks", () => {
35
+ it("drops links that match the poster's own profile", () => {
36
+ const links = parseFeedPostSocialLinks([
37
+ { platform: "Instagram", url: "https://instagram.com/me" },
38
+ { platform: "Website", url: "https://venue.example" },
39
+ ]);
40
+ if (!("links" in links)) throw new Error("expected links");
41
+ expect(
42
+ withoutPosterSocialLinks(links.links, ["https://www.instagram.com/me/"])
43
+ ).toEqual([
44
+ { platform: "Website", url: "https://venue.example/" },
45
+ ]);
46
+ });
47
+ });
48
+
49
+ describe("providerLinksFromOrganization", () => {
50
+ it("reads website and known social keys", () => {
51
+ expect(
52
+ providerLinksFromOrganization(
53
+ { instagram: "@club", twitter: "https://x.com/club" },
54
+ "https://club.example"
55
+ )
56
+ ).toEqual([
57
+ { platform: "Website", url: "https://club.example/" },
58
+ { platform: "Instagram", url: "https://instagram.com/club" },
59
+ { platform: "X", url: "https://x.com/club" },
60
+ ]);
61
+ });
62
+ });
@@ -14,6 +14,7 @@ import {
14
14
  calculateStripeProcessingFeeCents,
15
15
  calculateTotalPriceWithoutTax,
16
16
  calculateTotalBashPoints,
17
+ calculateMixedPricingBashPoints,
17
18
  calculateStripeDonationFee,
18
19
  calculateServiceFeeWithMembershipDiscount,
19
20
  calculatePartnerFeeWithMembershipDiscount,
@@ -398,6 +399,18 @@ describe("PaymentUtils - Core Functions", () => {
398
399
  const ticketList = new Map([["t1", [{ numberOfTickets: 1, ticketDateTime: new Date() }]]]);
399
400
  expect(calculateTotalBashPoints(tiers, ticketList)).toBe(0);
400
401
  });
402
+
403
+ test("mixed tiers charge dollars and BashPoints", () => {
404
+ const tiers = [
405
+ { id: "t1", price: 500, pricingType: "MIXED", priceBashPoints: 50 },
406
+ ] as any;
407
+ const ticketList = new Map([
408
+ ["t1", [{ numberOfTickets: 2, ticketDateTime: new Date() }]],
409
+ ]);
410
+ expect(calculateTotalPriceWithoutTax(tiers, ticketList)).toBe(1000);
411
+ expect(calculateTotalBashPoints(tiers, ticketList)).toBe(100);
412
+ expect(calculateMixedPricingBashPoints(tiers, ticketList)).toBe(100);
413
+ });
401
414
  });
402
415
 
403
416
  describe("Stripe donation fee", () => {
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Links stored on a service-mode BashFeed post. Not written back to a profile.
3
+ */
4
+ export const FEED_POST_SOCIAL_PLATFORMS = [
5
+ "Instagram",
6
+ "LinkedIn",
7
+ "X",
8
+ "Website",
9
+ ] as const;
10
+
11
+ export const FEED_POST_SOCIAL_LINK_MAX = 5;
12
+
13
+ export type FeedPostSocialPlatform = (typeof FEED_POST_SOCIAL_PLATFORMS)[number];
14
+
15
+ export type FeedPostSocialLink = {
16
+ platform: FeedPostSocialPlatform;
17
+ url: string;
18
+ label?: string;
19
+ };
20
+
21
+ function isPlatform(value: string): value is FeedPostSocialPlatform {
22
+ return (FEED_POST_SOCIAL_PLATFORMS as readonly string[]).includes(value);
23
+ }
24
+
25
+ /** https URL, or null when the value is not a normal web link. */
26
+ export function toFeedPostHttpsUrl(raw: string): string | null {
27
+ const trimmed = raw.trim();
28
+ if (!trimmed) return null;
29
+ if (/^(javascript|data|file|mailto|tel):/i.test(trimmed)) return null;
30
+
31
+ let candidate = trimmed;
32
+ if (/^http:\/\//i.test(candidate)) {
33
+ candidate = `https://${candidate.slice("http://".length)}`;
34
+ } else if (!/^https:\/\//i.test(candidate)) {
35
+ if (candidate.startsWith("@")) return null;
36
+ candidate = `https://${candidate.replace(/^\/+/, "")}`;
37
+ }
38
+
39
+ try {
40
+ const url = new URL(candidate);
41
+ if (url.protocol !== "https:") return null;
42
+ if (!url.hostname || !url.hostname.includes(".")) return null;
43
+ return url.toString();
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function handleToHttps(platform: FeedPostSocialPlatform, raw: string): string | null {
50
+ const trimmed = raw.trim();
51
+ const handle = trimmed.replace(/^@/, "");
52
+ if (!handle || /\s/.test(handle)) return null;
53
+ if (platform === "Instagram") return toFeedPostHttpsUrl(`https://instagram.com/${handle}`);
54
+ if (platform === "X") return toFeedPostHttpsUrl(`https://x.com/${handle}`);
55
+ if (platform === "LinkedIn") return toFeedPostHttpsUrl(`https://www.linkedin.com/in/${handle}`);
56
+ return null;
57
+ }
58
+
59
+ export function parseFeedPostSocialLinks(
60
+ input: unknown
61
+ ): { links: FeedPostSocialLink[] } | { error: string } {
62
+ if (input == null) return { links: [] };
63
+ if (!Array.isArray(input)) {
64
+ return { error: "Social links must be a list." };
65
+ }
66
+ if (input.length > FEED_POST_SOCIAL_LINK_MAX) {
67
+ return { error: `Add up to ${FEED_POST_SOCIAL_LINK_MAX} links.` };
68
+ }
69
+
70
+ const links: FeedPostSocialLink[] = [];
71
+ for (const row of input) {
72
+ if (!row || typeof row !== "object") {
73
+ return { error: "Each social link needs a platform and a URL." };
74
+ }
75
+ const record = row as { platform?: unknown; url?: unknown; label?: unknown };
76
+ const platform = typeof record.platform === "string" ? record.platform.trim() : "";
77
+ const urlRaw = typeof record.url === "string" ? record.url : "";
78
+ if (!isPlatform(platform)) {
79
+ return { error: "Use Instagram, LinkedIn, X, or a website." };
80
+ }
81
+ const url =
82
+ toFeedPostHttpsUrl(urlRaw) ??
83
+ (urlRaw.trim().startsWith("@") ? handleToHttps(platform, urlRaw) : null);
84
+ if (!url) {
85
+ return { error: "Links need a normal https address." };
86
+ }
87
+ const label =
88
+ typeof record.label === "string" && record.label.trim()
89
+ ? record.label.trim().slice(0, 40)
90
+ : undefined;
91
+ links.push(label ? { platform, url, label } : { platform, url });
92
+ }
93
+ return { links };
94
+ }
95
+
96
+ function urlKey(url: string): string {
97
+ try {
98
+ const parsed = new URL(url);
99
+ const host = parsed.hostname.replace(/^www\./, "").toLowerCase();
100
+ const path = parsed.pathname.replace(/\/$/, "").toLowerCase();
101
+ return `${host}${path}`;
102
+ } catch {
103
+ return url.trim().toLowerCase();
104
+ }
105
+ }
106
+
107
+ /** Drop links that match the poster's own profile URLs. */
108
+ export function withoutPosterSocialLinks(
109
+ links: FeedPostSocialLink[],
110
+ posterUrls: Array<string | null | undefined>
111
+ ): FeedPostSocialLink[] {
112
+ const blocked = new Set<string>();
113
+ for (const raw of posterUrls) {
114
+ if (!raw?.trim()) continue;
115
+ const https = toFeedPostHttpsUrl(raw);
116
+ if (https) blocked.add(urlKey(https));
117
+ }
118
+ if (blocked.size === 0) return links;
119
+ return links.filter((link) => !blocked.has(urlKey(link.url)));
120
+ }
121
+
122
+ function pushUnique(
123
+ links: FeedPostSocialLink[],
124
+ platform: FeedPostSocialPlatform,
125
+ raw: string
126
+ ): void {
127
+ if (links.length >= FEED_POST_SOCIAL_LINK_MAX) return;
128
+ const url =
129
+ toFeedPostHttpsUrl(raw) ??
130
+ (raw.trim().startsWith("@") ? handleToHttps(platform, raw) : null);
131
+ if (!url) return;
132
+ if (links.some((link) => link.platform === platform && urlKey(link.url) === urlKey(url))) {
133
+ return;
134
+ }
135
+ links.push({ platform, url });
136
+ }
137
+
138
+ /**
139
+ * Prefill from an organization's stored social JSON and website.
140
+ * Accepts `{ instagram, linkedin, x, twitter, website }` or an array of `{ platform, url }`.
141
+ */
142
+ export function providerLinksFromOrganization(
143
+ socialLinks: unknown,
144
+ websiteUrl: string | null | undefined
145
+ ): FeedPostSocialLink[] {
146
+ const links: FeedPostSocialLink[] = [];
147
+ if (websiteUrl?.trim()) {
148
+ pushUnique(links, "Website", websiteUrl);
149
+ }
150
+ if (!socialLinks) return links;
151
+
152
+ if (Array.isArray(socialLinks)) {
153
+ const parsed = parseFeedPostSocialLinks(socialLinks);
154
+ if ("links" in parsed) {
155
+ for (const link of parsed.links) pushUnique(links, link.platform, link.url);
156
+ }
157
+ return links;
158
+ }
159
+
160
+ if (typeof socialLinks !== "object") return links;
161
+ const record = socialLinks as Record<string, unknown>;
162
+ const pairs: Array<[FeedPostSocialPlatform, unknown]> = [
163
+ ["Instagram", record.instagram ?? record.Instagram],
164
+ ["LinkedIn", record.linkedin ?? record.LinkedIn],
165
+ ["X", record.x ?? record.X ?? record.twitter ?? record.Twitter],
166
+ ["Website", record.website ?? record.Website ?? record.url],
167
+ ];
168
+ for (const [platform, value] of pairs) {
169
+ if (typeof value === "string" && value.trim()) {
170
+ pushUnique(links, platform, value);
171
+ }
172
+ }
173
+ return links;
174
+ }
@@ -96,7 +96,7 @@ export function calculateTotalPriceWithoutTax(
96
96
  ): number {
97
97
  let total = 0;
98
98
  ticketTiers.forEach((tier: TicketTier) => {
99
- // Only calculate USD prices - BashPoints tickets should have price=0
99
+ // USD and mixed tiers charge the dollar price. Points-only tiers do not.
100
100
  if (tier.pricingType !== "BASHPOINTS") {
101
101
  const ticketListNumAndDate = ticketList.get(tier.id);
102
102
  ticketListNumAndDate?.forEach((numAndDate: NumberOfTicketsForDate) => {
@@ -120,8 +120,10 @@ export function calculateTotalBashPoints(
120
120
  ): number {
121
121
  let total = 0;
122
122
  ticketTiers.forEach((tier: TicketTier) => {
123
- // Only calculate BashPoints prices
124
- if (tier.pricingType === "BASHPOINTS" && tier.priceBashPoints) {
123
+ if (
124
+ (tier.pricingType === "BASHPOINTS" || tier.pricingType === "MIXED") &&
125
+ tier.priceBashPoints
126
+ ) {
125
127
  const ticketListNumAndDate = ticketList.get(tier.id);
126
128
  ticketListNumAndDate?.forEach((numAndDate: NumberOfTicketsForDate) => {
127
129
  total += tier.priceBashPoints! * (numAndDate.numberOfTickets ?? 0);
@@ -131,6 +133,22 @@ export function calculateTotalBashPoints(
131
133
  return total;
132
134
  }
133
135
 
136
+ /** BashPoints due on tiers that also charge a card (pricingType MIXED). */
137
+ export function calculateMixedPricingBashPoints(
138
+ ticketTiers: TicketTier[],
139
+ ticketList: Map<string, NumberOfTicketsForDate[]>
140
+ ): number {
141
+ let total = 0;
142
+ ticketTiers.forEach((tier: TicketTier) => {
143
+ if (tier.pricingType !== "MIXED" || !tier.priceBashPoints) return;
144
+ const ticketListNumAndDate = ticketList.get(tier.id);
145
+ ticketListNumAndDate?.forEach((numAndDate: NumberOfTicketsForDate) => {
146
+ total += tier.priceBashPoints! * (numAndDate.numberOfTickets ?? 0);
147
+ });
148
+ });
149
+ return total;
150
+ }
151
+
134
152
  export function convertCentsToDollars(
135
153
  price: number | undefined | null
136
154
  ): number {