@bash-app/bash-common 30.433.0 → 30.434.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.js +1 -0
- package/dist/__tests__/appleIapProducts.test.js.map +1 -1
- package/dist/__tests__/promoCodeScope.test.js +9 -2
- package/dist/__tests__/promoCodeScope.test.js.map +1 -1
- package/dist/__tests__/statusEnums.test.js +1 -0
- package/dist/__tests__/statusEnums.test.js.map +1 -1
- package/dist/appleIapProducts.d.ts +12 -2
- package/dist/appleIapProducts.d.ts.map +1 -1
- package/dist/appleIapProducts.js +13 -0
- package/dist/appleIapProducts.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/intros/__tests__/introsReleaseGates.test.d.ts +2 -0
- package/dist/intros/__tests__/introsReleaseGates.test.d.ts.map +1 -0
- package/dist/intros/__tests__/introsReleaseGates.test.js +140 -0
- package/dist/intros/__tests__/introsReleaseGates.test.js.map +1 -0
- package/dist/intros/__tests__/psychExperiment.test.d.ts +2 -0
- package/dist/intros/__tests__/psychExperiment.test.d.ts.map +1 -0
- package/dist/intros/__tests__/psychExperiment.test.js +63 -0
- package/dist/intros/__tests__/psychExperiment.test.js.map +1 -0
- package/dist/intros/__tests__/reasons.test.d.ts +2 -0
- package/dist/intros/__tests__/reasons.test.d.ts.map +1 -0
- package/dist/intros/__tests__/reasons.test.js +72 -0
- package/dist/intros/__tests__/reasons.test.js.map +1 -0
- package/dist/intros/__tests__/sharedNotes.test.d.ts +2 -0
- package/dist/intros/__tests__/sharedNotes.test.d.ts.map +1 -0
- package/dist/intros/__tests__/sharedNotes.test.js +19 -0
- package/dist/intros/__tests__/sharedNotes.test.js.map +1 -0
- package/dist/intros/__tests__/transitions.test.d.ts +2 -0
- package/dist/intros/__tests__/transitions.test.d.ts.map +1 -0
- package/dist/intros/__tests__/transitions.test.js +43 -0
- package/dist/intros/__tests__/transitions.test.js.map +1 -0
- package/dist/intros/__tests__/warmDisclosure.test.d.ts +2 -0
- package/dist/intros/__tests__/warmDisclosure.test.d.ts.map +1 -0
- package/dist/intros/__tests__/warmDisclosure.test.js +10 -0
- package/dist/intros/__tests__/warmDisclosure.test.js.map +1 -0
- package/dist/intros/age.d.ts +4 -0
- package/dist/intros/age.d.ts.map +1 -0
- package/dist/intros/age.js +14 -0
- package/dist/intros/age.js.map +1 -0
- package/dist/intros/attendance.d.ts +11 -0
- package/dist/intros/attendance.d.ts.map +1 -0
- package/dist/intros/attendance.js +27 -0
- package/dist/intros/attendance.js.map +1 -0
- package/dist/intros/attributeCatalog.d.ts +17 -0
- package/dist/intros/attributeCatalog.d.ts.map +1 -0
- package/dist/intros/attributeCatalog.js +96 -0
- package/dist/intros/attributeCatalog.js.map +1 -0
- package/dist/intros/index.d.ts +10 -0
- package/dist/intros/index.d.ts.map +1 -0
- package/dist/intros/index.js +10 -0
- package/dist/intros/index.js.map +1 -0
- package/dist/intros/intents.d.ts +8 -0
- package/dist/intros/intents.d.ts.map +1 -0
- package/dist/intros/intents.js +44 -0
- package/dist/intros/intents.js.map +1 -0
- package/dist/intros/psychExperiment.d.ts +29 -0
- package/dist/intros/psychExperiment.d.ts.map +1 -0
- package/dist/intros/psychExperiment.js +82 -0
- package/dist/intros/psychExperiment.js.map +1 -0
- package/dist/intros/reasons.d.ts +38 -0
- package/dist/intros/reasons.d.ts.map +1 -0
- package/dist/intros/reasons.js +162 -0
- package/dist/intros/reasons.js.map +1 -0
- package/dist/intros/sharedNotes.d.ts +10 -0
- package/dist/intros/sharedNotes.d.ts.map +1 -0
- package/dist/intros/sharedNotes.js +56 -0
- package/dist/intros/sharedNotes.js.map +1 -0
- package/dist/intros/transitions.d.ts +29 -0
- package/dist/intros/transitions.d.ts.map +1 -0
- package/dist/intros/transitions.js +128 -0
- package/dist/intros/transitions.js.map +1 -0
- package/dist/intros/validation.d.ts +50 -0
- package/dist/intros/validation.d.ts.map +1 -0
- package/dist/intros/validation.js +212 -0
- package/dist/intros/validation.js.map +1 -0
- package/dist/mirroredPrismaEnums.d.ts +1 -0
- package/dist/mirroredPrismaEnums.d.ts.map +1 -1
- package/dist/mirroredPrismaEnums.js +1 -0
- package/dist/mirroredPrismaEnums.js.map +1 -1
- package/dist/promoCodeScope.d.ts +7 -1
- package/dist/promoCodeScope.d.ts.map +1 -1
- package/dist/promoCodeScope.js +13 -4
- package/dist/promoCodeScope.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +321 -2
- package/src/__tests__/appleIapProducts.test.ts +3 -0
- package/src/__tests__/promoCodeScope.test.ts +16 -3
- package/src/__tests__/statusEnums.test.ts +1 -0
- package/src/appleIapProducts.ts +29 -2
- package/src/index.ts +1 -0
- package/src/intros/__tests__/introsReleaseGates.test.ts +181 -0
- package/src/intros/__tests__/psychExperiment.test.ts +99 -0
- package/src/intros/__tests__/reasons.test.ts +122 -0
- package/src/intros/__tests__/sharedNotes.test.ts +20 -0
- package/src/intros/__tests__/transitions.test.ts +61 -0
- package/src/intros/__tests__/warmDisclosure.test.ts +10 -0
- package/src/intros/age.ts +13 -0
- package/src/intros/attendance.ts +33 -0
- package/src/intros/attributeCatalog.ts +113 -0
- package/src/intros/index.ts +9 -0
- package/src/intros/intents.ts +49 -0
- package/src/intros/psychExperiment.ts +130 -0
- package/src/intros/reasons.ts +187 -0
- package/src/intros/sharedNotes.ts +59 -0
- package/src/intros/transitions.ts +172 -0
- package/src/intros/validation.ts +248 -0
- package/src/mirroredPrismaEnums.ts +1 -0
- package/src/promoCodeScope.ts +20 -4
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { isIntrosAdult } from "../age.js";
|
|
2
|
+
import {
|
|
3
|
+
attributeMayBeUsedInExperiments,
|
|
4
|
+
validateAttributeValue,
|
|
5
|
+
} from "../attributeCatalog.js";
|
|
6
|
+
import { attendanceCopy, visibleAttendanceTier } from "../attendance.js";
|
|
7
|
+
import { intentsComplement } from "../intents.js";
|
|
8
|
+
import { applyIntroductionAction, type TransitionIntro } from "../transitions.js";
|
|
9
|
+
import { parseProfilePatch, warmInviteNamesOtherPerson } from "../validation.js";
|
|
10
|
+
|
|
11
|
+
const mystery = (overrides: Partial<TransitionIntro> = {}): TransitionIntro => ({
|
|
12
|
+
source: "BashSuggested",
|
|
13
|
+
mode: "Mystery",
|
|
14
|
+
state: "Open",
|
|
15
|
+
closedReason: null,
|
|
16
|
+
interestLow: "Pending",
|
|
17
|
+
interestHigh: "Pending",
|
|
18
|
+
...overrides,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("Intros release gates", () => {
|
|
22
|
+
describe("SEC-02 / REV-02 / REV-04 mutual consent", () => {
|
|
23
|
+
it("does not reveal after only one person is interested", () => {
|
|
24
|
+
const result = applyIntroductionAction(mystery(), "low", "interested");
|
|
25
|
+
expect(result.ok && result.state).toBe("Open");
|
|
26
|
+
expect(result.ok && result.revealNow).toBe(false);
|
|
27
|
+
expect(result.ok && result.interestHigh).toBe("Pending");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("schedules a reveal only after both are interested, without revealing immediately", () => {
|
|
31
|
+
const result = applyIntroductionAction(
|
|
32
|
+
mystery({ interestLow: "Interested" }),
|
|
33
|
+
"high",
|
|
34
|
+
"interested"
|
|
35
|
+
);
|
|
36
|
+
expect(result.ok && result.state).toBe("Mutual");
|
|
37
|
+
expect(result.ok && result.revealNow).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("SEC-03 / REV-06 withdrawal", () => {
|
|
42
|
+
it("closes a mutual intro when either person withdraws before reveal", () => {
|
|
43
|
+
const result = applyIntroductionAction(
|
|
44
|
+
mystery({
|
|
45
|
+
state: "Mutual",
|
|
46
|
+
interestLow: "Interested",
|
|
47
|
+
interestHigh: "Interested",
|
|
48
|
+
}),
|
|
49
|
+
"high",
|
|
50
|
+
"withdraw"
|
|
51
|
+
);
|
|
52
|
+
expect(result.ok && result.closedReason).toBe("Withdrawn");
|
|
53
|
+
expect(result.ok && result.revealNow).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("SEC-08 / MAT romance isolation", () => {
|
|
58
|
+
it("matches romance only when both selected romance", () => {
|
|
59
|
+
expect(intentsComplement(["Romance"], ["Friends"])).toBeNull();
|
|
60
|
+
expect(intentsComplement(["Romance", "Friends"], ["Friends"])).toBe("Friends");
|
|
61
|
+
expect(intentsComplement(["Romance"], ["Romance"])).toBe("Romance");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("matches mentorship as complementary roles", () => {
|
|
65
|
+
expect(intentsComplement(["MentorSeek"], ["MentorOffer"])).toBe("MentorSeek");
|
|
66
|
+
expect(intentsComplement(["MentorSeek"], ["MentorSeek"])).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not match people with no shared intention", () => {
|
|
70
|
+
expect(intentsComplement(["Friends"], ["Professional"])).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("SEC-09 minors", () => {
|
|
75
|
+
const now = new Date("2026-10-15T12:00:00Z");
|
|
76
|
+
|
|
77
|
+
it("rejects a missing birthday and anyone under 18", () => {
|
|
78
|
+
expect(isIntrosAdult(null, now)).toBe(false);
|
|
79
|
+
expect(isIntrosAdult(new Date("2010-01-01T00:00:00Z"), now)).toBe(false);
|
|
80
|
+
expect(isIntrosAdult(new Date("2008-10-16T00:00:00Z"), now)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("accepts someone who turned 18", () => {
|
|
84
|
+
expect(isIntrosAdult(new Date("2008-10-15T00:00:00Z"), now)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("SEC-11 warm invite names", () => {
|
|
89
|
+
it("names the other person only when both recipients chose named intros", () => {
|
|
90
|
+
expect(warmInviteNamesOtherPerson("Named", "Named")).toBe(true);
|
|
91
|
+
expect(warmInviteNamesOtherPerson("Named", "Anonymous")).toBe(false);
|
|
92
|
+
expect(warmInviteNamesOtherPerson(null, null)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("SEC-07 / ONB-08 private attributes", () => {
|
|
97
|
+
it("refuses experimental matching for Human Design", () => {
|
|
98
|
+
expect(attributeMayBeUsedInExperiments("HumanDesign")).toBe(false);
|
|
99
|
+
const parsed = parseProfilePatch({
|
|
100
|
+
attributes: {
|
|
101
|
+
HumanDesign: {
|
|
102
|
+
value: "Generator",
|
|
103
|
+
visibility: "OnlyMe",
|
|
104
|
+
allowExperimentalMatching: true,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
expect(parsed.ok).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("keeps a private Myers-Briggs value valid without using it as a score", () => {
|
|
112
|
+
const value = validateAttributeValue("MyersBriggs", "enfp");
|
|
113
|
+
expect(value.ok && value.value).toBe("ENFP");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("accepts a null unknown attribute as a removal and rejects a value for it", () => {
|
|
117
|
+
const removed = parseProfilePatch({ attributes: { Enneagram: null } });
|
|
118
|
+
expect(removed.ok && removed.value.discardAttributes).toEqual(["Enneagram"]);
|
|
119
|
+
const invented = parseProfilePatch({
|
|
120
|
+
attributes: { Enneagram: { value: "Type 4", visibility: "OnlyMe", allowExperimentalMatching: true } },
|
|
121
|
+
});
|
|
122
|
+
expect(invented.ok).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("REV-14 attendance wording", () => {
|
|
127
|
+
it("never says someone is checked in unless they allowed that disclosure", () => {
|
|
128
|
+
expect(attendanceCopy("CheckedIn", false)).toBe(
|
|
129
|
+
"Could be a good night to meet someone new."
|
|
130
|
+
);
|
|
131
|
+
expect(visibleAttendanceTier({
|
|
132
|
+
checkedIn: true,
|
|
133
|
+
registered: true,
|
|
134
|
+
shareRegistration: false,
|
|
135
|
+
})).toBe("Recommended");
|
|
136
|
+
expect(attendanceCopy("CheckedIn", true)).toBe("Checked in.");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("ONB-05 romance ages", () => {
|
|
141
|
+
it("rejects a romance age range that includes minors", () => {
|
|
142
|
+
const parsed = parseProfilePatch({
|
|
143
|
+
romance: { seekingGenders: ["everyone"], ageMin: 16, ageMax: 30 },
|
|
144
|
+
});
|
|
145
|
+
expect(parsed.ok).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("WRM-05 warm mutual accept", () => {
|
|
150
|
+
it("reveals a warm intro only after the second person accepts", () => {
|
|
151
|
+
const first = applyIntroductionAction(
|
|
152
|
+
mystery({ source: "Warm" }),
|
|
153
|
+
"low",
|
|
154
|
+
"sounds_good"
|
|
155
|
+
);
|
|
156
|
+
expect(first.ok && first.revealNow).toBe(false);
|
|
157
|
+
if (!first.ok) return;
|
|
158
|
+
const second = applyIntroductionAction(
|
|
159
|
+
mystery({
|
|
160
|
+
source: "Warm",
|
|
161
|
+
interestLow: first.interestLow,
|
|
162
|
+
interestHigh: first.interestHigh,
|
|
163
|
+
}),
|
|
164
|
+
"high",
|
|
165
|
+
"sounds_good"
|
|
166
|
+
);
|
|
167
|
+
expect(second.ok && second.state).toBe("Revealed");
|
|
168
|
+
expect(second.ok && second.revealNow).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("does not reveal when one person declines", () => {
|
|
172
|
+
const result = applyIntroductionAction(
|
|
173
|
+
mystery({ source: "Warm", interestLow: "Interested" }),
|
|
174
|
+
"high",
|
|
175
|
+
"decline"
|
|
176
|
+
);
|
|
177
|
+
expect(result.ok && result.state).toBe("Closed");
|
|
178
|
+
expect(result.ok && result.closedReason).toBe("Declined");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { introHint } from "../reasons.js";
|
|
2
|
+
import {
|
|
3
|
+
appendPsychExperiment,
|
|
4
|
+
experimentContribution,
|
|
5
|
+
psychExperimentCode,
|
|
6
|
+
rankWithExperiment,
|
|
7
|
+
} from "../psychExperiment.js";
|
|
8
|
+
|
|
9
|
+
const closeEnergy = {
|
|
10
|
+
kind: "BigFive",
|
|
11
|
+
allowExperimentalMatching: true,
|
|
12
|
+
value: { extraversion: 80, openness: 10, conscientiousness: 10, agreeableness: 10, neuroticism: 10 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("experimental psychology matching", () => {
|
|
16
|
+
it("adds no signal without both people's experiment consent", () => {
|
|
17
|
+
const other = { experimentConsent: true, attributes: [closeEnergy] };
|
|
18
|
+
expect(
|
|
19
|
+
experimentContribution({ experimentConsent: false, attributes: [closeEnergy] }, other)
|
|
20
|
+
).toEqual({ codes: [], rankBoost: 0 });
|
|
21
|
+
expect(psychExperimentCode({ experimentConsent: false, attributes: [closeEnergy] }, other)).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("ignores an attribute the person did not allow, including Human Design", () => {
|
|
25
|
+
const design = {
|
|
26
|
+
kind: "HumanDesign",
|
|
27
|
+
allowExperimentalMatching: true,
|
|
28
|
+
value: "Generator",
|
|
29
|
+
};
|
|
30
|
+
const withheld = { ...closeEnergy, allowExperimentalMatching: false };
|
|
31
|
+
expect(
|
|
32
|
+
experimentContribution(
|
|
33
|
+
{ experimentConsent: true, attributes: [design, withheld] },
|
|
34
|
+
{ experimentConsent: true, attributes: [design, closeEnergy] }
|
|
35
|
+
).rankBoost
|
|
36
|
+
).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("reorders on a consented social-energy note and does not expose the score", () => {
|
|
40
|
+
const contribution = experimentContribution(
|
|
41
|
+
{ experimentConsent: true, attributes: [closeEnergy] },
|
|
42
|
+
{
|
|
43
|
+
experimentConsent: true,
|
|
44
|
+
attributes: [{ ...closeEnergy, value: { ...closeEnergy.value, extraversion: 70 } }],
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
expect(contribution.rankBoost).toBe(1);
|
|
48
|
+
expect(contribution.codes).toContain("PSYCH_SIGNAL:similar_social_energy");
|
|
49
|
+
const codes = appendPsychExperiment(["INTENT:Friends"],
|
|
50
|
+
{ experimentConsent: true, attributes: [closeEnergy] },
|
|
51
|
+
{ experimentConsent: true, attributes: [closeEnergy] },
|
|
52
|
+
true
|
|
53
|
+
);
|
|
54
|
+
expect(introHint(codes)).toMatch(/social energy/i);
|
|
55
|
+
expect(introHint(codes)).toMatch(/not a compatibility score/i);
|
|
56
|
+
expect(JSON.stringify(codes)).not.toMatch(/80|ENFP|Generator|Achiever/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("leaves baseline order and codes unchanged when the experiment flag is off", () => {
|
|
60
|
+
const people = ["first", "second"];
|
|
61
|
+
const ranker = jest.fn(() => ({ codes: ["PSYCH_SHARED"], rankBoost: 2 }));
|
|
62
|
+
expect(rankWithExperiment(people, ranker, false).map((row) => row.item)).toEqual(people);
|
|
63
|
+
expect(ranker).not.toHaveBeenCalled();
|
|
64
|
+
expect(
|
|
65
|
+
appendPsychExperiment(
|
|
66
|
+
["INTENT:Friends"],
|
|
67
|
+
{ experimentConsent: true, attributes: [closeEnergy] },
|
|
68
|
+
{ experimentConsent: true, attributes: [closeEnergy] },
|
|
69
|
+
false
|
|
70
|
+
)
|
|
71
|
+
).toEqual(["INTENT:Friends"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("puts the stronger consented overlap ahead without replacing the baseline reason", () => {
|
|
75
|
+
const ranked = rankWithExperiment(
|
|
76
|
+
[
|
|
77
|
+
{ id: "plain", boost: 0 },
|
|
78
|
+
{ id: "closer", boost: 2 },
|
|
79
|
+
],
|
|
80
|
+
(item) => ({ codes: item.boost ? ["PSYCH_SHARED"] : [], rankBoost: item.boost }),
|
|
81
|
+
true
|
|
82
|
+
);
|
|
83
|
+
expect(ranked.map((row) => row.item.id)).toEqual(["closer", "plain"]);
|
|
84
|
+
expect(ranked[1]?.contribution.codes).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("caps the reorder boost at 2", () => {
|
|
88
|
+
const shared = {
|
|
89
|
+
experimentConsent: true,
|
|
90
|
+
attributes: [
|
|
91
|
+
closeEnergy,
|
|
92
|
+
{ kind: "LocusOfControl", allowExperimentalMatching: true, value: "internal" },
|
|
93
|
+
{ kind: "CliftonStrengths", allowExperimentalMatching: true, value: ["Learner"] },
|
|
94
|
+
{ kind: "MyersBriggs", allowExperimentalMatching: true, value: "ENFP" },
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
expect(experimentContribution(shared, shared).rankBoost).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
eventScopedReason,
|
|
3
|
+
introHint,
|
|
4
|
+
pairMatchReason,
|
|
5
|
+
rankEventForPair,
|
|
6
|
+
eventReasonSpecificity,
|
|
7
|
+
romancePairFits,
|
|
8
|
+
type RomanceFit,
|
|
9
|
+
} from "../reasons.js";
|
|
10
|
+
|
|
11
|
+
describe("matching reasons", () => {
|
|
12
|
+
it("does not globally match two people who only both want friends", () => {
|
|
13
|
+
expect(
|
|
14
|
+
pairMatchReason(
|
|
15
|
+
{ intents: ["Friends"], topics: ["jazz"], work: [] },
|
|
16
|
+
{ intents: ["Friends"], topics: ["photography"], work: [] }
|
|
17
|
+
)
|
|
18
|
+
).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("matches friends who share a topic and explains it without a name", () => {
|
|
22
|
+
const reason = pairMatchReason(
|
|
23
|
+
{ intents: ["Friends"], topics: ["photography"], work: [] },
|
|
24
|
+
{ intents: ["Friends"], topics: ["Photography"], work: [] }
|
|
25
|
+
);
|
|
26
|
+
expect(reason?.intent).toBe("Friends");
|
|
27
|
+
expect(introHint(reason?.codes ?? [])).toMatch(/photography/i);
|
|
28
|
+
expect(introHint(reason?.codes ?? [])).not.toMatch(/Alex|Jordan/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("matches a mentor with someone looking for one", () => {
|
|
32
|
+
expect(
|
|
33
|
+
pairMatchReason(
|
|
34
|
+
{ intents: ["MentorSeek"], topics: [], work: [] },
|
|
35
|
+
{ intents: ["MentorOffer"], topics: [], work: [] }
|
|
36
|
+
)?.intent
|
|
37
|
+
).toBe("MentorSeek");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("allows romance only at an event that permits it", () => {
|
|
41
|
+
const people = {
|
|
42
|
+
a: { intents: ["Romance"], topics: [], work: [] },
|
|
43
|
+
b: { intents: ["Romance"], topics: [], work: [] },
|
|
44
|
+
};
|
|
45
|
+
expect(eventScopedReason(people.a, people.b, false)).toBeNull();
|
|
46
|
+
expect(eventScopedReason(people.a, people.b, true)?.intent).toBe("Romance");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects a romance pair that misses an age or gender limit and accepts an empty seeking list", () => {
|
|
50
|
+
const older: RomanceFit = {
|
|
51
|
+
consented: true,
|
|
52
|
+
dob: new Date("1980-01-01T00:00:00Z"),
|
|
53
|
+
gender: "Female",
|
|
54
|
+
seekingGenders: ["Male"],
|
|
55
|
+
ageMin: 30,
|
|
56
|
+
ageMax: 45,
|
|
57
|
+
};
|
|
58
|
+
const youngerMale: RomanceFit = {
|
|
59
|
+
consented: true,
|
|
60
|
+
dob: new Date("2000-06-01T00:00:00Z"),
|
|
61
|
+
gender: "Male",
|
|
62
|
+
seekingGenders: [],
|
|
63
|
+
ageMin: null,
|
|
64
|
+
ageMax: null,
|
|
65
|
+
};
|
|
66
|
+
const fittingMale: RomanceFit = {
|
|
67
|
+
...youngerMale,
|
|
68
|
+
dob: new Date("1990-06-01T00:00:00Z"),
|
|
69
|
+
};
|
|
70
|
+
const now = new Date("2026-10-20T00:00:00Z");
|
|
71
|
+
const profiles = {
|
|
72
|
+
a: { intents: ["Romance"], topics: [], work: [] },
|
|
73
|
+
b: { intents: ["Romance"], topics: [], work: [] },
|
|
74
|
+
};
|
|
75
|
+
expect(romancePairFits(older, youngerMale, now)).toBe(false);
|
|
76
|
+
expect(romancePairFits(older, fittingMale, now)).toBe(true);
|
|
77
|
+
expect(romancePairFits(older, { ...fittingMale, gender: "Female" }, now)).toBe(false);
|
|
78
|
+
expect(eventScopedReason(profiles.a, profiles.b, true, { a: older, b: youngerMale }, now)).toBeNull();
|
|
79
|
+
expect(pairMatchReason(profiles.a, profiles.b, { a: older, b: fittingMale }, now)?.intent).toBe("Romance");
|
|
80
|
+
expect(
|
|
81
|
+
eventScopedReason(profiles.a, profiles.b, true, { a: older, b: { ...fittingMale, gender: "Female" } }, now)
|
|
82
|
+
).toBeNull();
|
|
83
|
+
expect(
|
|
84
|
+
eventScopedReason(
|
|
85
|
+
profiles.a,
|
|
86
|
+
profiles.b,
|
|
87
|
+
true,
|
|
88
|
+
{ a: { ...older, seekingGenders: [] }, b: { ...fittingMale, seekingGenders: [] } },
|
|
89
|
+
now
|
|
90
|
+
)?.intent
|
|
91
|
+
).toBe("Romance");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("still matches two people who only both want friends at an event, behind a shared interest", () => {
|
|
95
|
+
const broad = eventScopedReason(
|
|
96
|
+
{ intents: ["Friends"], topics: ["jazz"], work: [] },
|
|
97
|
+
{ intents: ["Friends"], topics: ["photography"], work: [] },
|
|
98
|
+
false
|
|
99
|
+
);
|
|
100
|
+
const specific = eventScopedReason(
|
|
101
|
+
{ intents: ["Friends"], topics: ["photography"], work: [] },
|
|
102
|
+
{ intents: ["Friends"], topics: ["Photography"], work: [] },
|
|
103
|
+
false
|
|
104
|
+
);
|
|
105
|
+
expect(broad?.intent).toBe("Friends");
|
|
106
|
+
expect(specific && eventReasonSpecificity(specific)).toBeGreaterThan(broad ? eventReasonSpecificity(broad) : 0);
|
|
107
|
+
expect(
|
|
108
|
+
eventReasonSpecificity({ intent: "MentorSeek", codes: ["INTENT:MentorSeek"] })
|
|
109
|
+
).toBeGreaterThan(specific ? eventReasonSpecificity(specific) : 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("prefers a bash whose type matches a shared interest", () => {
|
|
113
|
+
const id = rankEventForPair(
|
|
114
|
+
[
|
|
115
|
+
{ id: "concert", eventType: "Music", city: "Salt Lake City" },
|
|
116
|
+
{ id: "mixer", eventType: "photography workshop", city: "Provo" },
|
|
117
|
+
],
|
|
118
|
+
["SHARED_TOPIC:photography"]
|
|
119
|
+
);
|
|
120
|
+
expect(id).toBe("mixer");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describeSharedNote } from "../sharedNotes.js";
|
|
2
|
+
|
|
3
|
+
describe("notes visible after reveal", () => {
|
|
4
|
+
it("describes an allowed note without turning it into a score", () => {
|
|
5
|
+
const note = describeSharedNote("BigFive", {
|
|
6
|
+
openness: 10,
|
|
7
|
+
conscientiousness: 20,
|
|
8
|
+
extraversion: 80,
|
|
9
|
+
agreeableness: 40,
|
|
10
|
+
neuroticism: 50,
|
|
11
|
+
});
|
|
12
|
+
expect(note?.tier).toBe("Research-backed construct");
|
|
13
|
+
expect(note?.text).toMatch(/Extraversion 80/);
|
|
14
|
+
expect(JSON.stringify(note)).not.toMatch(/compatibility/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("ignores a kind this catalog does not support", () => {
|
|
18
|
+
expect(describeSharedNote("Enneagram", "4")).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { applyIntroductionAction } from "../transitions.js";
|
|
2
|
+
|
|
3
|
+
const openMystery = {
|
|
4
|
+
source: "BashSuggested" as const,
|
|
5
|
+
mode: "Mystery" as const,
|
|
6
|
+
state: "Open" as const,
|
|
7
|
+
closedReason: null,
|
|
8
|
+
interestLow: "Pending",
|
|
9
|
+
interestHigh: "Pending",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("introduction transitions", () => {
|
|
13
|
+
it("treats mutual interest as the reveal consent without revealing yet", () => {
|
|
14
|
+
const one = applyIntroductionAction(openMystery, "low", "interested");
|
|
15
|
+
expect(one.ok && one.state).toBe("Open");
|
|
16
|
+
if (!one.ok) return;
|
|
17
|
+
const both = applyIntroductionAction(
|
|
18
|
+
{ ...openMystery, interestLow: one.interestLow, interestHigh: one.interestHigh },
|
|
19
|
+
"high",
|
|
20
|
+
"interested"
|
|
21
|
+
);
|
|
22
|
+
expect(both.ok && both.state).toBe("Mutual");
|
|
23
|
+
expect(both.ok && both.revealNow).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("reveals a warm intro only when both accept", () => {
|
|
27
|
+
const one = applyIntroductionAction(
|
|
28
|
+
{ ...openMystery, source: "Warm" },
|
|
29
|
+
"low",
|
|
30
|
+
"sounds_good"
|
|
31
|
+
);
|
|
32
|
+
expect(one.ok && one.state).toBe("Open");
|
|
33
|
+
if (!one.ok) return;
|
|
34
|
+
const both = applyIntroductionAction(
|
|
35
|
+
{
|
|
36
|
+
...openMystery,
|
|
37
|
+
source: "Warm",
|
|
38
|
+
interestLow: one.interestLow,
|
|
39
|
+
interestHigh: one.interestHigh,
|
|
40
|
+
},
|
|
41
|
+
"high",
|
|
42
|
+
"sounds_good"
|
|
43
|
+
);
|
|
44
|
+
expect(both.ok && both.revealNow).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("lets a withdrawal close an intro before reveal", () => {
|
|
48
|
+
const result = applyIntroductionAction(
|
|
49
|
+
{ ...openMystery, state: "Mutual", interestLow: "Interested", interestHigh: "Interested" },
|
|
50
|
+
"low",
|
|
51
|
+
"withdraw"
|
|
52
|
+
);
|
|
53
|
+
expect(result.ok && result.state).toBe("Closed");
|
|
54
|
+
expect(result.ok && result.closedReason).toBe("Withdrawn");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects say hi before reveal", () => {
|
|
58
|
+
const result = applyIntroductionAction(openMystery, "low", "say_hi");
|
|
59
|
+
expect(result.ok).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { warmInviteNamesOtherPerson } from "../validation.js";
|
|
2
|
+
|
|
3
|
+
describe("warm intro disclosure", () => {
|
|
4
|
+
it("hides the other person's name unless both chose named intros", () => {
|
|
5
|
+
expect(warmInviteNamesOtherPerson("Named", "Named")).toBe(true);
|
|
6
|
+
expect(warmInviteNamesOtherPerson("Named", "Anonymous")).toBe(false);
|
|
7
|
+
expect(warmInviteNamesOtherPerson(null, "Named")).toBe(false);
|
|
8
|
+
expect(warmInviteNamesOtherPerson("Off", "Off")).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const INTROS_MINIMUM_AGE = 18;
|
|
2
|
+
|
|
3
|
+
export function ageInYears(dob: Date, now = new Date()): number {
|
|
4
|
+
let age = now.getUTCFullYear() - dob.getUTCFullYear();
|
|
5
|
+
const month = now.getUTCMonth() - dob.getUTCMonth();
|
|
6
|
+
if (month < 0 || (month === 0 && now.getUTCDate() < dob.getUTCDate())) age -= 1;
|
|
7
|
+
return age;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isIntrosAdult(dob: Date | null | undefined, now = new Date()): boolean {
|
|
11
|
+
if (!dob) return false;
|
|
12
|
+
return ageInYears(dob, now) >= INTROS_MINIMUM_AGE;
|
|
13
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const CONFIRMED_TICKET_STATUSES = [
|
|
2
|
+
"Pending",
|
|
3
|
+
"P2PConfirmed",
|
|
4
|
+
"CheckedIn",
|
|
5
|
+
"Attended",
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
export function isConfirmedTicketStatus(status: string | null | undefined): boolean {
|
|
9
|
+
return (CONFIRMED_TICKET_STATUSES as readonly string[]).includes(status ?? "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AttendanceTier = "Recommended" | "Interested" | "Registered" | "CheckedIn";
|
|
13
|
+
|
|
14
|
+
/** Strongest public wording without evidence and disclosure consent. */
|
|
15
|
+
export function attendanceCopy(
|
|
16
|
+
tier: AttendanceTier,
|
|
17
|
+
shareRegistration: boolean
|
|
18
|
+
): string {
|
|
19
|
+
if (tier === "CheckedIn" && shareRegistration) return "Checked in.";
|
|
20
|
+
if (tier === "Registered" && shareRegistration) return "Your match has a spot.";
|
|
21
|
+
return "Could be a good night to meet someone new.";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function visibleAttendanceTier(args: {
|
|
25
|
+
checkedIn: boolean;
|
|
26
|
+
registered: boolean;
|
|
27
|
+
shareRegistration: boolean;
|
|
28
|
+
}): AttendanceTier {
|
|
29
|
+
if (!args.shareRegistration) return "Recommended";
|
|
30
|
+
if (args.checkedIn) return "CheckedIn";
|
|
31
|
+
if (args.registered) return "Registered";
|
|
32
|
+
return "Recommended";
|
|
33
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export const INTROS_ATTRIBUTE_KINDS = [
|
|
2
|
+
"BigFive",
|
|
3
|
+
"MyersBriggs",
|
|
4
|
+
"LocusOfControl",
|
|
5
|
+
"CliftonStrengths",
|
|
6
|
+
"EmotionalIntelligence",
|
|
7
|
+
"HumanDesign",
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export type IntrosAttributeKind = (typeof INTROS_ATTRIBUTE_KINDS)[number];
|
|
11
|
+
|
|
12
|
+
export type IntrosEvidenceTier =
|
|
13
|
+
| "ResearchBackedConstruct"
|
|
14
|
+
| "SelfReportedTypology"
|
|
15
|
+
| "Exploratory";
|
|
16
|
+
|
|
17
|
+
export const CLIFTON_THEMES = [
|
|
18
|
+
"Achiever",
|
|
19
|
+
"Learner",
|
|
20
|
+
"Relator",
|
|
21
|
+
"Strategic",
|
|
22
|
+
"Woo",
|
|
23
|
+
"Empathy",
|
|
24
|
+
"Focus",
|
|
25
|
+
"Ideation",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export const HUMAN_DESIGN_TYPES = [
|
|
29
|
+
"Manifestor",
|
|
30
|
+
"Generator",
|
|
31
|
+
"ManifestingGenerator",
|
|
32
|
+
"Projector",
|
|
33
|
+
"Reflector",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export function isIntrosAttributeKind(value: string): value is IntrosAttributeKind {
|
|
37
|
+
return (INTROS_ATTRIBUTE_KINDS as readonly string[]).includes(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function attributeEvidenceTier(kind: IntrosAttributeKind): IntrosEvidenceTier {
|
|
41
|
+
if (kind === "BigFive" || kind === "LocusOfControl") return "ResearchBackedConstruct";
|
|
42
|
+
if (kind === "HumanDesign") return "Exploratory";
|
|
43
|
+
return "SelfReportedTypology";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function attributeMayBeUsedInExperiments(kind: IntrosAttributeKind): boolean {
|
|
47
|
+
return attributeEvidenceTier(kind) !== "Exploratory";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type AttributeValidation = { ok: true; value: unknown } | { ok: false; error: string };
|
|
51
|
+
|
|
52
|
+
export function validateAttributeValue(
|
|
53
|
+
kind: IntrosAttributeKind,
|
|
54
|
+
raw: unknown
|
|
55
|
+
): AttributeValidation {
|
|
56
|
+
if (kind === "MyersBriggs") {
|
|
57
|
+
if (typeof raw !== "string" || !/^[EI][SN][TF][JP]$/i.test(raw)) {
|
|
58
|
+
return { ok: false, error: "Enter a 4-letter type such as ENFP" };
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, value: raw.toUpperCase() };
|
|
61
|
+
}
|
|
62
|
+
if (kind === "LocusOfControl") {
|
|
63
|
+
if (raw !== "internal" && raw !== "external" && raw !== "mixed") {
|
|
64
|
+
return { ok: false, error: "Choose internal, external, or mixed" };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, value: raw };
|
|
67
|
+
}
|
|
68
|
+
if (kind === "EmotionalIntelligence") {
|
|
69
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1 || raw > 5) {
|
|
70
|
+
return { ok: false, error: "Choose a number from 1 to 5" };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, value: raw };
|
|
73
|
+
}
|
|
74
|
+
if (kind === "HumanDesign") {
|
|
75
|
+
if (typeof raw !== "string" || !(HUMAN_DESIGN_TYPES as readonly string[]).includes(raw)) {
|
|
76
|
+
return { ok: false, error: "Choose a Human Design type" };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, value: raw };
|
|
79
|
+
}
|
|
80
|
+
if (kind === "CliftonStrengths") {
|
|
81
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > 5) {
|
|
82
|
+
return { ok: false, error: "Choose up to 5 themes" };
|
|
83
|
+
}
|
|
84
|
+
const themes = raw.filter((item): item is string => typeof item === "string");
|
|
85
|
+
if (
|
|
86
|
+
themes.length !== raw.length ||
|
|
87
|
+
themes.some((theme) => !(CLIFTON_THEMES as readonly string[]).includes(theme))
|
|
88
|
+
) {
|
|
89
|
+
return { ok: false, error: "Use theme names only" };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, value: themes };
|
|
92
|
+
}
|
|
93
|
+
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
94
|
+
return { ok: false, error: "Big Five needs five scores" };
|
|
95
|
+
}
|
|
96
|
+
const record = raw as Record<string, unknown>;
|
|
97
|
+
const keys = [
|
|
98
|
+
"openness",
|
|
99
|
+
"conscientiousness",
|
|
100
|
+
"extraversion",
|
|
101
|
+
"agreeableness",
|
|
102
|
+
"neuroticism",
|
|
103
|
+
] as const;
|
|
104
|
+
const next: Record<string, number> = {};
|
|
105
|
+
for (const key of keys) {
|
|
106
|
+
const n = record[key];
|
|
107
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > 100) {
|
|
108
|
+
return { ok: false, error: "Each Big Five score is a whole number from 0 to 100" };
|
|
109
|
+
}
|
|
110
|
+
next[key] = n;
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, value: next };
|
|
113
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./age.js";
|
|
2
|
+
export * from "./attendance.js";
|
|
3
|
+
export * from "./attributeCatalog.js";
|
|
4
|
+
export * from "./intents.js";
|
|
5
|
+
export * from "./psychExperiment.js";
|
|
6
|
+
export * from "./reasons.js";
|
|
7
|
+
export * from "./sharedNotes.js";
|
|
8
|
+
export * from "./transitions.js";
|
|
9
|
+
export * from "./validation.js";
|