@bash-app/bash-common 30.427.0 → 30.430.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 (40) hide show
  1. package/dist/bashFeedTypes.d.ts +3 -0
  2. package/dist/bashFeedTypes.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/utils/__tests__/feedPostSocialLinks.test.d.ts +2 -0
  8. package/dist/utils/__tests__/feedPostSocialLinks.test.d.ts.map +1 -0
  9. package/dist/utils/__tests__/feedPostSocialLinks.test.js +46 -0
  10. package/dist/utils/__tests__/feedPostSocialLinks.test.js.map +1 -0
  11. package/dist/utils/__tests__/paymentUtils.test.js +12 -1
  12. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  13. package/dist/utils/__tests__/ticketTierReleaseWindow.test.d.ts +2 -0
  14. package/dist/utils/__tests__/ticketTierReleaseWindow.test.d.ts.map +1 -0
  15. package/dist/utils/__tests__/ticketTierReleaseWindow.test.js +90 -0
  16. package/dist/utils/__tests__/ticketTierReleaseWindow.test.js.map +1 -0
  17. package/dist/utils/__tests__/ticketTierSchedule.test.js +15 -3
  18. package/dist/utils/__tests__/ticketTierSchedule.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/dist/utils/ticketTierSchedule.d.ts.map +1 -1
  28. package/dist/utils/ticketTierSchedule.js +2 -8
  29. package/dist/utils/ticketTierSchedule.js.map +1 -1
  30. package/package.json +1 -1
  31. package/prisma/schema.prisma +5 -2
  32. package/src/bashFeedTypes.ts +4 -0
  33. package/src/index.ts +1 -0
  34. package/src/utils/__tests__/feedPostSocialLinks.test.ts +62 -0
  35. package/src/utils/__tests__/paymentUtils.test.ts +13 -0
  36. package/src/utils/__tests__/ticketTierReleaseWindow.test.ts +117 -0
  37. package/src/utils/__tests__/ticketTierSchedule.test.ts +15 -3
  38. package/src/utils/feedPostSocialLinks.ts +174 -0
  39. package/src/utils/paymentUtils.ts +21 -3
  40. package/src/utils/ticketTierSchedule.ts +2 -9
@@ -0,0 +1,117 @@
1
+ import {
2
+ resolveTicketTierScheduleState,
3
+ scheduleValidationHasErrors,
4
+ validateTicketTierScheduleInput,
5
+ } from "../ticketTierSchedule.js";
6
+
7
+ /** Sale opens at availableAt and closes at expiresAt. Boundaries are inclusive on open, exclusive on the last millisecond before close. */
8
+ const RELEASE = new Date("2026-12-11T01:00:00.000Z");
9
+ const EXPIRES = new Date("2026-12-17T13:00:00.000Z");
10
+ const INVENTORY = { availableInventory: 40 };
11
+
12
+ function shift(instant: Date, ms: number): Date {
13
+ return new Date(instant.getTime() + ms);
14
+ }
15
+
16
+ describe("ticket release and expiry windows", () => {
17
+ it("keeps a release-only tier off sale until the release instant, then on sale", () => {
18
+ const tier = { availabilityType: "OnDate", availableAt: RELEASE };
19
+
20
+ const before = resolveTicketTierScheduleState(tier, {
21
+ ...INVENTORY,
22
+ now: shift(RELEASE, -1),
23
+ });
24
+ const atRelease = resolveTicketTierScheduleState(tier, {
25
+ ...INVENTORY,
26
+ now: RELEASE,
27
+ });
28
+ const later = resolveTicketTierScheduleState(tier, {
29
+ ...INVENTORY,
30
+ now: shift(RELEASE, 7 * 24 * 60 * 60 * 1000),
31
+ });
32
+
33
+ expect(before.canPurchase).toBe(false);
34
+ expect(before.phase).toBe("TeasePreRelease");
35
+ expect(atRelease.canPurchase).toBe(true);
36
+ expect(atRelease.phase).toBe("Live");
37
+ expect(later.canPurchase).toBe(true);
38
+ expect(later.phase).toBe("Live");
39
+ });
40
+
41
+ it("treats a past release as already on sale", () => {
42
+ const issues = validateTicketTierScheduleInput({
43
+ availabilityType: "OnDate",
44
+ availableAt: shift(RELEASE, -60_000),
45
+ });
46
+ expect(scheduleValidationHasErrors(issues)).toBe(false);
47
+
48
+ const state = resolveTicketTierScheduleState(
49
+ { availabilityType: "OnDate", availableAt: shift(RELEASE, -60_000) },
50
+ { ...INVENTORY, now: RELEASE }
51
+ );
52
+ expect(state.canPurchase).toBe(true);
53
+ expect(state.phase).toBe("Live");
54
+ });
55
+
56
+ it("keeps an expiry-only tier on sale until the expiration instant", () => {
57
+ const tier = { availabilityType: "Immediate", expiresAt: EXPIRES };
58
+
59
+ const before = resolveTicketTierScheduleState(tier, {
60
+ ...INVENTORY,
61
+ now: shift(EXPIRES, -1),
62
+ });
63
+ const atExpiry = resolveTicketTierScheduleState(tier, {
64
+ ...INVENTORY,
65
+ now: EXPIRES,
66
+ });
67
+
68
+ expect(before.canPurchase).toBe(true);
69
+ expect(before.phase).toBe("Live");
70
+ expect(atExpiry.canPurchase).toBe(false);
71
+ expect(atExpiry.phase).toBe("Expired");
72
+ expect(atExpiry.guestVisible).toBe(false);
73
+ });
74
+
75
+ it("sells a dated tier only inside the release-to-expiry window", () => {
76
+ const tier = {
77
+ availabilityType: "OnDate",
78
+ availableAt: RELEASE,
79
+ expiresAt: EXPIRES,
80
+ };
81
+
82
+ expect(
83
+ resolveTicketTierScheduleState(tier, {
84
+ ...INVENTORY,
85
+ now: shift(RELEASE, -1),
86
+ }).canPurchase
87
+ ).toBe(false);
88
+ expect(
89
+ resolveTicketTierScheduleState(tier, {
90
+ ...INVENTORY,
91
+ now: RELEASE,
92
+ }).phase
93
+ ).toBe("Live");
94
+ expect(
95
+ resolveTicketTierScheduleState(tier, {
96
+ ...INVENTORY,
97
+ now: shift(EXPIRES, -1),
98
+ }).canPurchase
99
+ ).toBe(true);
100
+ expect(
101
+ resolveTicketTierScheduleState(tier, {
102
+ ...INVENTORY,
103
+ now: EXPIRES,
104
+ }).phase
105
+ ).toBe("Expired");
106
+ });
107
+
108
+ it("rejects an expiration that is not after the release", () => {
109
+ const issues = validateTicketTierScheduleInput({
110
+ availabilityType: "OnDate",
111
+ availableAt: EXPIRES,
112
+ expiresAt: RELEASE,
113
+ });
114
+ expect(scheduleValidationHasErrors(issues)).toBe(true);
115
+ expect(issues.some((issue) => issue.field === "expiresAt")).toBe(true);
116
+ });
117
+ });
@@ -115,9 +115,21 @@ describe("resolveTicketTierScheduleState", () => {
115
115
  });
