@bash-app/bash-common 30.293.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.
Files changed (51) hide show
  1. package/dist/__tests__/bashSectionTitleForBashType.test.js +3 -0
  2. package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
  3. package/dist/definitions.d.ts +77 -0
  4. package/dist/definitions.d.ts.map +1 -1
  5. package/dist/definitions.js +1 -0
  6. package/dist/definitions.js.map +1 -1
  7. package/dist/extendedSchemas.d.ts +7 -0
  8. package/dist/extendedSchemas.d.ts.map +1 -1
  9. package/dist/extendedSchemas.js.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/membershipDefinitions.d.ts +9 -0
  15. package/dist/membershipDefinitions.d.ts.map +1 -1
  16. package/dist/membershipDefinitions.js +10 -1
  17. package/dist/membershipDefinitions.js.map +1 -1
  18. package/dist/utils/__tests__/reputationUtils.serviceListing.test.js +18 -0
  19. package/dist/utils/__tests__/reputationUtils.serviceListing.test.js.map +1 -1
  20. package/dist/utils/__tests__/reviewDimensionUtils.test.d.ts +2 -0
  21. package/dist/utils/__tests__/reviewDimensionUtils.test.d.ts.map +1 -0
  22. package/dist/utils/__tests__/reviewDimensionUtils.test.js +48 -0
  23. package/dist/utils/__tests__/reviewDimensionUtils.test.js.map +1 -0
  24. package/dist/utils/__tests__/trustScoreUtils.test.d.ts +2 -0
  25. package/dist/utils/__tests__/trustScoreUtils.test.d.ts.map +1 -0
  26. package/dist/utils/__tests__/trustScoreUtils.test.js +101 -0
  27. package/dist/utils/__tests__/trustScoreUtils.test.js.map +1 -0
  28. package/dist/utils/reputationUtils.d.ts.map +1 -1
  29. package/dist/utils/reputationUtils.js +7 -1
  30. package/dist/utils/reputationUtils.js.map +1 -1
  31. package/dist/utils/reviewDimensionUtils.d.ts +33 -0
  32. package/dist/utils/reviewDimensionUtils.d.ts.map +1 -0
  33. package/dist/utils/reviewDimensionUtils.js +35 -0
  34. package/dist/utils/reviewDimensionUtils.js.map +1 -0
  35. package/dist/utils/trustScoreUtils.d.ts +25 -0
  36. package/dist/utils/trustScoreUtils.d.ts.map +1 -0
  37. package/dist/utils/trustScoreUtils.js +99 -0
  38. package/dist/utils/trustScoreUtils.js.map +1 -0
  39. package/package.json +1 -1
  40. package/prisma/schema.prisma +133 -15
  41. package/src/__tests__/bashSectionTitleForBashType.test.ts +4 -0
  42. package/src/definitions.ts +85 -1
  43. package/src/extendedSchemas.ts +7 -0
  44. package/src/index.ts +2 -0
  45. package/src/membershipDefinitions.ts +12 -1
  46. package/src/utils/__tests__/reputationUtils.serviceListing.test.ts +24 -0
  47. package/src/utils/__tests__/reviewDimensionUtils.test.ts +65 -0
  48. package/src/utils/__tests__/trustScoreUtils.test.ts +105 -0
  49. package/src/utils/reputationUtils.ts +7 -1
  50. package/src/utils/reviewDimensionUtils.ts +70 -0
  51. package/src/utils/trustScoreUtils.ts +136 -0
@@ -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" ? "Google" : undefined;
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
+ }