@bash-app/bash-common 30.35.0 → 30.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bash-app/bash-common",
3
- "version": "30.35.0",
3
+ "version": "30.37.0",
4
4
  "description": "Common data and scripts to use on the frontend and backend",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -38,7 +38,6 @@
38
38
  "devDependencies": {
39
39
  "@types/jest": "^29.5.5",
40
40
  "@types/luxon": "^3.4.2",
41
- "@types/luxon": "^3.4.2",
42
41
  "@types/node": "^20.11.1",
43
42
  "@types/react": "^18.3.2",
44
43
  "@types/shelljs": "^0.8.15",
@@ -314,6 +314,7 @@ model BashEvent {
314
314
  userPromoCodeRedemption UserPromoCodeRedemption[]
315
315
  averageRating Float? @default(0)
316
316
  serviceBookings ServiceBooking[] // Add this field to create the reverse relation
317
+ userReport UserReport[]
317
318
  }
318
319
 
319
320
  model Coordinates {
@@ -984,7 +985,7 @@ model User {
984
985
  stripeCustomerId String? @unique
985
986
  stripeAccountId String? @unique
986
987
  isSuperUser Boolean @default(false)
987
- // isSuspended Boolean @default(false)
988
+ isSuspended Boolean @default(false)
988
989
  intent UserIntent?
989
990
  googleCalendarAccess String?
990
991
  givenName String?
@@ -997,6 +998,9 @@ model User {
997
998
  gender Gender?
998
999
  sex Sex?
999
1000
  roles UserRole[] @default([User])
1001
+ aboutMe String?
1002
+ levelBadge String?
1003
+ temporaryBadges String[] @default([])
1000
1004
  ownedServices Service[] @relation("OwnedService")
1001
1005
  createdServices Service[] @relation("CreatedService")
1002
1006
  createdEvents BashEvent[] @relation("CreatedEvent")
@@ -1054,13 +1058,11 @@ model User {
1054
1058
  volunteerService VolunteerService[]
1055
1059
  stripeAccounts StripeAccount[]
1056
1060
  userPromoCodeRedemption UserPromoCodeRedemption[]
1057
- accepted Boolean? @default(false) // Tracks if the user accepted the invitation
1058
- boughtTicket Boolean? @default(false) // Tracks if the user bought a ticket
1059
- noPay Boolean? @default(false) // Tracks if the user is marked as "noPay"
1060
- supportedEvent Boolean? @default(false) // Tracks if the user supported the event
1061
+ accepted Boolean? @default(false)
1062
+ boughtTicket Boolean? @default(false)
1063
+ noPay Boolean? @default(false)
1064
+ supportedEvent Boolean? @default(false)
1061
1065
 
1062
- // primaryStripeAccountDBId String? @unique
1063
- // primaryStripeAccountDB StripeAccount? @relation("PrimaryStripeAccount", fields: [primaryStripeAccountDBId], references: [id], onDelete: Restrict, onUpdate: Restrict)
1064
1066
  serviceBookingForCreator ServiceBooking[] @relation("BookingCreator")
1065
1067
  serviceBookingForUser ServiceBooking[] @relation("BookingForUser")
1066
1068
  serviceBookingCheckout ServiceBookingCheckout[]
@@ -1072,19 +1074,89 @@ model User {
1072
1074
  unblocksCreated UnblockedUserHistory[] @relation("UserUnblocksMade")
1073
1075
  unblocksReceived UnblockedUserHistory[] @relation("UserUnblocksReceived")
1074
1076
 
1075
- preferences UserPreference?
1077
+ preferences UserPreferences?
1076
1078
 
1079
+ // Add fields for user suspension
1080
+ suspendedUntil DateTime?
1081
+ suspendedById String?
1082
+ suspendedBy User? @relation("SuspendedUsers", fields: [suspendedById], references: [id], onDelete: SetNull)
1083
+ suspendedUsers User[] @relation("SuspendedUsers")
1084
+
1085
+ // Add relations for reports and demerits
1086
+ reportsMade UserReport[] @relation("ReportsMade")
1087
+ reportsReceived UserReport[] @relation("ReportsReceived")
1088
+ reportsReviewed UserReport[] @relation("ReportsReviewed")
1089
+ demerits Demerit[] @relation("UserDemerits")
1090
+ issuedDemerits Demerit[] @relation("IssuedDemerits")
1091
+ expungedDemerits Demerit[] @relation("ExpungedDemerits")
1077
1092
  }
1078
1093
 
1079
- model UserPreference {
1080
- id String @id @default(cuid())
1081
- userId String @unique
1082
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
1083
- privacySettings Json? // Stores privacy settings like hiddenBashIds and hideActivitySection
1084
- theme String? // For future use - can store theme preferences
1085
- notifications Json? // For future use - can store notification preferences
1086
- createdAt DateTime @default(now())
1087
- updatedAt DateTime @updatedAt
1094
+ model UserPreferences {
1095
+ id String @id @default(cuid())
1096
+ userId String @unique
1097
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
1098
+
1099
+ // Notification Preferences
1100
+ emailNotifications Boolean @default(true)
1101
+ pushNotifications Boolean @default(true)
1102
+ smsNotifications Boolean @default(false)
1103
+ newMessageNotify Boolean @default(true)
1104
+ eventReminderNotify Boolean @default(true)
1105
+ friendRequestNotify Boolean @default(true)
1106
+ commentNotify Boolean @default(true)
1107
+ invitationNotify Boolean @default(true)
1108
+ eventUpdatesNotify Boolean @default(true)
1109
+ servicePromotionsNotify Boolean @default(true)
1110
+
1111
+ // Privacy Settings
1112
+ profileVisibility String @default("PUBLIC") // PUBLIC, FRIENDS, PRIVATE
1113
+ showOnlineStatus Boolean @default(true)
1114
+ allowTagging Boolean @default(true)
1115
+ allowDirectMessages Boolean @default(true)
1116
+ showActivityStatus Boolean @default(true)
1117
+ hiddenBashIds String[] @default([])
1118
+ hideActivitySection Boolean @default(false)
1119
+ allowLocationSharing Boolean @default(false)
1120
+ blockSearchEngineIndex Boolean @default(false)
1121
+
1122
+ // UI/UX Preferences
1123
+ theme String @default("SYSTEM") // LIGHT, DARK, SYSTEM
1124
+ language String @default("en-US")
1125
+ timeZone String @default("America/New_York")
1126
+ defaultLandingPage String @default("dashboard")
1127
+ contentDensity String @default("COMFORTABLE") // COMPACT, COMFORTABLE, SPACIOUS
1128
+ fontScale String @default("MEDIUM") // SMALL, MEDIUM, LARGE
1129
+ animationsEnabled Boolean @default(true)
1130
+ useHighContrastMode Boolean @default(false)
1131
+
1132
+ // Content Preferences
1133
+ contentFilters String[] @default([])
1134
+ topicInterests String[] @default([])
1135
+ hideSeenContent Boolean @default(false)
1136
+ autoplayVideos Boolean @default(true)
1137
+ contentSortPreference String @default("RECENT") // RECENT, POPULAR, RELEVANT
1138
+ contentLanguages String[] @default(["en"])
1139
+ showSensitiveContent Boolean @default(false)
1140
+
1141
+ // Calendar & Event Preferences
1142
+ defaultCalendarView String @default("MONTH") // DAY, WEEK, MONTH, AGENDA
1143
+ calendarStartDay Int @default(0) // 0=Sunday, 1=Monday
1144
+ defaultEventReminder Int @default(60) // Minutes before event
1145
+ attendancePrivacy String @default("PUBLIC") // PUBLIC, FRIENDS, PRIVATE
1146
+
1147
+ // Communications Preferences
1148
+ communicationFrequency String @default("NORMAL") // LOW, NORMAL, HIGH
1149
+ preferredContactMethod String @default("EMAIL") // EMAIL, PUSH, SMS
1150
+
1151
+ // Data & Payment Preferences
1152
+ dataUsageConsent Boolean @default(true)
1153
+ analyticsConsent Boolean @default(true)
1154
+ preferredCurrency String @default("USD")
1155
+ savePaymentInfo Boolean @default(false)
1156
+ showEventPrices Boolean @default(true)
1157
+
1158
+ createdAt DateTime @default(now())
1159
+ updatedAt DateTime @updatedAt
1088
1160
  }
1089
1161
 
1090
1162
  model Contact {
@@ -2084,3 +2156,58 @@ enum EntityType {
2084
2156
  ORGANIZATION
2085
2157
  BASH_EVENT // For BashEvents
2086
2158
  }
2159
+
2160
+ // Adding new models for the demerit system
2161
+
2162
+ model UserReport {
2163
+ id String @id @default(cuid())
2164
+ reporterId String // Host/reporter user ID
2165
+ reporter User @relation("ReportsMade", fields: [reporterId], references: [id], onDelete: Cascade)
2166
+ reportedId String // Reported user ID
2167
+ reported User @relation("ReportsReceived", fields: [reportedId], references: [id], onDelete: Cascade)
2168
+ bashEventId String?
2169
+ bashEvent BashEvent? @relation(fields: [bashEventId], references: [id], onDelete: SetNull)
2170
+ reason String
2171
+ details String
2172
+ createdAt DateTime @default(now())
2173
+ status ReportStatus @default(Pending)
2174
+ reviewerId String? // Super user who reviewed the report
2175
+ reviewer User? @relation("ReportsReviewed", fields: [reviewerId], references: [id], onDelete: SetNull)
2176
+ reviewedAt DateTime?
2177
+ reviewNotes String?
2178
+ demerits Demerit[]
2179
+
2180
+ @@index([reporterId])
2181
+ @@index([reportedId])
2182
+ @@index([bashEventId])
2183
+ @@index([status])
2184
+ }
2185
+
2186
+ model Demerit {
2187
+ id String @id @default(cuid())
2188
+ userId String
2189
+ user User @relation("UserDemerits", fields: [userId], references: [id], onDelete: Cascade)
2190
+ reportId String?
2191
+ report UserReport? @relation(fields: [reportId], references: [id], onDelete: SetNull)
2192
+ issuerId String // Super user who issued the demerit
2193
+ issuer User @relation("IssuedDemerits", fields: [issuerId], references: [id], onDelete: Cascade)
2194
+ reason String
2195
+ points Int @default(1)
2196
+ issuedAt DateTime @default(now())
2197
+ expiresAt DateTime?
2198
+ isExpunged Boolean @default(false)
2199
+ expungedAt DateTime?
2200
+ expungedById String?
2201
+ expungedBy User? @relation("ExpungedDemerits", fields: [expungedById], references: [id], onDelete: SetNull)
2202
+
2203
+ @@index([userId])
2204
+ @@index([issuerId])
2205
+ @@index([reportId])
2206
+ }
2207
+
2208
+ enum ReportStatus {
2209
+ Pending
2210
+ Approved
2211
+ Rejected
2212
+ Resolved
2213
+ }
@@ -946,3 +946,217 @@ export interface SocialMediaProfile {
946
946
  url: string;
947
947
  userId: string;
948
948
  }
949
+
950
+ // Badge Types
951
+ export enum BadgeLevel {
952
+ Novice = "Novice",
953
+ Beginner = "Beginner",
954
+ Regular = "Regular",
955
+ Enthusiast = "Enthusiast",
956
+ Aficionado = "Aficionado",
957
+ Connoisseur = "Connoisseur",
958
+ Maverick = "Maverick",
959
+ Master = "Master",
960
+ Elite = "Elite",
961
+ Legend = "Legend"
962
+ }
963
+
964
+ export enum BadgeCategory {
965
+ Hosting = "Hosting",
966
+ Attendance = "Attendance",
967
+ Community = "Community",
968
+ Achievement = "Achievement",
969
+ Special = "Special"
970
+ }
971
+
972
+ // Type definitions for badges and reviews
973
+ export interface Badge {
974
+ id: string;
975
+ name: string;
976
+ description: string;
977
+ imageUrl: string;
978
+ category: BadgeCategory;
979
+ earnedOn?: string;
980
+ isActive: boolean;
981
+ }
982
+
983
+ export interface LevelBadge extends Badge {
984
+ level: BadgeLevel;
985
+ requiredEvents: number;
986
+ requiredRating?: number;
987
+ }
988
+
989
+ export interface TemporaryBadge extends Badge {
990
+ expiresOn?: string;
991
+ }
992
+
993
+ export interface UserLevelBadge {
994
+ badgeId: string;
995
+ earnedOn: string;
996
+ }
997
+
998
+ export interface UserTemporaryBadge {
999
+ badgeId: string;
1000
+ earnedOn: string;
1001
+ expiresOn?: string;
1002
+ }
1003
+
1004
+ export interface Review {
1005
+ id: string;
1006
+ rating: number; // 1-5 stars
1007
+ creatorId: string;
1008
+ bashEventId: string;
1009
+ createdAt: string;
1010
+ updatedAt: string;
1011
+ comments?: ReviewComment[];
1012
+ }
1013
+
1014
+ export interface ReviewComment {
1015
+ id: string;
1016
+ creatorId: string;
1017
+ content: string;
1018
+ reviewId: string;
1019
+ createdAt?: string;
1020
+ }
1021
+
1022
+ export interface ReviewWithEvent extends Review {
1023
+ bashEvent: {
1024
+ id: string;
1025
+ title: string;
1026
+ startDateTime: string;
1027
+ };
1028
+ }
1029
+
1030
+ export interface ReviewWithUser extends Review {
1031
+ creator: {
1032
+ id: string;
1033
+ givenName?: string;
1034
+ familyName?: string;
1035
+ image?: string;
1036
+ email: string;
1037
+ };
1038
+ }
1039
+
1040
+ export interface ReviewWithUserAndEvent extends ReviewWithUser, ReviewWithEvent {}
1041
+
1042
+ export interface NewReview {
1043
+ rating: number;
1044
+ bashEventId: string;
1045
+ commentContent?: string;
1046
+ }
1047
+
1048
+ export interface ReviewStats {
1049
+ averageRating: number;
1050
+ totalRatings: number;
1051
+ ratings: {
1052
+ 1: number;
1053
+ 2: number;
1054
+ 3: number;
1055
+ 4: number;
1056
+ 5: number;
1057
+ };
1058
+ }
1059
+
1060
+ // Default badge collections
1061
+ export const DEFAULT_LEVEL_BADGES: Record<string, LevelBadge> = {
1062
+ novice: {
1063
+ id: "novice",
1064
+ name: "Novice Basher",
1065
+ description: "Just getting started with Bash. Has hosted 1-2 events.",
1066
+ imageUrl: "/badges/novice.png",
1067
+ category: BadgeCategory.Hosting,
1068
+ level: BadgeLevel.Novice,
1069
+ requiredEvents: 1,
1070
+ isActive: true
1071
+ },
1072
+ regular: {
1073
+ id: "regular",
1074
+ name: "Regular Basher",
1075
+ description: "A consistent Bash user who has hosted 5+ events with good ratings.",
1076
+ imageUrl: "/badges/regular.png",
1077
+ category: BadgeCategory.Hosting,
1078
+ level: BadgeLevel.Regular,
1079
+ requiredEvents: 5,
1080
+ requiredRating: 4.0,
1081
+ isActive: true
1082
+ },
1083
+ maverick: {
1084
+ id: "maverick",
1085
+ name: "Maverick Basher",
1086
+ description: "A seasoned Bash host with 15+ successful events and excellent ratings.",
1087
+ imageUrl: "/badges/maverick.png",
1088
+ category: BadgeCategory.Hosting,
1089
+ level: BadgeLevel.Maverick,
1090
+ requiredEvents: 15,
1091
+ requiredRating: 4.5,
1092
+ isActive: true
1093
+ },
1094
+ legend: {
1095
+ id: "legend",
1096
+ name: "Bash Legend",
1097
+ description: "An elite Bash host with 30+ events and exceptional community standing.",
1098
+ imageUrl: "/badges/legend.png",
1099
+ category: BadgeCategory.Hosting,
1100
+ level: BadgeLevel.Legend,
1101
+ requiredEvents: 30,
1102
+ requiredRating: 4.8,
1103
+ isActive: true
1104
+ }
1105
+ };
1106
+
1107
+ export const DEFAULT_TEMPORARY_BADGES: Record<string, TemporaryBadge> = {
1108
+ trendingStar: {
1109
+ id: "trendingStar",
1110
+ name: "Trending Star",
1111
+ description: "Your event was trending in the last 30 days!",
1112
+ imageUrl: "/badges/trending.png",
1113
+ category: BadgeCategory.Achievement,
1114
+ isActive: true,
1115
+ expiresOn: undefined // Set dynamically when awarded
1116
+ },
1117
+ topHost: {
1118
+ id: "topHost",
1119
+ name: "Top Host",
1120
+ description: "One of the top-rated hosts this month.",
1121
+ imageUrl: "/badges/top-host.png",
1122
+ category: BadgeCategory.Hosting,
1123
+ isActive: true,
1124
+ expiresOn: undefined // Set dynamically when awarded
1125
+ },
1126
+ earlyAdopter: {
1127
+ id: "earlyAdopter",
1128
+ name: "Early Adopter",
1129
+ description: "Joined the platform early and helped shape the community.",
1130
+ imageUrl: "/badges/early-adopter.png",
1131
+ category: BadgeCategory.Special,
1132
+ isActive: true
1133
+ },
1134
+ socialButterfly: {
1135
+ id: "socialButterfly",
1136
+ name: "Social Butterfly",
1137
+ description: "Attended 10+ events in the last 30 days.",
1138
+ imageUrl: "/badges/butterfly.png",
1139
+ category: BadgeCategory.Attendance,
1140
+ isActive: true,
1141
+ expiresOn: undefined // Set dynamically when awarded
1142
+ }
1143
+ };
1144
+
1145
+ // Badge API endpoints
1146
+ export const BADGE_API_ENDPOINTS = {
1147
+ GET_USER_BADGES: (userId: string) => `/user/${userId}/badges`,
1148
+ UPDATE_USER_BADGES: (userId: string) => `/user/${userId}/badges`,
1149
+ GET_HOST_RATING: (userId: string) => `/user/${userId}/host-rating`,
1150
+ GET_USER_TRENDING_EVENTS: (userId: string) => `/user/${userId}/trending-events`,
1151
+ GET_USER_TOP_HOST_STATUS: (userId: string) => `/user/${userId}/is-top-host`,
1152
+ CALCULATE_BADGES: (userId: string) => `/user/${userId}/calculate-badges`,
1153
+ };
1154
+
1155
+ // Review API endpoints
1156
+ export const REVIEW_API_ENDPOINTS = {
1157
+ GET_HOST_REVIEWS: (userId: string) => `/user/${userId}/host-reviews`,
1158
+ GET_REVIEW_STATS: (userId: string) => `/user/${userId}/review-stats`,
1159
+ ELIGIBLE_FOR_REVIEW: (userId: string, hostId: string) =>
1160
+ `/user/${userId}/eligible-for-review/${hostId}`,
1161
+ CREATE_REVIEW: () => `/reviews`,
1162
+ };
@@ -41,25 +41,24 @@ import {
41
41
  SocialMediaPlatform,
42
42
  SocialMediaProfile,
43
43
  Sponsor,
44
+ SponsoredEvent,
44
45
  StripeAccount,
45
46
  TargetAudience,
46
47
  Ticket,
48
+ TicketMetadata,
47
49
  TicketTier,
50
+ TicketTransfer,
48
51
  User,
52
+ UserPreferences,
49
53
  UserSubscription,
50
54
  Vendor,
51
55
  Venue,
52
56
  VolunteerService,
53
- TicketTransfer,
54
- TicketMetadata,
55
- SponsoredEvent,
56
57
  } from "@prisma/client";
57
58
  import { SERVICE_LINK_DATA_TO_INCLUDE } from "./definitions";
58
- import { serviceKeysArray } from "./utils/service/serviceUtils";
59
59
  import {
60
- createAllTrueObject,
61
60
  RemoveCommonProperties,
62
- UnionFromArray,
61
+ UnionFromArray
63
62
  } from "./utils/typeUtils";
64
63
 
65
64
  //------------------------------------------------------user subscriptions------------------------------------------------------
@@ -113,6 +112,9 @@ export const FRONT_END_USER_DATA_TO_SELECT = {
113
112
  boughtTicket: true,
114
113
  noPay: true,
115
114
  supportedEvent: true,
115
+ aboutMe: true,
116
+ levelBadge: true,
117
+ temporaryBadges: true,
116
118
  } satisfies Prisma.UserSelect;
117
119
 
118
120
  export const PRIVATE_USER_ACCOUNT_TO_SELECT = {
@@ -780,7 +782,8 @@ export const CONTACT_DATA_TO_INCLUDE = {
780
782
 
781
783
  export interface UserExt extends User {
782
784
  services?: Service[] | null;
783
-
785
+ preferences?: UserPreferences | null;
786
+
784
787
  // Do not include in fetch as there could be thousands of these
785
788
  userSubscription?: UserSubscriptionExt | null;
786
789
  associatedBashes?: AssociatedBash[] | null;
@@ -0,0 +1,69 @@
1
+ import { DEFAULT_LEVEL_BADGES, DEFAULT_TEMPORARY_BADGES, LevelBadge, TemporaryBadge, UserTemporaryBadge } from "../definitions";
2
+
3
+ /**
4
+ * Get the level badge details for a given badge ID
5
+ * @param badgeId The ID of the level badge
6
+ * @returns The level badge details or undefined if not found
7
+ */
8
+ export const getLevelBadgeById = (badgeId: string): LevelBadge | undefined => {
9
+ return DEFAULT_LEVEL_BADGES[badgeId];
10
+ };
11
+
12
+ /**
13
+ * Get the temporary badge details for a given badge ID
14
+ * @param badgeId The ID of the temporary badge
15
+ * @returns The temporary badge details or undefined if not found
16
+ */
17
+ export const getTemporaryBadgeById = (badgeId: string): TemporaryBadge | undefined => {
18
+ return DEFAULT_TEMPORARY_BADGES[badgeId];
19
+ };
20
+
21
+ /**
22
+ * Check if a temporary badge is expired
23
+ * @param badge The temporary badge to check
24
+ * @returns True if the badge is expired, false otherwise
25
+ */
26
+ export const isTemporaryBadgeExpired = (badge: UserTemporaryBadge): boolean => {
27
+ if (!badge.expiresOn) return false;
28
+
29
+ const expiryDate = new Date(badge.expiresOn);
30
+ const currentDate = new Date();
31
+
32
+ return expiryDate < currentDate;
33
+ };
34
+
35
+ /**
36
+ * Filter out expired temporary badges
37
+ * @param badges Array of temporary badges
38
+ * @returns Array of non-expired temporary badges
39
+ */
40
+ export const filterExpiredTemporaryBadges = (badges: UserTemporaryBadge[]): UserTemporaryBadge[] => {
41
+ return badges.filter(badge => !isTemporaryBadgeExpired(badge));
42
+ };
43
+
44
+ /**
45
+ * Determine the appropriate level badge based on event count and rating
46
+ * @param eventCount Number of hosted events
47
+ * @param rating Average host rating
48
+ * @returns The ID of the highest level badge the user qualifies for, or null if none
49
+ */
50
+ export const determineLevelBadge = (eventCount: number, rating: number): string | null => {
51
+ // Sort badge IDs by required events in descending order
52
+ const sortedBadgeIds = Object.keys(DEFAULT_LEVEL_BADGES).sort(
53
+ (a, b) => DEFAULT_LEVEL_BADGES[b].requiredEvents - DEFAULT_LEVEL_BADGES[a].requiredEvents
54
+ );
55
+
56
+ // Find the highest level badge the user qualifies for
57
+ for (const badgeId of sortedBadgeIds) {
58
+ const badge = DEFAULT_LEVEL_BADGES[badgeId];
59
+
60
+ if (
61
+ eventCount >= badge.requiredEvents &&
62
+ (badge.requiredRating === undefined || rating >= badge.requiredRating)
63
+ ) {
64
+ return badgeId;
65
+ }
66
+ }
67
+
68
+ return null;
69
+ };
@@ -0,0 +1,79 @@
1
+ import { Review, ReviewStats } from "../definitions";
2
+
3
+ /**
4
+ * Calculate average rating from an array of reviews
5
+ * @param reviews Array of reviews
6
+ * @returns Average rating (1-5)
7
+ */
8
+ export const calculateAverageRating = (reviews: Review[]): number => {
9
+ if (!reviews.length) return 0;
10
+
11
+ const sum = reviews.reduce((total, review) => total + review.rating, 0);
12
+ return parseFloat((sum / reviews.length).toFixed(1));
13
+ };
14
+
15
+ /**
16
+ * Generate review statistics from an array of reviews
17
+ * @param reviews Array of reviews
18
+ * @returns ReviewStats object with distribution and averages
19
+ */
20
+ export const generateReviewStats = (reviews: Review[]): ReviewStats => {
21
+ const ratings = {
22
+ 1: 0,
23
+ 2: 0,
24
+ 3: 0,
25
+ 4: 0,
26
+ 5: 0
27
+ };
28
+
29
+ // Count each rating level
30
+ reviews.forEach(review => {
31
+ const rating = review.rating;
32
+ if (rating >= 1 && rating <= 5) {
33
+ ratings[rating as keyof typeof ratings]++;
34
+ }
35
+ });
36
+
37
+ return {
38
+ averageRating: calculateAverageRating(reviews),
39
+ totalRatings: reviews.length,
40
+ ratings
41
+ };
42
+ };
43
+
44
+ /**
45
+ * Format rating as a string with star emoji
46
+ * @param rating Numeric rating
47
+ * @returns Formatted string (e.g., "4.5 ★★★★½")
48
+ */
49
+ export const formatRatingWithStars = (rating: number): string => {
50
+ const fullStars = Math.floor(rating);
51
+ const hasHalfStar = rating % 1 >= 0.5;
52
+
53
+ let stars = '★'.repeat(fullStars);
54
+ if (hasHalfStar) stars += '½';
55
+
56
+ return `${rating.toFixed(1)} ${stars}`;
57
+ };
58
+
59
+ /**
60
+ * Check if a user is eligible to review an event
61
+ * @param userId User ID
62
+ * @param eventId Event ID
63
+ * @param alreadyReviewed Array of event IDs user has already reviewed
64
+ * @param eventsAttended Array of event IDs user has attended
65
+ * @returns Boolean indicating if user can review the event
66
+ */
67
+ export const isEligibleToReview = (
68
+ userId: string,
69
+ eventId: string,
70
+ hostId: string,
71
+ alreadyReviewed: string[],
72
+ eventsAttended: string[]
73
+ ): boolean => {
74
+ return (
75
+ userId !== hostId && // User is not the host
76
+ eventsAttended.includes(eventId) && // User attended the event
77
+ !alreadyReviewed.includes(eventId) // User has not already reviewed the event
78
+ );
79
+ };