@bash-app/bash-common 30.314.0 → 30.318.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__/appleIapProducts.test.d.ts +2 -0
- package/dist/__tests__/appleIapProducts.test.d.ts.map +1 -0
- package/dist/__tests__/appleIapProducts.test.js +32 -0
- package/dist/__tests__/appleIapProducts.test.js.map +1 -0
- package/dist/__tests__/hostCrmGrowthPack.test.d.ts +2 -0
- package/dist/__tests__/hostCrmGrowthPack.test.d.ts.map +1 -0
- package/dist/__tests__/hostCrmGrowthPack.test.js +25 -0
- package/dist/__tests__/hostCrmGrowthPack.test.js.map +1 -0
- package/dist/__tests__/hostCrmSegments.test.d.ts +2 -0
- package/dist/__tests__/hostCrmSegments.test.d.ts.map +1 -0
- package/dist/__tests__/hostCrmSegments.test.js +198 -0
- package/dist/__tests__/hostCrmSegments.test.js.map +1 -0
- package/dist/__tests__/relationshipHealth.test.d.ts +2 -0
- package/dist/__tests__/relationshipHealth.test.d.ts.map +1 -0
- package/dist/__tests__/relationshipHealth.test.js +265 -0
- package/dist/__tests__/relationshipHealth.test.js.map +1 -0
- package/dist/appleIapProducts.d.ts +64 -0
- package/dist/appleIapProducts.d.ts.map +1 -0
- package/dist/appleIapProducts.js +113 -0
- package/dist/appleIapProducts.js.map +1 -0
- package/dist/definitions.d.ts +3 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +2 -0
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +27 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/hostCrmAutomation.d.ts +89 -0
- package/dist/hostCrmAutomation.d.ts.map +1 -0
- package/dist/hostCrmAutomation.js +50 -0
- package/dist/hostCrmAutomation.js.map +1 -0
- package/dist/hostCrmInsights.d.ts +39 -0
- package/dist/hostCrmInsights.d.ts.map +1 -0
- package/dist/hostCrmInsights.js +2 -0
- package/dist/hostCrmInsights.js.map +1 -0
- package/dist/hostCrmSegments.d.ts +82 -2
- package/dist/hostCrmSegments.d.ts.map +1 -1
- package/dist/hostCrmSegments.js +93 -3
- package/dist/hostCrmSegments.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/membershipDefinitions.d.ts +2 -0
- package/dist/membershipDefinitions.d.ts.map +1 -1
- package/dist/membershipDefinitions.js +3 -0
- package/dist/membershipDefinitions.js.map +1 -1
- package/dist/relationshipHealth.d.ts +28 -0
- package/dist/relationshipHealth.d.ts.map +1 -0
- package/dist/relationshipHealth.js +91 -0
- package/dist/relationshipHealth.js.map +1 -0
- package/dist/sms/smsTemplates.d.ts +13 -1
- package/dist/sms/smsTemplates.d.ts.map +1 -1
- package/dist/sms/smsTemplates.js +3 -0
- package/dist/sms/smsTemplates.js.map +1 -1
- package/dist/utils/__tests__/slugUtils.test.js +9 -1
- package/dist/utils/__tests__/slugUtils.test.js.map +1 -1
- package/dist/utils/slugUtils.d.ts +7 -0
- package/dist/utils/slugUtils.d.ts.map +1 -1
- package/dist/utils/slugUtils.js +9 -0
- package/dist/utils/slugUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +154 -2
- package/src/__tests__/appleIapProducts.test.ts +52 -0
- package/src/__tests__/hostCrmGrowthPack.test.ts +33 -0
- package/src/__tests__/hostCrmSegments.test.ts +247 -0
- package/src/__tests__/relationshipHealth.test.ts +370 -0
- package/src/appleIapProducts.ts +203 -0
- package/src/definitions.ts +2 -0
- package/src/extendedSchemas.ts +30 -1
- package/src/hostCrmAutomation.ts +138 -0
- package/src/hostCrmInsights.ts +39 -0
- package/src/hostCrmSegments.ts +208 -7
- package/src/index.ts +4 -0
- package/src/membershipDefinitions.ts +3 -0
- package/src/relationshipHealth.ts +134 -0
- package/src/sms/smsTemplates.ts +6 -0
- package/src/utils/__tests__/slugUtils.test.ts +15 -0
- package/src/utils/slugUtils.ts +13 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HostCrmAutomationAudienceType as HostCrmAutomationAudienceTypeEnum,
|
|
3
|
+
HostCrmAutomationChannel as HostCrmAutomationChannelEnum,
|
|
4
|
+
HostCrmAutomationTrigger as HostCrmAutomationTriggerEnum,
|
|
5
|
+
} from "@prisma/client";
|
|
6
|
+
|
|
7
|
+
/** Mirrors Prisma `HostCrmAutomationTrigger` — `@prisma/client` browser bundle omits runtime enum exports. */
|
|
8
|
+
export const HostCrmAutomationTrigger = {
|
|
9
|
+
Recurring: "Recurring",
|
|
10
|
+
NewlyAtRisk: "NewlyAtRisk",
|
|
11
|
+
PostEvent: "PostEvent",
|
|
12
|
+
} as const satisfies Record<HostCrmAutomationTriggerEnum, HostCrmAutomationTriggerEnum>;
|
|
13
|
+
|
|
14
|
+
export type HostCrmAutomationTrigger = HostCrmAutomationTriggerEnum;
|
|
15
|
+
|
|
16
|
+
/** Mirrors Prisma `HostCrmAutomationChannel` — `@prisma/client` browser bundle omits runtime enum exports. */
|
|
17
|
+
export const HostCrmAutomationChannel = {
|
|
18
|
+
Email: "Email",
|
|
19
|
+
Sms: "Sms",
|
|
20
|
+
} as const satisfies Record<HostCrmAutomationChannelEnum, HostCrmAutomationChannelEnum>;
|
|
21
|
+
|
|
22
|
+
export type HostCrmAutomationChannel = HostCrmAutomationChannelEnum;
|
|
23
|
+
|
|
24
|
+
/** Mirrors Prisma `HostCrmAutomationAudienceType` — `@prisma/client` browser bundle omits runtime enum exports. */
|
|
25
|
+
export const HostCrmAutomationAudienceType = {
|
|
26
|
+
Preset: "Preset",
|
|
27
|
+
Segment: "Segment",
|
|
28
|
+
} as const satisfies Record<
|
|
29
|
+
HostCrmAutomationAudienceTypeEnum,
|
|
30
|
+
HostCrmAutomationAudienceTypeEnum
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
export type HostCrmAutomationAudienceType = HostCrmAutomationAudienceTypeEnum;
|
|
34
|
+
|
|
35
|
+
/** Minimum days between two runs of a Recurring automation. */
|
|
36
|
+
export const HOST_CRM_AUTOMATION_MIN_CADENCE_DAYS = 1;
|
|
37
|
+
/** Default cadence for a newly created Recurring automation, matching the legacy win-back cron interval. */
|
|
38
|
+
export const HOST_CRM_AUTOMATION_DEFAULT_CADENCE_DAYS = 30;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* API/client shape of a `HostCrmAutomation` row — dates are ISO strings (as
|
|
42
|
+
* returned by the JSON API), not the raw Prisma `Date` type. See
|
|
43
|
+
* `api/src/api/user/privateUser/hostCrmAutomationApi.ts` for the endpoints
|
|
44
|
+
* that produce this shape and `bash-app/src/dataLayer/user/attendeeAudienceFetcher.ts`
|
|
45
|
+
* for the client fetchers.
|
|
46
|
+
*/
|
|
47
|
+
export type HostCrmAutomation = {
|
|
48
|
+
id: string;
|
|
49
|
+
hostUserId: string;
|
|
50
|
+
name: string;
|
|
51
|
+
trigger: HostCrmAutomationTrigger;
|
|
52
|
+
channel: HostCrmAutomationChannel;
|
|
53
|
+
/** Ignored for PostEvent — audience there is implicit (that event's ticket holders). */
|
|
54
|
+
audienceType: HostCrmAutomationAudienceType | null;
|
|
55
|
+
audiencePresetId: string | null;
|
|
56
|
+
audienceSegmentId: string | null;
|
|
57
|
+
subject: string | null;
|
|
58
|
+
body: string;
|
|
59
|
+
/** Only used by the Recurring trigger. */
|
|
60
|
+
cadenceDays: number | null;
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
lastRunAt: string | null;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
updatedAt: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Payload for creating or fully updating a `HostCrmAutomation`. */
|
|
68
|
+
export type HostCrmAutomationInput = {
|
|
69
|
+
name: string;
|
|
70
|
+
trigger: HostCrmAutomationTrigger;
|
|
71
|
+
channel: HostCrmAutomationChannel;
|
|
72
|
+
audienceType?: HostCrmAutomationAudienceType | null;
|
|
73
|
+
audiencePresetId?: string | null;
|
|
74
|
+
audienceSegmentId?: string | null;
|
|
75
|
+
subject?: string | null;
|
|
76
|
+
body: string;
|
|
77
|
+
cadenceDays?: number | null;
|
|
78
|
+
enabled?: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* "Growth pack" — the one-click Host CRM automation pair (win-back + post-event
|
|
83
|
+
* thank-you). Specs live here so API enable, legacy backfill, and UI detection
|
|
84
|
+
* cannot drift apart.
|
|
85
|
+
*/
|
|
86
|
+
export const HOST_CRM_GROWTH_PACK_WINBACK_PRESET_ID = "lapsed_180";
|
|
87
|
+
export const HOST_CRM_GROWTH_PACK_WINBACK_NAME = "Win-back email";
|
|
88
|
+
export const HOST_CRM_GROWTH_PACK_POST_EVENT_NAME = "Post-event thank-you";
|
|
89
|
+
|
|
90
|
+
/** Shared with the live win-back blast defaults — keep in lockstep. */
|
|
91
|
+
export const HOST_CRM_WINBACK_DEFAULT_SUBJECT =
|
|
92
|
+
"We miss you — come back out with us";
|
|
93
|
+
export const HOST_CRM_WINBACK_DEFAULT_BODY =
|
|
94
|
+
"It's been a while since your last bash with us. We'd love to see you again.";
|
|
95
|
+
|
|
96
|
+
/** Post-event cron substitutes {{eventTitle}}, {{firstName}}, {{bashFeedUrl}}. */
|
|
97
|
+
export const HOST_CRM_GROWTH_PACK_POST_EVENT_SUBJECT =
|
|
98
|
+
"Thank you for coming to {{eventTitle}}";
|
|
99
|
+
export const HOST_CRM_GROWTH_PACK_POST_EVENT_BODY =
|
|
100
|
+
"Hi {{firstName}},\n\nThanks so much for being part of {{eventTitle}} — it wouldn't have been the same without you. Hope to see you at the next one!\n\n{{bashFeedUrl}}";
|
|
101
|
+
|
|
102
|
+
type GrowthPackAutomationShape = {
|
|
103
|
+
trigger: string;
|
|
104
|
+
channel: string;
|
|
105
|
+
audiencePresetId?: string | null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** True when a Recurring Email + lapsed_180 win-back automation already exists. */
|
|
109
|
+
export function hostCrmHasWinBackPackAutomation(
|
|
110
|
+
automations: GrowthPackAutomationShape[]
|
|
111
|
+
): boolean {
|
|
112
|
+
return automations.some(
|
|
113
|
+
(a) =>
|
|
114
|
+
a.trigger === HostCrmAutomationTrigger.Recurring &&
|
|
115
|
+
a.channel === HostCrmAutomationChannel.Email &&
|
|
116
|
+
a.audiencePresetId === HOST_CRM_GROWTH_PACK_WINBACK_PRESET_ID
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when a PostEvent Email thank-you automation already exists. */
|
|
121
|
+
export function hostCrmHasPostEventPackAutomation(
|
|
122
|
+
automations: GrowthPackAutomationShape[]
|
|
123
|
+
): boolean {
|
|
124
|
+
return automations.some(
|
|
125
|
+
(a) =>
|
|
126
|
+
a.trigger === HostCrmAutomationTrigger.PostEvent &&
|
|
127
|
+
a.channel === HostCrmAutomationChannel.Email
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function hostCrmGrowthPackIsComplete(
|
|
132
|
+
automations: GrowthPackAutomationShape[]
|
|
133
|
+
): boolean {
|
|
134
|
+
return (
|
|
135
|
+
hostCrmHasWinBackPackAutomation(automations) &&
|
|
136
|
+
hostCrmHasPostEventPackAutomation(automations)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MembershipTier } from "./membershipDefinitions.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One curated predictive-insight observation for the Attendee CRM's
|
|
5
|
+
* Recommended-actions accordion. Deliberately a discriminated union of
|
|
6
|
+
* discrete, computed facts (not prose) — same design choice already made for
|
|
7
|
+
* `HostCrmTimelineEntry` — so the drawer/card renders each fact directly and
|
|
8
|
+
* a future AI layer can consume the same structured data without re-deriving it.
|
|
9
|
+
*/
|
|
10
|
+
export type HostCrmInsight =
|
|
11
|
+
| {
|
|
12
|
+
type: "MostLikelyToReturn";
|
|
13
|
+
nextEventTitle: string;
|
|
14
|
+
nextEventStartsAt: string;
|
|
15
|
+
attendeeCount: number;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
type: "AttendanceTrend";
|
|
19
|
+
direction: "up" | "down" | "flat";
|
|
20
|
+
percent: number;
|
|
21
|
+
recentCount: number;
|
|
22
|
+
priorCount: number;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
type: "DayOfWeekRetention";
|
|
26
|
+
bestDay: string;
|
|
27
|
+
worstDay: string;
|
|
28
|
+
bestRepeatRate: number;
|
|
29
|
+
worstRepeatRate: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Response shape for the Predictive Insights card. Gated server-side —
|
|
34
|
+
* `locked: true` hosts never receive computed `insights`, so a Basic-tier
|
|
35
|
+
* host can't see real data even by inspecting the network response.
|
|
36
|
+
*/
|
|
37
|
+
export type HostCrmInsightsResult =
|
|
38
|
+
| { locked: true; requiredTier: MembershipTier }
|
|
39
|
+
| { locked: false; insights: HostCrmInsight[] };
|
package/src/hostCrmSegments.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { SocialEventCadence } from "@prisma/client";
|
|
2
2
|
|
|
3
|
+
import type {
|
|
4
|
+
RelationshipHealthResult,
|
|
5
|
+
RelationshipHealthStatus,
|
|
6
|
+
} from "./relationshipHealth.js";
|
|
7
|
+
|
|
3
8
|
/** Rule-based CRM segment (stored as JSON on HostCrmSavedSegment.rules). */
|
|
4
9
|
export type HostCrmSegmentRules = {
|
|
5
10
|
tag?: string;
|
|
6
11
|
cadence?: SocialEventCadence;
|
|
7
12
|
minEventCount?: number;
|
|
13
|
+
/** First-timers-style segment: attended at most this many of the host's bashes. */
|
|
14
|
+
maxEventCount?: number;
|
|
8
15
|
lapsedDays?: number;
|
|
9
16
|
/** Ticket buyers for this bash event id */
|
|
10
17
|
sourceEventId?: string;
|
|
@@ -14,10 +21,30 @@ export type HostCrmSegmentRules = {
|
|
|
14
21
|
tierTitle?: string;
|
|
15
22
|
/** Match attendee city (case-insensitive substring) */
|
|
16
23
|
city?: string;
|
|
17
|
-
/** Built-in preset id (e.g.
|
|
24
|
+
/** Built-in preset id (e.g. top_spenders) when rules alone are insufficient */
|
|
18
25
|
presetId?: string;
|
|
26
|
+
/** Relationship Health status (New/Steady/Cooling/AtRisk) */
|
|
27
|
+
healthStatus?: RelationshipHealthStatus;
|
|
28
|
+
/** Lifetime spend across host bashes, in cents */
|
|
29
|
+
minTotalSpendCents?: number;
|
|
30
|
+
/** Bought a ticket but never checked in to an ended bash */
|
|
31
|
+
noShow?: boolean;
|
|
32
|
+
/** UI label: "Uses Promo Codes" — redeemed a promo code on a host bash */
|
|
33
|
+
discountResponsive?: boolean;
|
|
34
|
+
/** UI label: "Last-Minute Buyer" — typically buys within 48h of the event */
|
|
35
|
+
fastDecider?: boolean;
|
|
36
|
+
/** UI label: "Series Regular" — attended 2+ events in the same EventSeries */
|
|
37
|
+
seriesLoyalist?: boolean;
|
|
38
|
+
/** Refunded/cancelled a ticket and hasn't been back since */
|
|
39
|
+
refundChurn?: boolean;
|
|
40
|
+
/** UI label: "Brings Friends" — buys multiple tickets per checkout or brings plus-ones */
|
|
41
|
+
bringsGuests?: boolean;
|
|
42
|
+
/** Ticket buyers for any of these bash event ids (e.g. the host's 3 most recently ended bashes — see the last3Events preset) */
|
|
43
|
+
sourceEventIdIn?: string[];
|
|
19
44
|
};
|
|
20
45
|
|
|
46
|
+
export type HostCrmDiscoverySource = "promoter" | "referral" | "direct";
|
|
47
|
+
|
|
21
48
|
export type HostCrmAudienceMember = {
|
|
22
49
|
userId: string | null;
|
|
23
50
|
email: string | null;
|
|
@@ -32,19 +59,41 @@ export type HostCrmAudienceMember = {
|
|
|
32
59
|
reachableEmail: boolean;
|
|
33
60
|
reachableSms: boolean;
|
|
34
61
|
lastAttendedAt: string | null;
|
|
62
|
+
/** First attended bash time, used to derive this attendee's own cadence for Relationship Health. */
|
|
63
|
+
firstAttendedAt: string | null;
|
|
35
64
|
city: string | null;
|
|
36
65
|
tierTitles: string[];
|
|
37
66
|
/** Bash event ids this person attended (for cohort / source filters) */
|
|
38
67
|
sourceEventIds?: string[];
|
|
39
68
|
/** True when row is email-only (guest checkout) without a linked user id */
|
|
40
69
|
emailOnly: boolean;
|
|
70
|
+
/** Lifetime spend across host bashes, in cents (paidUnitPriceCents when set, else tier price). */
|
|
71
|
+
totalSpendCents: number;
|
|
72
|
+
/** Ended bashes where they bought a ticket but never checked in. */
|
|
73
|
+
noShowCount: number;
|
|
74
|
+
/** UI label: "Uses Promo Codes" */
|
|
75
|
+
discountResponsive: boolean;
|
|
76
|
+
/** UI label: "Last-Minute Buyer" — majority of purchases within 48h of the event start */
|
|
77
|
+
fastDecider: boolean;
|
|
78
|
+
/** UI label: "Series Regular" — attended 2+ events sharing an EventSeries */
|
|
79
|
+
seriesLoyalist: boolean;
|
|
80
|
+
/** Refunded/cancelled a ticket on a host bash and hasn't bought again since */
|
|
81
|
+
refundChurn: boolean;
|
|
82
|
+
/** UI label: "Brings Friends" — multi-ticket checkouts, group tiers, or RSVP plus-ones */
|
|
83
|
+
bringsGuests: boolean;
|
|
84
|
+
/** How this attendee was attributed to the host's audience, when known. */
|
|
85
|
+
discoverySource: HostCrmDiscoverySource;
|
|
86
|
+
/** This attendee's own derived cadence in days, or null when there isn't enough history to personalize it. */
|
|
87
|
+
expectedGapDays: number | null;
|
|
88
|
+
/** Predicted next-attendance date (now + expectedGapDays from lastAttendedAt), or null when unknown. */
|
|
89
|
+
nextExpectedAt: string | null;
|
|
41
90
|
};
|
|
42
91
|
|
|
43
|
-
export function applyHostCrmSegmentRules(
|
|
44
|
-
audience:
|
|
92
|
+
export function applyHostCrmSegmentRules<T extends HostCrmAudienceMember>(
|
|
93
|
+
audience: T[],
|
|
45
94
|
rules: HostCrmSegmentRules,
|
|
46
95
|
now: Date = new Date()
|
|
47
|
-
):
|
|
96
|
+
): T[] {
|
|
48
97
|
let rows = audience;
|
|
49
98
|
|
|
50
99
|
if (rules.tag?.trim()) {
|
|
@@ -60,6 +109,10 @@ export function applyHostCrmSegmentRules(
|
|
|
60
109
|
rows = rows.filter((r) => r.eventCount >= rules.minEventCount!);
|
|
61
110
|
}
|
|
62
111
|
|
|
112
|
+
if (typeof rules.maxEventCount === "number" && rules.maxEventCount > 0) {
|
|
113
|
+
rows = rows.filter((r) => r.eventCount <= rules.maxEventCount!);
|
|
114
|
+
}
|
|
115
|
+
|
|
63
116
|
if (typeof rules.lapsedDays === "number" && rules.lapsedDays > 0) {
|
|
64
117
|
const cutoff = now.getTime() - rules.lapsedDays * 24 * 60 * 60 * 1000;
|
|
65
118
|
rows = rows.filter((r) => {
|
|
@@ -78,6 +131,11 @@ export function applyHostCrmSegmentRules(
|
|
|
78
131
|
rows = rows.filter((r) => !r.sourceEventIds?.includes(excludeId));
|
|
79
132
|
}
|
|
80
133
|
|
|
134
|
+
if (rules.sourceEventIdIn && rules.sourceEventIdIn.length > 0) {
|
|
135
|
+
const idSet = new Set(rules.sourceEventIdIn);
|
|
136
|
+
rows = rows.filter((r) => r.sourceEventIds?.some((id) => idSet.has(id)));
|
|
137
|
+
}
|
|
138
|
+
|
|
81
139
|
if (rules.tierTitle?.trim()) {
|
|
82
140
|
const tier = rules.tierTitle.trim().toLowerCase();
|
|
83
141
|
rows = rows.filter((r) =>
|
|
@@ -90,6 +148,43 @@ export function applyHostCrmSegmentRules(
|
|
|
90
148
|
rows = rows.filter((r) => (r.city ?? "").toLowerCase().includes(cityNeedle));
|
|
91
149
|
}
|
|
92
150
|
|
|
151
|
+
if (typeof rules.minTotalSpendCents === "number" && rules.minTotalSpendCents > 0) {
|
|
152
|
+
rows = rows.filter((r) => r.totalSpendCents >= rules.minTotalSpendCents!);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (rules.noShow) {
|
|
156
|
+
rows = rows.filter((r) => r.noShowCount > 0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (rules.discountResponsive) {
|
|
160
|
+
rows = rows.filter((r) => r.discountResponsive);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (rules.fastDecider) {
|
|
164
|
+
rows = rows.filter((r) => r.fastDecider);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (rules.seriesLoyalist) {
|
|
168
|
+
rows = rows.filter((r) => r.seriesLoyalist);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (rules.refundChurn) {
|
|
172
|
+
rows = rows.filter((r) => r.refundChurn);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (rules.bringsGuests) {
|
|
176
|
+
rows = rows.filter((r) => r.bringsGuests);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (rules.healthStatus) {
|
|
180
|
+
rows = rows.filter((r) => {
|
|
181
|
+
const health = (
|
|
182
|
+
r as HostCrmAudienceMember & { relationshipHealth?: RelationshipHealthResult }
|
|
183
|
+
).relationshipHealth;
|
|
184
|
+
return health?.status === rules.healthStatus;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
93
188
|
return rows;
|
|
94
189
|
}
|
|
95
190
|
|
|
@@ -105,6 +200,18 @@ export const HOST_CRM_PRESET_SEGMENTS: Array<{
|
|
|
105
200
|
description: "People who have been to at least two of your bashes",
|
|
106
201
|
rules: { minEventCount: 2 },
|
|
107
202
|
},
|
|
203
|
+
{
|
|
204
|
+
id: "frequent_guest",
|
|
205
|
+
label: "Frequent guest (3+ bashes)",
|
|
206
|
+
description: "People who have been to at least three of your bashes",
|
|
207
|
+
rules: { minEventCount: 3 },
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "first_timers",
|
|
211
|
+
label: "First-timers",
|
|
212
|
+
description: "Came to exactly one of your bashes and haven't been back",
|
|
213
|
+
rules: { maxEventCount: 1 },
|
|
214
|
+
},
|
|
108
215
|
{
|
|
109
216
|
id: "lapsed_90",
|
|
110
217
|
label: "Haven't been in 90 days",
|
|
@@ -118,9 +225,103 @@ export const HOST_CRM_PRESET_SEGMENTS: Array<{
|
|
|
118
225
|
rules: { lapsedDays: 180 },
|
|
119
226
|
},
|
|
120
227
|
{
|
|
121
|
-
id: "
|
|
122
|
-
label: "
|
|
123
|
-
description: "
|
|
228
|
+
id: "top_spenders",
|
|
229
|
+
label: "Top spenders",
|
|
230
|
+
description: "Your top quartile of attendees by lifetime spend",
|
|
231
|
+
// Computed at query time from the full audience — see filterHostCrmAudience's presetId branch.
|
|
232
|
+
rules: {},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "no_show",
|
|
236
|
+
label: "Bought, didn't check in",
|
|
237
|
+
description: "Bought a ticket to a past bash but never checked in",
|
|
238
|
+
rules: { noShow: true },
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: "uses_promo_codes",
|
|
242
|
+
label: "Uses promo codes",
|
|
243
|
+
description: "Has redeemed a promo code on one of your bashes",
|
|
244
|
+
rules: { discountResponsive: true },
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: "last_minute_buyer",
|
|
248
|
+
label: "Last-minute buyer",
|
|
249
|
+
description: "Typically buys within 48 hours of the event",
|
|
250
|
+
rules: { fastDecider: true },
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: "refund_churn",
|
|
254
|
+
label: "Refunded and hasn't been back",
|
|
255
|
+
description: "Refunded or cancelled a ticket and hasn't returned since",
|
|
256
|
+
rules: { refundChurn: true },
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: "series_regular",
|
|
260
|
+
label: "Series regular",
|
|
261
|
+
description: "Attended two or more events in the same series",
|
|
262
|
+
rules: { seriesLoyalist: true },
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: "brings_friends",
|
|
266
|
+
label: "Brings friends",
|
|
267
|
+
description: "Usually buys multiple tickets or brings plus-ones",
|
|
268
|
+
rules: { bringsGuests: true },
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: "last3Events",
|
|
272
|
+
label: "Last 3 events",
|
|
273
|
+
description: "People who came to any of your 3 most recently ended bashes",
|
|
274
|
+
// Computed at query time from the host's finished events — see filterHostCrmAudience's presetId branch.
|
|
124
275
|
rules: {},
|
|
125
276
|
},
|
|
126
277
|
];
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One chronological entry in an attendee's relationship timeline (detail
|
|
281
|
+
* drawer). Deliberately a discriminated union of discrete facts rather than
|
|
282
|
+
* pre-rendered prose: the drawer renders it directly, and any future
|
|
283
|
+
* AI-drafted-message feature can consume the same structured facts to build
|
|
284
|
+
* a prompt without re-deriving them (see `buildAttendeeCrmContext`).
|
|
285
|
+
*/
|
|
286
|
+
export type HostCrmTimelineEntryType =
|
|
287
|
+
| "TicketPurchased"
|
|
288
|
+
| "CheckedIn"
|
|
289
|
+
| "Refunded"
|
|
290
|
+
| "DiscountApplied"
|
|
291
|
+
| "EmailSent"
|
|
292
|
+
| "SmsSent"
|
|
293
|
+
| "NoteAdded"
|
|
294
|
+
| "NoteUpdated"
|
|
295
|
+
| "TagAdded"
|
|
296
|
+
| "TagRemoved";
|
|
297
|
+
|
|
298
|
+
export type HostCrmTimelineEntry = {
|
|
299
|
+
type: HostCrmTimelineEntryType;
|
|
300
|
+
/** ISO timestamp. */
|
|
301
|
+
at: string;
|
|
302
|
+
/** Short human-readable detail (event title, discount code, tag name, etc.). */
|
|
303
|
+
detail?: string;
|
|
304
|
+
/** Rendering-only flag: true when a TicketPurchased entry was for a VIP-titled tier. */
|
|
305
|
+
isVip?: boolean;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** Drawer-only behavioral badges, kept off the always-visible attendee row. */
|
|
309
|
+
export type HostCrmAttendeeDetailBadges = {
|
|
310
|
+
fastDecider: boolean;
|
|
311
|
+
discountResponsive: boolean;
|
|
312
|
+
seriesLoyalist: boolean;
|
|
313
|
+
refundChurn: boolean;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Full detail payload for one attendee's drawer — the response shape for
|
|
318
|
+
* `GET /attendee-audience/:attendeeUserId/detail`, built by the shared
|
|
319
|
+
* `buildAttendeeCrmContext` function so a future AI compose endpoint can
|
|
320
|
+
* reuse the same assembly without refactoring this contract.
|
|
321
|
+
*/
|
|
322
|
+
export type HostCrmAttendeeDetail = {
|
|
323
|
+
member: HostCrmAudienceMember;
|
|
324
|
+
relationshipHealth: RelationshipHealthResult;
|
|
325
|
+
badges: HostCrmAttendeeDetailBadges;
|
|
326
|
+
timeline: HostCrmTimelineEntry[];
|
|
327
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export * from "./storeTypes.js";
|
|
|
19
19
|
export * from "./storePrintArtworkUtils.js";
|
|
20
20
|
export * from "./bashFeedTypes.js";
|
|
21
21
|
export * from "./membershipDefinitions.js";
|
|
22
|
+
export * from "./appleIapProducts.js";
|
|
22
23
|
export * from "./onSaleCapabilityRecommendations.js";
|
|
23
24
|
export * from "./ticketBnplPaymentMethods.js";
|
|
24
25
|
export * from "./aiApproval.js";
|
|
@@ -30,6 +31,9 @@ export * from "./entertainmentNotableVenues.js";
|
|
|
30
31
|
export * from "./productLaunch.js";
|
|
31
32
|
export * from "./accessScreening.js";
|
|
32
33
|
export * from "./hostCrmSegments.js";
|
|
34
|
+
export * from "./hostCrmInsights.js";
|
|
35
|
+
export * from "./hostCrmAutomation.js";
|
|
36
|
+
export * from "./relationshipHealth.js";
|
|
33
37
|
export * from "./hostCrmConstants.js";
|
|
34
38
|
export * from "./userReportTypes.js";
|
|
35
39
|
export * from "./mirroredPrismaEnums.js";
|
|
@@ -807,6 +807,8 @@ export const MEMBERSHIP_FEATURES = {
|
|
|
807
807
|
FEATURED_EVENTS_PAID: 'featured_events_paid',
|
|
808
808
|
BETA_FEATURES: 'beta_features',
|
|
809
809
|
BASIC_NOTIFICATIONS: 'basic_notifications',
|
|
810
|
+
/** Host CRM: predictive insight cards (most-likely-to-return, attendance trend, day-of-week retention). */
|
|
811
|
+
HOST_CRM_INSIGHTS: 'host_crm_insights',
|
|
810
812
|
|
|
811
813
|
// Pro features
|
|
812
814
|
FREE_FEATURED_EVENTS: 'free_featured_events',
|
|
@@ -850,6 +852,7 @@ export function hasFeatureAccess(userTier: MembershipTier, feature: string): boo
|
|
|
850
852
|
case MEMBERSHIP_FEATURES.FEATURED_EVENTS_PAID:
|
|
851
853
|
case MEMBERSHIP_FEATURES.BETA_FEATURES:
|
|
852
854
|
case MEMBERSHIP_FEATURES.BASIC_NOTIFICATIONS:
|
|
855
|
+
case MEMBERSHIP_FEATURES.HOST_CRM_INSIGHTS:
|
|
853
856
|
return tierIndex >= 1; // Creator+
|
|
854
857
|
|
|
855
858
|
case MEMBERSHIP_FEATURES.FREE_FEATURED_EVENTS:
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-attendee Relationship Health — one deterministic, inspectable signal for
|
|
3
|
+
* the Attendee CRM, replacing the narrower "lapsed VIP alert" idea with a
|
|
4
|
+
* status every attendee gets. Deliberately rule-based (not LLM-guessed) so a
|
|
5
|
+
* host can see exactly why a status fired via `reasons`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type RelationshipHealthStatus = "New" | "Steady" | "Cooling" | "AtRisk";
|
|
9
|
+
|
|
10
|
+
export const RELATIONSHIP_HEALTH_LABELS: Record<RelationshipHealthStatus, string> = {
|
|
11
|
+
New: "New",
|
|
12
|
+
Steady: "Steady",
|
|
13
|
+
Cooling: "Cooling",
|
|
14
|
+
AtRisk: "At risk",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RelationshipHealthInputs = {
|
|
18
|
+
eventCount: number;
|
|
19
|
+
/** ISO date of the most recent attended bash, if any. */
|
|
20
|
+
lastAttendedAt: string | null;
|
|
21
|
+
/** ISO date of the first attended bash, if any (used to derive this attendee's own cadence). */
|
|
22
|
+
firstAttendedAt: string | null;
|
|
23
|
+
tags: string[];
|
|
24
|
+
/** ISO date of the most recent host outreach (CRM email/SMS) to this attendee, if any. Display-only input — does not change the bucket. */
|
|
25
|
+
lastOutreachAt: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type RelationshipHealthResult = {
|
|
29
|
+
status: RelationshipHealthStatus;
|
|
30
|
+
/** Ordered, human-readable trace of the exact signals behind this status — shown on hover/click, never hidden. */
|
|
31
|
+
reasons: string[];
|
|
32
|
+
daysSinceLastAttended: number | null;
|
|
33
|
+
/** This attendee's own derived cadence in days, or null when there isn't enough history to personalize it. */
|
|
34
|
+
expectedGapDays: number | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const DEFAULT_EXPECTED_GAP_DAYS = 60;
|
|
38
|
+
const MIN_EXPECTED_GAP_DAYS = 14;
|
|
39
|
+
const NEW_WINDOW_DAYS = 45;
|
|
40
|
+
const SINGLE_EVENT_COOLING_DAYS = 120;
|
|
41
|
+
const STEADY_RATIO = 1.5;
|
|
42
|
+
const COOLING_RATIO = 3;
|
|
43
|
+
|
|
44
|
+
function isVip(tags: string[]): boolean {
|
|
45
|
+
return tags.some((t) => t.trim().toLowerCase() === "vip");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function daysBetween(later: Date, earlier: Date): number {
|
|
49
|
+
return Math.round((later.getTime() - earlier.getTime()) / (24 * 60 * 60 * 1000));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function plural(n: number): string {
|
|
53
|
+
return n === 1 ? "" : "s";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function computeRelationshipHealth(
|
|
57
|
+
inputs: RelationshipHealthInputs,
|
|
58
|
+
now: Date = new Date()
|
|
59
|
+
): RelationshipHealthResult {
|
|
60
|
+
const reasons: string[] = [];
|
|
61
|
+
const vip = isVip(inputs.tags);
|
|
62
|
+
|
|
63
|
+
if (inputs.eventCount === 0) {
|
|
64
|
+
reasons.push("No attended bashes on record yet");
|
|
65
|
+
return { status: "New", reasons, daysSinceLastAttended: null, expectedGapDays: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lastAttended = inputs.lastAttendedAt ? new Date(inputs.lastAttendedAt) : null;
|
|
69
|
+
const daysSinceLastAttended = lastAttended ? daysBetween(now, lastAttended) : null;
|
|
70
|
+
if (daysSinceLastAttended !== null) {
|
|
71
|
+
reasons.push(
|
|
72
|
+
`Last attended ${daysSinceLastAttended} day${plural(daysSinceLastAttended)} ago`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let status: RelationshipHealthStatus;
|
|
77
|
+
let expectedGapDays: number | null = null;
|
|
78
|
+
|
|
79
|
+
if (inputs.eventCount === 1) {
|
|
80
|
+
reasons.push("Attended 1 bash so far");
|
|
81
|
+
if (daysSinceLastAttended === null || daysSinceLastAttended <= NEW_WINDOW_DAYS) {
|
|
82
|
+
status = "New";
|
|
83
|
+
} else if (daysSinceLastAttended <= SINGLE_EVENT_COOLING_DAYS) {
|
|
84
|
+
status = "Cooling";
|
|
85
|
+
} else {
|
|
86
|
+
status = "AtRisk";
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
const first = inputs.firstAttendedAt ? new Date(inputs.firstAttendedAt) : null;
|
|
90
|
+
expectedGapDays = DEFAULT_EXPECTED_GAP_DAYS;
|
|
91
|
+
if (first && lastAttended) {
|
|
92
|
+
const spanDays = daysBetween(lastAttended, first);
|
|
93
|
+
if (spanDays > 0) {
|
|
94
|
+
expectedGapDays = Math.max(
|
|
95
|
+
MIN_EXPECTED_GAP_DAYS,
|
|
96
|
+
Math.round(spanDays / (inputs.eventCount - 1))
|
|
97
|
+
);
|
|
98
|
+
reasons.push(
|
|
99
|
+
`Typically attends every ~${expectedGapDays} days (based on ${inputs.eventCount} bashes)`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (expectedGapDays === DEFAULT_EXPECTED_GAP_DAYS) {
|
|
104
|
+
reasons.push(
|
|
105
|
+
`Using a default ${DEFAULT_EXPECTED_GAP_DAYS}-day cadence — not enough history to personalize yet`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ratio = daysSinceLastAttended === null ? 0 : daysSinceLastAttended / expectedGapDays;
|
|
110
|
+
if (ratio <= STEADY_RATIO) {
|
|
111
|
+
status = "Steady";
|
|
112
|
+
} else if (ratio <= COOLING_RATIO) {
|
|
113
|
+
status = "Cooling";
|
|
114
|
+
} else {
|
|
115
|
+
status = "AtRisk";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (vip && status === "Cooling") {
|
|
120
|
+
status = "AtRisk";
|
|
121
|
+
reasons.push("Tagged VIP — escalated from Cooling");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (inputs.lastOutreachAt) {
|
|
125
|
+
const daysSinceOutreach = daysBetween(now, new Date(inputs.lastOutreachAt));
|
|
126
|
+
reasons.push(
|
|
127
|
+
`Last contacted ${daysSinceOutreach} day${plural(daysSinceOutreach)} ago`
|
|
128
|
+
);
|
|
129
|
+
} else if (status === "Cooling" || status === "AtRisk") {
|
|
130
|
+
reasons.push("No outreach on record");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { status, reasons, daysSinceLastAttended, expectedGapDays };
|
|
134
|
+
}
|
package/src/sms/smsTemplates.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* Consumers use these keys for type-safe `sendTemplatedSms` calls.
|
|
4
4
|
*/
|
|
5
5
|
export const SMS_TEMPLATE_KEYS = [
|
|
6
|
+
"EVENT_REMINDER_1WEEK",
|
|
7
|
+
"EVENT_REMINDER_24H",
|
|
8
|
+
"EVENT_REMINDER_3H",
|
|
6
9
|
"EVENT_REMINDER_1H",
|
|
7
10
|
"EVENT_VENUE_CHANGED",
|
|
8
11
|
"EVENT_WAITLIST_APPROVED",
|
|
@@ -13,6 +16,9 @@ export type SmsTemplateKey = (typeof SMS_TEMPLATE_KEYS)[number];
|
|
|
13
16
|
|
|
14
17
|
/** Typed context per template (API maps these to final message strings). */
|
|
15
18
|
export type SmsTemplateContext = {
|
|
19
|
+
EVENT_REMINDER_1WEEK: { eventTitle: string; shortUrl: string };
|
|
20
|
+
EVENT_REMINDER_24H: { eventTitle: string; shortUrl: string };
|
|
21
|
+
EVENT_REMINDER_3H: { eventTitle: string; shortUrl: string };
|
|
16
22
|
EVENT_REMINDER_1H: { eventTitle: string; shortUrl: string };
|
|
17
23
|
EVENT_VENUE_CHANGED: { eventTitle: string; shortUrl: string };
|
|
18
24
|
EVENT_WAITLIST_APPROVED: { eventTitle: string; shortUrl: string };
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
generateUniqueSlug,
|
|
11
11
|
generateBashDetailUrl,
|
|
12
12
|
getPublicBashDetailPath,
|
|
13
|
+
getPublicBashMemoriesPath,
|
|
13
14
|
getBashEventWizardStepPath,
|
|
14
15
|
getBashEventWizardPayoutStepPath,
|
|
15
16
|
parseBashUrlParams,
|
|
@@ -79,6 +80,20 @@ describe("getPublicBashDetailPath", () => {
|
|
|
79
80
|
});
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
describe("getPublicBashMemoriesPath", () => {
|
|
84
|
+
test("nests /memories under the slug detail path", () => {
|
|
85
|
+
expect(getPublicBashMemoriesPath("abc123", "summer-bash")).toBe(
|
|
86
|
+
"/bash/abc123-summer-bash/memories"
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("nests /memories under the id-only path when slug is missing", () => {
|
|
91
|
+
expect(getPublicBashMemoriesPath("abc123", null)).toBe(
|
|
92
|
+
`${BASH_DETAIL_URL}/abc123/memories`
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
82
97
|
describe("getBashEventWizardStepPath", () => {
|
|
83
98
|
test("builds wizard step path", () => {
|
|
84
99
|
expect(getBashEventWizardStepPath("evt-1", 3, 2)).toBe(
|