@bash-app/bash-common 30.292.0 → 30.295.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 +3 -0
- package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
- package/dist/definitions.d.ts +77 -0
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +1 -0
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +7 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/hostCrmConstants.d.ts +11 -0
- package/dist/hostCrmConstants.d.ts.map +1 -0
- package/dist/hostCrmConstants.js +10 -0
- package/dist/hostCrmConstants.js.map +1 -0
- package/dist/hostCrmSegments.d.ts +47 -0
- package/dist/hostCrmSegments.d.ts.map +1 -0
- package/dist/hostCrmSegments.js +65 -0
- package/dist/hostCrmSegments.js.map +1 -0
- 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 +9 -0
- package/dist/membershipDefinitions.d.ts.map +1 -1
- package/dist/membershipDefinitions.js +10 -1
- package/dist/membershipDefinitions.js.map +1 -1
- package/dist/utils/__tests__/reputationUtils.serviceListing.test.js +18 -0
- package/dist/utils/__tests__/reputationUtils.serviceListing.test.js.map +1 -1
- package/dist/utils/__tests__/reviewDimensionUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/reviewDimensionUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/reviewDimensionUtils.test.js +48 -0
- package/dist/utils/__tests__/reviewDimensionUtils.test.js.map +1 -0
- package/dist/utils/__tests__/trustScoreUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/trustScoreUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/trustScoreUtils.test.js +101 -0
- package/dist/utils/__tests__/trustScoreUtils.test.js.map +1 -0
- package/dist/utils/reputationUtils.d.ts.map +1 -1
- package/dist/utils/reputationUtils.js +7 -1
- package/dist/utils/reputationUtils.js.map +1 -1
- package/dist/utils/reviewDimensionUtils.d.ts +33 -0
- package/dist/utils/reviewDimensionUtils.d.ts.map +1 -0
- package/dist/utils/reviewDimensionUtils.js +35 -0
- package/dist/utils/reviewDimensionUtils.js.map +1 -0
- package/dist/utils/trustScoreUtils.d.ts +25 -0
- package/dist/utils/trustScoreUtils.d.ts.map +1 -0
- package/dist/utils/trustScoreUtils.js +99 -0
- package/dist/utils/trustScoreUtils.js.map +1 -0
- package/package.json +1 -1
- package/prisma/schema.prisma +181 -15
- package/src/__tests__/bashSectionTitleForBashType.test.ts +4 -0
- package/src/definitions.ts +85 -1
- package/src/extendedSchemas.ts +7 -0
- package/src/hostCrmConstants.ts +13 -0
- package/src/hostCrmSegments.ts +126 -0
- package/src/index.ts +4 -0
- package/src/membershipDefinitions.ts +12 -1
- package/src/utils/__tests__/reputationUtils.serviceListing.test.ts +24 -0
- package/src/utils/__tests__/reviewDimensionUtils.test.ts +65 -0
- package/src/utils/__tests__/trustScoreUtils.test.ts +105 -0
- package/src/utils/reputationUtils.ts +7 -1
- package/src/utils/reviewDimensionUtils.ts +70 -0
- package/src/utils/trustScoreUtils.ts +136 -0
|
@@ -219,6 +219,17 @@ export const BASHPOINTS_REWARDS: Record<MembershipTier, {
|
|
|
219
219
|
},
|
|
220
220
|
} as const;
|
|
221
221
|
|
|
222
|
+
/** Referral tier qualification thresholds — single source of truth for grant + stats UI. */
|
|
223
|
+
export const AMBASHADOR_QUALIFICATION = {
|
|
224
|
+
freeSignups: 1,
|
|
225
|
+
paidSignups: 1,
|
|
226
|
+
} as const;
|
|
227
|
+
|
|
228
|
+
export const INSIDER_QUALIFICATION = {
|
|
229
|
+
freeSignups: 100,
|
|
230
|
+
paidSignups: 10,
|
|
231
|
+
} as const;
|
|
232
|
+
|
|
222
233
|
/**
|
|
223
234
|
* BashPoints system rules and limits
|
|
224
235
|
*
|
|
@@ -233,7 +244,7 @@ export const BASHPOINTS_CONFIG = {
|
|
|
233
244
|
paymentVerificationBonus: 500,
|
|
234
245
|
|
|
235
246
|
// Tier achievement bonuses (one-time when user reaches each tier)
|
|
236
|
-
ambashadorAchievementBonus: 500, // Ambashador:
|
|
247
|
+
ambashadorAchievementBonus: 500, // Ambashador: 1 free or 1 paid referral
|
|
237
248
|
scoutAchievementBonus: 250, // Scout: first service profile referral
|
|
238
249
|
insiderAchievementBonus: 1000, // Bash Insider: 10 paid or 100 free referrals
|
|
239
250
|
agentAchievementBonus: 500, // Agent: 5+ service profile referrals
|
|
@@ -33,4 +33,28 @@ describe("getServiceListingReputation", () => {
|
|
|
33
33
|
})?.sourceLabel
|
|
34
34
|
).toBeUndefined();
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it("uses Bash sourceLabel when native reviews exist", () => {
|
|
38
|
+
expect(
|
|
39
|
+
getServiceListingReputation({
|
|
40
|
+
serviceRating: 4.6,
|
|
41
|
+
totalRatings: 12,
|
|
42
|
+
externalReviewSource: "google",
|
|
43
|
+
})
|
|
44
|
+
).toEqual({
|
|
45
|
+
averageRating: 4.6,
|
|
46
|
+
totalRatings: 12,
|
|
47
|
+
sourceLabel: "Bash",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns null below threshold even with google external source", () => {
|
|
52
|
+
expect(
|
|
53
|
+
getServiceListingReputation({
|
|
54
|
+
serviceRating: 4.9,
|
|
55
|
+
totalRatings: 0,
|
|
56
|
+
externalReviewSource: "google",
|
|
57
|
+
})
|
|
58
|
+
).toBeNull();
|
|
59
|
+
});
|
|
36
60
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
averageNullable,
|
|
3
|
+
parseOptionalDimensionRating,
|
|
4
|
+
parseRequiredOverallRating,
|
|
5
|
+
percentTrue,
|
|
6
|
+
} from "../reviewDimensionUtils.js";
|
|
7
|
+
|
|
8
|
+
describe("reviewDimensionUtils", () => {
|
|
9
|
+
describe("parseRequiredOverallRating", () => {
|
|
10
|
+
it("accepts integers 1–5", () => {
|
|
11
|
+
expect(parseRequiredOverallRating(4)).toBe(4);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("rejects out of range", () => {
|
|
15
|
+
expect(() => parseRequiredOverallRating(0)).toThrow();
|
|
16
|
+
expect(() => parseRequiredOverallRating(6)).toThrow();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("parseOptionalDimensionRating", () => {
|
|
21
|
+
it("returns null when omitted", () => {
|
|
22
|
+
expect(parseOptionalDimensionRating(undefined, "communication")).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("parses valid optional ratings", () => {
|
|
26
|
+
expect(parseOptionalDimensionRating(3, "communication")).toBe(3);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("averageNullable", () => {
|
|
31
|
+
it("returns null for empty input", () => {
|
|
32
|
+
expect(averageNullable([])).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("averages present values", () => {
|
|
36
|
+
expect(averageNullable([4, 5, null])).toBe(4.5);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("percentTrue", () => {
|
|
41
|
+
it("returns null when no booleans", () => {
|
|
42
|
+
expect(percentTrue([null, undefined])).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("computes yes percentage", () => {
|
|
46
|
+
expect(percentTrue([true, false, true])).toBe(67);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns 100 when all true", () => {
|
|
50
|
+
expect(percentTrue([true, true])).toBe(100);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("edge cases", () => {
|
|
55
|
+
it("parseOptionalDimensionRating rejects non-integers", () => {
|
|
56
|
+
expect(() =>
|
|
57
|
+
parseOptionalDimensionRating(3.5, "qualityRating")
|
|
58
|
+
).toThrow(/integer/i);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("averageNullable ignores NaN", () => {
|
|
62
|
+
expect(averageNullable([4, Number.NaN, 2])).toBe(3);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { computeTrustScoreFromInputs } from "../trustScoreUtils.js";
|
|
2
|
+
|
|
3
|
+
describe("trustScoreUtils", () => {
|
|
4
|
+
it("returns a score between 0 and 100 for services", () => {
|
|
5
|
+
const result = computeTrustScoreFromInputs("service", {
|
|
6
|
+
bayesianReviewScore: 4.5,
|
|
7
|
+
completedVolume: 25,
|
|
8
|
+
cancellationRate: 0.05,
|
|
9
|
+
responseRate: 1,
|
|
10
|
+
identityVerified: true,
|
|
11
|
+
verifiedCompletionRate: 0.9,
|
|
12
|
+
accountAgeDays: 400,
|
|
13
|
+
disputeRate: 0,
|
|
14
|
+
repeatCustomerRate: 0.2,
|
|
15
|
+
});
|
|
16
|
+
expect(result.score).not.toBeNull();
|
|
17
|
+
expect(result.score!).toBeGreaterThanOrEqual(0);
|
|
18
|
+
expect(result.score!).toBeLessThanOrEqual(100);
|
|
19
|
+
expect(result.components.reviews).toBeGreaterThan(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses host weights including attendance signal", () => {
|
|
23
|
+
const result = computeTrustScoreFromInputs("host", {
|
|
24
|
+
bayesianReviewScore: 4,
|
|
25
|
+
completedVolume: 10,
|
|
26
|
+
cancellationRate: 0.1,
|
|
27
|
+
responseRate: 0.8,
|
|
28
|
+
identityVerified: false,
|
|
29
|
+
verifiedAttendanceRate: 0.75,
|
|
30
|
+
accountAgeDays: 30,
|
|
31
|
+
disputeRate: 0.02,
|
|
32
|
+
repeatCustomerRate: 0.1,
|
|
33
|
+
});
|
|
34
|
+
expect(result.weights.attendance).toBe(0.05);
|
|
35
|
+
expect(result.score).not.toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null score when insufficient review signal defaults", () => {
|
|
39
|
+
const result = computeTrustScoreFromInputs("service", {
|
|
40
|
+
bayesianReviewScore: null,
|
|
41
|
+
completedVolume: 0,
|
|
42
|
+
cancellationRate: 0,
|
|
43
|
+
responseRate: 1,
|
|
44
|
+
identityVerified: false,
|
|
45
|
+
verifiedCompletionRate: 0,
|
|
46
|
+
accountAgeDays: 1,
|
|
47
|
+
disputeRate: 0,
|
|
48
|
+
repeatCustomerRate: 0,
|
|
49
|
+
});
|
|
50
|
+
expect(result.score).not.toBeNull();
|
|
51
|
+
expect(result.components.reviews).toBe(0.5);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("penalizes high cancellation and dispute rates", () => {
|
|
55
|
+
const good = computeTrustScoreFromInputs("service", {
|
|
56
|
+
bayesianReviewScore: 4.5,
|
|
57
|
+
completedVolume: 50,
|
|
58
|
+
cancellationRate: 0.05,
|
|
59
|
+
responseRate: 1,
|
|
60
|
+
identityVerified: true,
|
|
61
|
+
verifiedCompletionRate: 0.9,
|
|
62
|
+
accountAgeDays: 500,
|
|
63
|
+
disputeRate: 0,
|
|
64
|
+
repeatCustomerRate: 0.3,
|
|
65
|
+
});
|
|
66
|
+
const bad = computeTrustScoreFromInputs("service", {
|
|
67
|
+
bayesianReviewScore: 4.5,
|
|
68
|
+
completedVolume: 50,
|
|
69
|
+
cancellationRate: 0.5,
|
|
70
|
+
responseRate: 0.5,
|
|
71
|
+
identityVerified: true,
|
|
72
|
+
verifiedCompletionRate: 0.2,
|
|
73
|
+
accountAgeDays: 500,
|
|
74
|
+
disputeRate: 0.2,
|
|
75
|
+
repeatCustomerRate: 0,
|
|
76
|
+
});
|
|
77
|
+
expect(good.score!).toBeGreaterThan(bad.score!);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("boosts score when identity is verified", () => {
|
|
81
|
+
const verified = computeTrustScoreFromInputs("service", {
|
|
82
|
+
bayesianReviewScore: 4,
|
|
83
|
+
completedVolume: 10,
|
|
84
|
+
cancellationRate: 0.1,
|
|
85
|
+
responseRate: 1,
|
|
86
|
+
identityVerified: true,
|
|
87
|
+
verifiedCompletionRate: 0.5,
|
|
88
|
+
accountAgeDays: 200,
|
|
89
|
+
disputeRate: 0,
|
|
90
|
+
repeatCustomerRate: 0.1,
|
|
91
|
+
});
|
|
92
|
+
const unverified = computeTrustScoreFromInputs("service", {
|
|
93
|
+
bayesianReviewScore: 4,
|
|
94
|
+
completedVolume: 10,
|
|
95
|
+
cancellationRate: 0.1,
|
|
96
|
+
responseRate: 1,
|
|
97
|
+
identityVerified: false,
|
|
98
|
+
verifiedCompletionRate: 0.5,
|
|
99
|
+
accountAgeDays: 200,
|
|
100
|
+
disputeRate: 0,
|
|
101
|
+
repeatCustomerRate: 0.1,
|
|
102
|
+
});
|
|
103
|
+
expect(verified.score!).toBeGreaterThan(unverified.score!);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -85,7 +85,13 @@ export function getServiceListingReputation(service: {
|
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
87
|
const sourceLabel =
|
|
88
|
-
service.externalReviewSource === "google"
|
|
88
|
+
service.externalReviewSource === "google" && (service.totalRatings ?? 0) === 0
|
|
89
|
+
? "Google"
|
|
90
|
+
: (service.totalRatings ?? 0) > 0
|
|
91
|
+
? "Bash"
|
|
92
|
+
: service.externalReviewSource === "google"
|
|
93
|
+
? "Google"
|
|
94
|
+
: undefined;
|
|
89
95
|
return {
|
|
90
96
|
averageRating: summary.averageRating,
|
|
91
97
|
totalRatings: summary.totalRatings,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Optional 1–5 dimension rating from API/UI payloads. */
|
|
2
|
+
export type OptionalDimensionRating = number | null | undefined;
|
|
3
|
+
|
|
4
|
+
export interface EventReviewDimensionsInput {
|
|
5
|
+
organizationRating?: OptionalDimensionRating;
|
|
6
|
+
atmosphereRating?: OptionalDimensionRating;
|
|
7
|
+
valueRating?: OptionalDimensionRating;
|
|
8
|
+
safetyRating?: OptionalDimensionRating;
|
|
9
|
+
wouldAttendAgain?: boolean | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ServiceReviewDimensionsInput {
|
|
13
|
+
communicationRating?: OptionalDimensionRating;
|
|
14
|
+
professionalismRating?: OptionalDimensionRating;
|
|
15
|
+
qualityRating?: OptionalDimensionRating;
|
|
16
|
+
wouldHireAgain?: boolean | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReviewDimensionAverages {
|
|
20
|
+
organization?: number | null;
|
|
21
|
+
atmosphere?: number | null;
|
|
22
|
+
value?: number | null;
|
|
23
|
+
safety?: number | null;
|
|
24
|
+
communication?: number | null;
|
|
25
|
+
professionalism?: number | null;
|
|
26
|
+
quality?: number | null;
|
|
27
|
+
wouldAttendAgainPercent?: number | null;
|
|
28
|
+
wouldHireAgainPercent?: number | null;
|
|
29
|
+
wouldWorkAgainPercent?: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MIN_DIM = 1;
|
|
33
|
+
const MAX_DIM = 5;
|
|
34
|
+
|
|
35
|
+
/** Validate optional 1–5 dimension; returns null when omitted. */
|
|
36
|
+
export function parseOptionalDimensionRating(
|
|
37
|
+
value: OptionalDimensionRating,
|
|
38
|
+
fieldName: string
|
|
39
|
+
): number | null {
|
|
40
|
+
if (value === null || value === undefined) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const n = Number(value);
|
|
44
|
+
if (!Number.isInteger(n) || n < MIN_DIM || n > MAX_DIM) {
|
|
45
|
+
throw new Error(`${fieldName} must be an integer from ${MIN_DIM} to ${MAX_DIM}`);
|
|
46
|
+
}
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseRequiredOverallRating(value: unknown): number {
|
|
51
|
+
const n = Number(value);
|
|
52
|
+
if (!Number.isInteger(n) || n < MIN_DIM || n > MAX_DIM) {
|
|
53
|
+
throw new Error(`Overall rating must be an integer from ${MIN_DIM} to ${MAX_DIM}`);
|
|
54
|
+
}
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function averageNullable(values: (number | null | undefined)[]): number | null {
|
|
59
|
+
const nums = values.filter((v): v is number => typeof v === "number" && !Number.isNaN(v));
|
|
60
|
+
if (nums.length === 0) return null;
|
|
61
|
+
const sum = nums.reduce((a, b) => a + b, 0);
|
|
62
|
+
return Math.round((sum / nums.length) * 100) / 100;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function percentTrue(values: (boolean | null | undefined)[]): number | null {
|
|
66
|
+
const defined = values.filter((v): v is boolean => typeof v === "boolean");
|
|
67
|
+
if (defined.length === 0) return null;
|
|
68
|
+
const yes = defined.filter(Boolean).length;
|
|
69
|
+
return Math.round((yes / defined.length) * 100);
|
|
70
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { bayesianAverage } from "./reputationUtils.js";
|
|
2
|
+
|
|
3
|
+
export type TrustScoreActorType = "host" | "service";
|
|
4
|
+
|
|
5
|
+
export interface TrustScoreInputs {
|
|
6
|
+
bayesianReviewScore: number | null;
|
|
7
|
+
completedVolume: number;
|
|
8
|
+
cancellationRate: number;
|
|
9
|
+
responseRate: number;
|
|
10
|
+
identityVerified: boolean;
|
|
11
|
+
verifiedAttendanceRate?: number;
|
|
12
|
+
verifiedCompletionRate?: number;
|
|
13
|
+
accountAgeDays: number;
|
|
14
|
+
disputeRate: number;
|
|
15
|
+
repeatCustomerRate: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TrustScoreBreakdown {
|
|
19
|
+
score: number | null;
|
|
20
|
+
components: Record<string, number>;
|
|
21
|
+
weights: Record<string, number>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const HOST_WEIGHTS: Record<string, number> = {
|
|
25
|
+
reviews: 0.35,
|
|
26
|
+
volume: 0.15,
|
|
27
|
+
cancellation: 0.1,
|
|
28
|
+
response: 0.1,
|
|
29
|
+
identity: 0.1,
|
|
30
|
+
attendance: 0.05,
|
|
31
|
+
accountAge: 0.05,
|
|
32
|
+
disputes: 0.05,
|
|
33
|
+
repeat: 0.05,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const SERVICE_WEIGHTS: Record<string, number> = {
|
|
37
|
+
reviews: 0.4,
|
|
38
|
+
volume: 0.15,
|
|
39
|
+
cancellation: 0.1,
|
|
40
|
+
response: 0.1,
|
|
41
|
+
identity: 0.1,
|
|
42
|
+
completion: 0.1,
|
|
43
|
+
accountAge: 0.05,
|
|
44
|
+
disputes: 0.05,
|
|
45
|
+
repeat: 0.05,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function clamp01(n: number): number {
|
|
49
|
+
if (n < 0) return 0;
|
|
50
|
+
if (n > 1) return 1;
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function logScaledVolume(count: number, cap = 100): number {
|
|
55
|
+
if (count <= 0) return 0;
|
|
56
|
+
return clamp01(Math.log10(count + 1) / Math.log10(cap + 1));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function accountAgeScore(days: number): number {
|
|
60
|
+
return clamp01(Math.log10(Math.max(days, 1) + 1) / Math.log10(365 * 3 + 1));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function inverseRate(rate: number): number {
|
|
64
|
+
return clamp01(1 - clamp01(rate));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compute a 0–100 trust score from normalized component signals (0–1 each).
|
|
69
|
+
*/
|
|
70
|
+
export function computeTrustScoreFromInputs(
|
|
71
|
+
actorType: TrustScoreActorType,
|
|
72
|
+
inputs: TrustScoreInputs
|
|
73
|
+
): TrustScoreBreakdown {
|
|
74
|
+
const weights = actorType === "host" ? HOST_WEIGHTS : SERVICE_WEIGHTS;
|
|
75
|
+
|
|
76
|
+
const reviewComponent =
|
|
77
|
+
inputs.bayesianReviewScore != null
|
|
78
|
+
? clamp01(inputs.bayesianReviewScore / 5)
|
|
79
|
+
: 0.5;
|
|
80
|
+
|
|
81
|
+
const components: Record<string, number> = {
|
|
82
|
+
reviews: reviewComponent,
|
|
83
|
+
volume: logScaledVolume(inputs.completedVolume),
|
|
84
|
+
cancellation: inverseRate(inputs.cancellationRate),
|
|
85
|
+
response: clamp01(inputs.responseRate),
|
|
86
|
+
identity: inputs.identityVerified ? 1 : 0,
|
|
87
|
+
accountAge: accountAgeScore(inputs.accountAgeDays),
|
|
88
|
+
disputes: inverseRate(inputs.disputeRate),
|
|
89
|
+
repeat: clamp01(inputs.repeatCustomerRate),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (actorType === "host") {
|
|
93
|
+
components.attendance = clamp01(inputs.verifiedAttendanceRate ?? 0);
|
|
94
|
+
} else {
|
|
95
|
+
components.completion = clamp01(inputs.verifiedCompletionRate ?? 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let weighted = 0;
|
|
99
|
+
let weightSum = 0;
|
|
100
|
+
for (const [key, weight] of Object.entries(weights)) {
|
|
101
|
+
const val = components[key];
|
|
102
|
+
if (val == null) continue;
|
|
103
|
+
weighted += val * weight;
|
|
104
|
+
weightSum += weight;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (weightSum === 0) {
|
|
108
|
+
return { score: null, components, weights };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const normalized = weighted / weightSum;
|
|
112
|
+
const score = Math.round(normalized * 100);
|
|
113
|
+
return { score, components, weights };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Convenience when only review stats are available (early bootstrap). */
|
|
117
|
+
export function trustScoreFromReviewAverage(
|
|
118
|
+
actorType: TrustScoreActorType,
|
|
119
|
+
averageRating: number | null | undefined,
|
|
120
|
+
totalRatings: number | null | undefined
|
|
121
|
+
): number | null {
|
|
122
|
+
const bayesian = bayesianAverage(averageRating, totalRatings);
|
|
123
|
+
const result = computeTrustScoreFromInputs(actorType, {
|
|
124
|
+
bayesianReviewScore: bayesian,
|
|
125
|
+
completedVolume: totalRatings ?? 0,
|
|
126
|
+
cancellationRate: 0,
|
|
127
|
+
responseRate: 1,
|
|
128
|
+
identityVerified: false,
|
|
129
|
+
accountAgeDays: 30,
|
|
130
|
+
disputeRate: 0,
|
|
131
|
+
repeatCustomerRate: 0,
|
|
132
|
+
verifiedAttendanceRate: 0,
|
|
133
|
+
verifiedCompletionRate: 0,
|
|
134
|
+
});
|
|
135
|
+
return result.score;
|
|
136
|
+
}
|