@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.
Files changed (61) 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 +3 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -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/service/__tests__/serviceListingMediaUtils.test.d.ts +2 -0
  36. package/dist/utils/service/__tests__/serviceListingMediaUtils.test.d.ts.map +1 -0
  37. package/dist/utils/service/__tests__/serviceListingMediaUtils.test.js +33 -0
  38. package/dist/utils/service/__tests__/serviceListingMediaUtils.test.js.map +1 -0
  39. package/dist/utils/service/serviceListingMediaUtils.d.ts +22 -0
  40. package/dist/utils/service/serviceListingMediaUtils.d.ts.map +1 -0
  41. package/dist/utils/service/serviceListingMediaUtils.js +40 -0
  42. package/dist/utils/service/serviceListingMediaUtils.js.map +1 -0
  43. package/dist/utils/trustScoreUtils.d.ts +25 -0
  44. package/dist/utils/trustScoreUtils.d.ts.map +1 -0
  45. package/dist/utils/trustScoreUtils.js +99 -0
  46. package/dist/utils/trustScoreUtils.js.map +1 -0
  47. package/package.json +1 -1
  48. package/prisma/schema.prisma +133 -15
  49. package/src/__tests__/bashSectionTitleForBashType.test.ts +4 -0
  50. package/src/definitions.ts +85 -1
  51. package/src/extendedSchemas.ts +7 -0
  52. package/src/index.ts +3 -0
  53. package/src/membershipDefinitions.ts +12 -1
  54. package/src/utils/__tests__/reputationUtils.serviceListing.test.ts +24 -0
  55. package/src/utils/__tests__/reviewDimensionUtils.test.ts +65 -0
  56. package/src/utils/__tests__/trustScoreUtils.test.ts +105 -0
  57. package/src/utils/reputationUtils.ts +7 -1
  58. package/src/utils/reviewDimensionUtils.ts +70 -0
  59. package/src/utils/service/__tests__/serviceListingMediaUtils.test.ts +44 -0
  60. package/src/utils/service/serviceListingMediaUtils.ts +62 -0
  61. package/src/utils/trustScoreUtils.ts +136 -0
@@ -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). */
@@ -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: 3 free or 1 paid referral
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" ? "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,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
+ }