116
116
 
117
117
  describe("validateTicketTierScheduleInput", () => {
118
- it("requires availableAt for OnDate", () => {
119
- const issues = validateTicketTierScheduleInput({ availabilityType: "OnDate" });
120
- expect(scheduleValidationHasErrors(issues)).toBe(true);
118
+ it("treats a missing release datetime as already on sale", () => {
119
+ const releaseOnly = validateTicketTierScheduleInput({
120
+ availabilityType: "OnDate",
121
+ availableAt: new Date("2030-06-02T12:00:00Z"),
122
+ });
123
+ const expireOnly = validateTicketTierScheduleInput({
124
+ availabilityType: "Immediate",
125
+ expiresAt: new Date("2030-06-02T12:00:00Z"),
126
+ });
127
+ const unsetRelease = validateTicketTierScheduleInput({
128
+ availabilityType: "OnDate",
129
+ });
130
+ expect(scheduleValidationHasErrors(releaseOnly)).toBe(false);
131
+ expect(scheduleValidationHasErrors(expireOnly)).toBe(false);
132
+ expect(scheduleValidationHasErrors(unsetRelease)).toBe(false);
121
133
  });
122
134
 
123
135
  it("rejects expires before release", () => {
@@ -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 {
@@ -325,19 +325,12 @@ export function validateTicketTierScheduleInput(
325
325
  bashEvent?: { startDateTime?: Date | string | null }
326
326
  ): ScheduleValidationIssue[] {
327
327
  const issues: ScheduleValidationIssue[] = [];
328
- const type = tier.availabilityType ?? "Immediate";
329
328
  const availableAt = toDate(tier.availableAt);
330
329
  const expiresAt = toDate(tier.expiresAt);
331
330
  const start = bashEvent ? toDate(bashEvent.startDateTime) : null;
332
331
 
333
- if (isOnDate(type) && !availableAt) {
334
- issues.push({
335
- field: "availableAt",
336
- message: "Release date and time are required when scheduling a tier.",
337
- severity: "error",
338
- });
339
- }
340
-
332
+ // No release datetime means the tier is already on sale. Expiration can
333
+ // stand alone, and a release date does not require an expiration.
341
334
  if (availableAt && expiresAt && expiresAt <= availableAt) {
342
335
  issues.push({
343
336
  field: "expiresAt",