@bash-app/bash-common 30.386.0 → 30.393.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__/bashSectionTitleForBashType.test.js +5 -0
- package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
- package/dist/__tests__/budgetApproval.test.d.ts +2 -0
- package/dist/__tests__/budgetApproval.test.d.ts.map +1 -0
- package/dist/__tests__/budgetApproval.test.js +109 -0
- package/dist/__tests__/budgetApproval.test.js.map +1 -0
- package/dist/__tests__/crewSocialCopy.test.d.ts +2 -0
- package/dist/__tests__/crewSocialCopy.test.d.ts.map +1 -0
- package/dist/__tests__/crewSocialCopy.test.js +21 -0
- package/dist/__tests__/crewSocialCopy.test.js.map +1 -0
- package/dist/__tests__/discoveryScope.test.d.ts +2 -0
- package/dist/__tests__/discoveryScope.test.d.ts.map +1 -0
- package/dist/__tests__/discoveryScope.test.js +40 -0
- package/dist/__tests__/discoveryScope.test.js.map +1 -0
- package/dist/__tests__/scenePulse.test.d.ts +2 -0
- package/dist/__tests__/scenePulse.test.d.ts.map +1 -0
- package/dist/__tests__/scenePulse.test.js +52 -0
- package/dist/__tests__/scenePulse.test.js.map +1 -0
- package/dist/__tests__/suggestedNight.test.d.ts +2 -0
- package/dist/__tests__/suggestedNight.test.d.ts.map +1 -0
- package/dist/__tests__/suggestedNight.test.js +10 -0
- package/dist/__tests__/suggestedNight.test.js.map +1 -0
- package/dist/__tests__/suggestionScore.test.d.ts +2 -0
- package/dist/__tests__/suggestionScore.test.d.ts.map +1 -0
- package/dist/__tests__/suggestionScore.test.js +68 -0
- package/dist/__tests__/suggestionScore.test.js.map +1 -0
- package/dist/__tests__/userTaste.test.d.ts +2 -0
- package/dist/__tests__/userTaste.test.d.ts.map +1 -0
- package/dist/__tests__/userTaste.test.js +21 -0
- package/dist/__tests__/userTaste.test.js.map +1 -0
- package/dist/budgetApproval.d.ts +126 -0
- package/dist/budgetApproval.d.ts.map +1 -0
- package/dist/budgetApproval.js +161 -0
- package/dist/budgetApproval.js.map +1 -0
- package/dist/crewSocialCopy.d.ts +16 -0
- package/dist/crewSocialCopy.d.ts.map +1 -0
- package/dist/crewSocialCopy.js +23 -0
- package/dist/crewSocialCopy.js.map +1 -0
- package/dist/definitions.d.ts +6 -0
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +6 -0
- package/dist/definitions.js.map +1 -1
- package/dist/discoveryScope.d.ts +48 -0
- package/dist/discoveryScope.d.ts.map +1 -0
- package/dist/discoveryScope.js +114 -0
- package/dist/discoveryScope.js.map +1 -0
- package/dist/extendedSchemas.d.ts +7 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +8 -0
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/home.d.ts +6 -1
- package/dist/home.d.ts.map +1 -1
- package/dist/home.js +5 -1
- package/dist/home.js.map +1 -1
- package/dist/hostCrmCrowdPulse.d.ts +2 -0
- package/dist/hostCrmCrowdPulse.d.ts.map +1 -1
- package/dist/hostCrmCrowdPulse.js.map +1 -1
- package/dist/hostCrmSegments.d.ts +6 -0
- package/dist/hostCrmSegments.d.ts.map +1 -1
- package/dist/hostCrmSegments.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -2
- package/dist/index.js.map +1 -1
- package/dist/scenePulse.d.ts +66 -0
- package/dist/scenePulse.d.ts.map +1 -0
- package/dist/scenePulse.js +54 -0
- package/dist/scenePulse.js.map +1 -0
- package/dist/sms/smsTemplates.d.ts +6 -1
- package/dist/sms/smsTemplates.d.ts.map +1 -1
- package/dist/sms/smsTemplates.js +1 -0
- package/dist/sms/smsTemplates.js.map +1 -1
- package/dist/suggestedNight.d.ts +12 -0
- package/dist/suggestedNight.d.ts.map +1 -0
- package/dist/suggestedNight.js +12 -0
- package/dist/suggestedNight.js.map +1 -0
- package/dist/suggestionScore.d.ts +42 -0
- package/dist/suggestionScore.d.ts.map +1 -0
- package/dist/suggestionScore.js +100 -0
- package/dist/suggestionScore.js.map +1 -0
- package/dist/userTaste.d.ts +40 -0
- package/dist/userTaste.d.ts.map +1 -0
- package/dist/userTaste.js +103 -0
- package/dist/userTaste.js.map +1 -0
- package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +9 -1
- package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -1
- package/dist/utils/featuredDiscoveryGeo.d.ts +7 -0
- package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -1
- package/dist/utils/featuredDiscoveryGeo.js +11 -0
- package/dist/utils/featuredDiscoveryGeo.js.map +1 -1
- package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts +2 -0
- package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js +353 -0
- package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js.map +1 -0
- package/dist/utils/service/foodAndBeverageQuote.d.ts +171 -0
- package/dist/utils/service/foodAndBeverageQuote.d.ts.map +1 -0
- package/dist/utils/service/foodAndBeverageQuote.js +467 -0
- package/dist/utils/service/foodAndBeverageQuote.js.map +1 -0
- package/package.json +1 -1
- package/prisma/schema.prisma +185 -53
- package/src/__tests__/bashSectionTitleForBashType.test.ts +10 -0
- package/src/__tests__/budgetApproval.test.ts +153 -0
- package/src/__tests__/crewSocialCopy.test.ts +25 -0
- package/src/__tests__/discoveryScope.test.ts +65 -0
- package/src/__tests__/scenePulse.test.ts +69 -0
- package/src/__tests__/suggestedNight.test.ts +11 -0
- package/src/__tests__/suggestionScore.test.ts +81 -0
- package/src/__tests__/userTaste.test.ts +33 -0
- package/src/budgetApproval.ts +303 -0
- package/src/crewSocialCopy.ts +37 -0
- package/src/definitions.ts +11 -0
- package/src/discoveryScope.ts +170 -0
- package/src/extendedSchemas.ts +8 -0
- package/src/home.ts +15 -0
- package/src/hostCrmCrowdPulse.ts +2 -0
- package/src/hostCrmSegments.ts +6 -0
- package/src/index.ts +18 -3
- package/src/scenePulse.ts +113 -0
- package/src/sms/smsTemplates.ts +6 -0
- package/src/suggestedNight.ts +20 -0
- package/src/suggestionScore.ts +140 -0
- package/src/userTaste.ts +152 -0
- package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +13 -0
- package/src/utils/featuredDiscoveryGeo.ts +15 -0
- package/src/utils/service/__tests__/foodAndBeverageQuote.test.ts +502 -0
- package/src/utils/service/foodAndBeverageQuote.ts +613 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parseScenePulseScope — city default, invalid → null.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, test } from "@jest/globals";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
emptyScenePulseEnergy,
|
|
8
|
+
normalizeScenePulseGeoOverride,
|
|
9
|
+
parseScenePulseScope,
|
|
10
|
+
} from "../scenePulse.js";
|
|
11
|
+
|
|
12
|
+
describe("parseScenePulseScope", () => {
|
|
13
|
+
test("defaults missing to city", () => {
|
|
14
|
+
expect(parseScenePulseScope(undefined)).toBe("city");
|
|
15
|
+
expect(parseScenePulseScope(null)).toBe("city");
|
|
16
|
+
expect(parseScenePulseScope("")).toBe("city");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("accepts city region world", () => {
|
|
20
|
+
expect(parseScenePulseScope("city")).toBe("city");
|
|
21
|
+
expect(parseScenePulseScope("Region")).toBe("region");
|
|
22
|
+
expect(parseScenePulseScope(" WORLD ")).toBe("world");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("rejects unknown values", () => {
|
|
26
|
+
expect(parseScenePulseScope("you")).toBeNull();
|
|
27
|
+
expect(parseScenePulseScope(12)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("normalizeScenePulseGeoOverride", () => {
|
|
32
|
+
test("returns null when city and state are blank", () => {
|
|
33
|
+
expect(normalizeScenePulseGeoOverride({})).toBeNull();
|
|
34
|
+
expect(
|
|
35
|
+
normalizeScenePulseGeoOverride({ city: " ", state: "" })
|
|
36
|
+
).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("trims city and state and ignores empty sibling", () => {
|
|
40
|
+
expect(
|
|
41
|
+
normalizeScenePulseGeoOverride({ city: " Denver ", state: " " })
|
|
42
|
+
).toEqual({ city: "Denver", state: null });
|
|
43
|
+
expect(
|
|
44
|
+
normalizeScenePulseGeoOverride({ city: "", state: "CO" })
|
|
45
|
+
).toEqual({ city: null, state: "CO" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("caps overly long values", () => {
|
|
49
|
+
const city = "A".repeat(90);
|
|
50
|
+
const parsed = normalizeScenePulseGeoOverride({ city, state: "Utah" });
|
|
51
|
+
expect(parsed?.city).toHaveLength(80);
|
|
52
|
+
expect(parsed?.state).toBe("Utah");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("emptyScenePulseEnergy", () => {
|
|
57
|
+
test("zeros every energy field", () => {
|
|
58
|
+
expect(emptyScenePulseEnergy()).toEqual({
|
|
59
|
+
liveNow: 0,
|
|
60
|
+
going: 0,
|
|
61
|
+
tonightBashes: 0,
|
|
62
|
+
tonightGoing: 0,
|
|
63
|
+
weekendBashes: 0,
|
|
64
|
+
weekendGoing: 0,
|
|
65
|
+
newThisWeek: 0,
|
|
66
|
+
namesInTheRoom: 0,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { unfavoriteSuggestedNightAction } from "../suggestedNight.js";
|
|
2
|
+
|
|
3
|
+
describe("unfavoriteSuggestedNightAction", () => {
|
|
4
|
+
it("deletes when never reminded", () => {
|
|
5
|
+
expect(unfavoriteSuggestedNightAction(null)).toBe("delete");
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it("dismisses after a nudge", () => {
|
|
9
|
+
expect(unfavoriteSuggestedNightAction(new Date())).toBe("dismiss");
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { DiscoveryScope } from "../discoveryScope.js";
|
|
2
|
+
import { scoreSuggestion, SUGGESTION_WEIGHTS } from "../suggestionScore.js";
|
|
3
|
+
import { UserTasteKind, UserTasteSource } from "../userTaste.js";
|
|
4
|
+
|
|
5
|
+
function base() {
|
|
6
|
+
return {
|
|
7
|
+
connectionsGoingVisible: 0,
|
|
8
|
+
connectionsGoingHidden: 0,
|
|
9
|
+
followedHost: false,
|
|
10
|
+
attendedHost: false,
|
|
11
|
+
eventType: "Festival",
|
|
12
|
+
causeTags: [] as string[],
|
|
13
|
+
musicGenre: null,
|
|
14
|
+
discoveryScope: DiscoveryScope.Local,
|
|
15
|
+
artistsToSee: [] as string[],
|
|
16
|
+
eventTitle: "Block party",
|
|
17
|
+
distanceMiles: 5,
|
|
18
|
+
radiusMiles: 50,
|
|
19
|
+
isFeatured: false,
|
|
20
|
+
trendingScore: 0,
|
|
21
|
+
paidFeatureTasteOverlap: false,
|
|
22
|
+
tastes: [] as Array<{
|
|
23
|
+
kind: string;
|
|
24
|
+
value: string;
|
|
25
|
+
weight: number;
|
|
26
|
+
source: string;
|
|
27
|
+
}>,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("scoreSuggestion", () => {
|
|
32
|
+
it("ranks crew going above paid taste overlap", () => {
|
|
33
|
+
const crew = scoreSuggestion({
|
|
34
|
+
...base(),
|
|
35
|
+
connectionsGoingVisible: 1,
|
|
36
|
+
});
|
|
37
|
+
const paid = scoreSuggestion({
|
|
38
|
+
...base(),
|
|
39
|
+
paidFeatureTasteOverlap: true,
|
|
40
|
+
tastes: [
|
|
41
|
+
{
|
|
42
|
+
kind: UserTasteKind.EventType,
|
|
43
|
+
value: "Festival",
|
|
44
|
+
weight: 1,
|
|
45
|
+
source: UserTasteSource.Explicit,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
expect(crew).toBeGreaterThan(SUGGESTION_WEIGHTS.paidFeatureTaste);
|
|
50
|
+
expect(crew).toBeGreaterThan(
|
|
51
|
+
paid - SUGGESTION_WEIGHTS.explicitTaste
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("lets travel taste break radius for National events only", () => {
|
|
56
|
+
const tastes = [
|
|
57
|
+
{
|
|
58
|
+
kind: UserTasteKind.DiscoveryScope,
|
|
59
|
+
value: DiscoveryScope.National,
|
|
60
|
+
weight: 1,
|
|
61
|
+
source: UserTasteSource.Explicit,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
expect(
|
|
65
|
+
scoreSuggestion({
|
|
66
|
+
...base(),
|
|
67
|
+
discoveryScope: DiscoveryScope.National,
|
|
68
|
+
distanceMiles: 900,
|
|
69
|
+
tastes,
|
|
70
|
+
})
|
|
71
|
+
).toBeGreaterThan(0);
|
|
72
|
+
expect(
|
|
73
|
+
scoreSuggestion({
|
|
74
|
+
...base(),
|
|
75
|
+
discoveryScope: DiscoveryScope.Local,
|
|
76
|
+
distanceMiles: 900,
|
|
77
|
+
tastes,
|
|
78
|
+
})
|
|
79
|
+
).toBe(Number.NEGATIVE_INFINITY);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { UserTasteKind, isValidTasteValue, userHasTravelTaste } from "../userTaste.js";
|
|
2
|
+
import { DiscoveryScope } from "../discoveryScope.js";
|
|
3
|
+
|
|
4
|
+
describe("userTaste", () => {
|
|
5
|
+
it("accepts Festival event types and rejects junk", () => {
|
|
6
|
+
expect(isValidTasteValue(UserTasteKind.EventType, "Festival")).toBe(true);
|
|
7
|
+
expect(isValidTasteValue(UserTasteKind.EventType, "Networking")).toBe(
|
|
8
|
+
false
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("accepts cause tags and genres", () => {
|
|
13
|
+
expect(isValidTasteValue(UserTasteKind.CauseTag, "lgbtq")).toBe(true);
|
|
14
|
+
expect(isValidTasteValue(UserTasteKind.MusicGenre, "House")).toBe(true);
|
|
15
|
+
expect(isValidTasteValue(UserTasteKind.DiscoveryScope, DiscoveryScope.National)).toBe(
|
|
16
|
+
true
|
|
17
|
+
);
|
|
18
|
+
expect(
|
|
19
|
+
isValidTasteValue(UserTasteKind.DiscoveryScope, DiscoveryScope.Unspecified)
|
|
20
|
+
).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("detects travel taste", () => {
|
|
24
|
+
expect(
|
|
25
|
+
userHasTravelTaste([
|
|
26
|
+
{ kind: UserTasteKind.DiscoveryScope, value: DiscoveryScope.National },
|
|
27
|
+
])
|
|
28
|
+
).toBe(true);
|
|
29
|
+
expect(
|
|
30
|
+
userHasTravelTaste([{ kind: UserTasteKind.EventType, value: "Festival" }])
|
|
31
|
+
).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-level budget approval requests — plan sign-off and pre-plan cap asks.
|
|
3
|
+
* Shared types and validation for api + bash-app.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
BASH_EVENT_WIZARD_HOW_MAJOR_STEP,
|
|
8
|
+
BASH_EVENT_WIZARD_HOW_SUBSTEP,
|
|
9
|
+
} from "./definitions.js";
|
|
10
|
+
|
|
11
|
+
export const BUDGET_APPROVAL_TTL_DAYS = 14;
|
|
12
|
+
|
|
13
|
+
export const BudgetApprovalKind = {
|
|
14
|
+
CapAsk: "CapAsk",
|
|
15
|
+
PlanSignOff: "PlanSignOff",
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export type BudgetApprovalKind =
|
|
19
|
+
(typeof BudgetApprovalKind)[keyof typeof BudgetApprovalKind];
|
|
20
|
+
|
|
21
|
+
export const BUDGET_APPROVAL_KINDS = [
|
|
22
|
+
BudgetApprovalKind.CapAsk,
|
|
23
|
+
BudgetApprovalKind.PlanSignOff,
|
|
24
|
+
] as const;
|
|
25
|
+
export type BudgetApprovalKindName = BudgetApprovalKind;
|
|
26
|
+
|
|
27
|
+
export const BudgetApprovalStatus = {
|
|
28
|
+
Pending: "Pending",
|
|
29
|
+
Approved: "Approved",
|
|
30
|
+
ChangesRequested: "ChangesRequested",
|
|
31
|
+
Declined: "Declined",
|
|
32
|
+
Cancelled: "Cancelled",
|
|
33
|
+
Superseded: "Superseded",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
export type BudgetApprovalStatus =
|
|
37
|
+
(typeof BudgetApprovalStatus)[keyof typeof BudgetApprovalStatus];
|
|
38
|
+
|
|
39
|
+
export const BUDGET_APPROVAL_STATUSES = [
|
|
40
|
+
BudgetApprovalStatus.Pending,
|
|
41
|
+
BudgetApprovalStatus.Approved,
|
|
42
|
+
BudgetApprovalStatus.ChangesRequested,
|
|
43
|
+
BudgetApprovalStatus.Declined,
|
|
44
|
+
BudgetApprovalStatus.Cancelled,
|
|
45
|
+
BudgetApprovalStatus.Superseded,
|
|
46
|
+
] as const;
|
|
47
|
+
export type BudgetApprovalStatusName = BudgetApprovalStatus;
|
|
48
|
+
|
|
49
|
+
export const BUDGET_APPROVAL_ACTIONS = [
|
|
50
|
+
"approve",
|
|
51
|
+
"changes",
|
|
52
|
+
"decline",
|
|
53
|
+
] as const;
|
|
54
|
+
export type BudgetApprovalAction = (typeof BUDGET_APPROVAL_ACTIONS)[number];
|
|
55
|
+
|
|
56
|
+
export type BudgetApprovalSnapshotLine = {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
category: string | null;
|
|
60
|
+
totalAllocatedCents: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type BudgetApprovalLineNoteInput = {
|
|
64
|
+
budgetId?: string | null;
|
|
65
|
+
nameSnapshot: string;
|
|
66
|
+
categorySnapshot?: string | null;
|
|
67
|
+
allocatedCentsSnapshot: number;
|
|
68
|
+
note: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type BudgetApprovalDecideInput = {
|
|
72
|
+
action: BudgetApprovalAction;
|
|
73
|
+
kind: BudgetApprovalKindName;
|
|
74
|
+
approverName?: string | null;
|
|
75
|
+
decisionNote?: string | null;
|
|
76
|
+
authorizedCents?: number | null;
|
|
77
|
+
lineNotes?: BudgetApprovalLineNoteInput[] | null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
81
|
+
|
|
82
|
+
export function normalizeApproverEmail(email: string): string {
|
|
83
|
+
return email.trim().toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isValidApproverEmail(email: string): boolean {
|
|
87
|
+
return EMAIL_RE.test(email.trim());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isBudgetApprovalKind(
|
|
91
|
+
value: string
|
|
92
|
+
): value is BudgetApprovalKindName {
|
|
93
|
+
return (BUDGET_APPROVAL_KINDS as readonly string[]).includes(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isBudgetApprovalAction(
|
|
97
|
+
value: string
|
|
98
|
+
): value is BudgetApprovalAction {
|
|
99
|
+
return (BUDGET_APPROVAL_ACTIONS as readonly string[]).includes(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isBudgetApprovalExpired(
|
|
103
|
+
expiresAt: Date | string,
|
|
104
|
+
now: Date = new Date()
|
|
105
|
+
): boolean {
|
|
106
|
+
return now.getTime() > new Date(expiresAt).getTime();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function canonicalizeSnapshotLines(
|
|
110
|
+
lines: BudgetApprovalSnapshotLine[]
|
|
111
|
+
): BudgetApprovalSnapshotLine[] {
|
|
112
|
+
return [...lines]
|
|
113
|
+
.map((line) => ({
|
|
114
|
+
id: line.id,
|
|
115
|
+
name: line.name,
|
|
116
|
+
category: line.category ?? null,
|
|
117
|
+
totalAllocatedCents: line.totalAllocatedCents,
|
|
118
|
+
}))
|
|
119
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildBudgetApprovalSnapshot(
|
|
123
|
+
lines: Array<{
|
|
124
|
+
id: string;
|
|
125
|
+
name: string;
|
|
126
|
+
category?: string | null;
|
|
127
|
+
totalAllocatedCents: number;
|
|
128
|
+
}>
|
|
129
|
+
): { lines: BudgetApprovalSnapshotLine[]; totalCents: number; hash: string } {
|
|
130
|
+
const snapshotLines: BudgetApprovalSnapshotLine[] = lines.map((line) => ({
|
|
131
|
+
id: line.id,
|
|
132
|
+
name: line.name,
|
|
133
|
+
category: line.category ?? null,
|
|
134
|
+
totalAllocatedCents: line.totalAllocatedCents,
|
|
135
|
+
}));
|
|
136
|
+
const totalCents = snapshotLines.reduce(
|
|
137
|
+
(sum, line) => sum + line.totalAllocatedCents,
|
|
138
|
+
0
|
|
139
|
+
);
|
|
140
|
+
return {
|
|
141
|
+
lines: snapshotLines,
|
|
142
|
+
totalCents,
|
|
143
|
+
hash: budgetApprovalSnapshotHash(snapshotLines),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function budgetApprovalSnapshotHash(
|
|
148
|
+
lines: BudgetApprovalSnapshotLine[]
|
|
149
|
+
): string {
|
|
150
|
+
return JSON.stringify(canonicalizeSnapshotLines(lines));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function parseBudgetApprovalSnapshot(
|
|
154
|
+
json: unknown
|
|
155
|
+
): BudgetApprovalSnapshotLine[] {
|
|
156
|
+
if (!Array.isArray(json)) return [];
|
|
157
|
+
const lines: BudgetApprovalSnapshotLine[] = [];
|
|
158
|
+
for (const row of json) {
|
|
159
|
+
if (!row || typeof row !== "object") continue;
|
|
160
|
+
const rec = row as Record<string, unknown>;
|
|
161
|
+
if (typeof rec.id !== "string" || typeof rec.name !== "string") continue;
|
|
162
|
+
if (typeof rec.totalAllocatedCents !== "number") continue;
|
|
163
|
+
lines.push({
|
|
164
|
+
id: rec.id,
|
|
165
|
+
name: rec.name,
|
|
166
|
+
category: typeof rec.category === "string" ? rec.category : null,
|
|
167
|
+
totalAllocatedCents: rec.totalAllocatedCents,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function budgetApprovalSnapshotsMatch(
|
|
174
|
+
current: BudgetApprovalSnapshotLine[],
|
|
175
|
+
snapshot: BudgetApprovalSnapshotLine[]
|
|
176
|
+
): boolean {
|
|
177
|
+
return (
|
|
178
|
+
budgetApprovalSnapshotHash(current) === budgetApprovalSnapshotHash(snapshot)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function validateBudgetApprovalDecide(
|
|
183
|
+
input: BudgetApprovalDecideInput
|
|
184
|
+
): { ok: true } | { ok: false; error: string } {
|
|
185
|
+
const decisionNote = input.decisionNote?.trim() ?? "";
|
|
186
|
+
const lineNotes = (input.lineNotes ?? []).filter(
|
|
187
|
+
(note) => note.note.trim().length > 0
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if (input.kind === "CapAsk" && lineNotes.length > 0) {
|
|
191
|
+
return { ok: false, error: "A cap ask has no budget lines to annotate." };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (input.action === "approve") {
|
|
195
|
+
if (input.kind === "CapAsk") {
|
|
196
|
+
const cents = input.authorizedCents;
|
|
197
|
+
if (cents == null || !Number.isFinite(cents) || cents <= 0) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
error: "Enter an authorized amount greater than zero.",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { ok: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (input.action === "changes") {
|
|
208
|
+
if (input.kind === "CapAsk") {
|
|
209
|
+
if (!decisionNote) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: "Add a note so the host knows what to change.",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return { ok: true };
|
|
216
|
+
}
|
|
217
|
+
if (!decisionNote && lineNotes.length === 0) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error: "Add an overall note or a note on at least one line.",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { ok: true };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { ok: true };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function resolveApproverDisplayName(params: {
|
|
230
|
+
decidedName?: string | null;
|
|
231
|
+
hostHintName?: string | null;
|
|
232
|
+
email: string;
|
|
233
|
+
}): string {
|
|
234
|
+
const decided = params.decidedName?.trim();
|
|
235
|
+
if (decided) return decided;
|
|
236
|
+
const hint = params.hostHintName?.trim();
|
|
237
|
+
if (hint) return hint;
|
|
238
|
+
return params.email;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function formatBudgetCents(cents: number): string {
|
|
242
|
+
return new Intl.NumberFormat("en-US", {
|
|
243
|
+
style: "currency",
|
|
244
|
+
currency: "USD",
|
|
245
|
+
minimumFractionDigits: 0,
|
|
246
|
+
maximumFractionDigits: 0,
|
|
247
|
+
}).format(cents / 100);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function eventBudgetApprovalWizardPath(bashEventId: string): string {
|
|
251
|
+
return `/bash-event-wizard/${bashEventId}/${BASH_EVENT_WIZARD_HOW_MAJOR_STEP}/${BASH_EVENT_WIZARD_HOW_SUBSTEP.BUDGET}?budgetTab=lines`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export type BudgetApprovalPublicDto = {
|
|
255
|
+
kind: BudgetApprovalKindName;
|
|
256
|
+
status: BudgetApprovalStatusName;
|
|
257
|
+
hostNote: string | null;
|
|
258
|
+
hostDisplayName: string;
|
|
259
|
+
event: {
|
|
260
|
+
id: string;
|
|
261
|
+
title: string;
|
|
262
|
+
startDateTime: string | null;
|
|
263
|
+
slug: string | null;
|
|
264
|
+
};
|
|
265
|
+
snapshotLines: BudgetApprovalSnapshotLine[];
|
|
266
|
+
snapshotTotalCents: number;
|
|
267
|
+
expiresAt: string;
|
|
268
|
+
expired: boolean;
|
|
269
|
+
alreadyDecided: boolean;
|
|
270
|
+
eventIsPubliclyViewable: boolean;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export type BudgetApprovalHostDto = {
|
|
274
|
+
id: string;
|
|
275
|
+
kind: BudgetApprovalKindName;
|
|
276
|
+
status: BudgetApprovalStatusName;
|
|
277
|
+
approverEmail: string;
|
|
278
|
+
approverName: string | null;
|
|
279
|
+
hostNote: string | null;
|
|
280
|
+
decisionNote: string | null;
|
|
281
|
+
authorizedCents: number | null;
|
|
282
|
+
snapshotTotalCents: number;
|
|
283
|
+
staleAt: string | null;
|
|
284
|
+
decidedAt: string | null;
|
|
285
|
+
expiresAt: string;
|
|
286
|
+
createdAt: string;
|
|
287
|
+
isExpired: boolean;
|
|
288
|
+
emailError?: boolean;
|
|
289
|
+
lineNotes: Array<{
|
|
290
|
+
budgetId: string | null;
|
|
291
|
+
nameSnapshot: string;
|
|
292
|
+
categorySnapshot: string | null;
|
|
293
|
+
allocatedCentsSnapshot: number;
|
|
294
|
+
note: string;
|
|
295
|
+
}>;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export type BudgetApprovalHostSummary = {
|
|
299
|
+
latest: BudgetApprovalHostDto | null;
|
|
300
|
+
history: BudgetApprovalHostDto[];
|
|
301
|
+
planMatchesSnapshot: boolean;
|
|
302
|
+
currentAuthorizedCents: number | null;
|
|
303
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-safe social-proof lines for suggestions and reminders.
|
|
3
|
+
* Never name hidden attendees. Never use a follow-count when names are hidden.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type CrewGoingPerson = {
|
|
7
|
+
displayName: string;
|
|
8
|
+
attendanceVisible: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type CrewSocialCopy = {
|
|
12
|
+
line: string | null;
|
|
13
|
+
named: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function crewSocialProofCopy(
|
|
17
|
+
people: readonly CrewGoingPerson[]
|
|
18
|
+
): CrewSocialCopy {
|
|
19
|
+
const visible = people.filter((p) => p.attendanceVisible && p.displayName.trim());
|
|
20
|
+
const hidden = people.filter((p) => !p.attendanceVisible);
|
|
21
|
+
|
|
22
|
+
if (visible.length >= 2) {
|
|
23
|
+
const a = visible[0].displayName.trim();
|
|
24
|
+
const b = visible[1].displayName.trim();
|
|
25
|
+
return { line: `${a} and ${b} are going`, named: true };
|
|
26
|
+
}
|
|
27
|
+
if (visible.length === 1) {
|
|
28
|
+
return { line: `${visible[0].displayName.trim()} is going`, named: true };
|
|
29
|
+
}
|
|
30
|
+
if (hidden.length > 0) {
|
|
31
|
+
return { line: "Your crew's on this one", named: false };
|
|
32
|
+
}
|
|
33
|
+
return { line: null, named: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const CREW_GOING_RAIL_TITLE = "The crew's going";
|
|
37
|
+
export const SCENE_PROMPT_COPY = "What's your scene?";
|
package/src/definitions.ts
CHANGED
|
@@ -675,6 +675,7 @@ export const HTTP_CODE_BAD_REQUEST = 400 as const;
|
|
|
675
675
|
export const HTTP_CODE_UNAUTHORIZED = 401 as const;
|
|
676
676
|
export const HTTP_CODE_FORBIDDEN = 403 as const;
|
|
677
677
|
export const HTTP_CODE_NOT_FOUND = 404 as const;
|
|
678
|
+
export const HTTP_CODE_GONE = 410 as const;
|
|
678
679
|
export const ERR_UNAUTHORIZED_REQUEST =
|
|
679
680
|
"Unauthorized to perform requested action. Have you logged in?" as const;
|
|
680
681
|
export const ERR_SESSION_EXPIRED =
|
|
@@ -831,6 +832,11 @@ export type ApiServiceBookingParams = {
|
|
|
831
832
|
bashEventId?: string;
|
|
832
833
|
/** Booking-level bundled packages (e.g. "Gold DJ Package"), independent of hourly/daily rates. */
|
|
833
834
|
packages?: ApiServicePackageParams[];
|
|
835
|
+
/**
|
|
836
|
+
* F&B per-guest bookings: host-entered guest count snapshotted at booking time.
|
|
837
|
+
* Saved to ServiceBooking.expectedAttendeesAtBooking. No promoter true-up.
|
|
838
|
+
*/
|
|
839
|
+
foodAndBeverageHeadcount?: number;
|
|
834
840
|
};
|
|
835
841
|
|
|
836
842
|
export type ApiServiceCanBookParams = {} & ApiServiceBookingParams;
|
|
@@ -1817,6 +1823,11 @@ const SYNTHETIC_BASH_SECTION_TITLE: Record<string, string> = {
|
|
|
1817
1823
|
Featured: "Featured",
|
|
1818
1824
|
Popular: "Popular",
|
|
1819
1825
|
Trending: "Trending",
|
|
1826
|
+
CrewGoing: "The crew's going",
|
|
1827
|
+
Local: "Local",
|
|
1828
|
+
State: "Statewide",
|
|
1829
|
+
National: "Across the country",
|
|
1830
|
+
International: "Worth the flight",
|
|
1820
1831
|
};
|
|
1821
1832
|
|
|
1822
1833
|
/**
|