@bash-app/bash-common 30.293.0 → 30.297.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/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/service/__tests__/serviceListingMediaUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/serviceListingMediaUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/serviceListingMediaUtils.test.js +33 -0
- package/dist/utils/service/__tests__/serviceListingMediaUtils.test.js.map +1 -0
- package/dist/utils/service/serviceListingMediaUtils.d.ts +22 -0
- package/dist/utils/service/serviceListingMediaUtils.d.ts.map +1 -0
- package/dist/utils/service/serviceListingMediaUtils.js +40 -0
- package/dist/utils/service/serviceListingMediaUtils.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 +133 -15
- package/src/__tests__/bashSectionTitleForBashType.test.ts +4 -0
- package/src/definitions.ts +85 -1
- package/src/extendedSchemas.ts +7 -0
- package/src/index.ts +3 -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/service/__tests__/serviceListingMediaUtils.test.ts +44 -0
- package/src/utils/service/serviceListingMediaUtils.ts +62 -0
- package/src/utils/trustScoreUtils.ts +136 -0
package/src/definitions.ts
CHANGED
|
@@ -1471,6 +1471,7 @@ export const INCOME_RANGE_VALUES: ReadonlyArray<IncomeRange> = [
|
|
|
1471
1471
|
|
|
1472
1472
|
/** Homepage / discovery section labels for synthetic rows (not `BashEventType` enum values). */
|
|
1473
1473
|
const SYNTHETIC_BASH_SECTION_TITLE: Record<string, string> = {
|
|
1474
|
+
Ideas: "Ideas",
|
|
1474
1475
|
Featured: "Featured",
|
|
1475
1476
|
Trending: "Trending",
|
|
1476
1477
|
};
|
|
@@ -1817,11 +1818,19 @@ export interface UserTemporaryBadge {
|
|
|
1817
1818
|
|
|
1818
1819
|
export interface Review {
|
|
1819
1820
|
id: string;
|
|
1820
|
-
rating: number; // 1-5 stars
|
|
1821
|
+
rating: number; // 1-5 stars (Overall)
|
|
1821
1822
|
creatorId: string;
|
|
1822
1823
|
bashEventId: string;
|
|
1823
1824
|
createdAt: string;
|
|
1824
1825
|
updatedAt: string;
|
|
1826
|
+
organizationRating?: number | null;
|
|
1827
|
+
atmosphereRating?: number | null;
|
|
1828
|
+
valueRating?: number | null;
|
|
1829
|
+
safetyRating?: number | null;
|
|
1830
|
+
wouldAttendAgain?: boolean | null;
|
|
1831
|
+
verifiedBooking?: boolean;
|
|
1832
|
+
verifiedAttendance?: boolean;
|
|
1833
|
+
verifiedServiceCompletion?: boolean;
|
|
1825
1834
|
comments?: ReviewComment[];
|
|
1826
1835
|
}
|
|
1827
1836
|
|
|
@@ -1853,10 +1862,83 @@ export interface ReviewWithUser extends Review {
|
|
|
1853
1862
|
|
|
1854
1863
|
export interface ReviewWithUserAndEvent extends ReviewWithUser, ReviewWithEvent {}
|
|
1855
1864
|
|
|
1865
|
+
/** Wizard / API shape for BashEvent.itinerary JSON (see prisma BashEvent.itinerary). */
|
|
1866
|
+
export interface ItineraryItem {
|
|
1867
|
+
id: string;
|
|
1868
|
+
title: string;
|
|
1869
|
+
startTime: string;
|
|
1870
|
+
endTime: string;
|
|
1871
|
+
description?: string;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1856
1874
|
export interface NewReview {
|
|
1857
1875
|
rating: number;
|
|
1858
1876
|
bashEventId: string;
|
|
1859
1877
|
commentContent?: string;
|
|
1878
|
+
organizationRating?: number;
|
|
1879
|
+
atmosphereRating?: number;
|
|
1880
|
+
valueRating?: number;
|
|
1881
|
+
safetyRating?: number;
|
|
1882
|
+
wouldAttendAgain?: boolean;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
export interface ServiceReviewDimensions {
|
|
1886
|
+
communicationRating?: number | null;
|
|
1887
|
+
professionalismRating?: number | null;
|
|
1888
|
+
qualityRating?: number | null;
|
|
1889
|
+
wouldHireAgain?: boolean | null;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
export interface NewServiceReview extends ServiceReviewDimensions {
|
|
1893
|
+
overallRating: number;
|
|
1894
|
+
bashEventId: string;
|
|
1895
|
+
comment?: string;
|
|
1896
|
+
createPost?: boolean;
|
|
1897
|
+
postContent?: string;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
export interface ServiceReviewPublic {
|
|
1901
|
+
id: string;
|
|
1902
|
+
overallRating: number;
|
|
1903
|
+
reviewerRole: "Attendee" | "Host";
|
|
1904
|
+
comment?: string | null;
|
|
1905
|
+
createdAt: string;
|
|
1906
|
+
publishedAt?: string | null;
|
|
1907
|
+
verifiedBooking: boolean;
|
|
1908
|
+
verifiedAttendance: boolean;
|
|
1909
|
+
verifiedServiceCompletion: boolean;
|
|
1910
|
+
communicationRating?: number | null;
|
|
1911
|
+
professionalismRating?: number | null;
|
|
1912
|
+
qualityRating?: number | null;
|
|
1913
|
+
wouldHireAgain?: boolean | null;
|
|
1914
|
+
providerReplyText?: string | null;
|
|
1915
|
+
providerReplyAt?: string | null;
|
|
1916
|
+
feedPostId?: string | null;
|
|
1917
|
+
creator?: {
|
|
1918
|
+
id: string;
|
|
1919
|
+
givenName?: string | null;
|
|
1920
|
+
familyName?: string | null;
|
|
1921
|
+
image?: string | null;
|
|
1922
|
+
};
|
|
1923
|
+
bashEvent?: {
|
|
1924
|
+
id: string;
|
|
1925
|
+
title: string;
|
|
1926
|
+
startDateTime: string;
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
export interface ServiceReviewStats {
|
|
1931
|
+
overallAverage: number | null;
|
|
1932
|
+
totalReviews: number;
|
|
1933
|
+
dimensionAverages: import("./utils/reviewDimensionUtils.js").ReviewDimensionAverages;
|
|
1934
|
+
googleAverage?: number | null;
|
|
1935
|
+
googleCount?: number;
|
|
1936
|
+
trustScore?: number | null;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
export interface TrustScoreBreakdownPublic {
|
|
1940
|
+
score: number | null;
|
|
1941
|
+
components: Record<string, number>;
|
|
1860
1942
|
}
|
|
1861
1943
|
|
|
1862
1944
|
export interface ReviewStats {
|
|
@@ -1869,6 +1951,8 @@ export interface ReviewStats {
|
|
|
1869
1951
|
4: number;
|
|
1870
1952
|
5: number;
|
|
1871
1953
|
};
|
|
1954
|
+
dimensionAverages?: import("./utils/reviewDimensionUtils.js").ReviewDimensionAverages;
|
|
1955
|
+
trustScore?: number | null;
|
|
1872
1956
|
}
|
|
1873
1957
|
|
|
1874
1958
|
/** Helpful / Not helpful tallies on profile review cards (API: host-reviews). */
|
package/src/extendedSchemas.ts
CHANGED
|
@@ -287,6 +287,7 @@ export interface CompetitionPrizeOfferExt {
|
|
|
287
287
|
sponsorLabel?: string | null;
|
|
288
288
|
sourceServiceId?: string | null;
|
|
289
289
|
sourceSku?: string | null;
|
|
290
|
+
targetPrizeId?: string | null;
|
|
290
291
|
hostNote?: string | null;
|
|
291
292
|
createdAt: Date | string;
|
|
292
293
|
respondedAt?: Date | string | null;
|
|
@@ -296,6 +297,12 @@ export interface CompetitionPrizeOfferExt {
|
|
|
296
297
|
serviceName: string | null;
|
|
297
298
|
coverPhoto: string | null;
|
|
298
299
|
} | null;
|
|
300
|
+
targetPrize?: {
|
|
301
|
+
id: string;
|
|
302
|
+
name: string;
|
|
303
|
+
placeName: string | null;
|
|
304
|
+
sponsorCostCents: number | null;
|
|
305
|
+
} | null;
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
/** Competition row + relations; Override keeps new columns visible before consumers refresh Prisma. */
|
package/src/index.ts
CHANGED
|
@@ -366,6 +366,7 @@ export * from "./utils/service/serviceFeaturedSortUtils.js";
|
|
|
366
366
|
export * from "./utils/service/serviceBookingExposureUtils.js";
|
|
367
367
|
export * from "./utils/agencyBookingCheckoutCopy.js";
|
|
368
368
|
export * from "./utils/service/serviceUtils.js";
|
|
369
|
+
export * from "./utils/service/serviceListingMediaUtils.js";
|
|
369
370
|
export * from "./utils/service/venueSettlementUtils.js";
|
|
370
371
|
export * from "./utils/service/offerAllowListUtils.js";
|
|
371
372
|
export * from "./utils/service/offerComparisonUtils.js";
|
|
@@ -407,6 +408,8 @@ export * from "./utils/service/serviceRateUtils.js";
|
|
|
407
408
|
export * from "./utils/serviceAvailabilityDisplay.js";
|
|
408
409
|
export * from "./utils/entertainmentServiceTypeOptions.js";
|
|
409
410
|
export * from "./utils/reputationUtils.js";
|
|
411
|
+
export * from "./utils/reviewDimensionUtils.js";
|
|
412
|
+
export * from "./utils/trustScoreUtils.js";
|
|
410
413
|
export * from "./utils/stripeAccountUtils.js";
|
|
411
414
|
export * from "./utils/userUtils.js";
|
|
412
415
|
export * from "./utils/venueTypeFormatter.js";
|
|
@@ -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,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import { MediaType } from "@prisma/client";
|
|
3
|
+
import type { ServiceExt } from "../../../extendedSchemas.js";
|
|
4
|
+
import {
|
|
5
|
+
filterServiceListingMedia,
|
|
6
|
+
resolveServiceListingImage,
|
|
7
|
+
} from "../serviceListingMediaUtils.js";
|
|
8
|
+
|
|
9
|
+
describe("serviceListingMediaUtils", () => {
|
|
10
|
+
test("resolveServiceListingImage prefers coverPhoto when gallery has videos", () => {
|
|
11
|
+
expect(
|
|
12
|
+
resolveServiceListingImage({
|
|
13
|
+
coverPhoto: "https://cdn.example.com/cover.jpg",
|
|
14
|
+
media: [
|
|
15
|
+
{ type: MediaType.Video, url: "https://cdn.example.com/live.mp4" },
|
|
16
|
+
],
|
|
17
|
+
} as Pick<ServiceExt, "coverPhoto" | "media">)
|
|
18
|
+
).toBe("https://cdn.example.com/cover.jpg");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("filterServiceListingMedia excludes supporting videos", () => {
|
|
22
|
+
const filtered = filterServiceListingMedia({
|
|
23
|
+
media: [
|
|
24
|
+
{ type: MediaType.Video, url: "https://cdn.example.com/live.mp4" },
|
|
25
|
+
{ type: MediaType.Image, url: "https://cdn.example.com/still.jpg" },
|
|
26
|
+
],
|
|
27
|
+
} as Pick<ServiceExt, "media">);
|
|
28
|
+
|
|
29
|
+
expect(filtered.map((item) => item.url)).toEqual([
|
|
30
|
+
"https://cdn.example.com/still.jpg",
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("resolveServiceListingImage returns undefined when only videos exist", () => {
|
|
35
|
+
expect(
|
|
36
|
+
resolveServiceListingImage({
|
|
37
|
+
coverPhoto: null,
|
|
38
|
+
media: [
|
|
39
|
+
{ type: MediaType.Video, url: "https://cdn.example.com/live.mp4" },
|
|
40
|
+
],
|
|
41
|
+
} as Pick<ServiceExt, "coverPhoto" | "media">)
|
|
42
|
+
).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { MediaType, type Media } from "@prisma/client";
|
|
2
|
+
import type { ServiceExt } from "../../extendedSchemas.js";
|
|
3
|
+
|
|
4
|
+
export type ServiceListingOwner = {
|
|
5
|
+
uploadedImage?: string | null;
|
|
6
|
+
image?: string | null;
|
|
7
|
+
} | null | undefined;
|
|
8
|
+
|
|
9
|
+
function normalizeListingImageUrl(
|
|
10
|
+
url: string | null | undefined
|
|
11
|
+
): string | null {
|
|
12
|
+
if (!url?.trim()) return null;
|
|
13
|
+
return url.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** True when a URL is the service owner's personal profile photo, not listing media. */
|
|
17
|
+
export function isOwnerProfileListingImageUrl(
|
|
18
|
+
url: string,
|
|
19
|
+
owner?: ServiceListingOwner
|
|
20
|
+
): boolean {
|
|
21
|
+
if (!owner) return false;
|
|
22
|
+
const normalized = url.trim();
|
|
23
|
+
const profileUrls = [owner.uploadedImage, owner.image]
|
|
24
|
+
.map(normalizeListingImageUrl)
|
|
25
|
+
.filter((value): value is string => Boolean(value));
|
|
26
|
+
return profileUrls.some((profileUrl) => profileUrl === normalized);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Gallery images suitable for public service cards — excludes videos and profile avatars. */
|
|
30
|
+
export function filterServiceListingMedia(
|
|
31
|
+
service: Pick<ServiceExt, "media"> & { owner?: ServiceListingOwner }
|
|
32
|
+
): Media[] {
|
|
33
|
+
const media = Array.isArray(service.media) ? service.media : [];
|
|
34
|
+
return media.filter(
|
|
35
|
+
(item) =>
|
|
36
|
+
item?.type === MediaType.Image &&
|
|
37
|
+
typeof item.url === "string" &&
|
|
38
|
+
item.url.trim().length > 0 &&
|
|
39
|
+
!item.url.startsWith("https://maps.googleapis.com/") &&
|
|
40
|
+
!isOwnerProfileListingImageUrl(item.url, service.owner)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the still image for browse cards and list heroes.
|
|
46
|
+
*
|
|
47
|
+
* Prefers `coverPhoto`, then the first gallery image. Supporting videos in
|
|
48
|
+
* `media` are never used — cards must not autoplay performance clips.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveServiceListingImage(
|
|
51
|
+
service: Pick<ServiceExt, "coverPhoto" | "media"> & {
|
|
52
|
+
owner?: ServiceListingOwner;
|
|
53
|
+
}
|
|
54
|
+
): string | undefined {
|
|
55
|
+
const cover = normalizeListingImageUrl(service.coverPhoto);
|
|
56
|
+
if (cover && !isOwnerProfileListingImageUrl(cover, service.owner)) {
|
|
57
|
+
return cover;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const firstImage = filterServiceListingMedia(service)[0];
|
|
61
|
+
return firstImage?.url?.trim() || undefined;
|
|
62
|
+
}
|