@bash-app/bash-common 30.230.0 → 30.232.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 (46) hide show
  1. package/dist/__tests__/userReportTypes.test.d.ts +2 -0
  2. package/dist/__tests__/userReportTypes.test.d.ts.map +1 -0
  3. package/dist/__tests__/userReportTypes.test.js +81 -0
  4. package/dist/__tests__/userReportTypes.test.js.map +1 -0
  5. package/dist/bashFeedTypes.d.ts +10 -0
  6. package/dist/bashFeedTypes.d.ts.map +1 -1
  7. package/dist/definitions.d.ts +10 -0
  8. package/dist/definitions.d.ts.map +1 -1
  9. package/dist/definitions.js +8 -0
  10. package/dist/definitions.js.map +1 -1
  11. package/dist/extendedSchemas.d.ts +18 -1
  12. package/dist/extendedSchemas.d.ts.map +1 -1
  13. package/dist/extendedSchemas.js.map +1 -1
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/userReportTypes.d.ts +117 -0
  19. package/dist/userReportTypes.d.ts.map +1 -0
  20. package/dist/userReportTypes.js +99 -0
  21. package/dist/userReportTypes.js.map +1 -0
  22. package/dist/utils/__tests__/getBackendHost.test.js +21 -1
  23. package/dist/utils/__tests__/getBackendHost.test.js.map +1 -1
  24. package/dist/utils/__tests__/getFrontendHost.native.test.d.ts +14 -0
  25. package/dist/utils/__tests__/getFrontendHost.native.test.d.ts.map +1 -0
  26. package/dist/utils/__tests__/getFrontendHost.native.test.js +51 -0
  27. package/dist/utils/__tests__/getFrontendHost.native.test.js.map +1 -0
  28. package/dist/utils/slugUtils.d.ts +4 -0
  29. package/dist/utils/slugUtils.d.ts.map +1 -1
  30. package/dist/utils/slugUtils.js +9 -1
  31. package/dist/utils/slugUtils.js.map +1 -1
  32. package/dist/utils/urlUtils.d.ts.map +1 -1
  33. package/dist/utils/urlUtils.js +7 -0
  34. package/dist/utils/urlUtils.js.map +1 -1
  35. package/package.json +3 -1
  36. package/prisma/schema.prisma +23 -0
  37. package/src/__tests__/userReportTypes.test.ts +103 -0
  38. package/src/bashFeedTypes.ts +13 -1
  39. package/src/definitions.ts +13 -0
  40. package/src/extendedSchemas.ts +23 -0
  41. package/src/index.ts +1 -0
  42. package/src/userReportTypes.ts +252 -0
  43. package/src/utils/__tests__/getBackendHost.test.ts +28 -1
  44. package/src/utils/__tests__/getFrontendHost.native.test.ts +75 -0
  45. package/src/utils/slugUtils.ts +24 -1
  46. package/src/utils/urlUtils.ts +9 -0
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@bash-app/bash-common",
3
- "version": "30.230.0",
3
+ "version": "30.232.0",
4
4
  "description": "Common data and scripts to use on the frontend and backend",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "scripts": {
9
9
  "test": "jest --config jest.config.cjs",
10
+ "knip": "knip --config knip.config.ts",
10
11
  "build": "npm run generate && npm run tsc",
11
12
  "generate": "prisma generate",
12
13
  "db": "prisma generate && prisma db push",
@@ -48,6 +49,7 @@
48
49
  "@types/react": "^18.3.2",
49
50
  "@types/shelljs": "^0.10.0",
50
51
  "jest": "^29.7.0",
52
+ "knip": "^6.11.0",
51
53
  "npm": "^11.11.0",
52
54
  "shelljs": "^0.10.0",
53
55
  "stripe": "^20.1.2",
@@ -2539,6 +2539,7 @@ model User {
2539
2539
  reportsReceived UserReport[] @relation("ReportsReceived")
2540
2540
  reportsMade UserReport[] @relation("ReportsMade")
2541
2541
  reportsReviewed UserReport[] @relation("ReportsReviewed")
2542
+ reportEvidenceUploaded UserReportEvidence[] @relation("UserReportEvidenceUploader")
2542
2543
  userStats UserStats?
2543
2544
  userSubscription UserSubscription?
2544
2545
  vendorBid VendorBid[]
@@ -4426,6 +4427,7 @@ model UserReport {
4426
4427
  reviewedAt DateTime?
4427
4428
  reviewNotes String?
4428
4429
  demerits Demerit[]
4430
+ evidence UserReportEvidence[]
4429
4431
  bashEvent BashEvent? @relation(fields: [bashEventId], references: [id])
4430
4432
  reported User @relation("ReportsReceived", fields: [reportedId], references: [id], onDelete: Cascade)
4431
4433
  reporter User @relation("ReportsMade", fields: [reporterId], references: [id], onDelete: Cascade)
@@ -4437,6 +4439,22 @@ model UserReport {
4437
4439
  @@index([status])
4438
4440
  }
4439
4441
 
4442
+ model UserReportEvidence {
4443
+ id String @id @default(cuid())
4444
+ reportId String
4445
+ url String
4446
+ s3Key String
4447
+ mimetype String
4448
+ sizeBytes Int
4449
+ uploadedById String
4450
+ createdAt DateTime @default(now())
4451
+ report UserReport @relation(fields: [reportId], references: [id], onDelete: Cascade)
4452
+ uploadedBy User @relation("UserReportEvidenceUploader", fields: [uploadedById], references: [id], onDelete: Cascade)
4453
+
4454
+ @@index([reportId])
4455
+ @@index([uploadedById])
4456
+ }
4457
+
4440
4458
  model Demerit {
4441
4459
  id String @id @default(cuid())
4442
4460
  userId String
@@ -5485,6 +5503,7 @@ enum AudioVisualSupportSubType {
5485
5503
 
5486
5504
  enum PromotionAndMarketingSubType {
5487
5505
  Influencer
5506
+ ContentCreator
5488
5507
  Promoter
5489
5508
  CelebrityAppearance
5490
5509
  }
@@ -7505,6 +7524,10 @@ enum AuditAction {
7505
7524
  USER_SUSPENDED
7506
7525
  USER_UNSUSPENDED
7507
7526
  USER_DELETED
7527
+ USER_DEACTIVATED
7528
+ USER_DEMERITED
7529
+ USER_REPORTED
7530
+ USER_REPORT_IGNORED
7508
7531
  USER_DATA_EXPORTED
7509
7532
  PROFILE_UPDATED
7510
7533
 
@@ -0,0 +1,103 @@
1
+ import {
2
+ ALL_USER_REPORT_REASON_CODES,
3
+ REPORT_REASON_FUNNEL,
4
+ USER_REPORT_DETAILS_MAX_LENGTH,
5
+ USER_REPORT_EVIDENCE_MAX_BYTES,
6
+ USER_REPORT_EVIDENCE_MAX_FILES,
7
+ getUserReportReasonLabel,
8
+ getUserReportReasonPath,
9
+ isUserReportReasonCode,
10
+ } from "../userReportTypes.js";
11
+
12
+ describe("userReportTypes", () => {
13
+ describe("REPORT_REASON_FUNNEL", () => {
14
+ it("contains the six top-level categories", () => {
15
+ const codes = REPORT_REASON_FUNNEL.map((c) => c.code);
16
+ expect(codes).toEqual([
17
+ "HARASSMENT",
18
+ "IMPERSONATION",
19
+ "SPAM",
20
+ "UNSAFE",
21
+ "INAPPROPRIATE",
22
+ "OTHER",
23
+ ]);
24
+ });
25
+
26
+ it("each category has at least one sub-reason", () => {
27
+ for (const cat of REPORT_REASON_FUNNEL) {
28
+ expect(cat.reasons.length).toBeGreaterThan(0);
29
+ for (const r of cat.reasons) {
30
+ expect(r.code).toBeTruthy();
31
+ expect(r.label).toBeTruthy();
32
+ }
33
+ }
34
+ });
35
+
36
+ it("sub-reason codes are unique across the funnel", () => {
37
+ const seen = new Set<string>();
38
+ for (const cat of REPORT_REASON_FUNNEL) {
39
+ for (const r of cat.reasons) {
40
+ expect(seen.has(r.code)).toBe(false);
41
+ seen.add(r.code);
42
+ }
43
+ }
44
+ });
45
+
46
+ it("ALL_USER_REPORT_REASON_CODES mirrors the funnel", () => {
47
+ const flat = REPORT_REASON_FUNNEL.flatMap((c) => c.reasons.map((r) => r.code));
48
+ expect([...ALL_USER_REPORT_REASON_CODES]).toEqual(flat);
49
+ });
50
+ });
51
+
52
+ describe("isUserReportReasonCode", () => {
53
+ it("accepts any code in the funnel", () => {
54
+ for (const code of ALL_USER_REPORT_REASON_CODES) {
55
+ expect(isUserReportReasonCode(code)).toBe(true);
56
+ }
57
+ });
58
+
59
+ it.each([
60
+ ["unknown_code"],
61
+ [""],
62
+ ["spam_promotion"],
63
+ ["HARASSMENT"],
64
+ ])("rejects unknown / wrong-shape value: %s", (val) => {
65
+ expect(isUserReportReasonCode(val)).toBe(false);
66
+ });
67
+
68
+ it("rejects non-string values", () => {
69
+ expect(isUserReportReasonCode(undefined)).toBe(false);
70
+ expect(isUserReportReasonCode(null)).toBe(false);
71
+ expect(isUserReportReasonCode(42)).toBe(false);
72
+ expect(isUserReportReasonCode({})).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe("getUserReportReasonLabel / getUserReportReasonPath", () => {
77
+ it("returns the human label for a known code", () => {
78
+ expect(getUserReportReasonLabel("SPAM_SCAM")).toBe("Scam, fraud, or phishing");
79
+ });
80
+
81
+ it("returns the original code when unknown", () => {
82
+ expect(getUserReportReasonLabel("nope")).toBe("nope");
83
+ });
84
+
85
+ it("formats path as 'Category › Sub-reason'", () => {
86
+ expect(getUserReportReasonPath("HARASSMENT_BULLYING")).toBe(
87
+ "Harassment or bullying › Bullying or repeated targeting"
88
+ );
89
+ });
90
+
91
+ it("returns the original code for path lookup of unknown", () => {
92
+ expect(getUserReportReasonPath("missing")).toBe("missing");
93
+ });
94
+ });
95
+
96
+ describe("limits constants", () => {
97
+ it("are conservative enough for a friendly UI but not absurd", () => {
98
+ expect(USER_REPORT_EVIDENCE_MAX_FILES).toBe(5);
99
+ expect(USER_REPORT_EVIDENCE_MAX_BYTES).toBe(25 * 1024 * 1024);
100
+ expect(USER_REPORT_DETAILS_MAX_LENGTH).toBe(4000);
101
+ });
102
+ });
103
+ });
@@ -27,7 +27,16 @@ export interface BashFeedPostBashSummary {
27
27
  allowed?: string | null;
28
28
  notAllowed?: string | null;
29
29
  averageRating?: number | null;
30
- _count?: { tickets: number };
30
+ _count?: {
31
+ tickets: number;
32
+ interests?: number;
33
+ /** Tickets with `checkedInAt` set (see feed attach util). */
34
+ checkedIn?: number;
35
+ /** `PublicBashRsvp` rows with status Going. */
36
+ rsvpGoing?: number;
37
+ /** `PublicBashRsvp` rows with status Maybe. */
38
+ rsvpMaybe?: number;
39
+ };
31
40
  activeOffers?: Array<{
32
41
  id: string;
33
42
  offerType: string;
@@ -146,6 +155,9 @@ export interface BashFeedPostBashSummaryWire {
146
155
  tickets: number;
147
156
  /** IdeaInterest rows (Prisma relation name `interests`) */
148
157
  interests?: number;
158
+ checkedIn?: number;
159
+ rsvpGoing?: number;
160
+ rsvpMaybe?: number;
149
161
  };
150
162
  }
151
163
 
@@ -297,6 +297,17 @@ export const STRIPE_IDENTITY_RETURN_URL = `/verify-id` as const;
297
297
  export const MY_SERVICES_URL = "/my-services" as const;
298
298
  export const BASH_DETAIL_URL = `/bash` as const;
299
299
  export const BASH_DETAIL_URL_PATTERN = `/bash/$bashEventIdSlug` as const;
300
+
301
+ /**
302
+ * Bash event wizard indices for the "How" major step. Must match bash-app
303
+ * `bashEventWizardHowSubsteps.ts` / `BashEventWizard.tsx`.
304
+ */
305
+ export const BASH_EVENT_WIZARD_HOW_MAJOR_STEP = 7;
306
+ /** Payout / Stripe Connect substep within "How" (app `BASH_EVENT_WIZARD_HOW_SUBSTEP.PAYOUT`). */
307
+ export const BASH_EVENT_WIZARD_HOW_SUBSTEP_PAYOUT = 8;
308
+
309
+ export const BASH_EVENT_WIZARD_PATH_PREFIX = "/bash-event-wizard" as const;
310
+
300
311
  export const SERVICE_PAGE_URL = `/service-page` as const;
301
312
  export const LOGIN_URL = `/login` as const;
302
313
  export const TICKET_DETAILS = `/ticket-details` as const;
@@ -616,6 +627,8 @@ export interface ApiResult<T, P extends ErrorDataType = ErrorDataType> {
616
627
  /** e.g. safe path to navigate after password reset */
617
628
  returnTo?: string;
618
629
  error?: string;
630
+ /** Machine-readable error code for clients (e.g. duplicate ticket transfer). */
631
+ code?: string;
619
632
  errorType?: ApiErrorType;
620
633
  errorData?: P;
621
634
  rawResponse?: Response;
@@ -68,6 +68,7 @@ import {
68
68
  TicketMetadata,
69
69
  TicketTier,
70
70
  TicketTransfer,
71
+ TicketTransferInvite,
71
72
  User,
72
73
  UserPreferences,
73
74
  UserStats,
@@ -866,6 +867,17 @@ export interface ServiceBookingExt extends ServiceBooking {
866
867
 
867
868
  export type ServiceBookingPublicExt = Omit<ServiceBookingExt, "checkout">;
868
869
 
870
+ /**
871
+ * Aggregated booking activity attached to public/owner service list payloads.
872
+ * `confirmedBookings` is the "completed bookings" signal used to rank services
873
+ * by Popularity on the home page.
874
+ */
875
+ export interface ServiceBookingStats {
876
+ totalBookings: number;
877
+ confirmedBookings: number;
878
+ lastCompletedBooking: Date | string | null;
879
+ }
880
+
869
881
  export interface ServiceExt extends Service {
870
882
  owner?: PublicUser | null;
871
883
  creator?: PublicUser | null;
@@ -903,6 +915,9 @@ export interface ServiceExt extends Service {
903
915
  /** Some list endpoints historically used this key; prefer `bookings` */
904
916
  serviceBookings?: ServiceBookingExt[];
905
917
 
918
+ /** Attached by list endpoints (public/owner) for popularity sort and recency badges. */
919
+ bookingStats?: ServiceBookingStats;
920
+
906
921
  // bookedCheckouts: ServiceBookingCheckoutExt[]; //not necessary to include
907
922
  }
908
923
 
@@ -1223,6 +1238,14 @@ export interface TicketExt extends Ticket {
1223
1238
  checkout?: Checkout;
1224
1239
  transfers?: TicketTransfer[];
1225
1240
  metadata?: TicketMetadata[];
1241
+ /**
1242
+ * Included on authenticated GET /event/:id for the viewer's tickets when a
1243
+ * peer transfer invite row exists (see transfer-withdraw / check-in guards).
1244
+ */
1245
+ transferInvite?: Pick<
1246
+ TicketTransferInvite,
1247
+ "status" | "expiresAt" | "token" | "toEmail" | "toUserId"
1248
+ > | null;
1226
1249
  }
1227
1250
 
1228
1251
  export interface CheckoutExt extends Checkout {
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./partnerStoreTypes.js";
8
8
  export * from "./bashFeedTypes.js";
9
9
  export * from "./membershipDefinitions.js";
10
10
  export * from "./aiApproval.js";
11
+ export * from "./userReportTypes.js";
11
12
 
12
13
  // Re-export ALL Prisma enums as values (usable as runtime constants, e.g. ServiceTypes.Entertainment)
13
14
  // Excludes enums already re-exported by definitions.ts and membershipDefinitions.ts:
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Shared types for the user-report flow (public profile → admin dashboard).
3
+ * Reporting is a real-stakes flow, so all user-facing copy here is plain and specific.
4
+ *
5
+ * The selected sub-reason code is stored verbatim in `UserReport.reason`.
6
+ * Free text is stored in `UserReport.details`.
7
+ */
8
+
9
+ /** Top-level funnel category. */
10
+ export type UserReportCategoryCode =
11
+ | "HARASSMENT"
12
+ | "IMPERSONATION"
13
+ | "SPAM"
14
+ | "UNSAFE"
15
+ | "INAPPROPRIATE"
16
+ | "OTHER";
17
+
18
+ /** Specific sub-reason. Persisted as `UserReport.reason`. */
19
+ export type UserReportReasonCode =
20
+ // HARASSMENT
21
+ | "HARASSMENT_BULLYING"
22
+ | "HARASSMENT_THREATS"
23
+ | "HARASSMENT_HATE_SPEECH"
24
+ | "HARASSMENT_TARGETED"
25
+ // IMPERSONATION
26
+ | "IMPERSONATION_ME"
27
+ | "IMPERSONATION_SOMEONE_ELSE"
28
+ | "IMPERSONATION_FAKE_BUSINESS"
29
+ // SPAM
30
+ | "SPAM_PROMOTION"
31
+ | "SPAM_SCAM"
32
+ | "SPAM_FAKE_ENGAGEMENT"
33
+ // UNSAFE
34
+ | "UNSAFE_VIOLENT"
35
+ | "UNSAFE_SELF_HARM"
36
+ | "UNSAFE_MINORS"
37
+ | "UNSAFE_ILLEGAL_GOODS"
38
+ // INAPPROPRIATE
39
+ | "INAPPROPRIATE_NUDITY_SEXUAL"
40
+ | "INAPPROPRIATE_GRAPHIC_VIOLENCE"
41
+ | "INAPPROPRIATE_OFFENSIVE"
42
+ // OTHER
43
+ | "OTHER";
44
+
45
+ export interface UserReportReasonOption {
46
+ code: UserReportReasonCode;
47
+ label: string;
48
+ }
49
+
50
+ export interface UserReportCategoryOption {
51
+ code: UserReportCategoryCode;
52
+ label: string;
53
+ description: string;
54
+ reasons: UserReportReasonOption[];
55
+ }
56
+
57
+ /**
58
+ * Funnel definition shared between the report modal (UI) and the API
59
+ * (validation that the chosen `reason` is a known code).
60
+ */
61
+ export const REPORT_REASON_FUNNEL: readonly UserReportCategoryOption[] = [
62
+ {
63
+ code: "HARASSMENT",
64
+ label: "Harassment or bullying",
65
+ description: "Behavior that targets, intimidates, or threatens someone.",
66
+ reasons: [
67
+ { code: "HARASSMENT_BULLYING", label: "Bullying or repeated targeting" },
68
+ { code: "HARASSMENT_THREATS", label: "Threats of violence or harm" },
69
+ { code: "HARASSMENT_HATE_SPEECH", label: "Hate speech or discrimination" },
70
+ { code: "HARASSMENT_TARGETED", label: "Targeting me or someone I know" },
71
+ ],
72
+ },
73
+ {
74
+ code: "IMPERSONATION",
75
+ label: "Impersonation",
76
+ description: "Pretending to be a person or business they aren't.",
77
+ reasons: [
78
+ { code: "IMPERSONATION_ME", label: "Pretending to be me" },
79
+ { code: "IMPERSONATION_SOMEONE_ELSE", label: "Pretending to be someone else" },
80
+ { code: "IMPERSONATION_FAKE_BUSINESS", label: "Pretending to be a business or venue" },
81
+ ],
82
+ },
83
+ {
84
+ code: "SPAM",
85
+ label: "Spam or scam",
86
+ description: "Unsolicited promotion, scams, or fake engagement.",
87
+ reasons: [
88
+ { code: "SPAM_PROMOTION", label: "Unsolicited promotion" },
89
+ { code: "SPAM_SCAM", label: "Scam, fraud, or phishing" },
90
+ { code: "SPAM_FAKE_ENGAGEMENT", label: "Fake followers, ratings, or reviews" },
91
+ ],
92
+ },
93
+ {
94
+ code: "UNSAFE",
95
+ label: "Unsafe or threatening behavior",
96
+ description: "Activity that puts people at risk.",
97
+ reasons: [
98
+ { code: "UNSAFE_VIOLENT", label: "Violent behavior" },
99
+ { code: "UNSAFE_SELF_HARM", label: "Self-harm or suicide" },
100
+ { code: "UNSAFE_MINORS", label: "Endangering a minor" },
101
+ { code: "UNSAFE_ILLEGAL_GOODS", label: "Illegal goods or services" },
102
+ ],
103
+ },
104
+ {
105
+ code: "INAPPROPRIATE",
106
+ label: "Inappropriate content",
107
+ description: "Content that doesn't belong on Bash.",
108
+ reasons: [
109
+ { code: "INAPPROPRIATE_NUDITY_SEXUAL", label: "Nudity or sexual content" },
110
+ { code: "INAPPROPRIATE_GRAPHIC_VIOLENCE", label: "Graphic violence" },
111
+ { code: "INAPPROPRIATE_OFFENSIVE", label: "Offensive or disturbing content" },
112
+ ],
113
+ },
114
+ {
115
+ code: "OTHER",
116
+ label: "Something else",
117
+ description: "Tell us in your own words.",
118
+ reasons: [{ code: "OTHER", label: "Other (describe below)" }],
119
+ },
120
+ ] as const;
121
+
122
+ /** All valid reason codes (built from the funnel — no drift). */
123
+ export const ALL_USER_REPORT_REASON_CODES: readonly UserReportReasonCode[] =
124
+ REPORT_REASON_FUNNEL.flatMap((c) => c.reasons.map((r) => r.code));
125
+
126
+ export const isUserReportReasonCode = (
127
+ value: unknown
128
+ ): value is UserReportReasonCode =>
129
+ typeof value === "string" &&
130
+ (ALL_USER_REPORT_REASON_CODES as readonly string[]).includes(value);
131
+
132
+ /** Look up the human-readable label for a reason code. */
133
+ export function getUserReportReasonLabel(code: string): string {
134
+ for (const cat of REPORT_REASON_FUNNEL) {
135
+ const match = cat.reasons.find((r) => r.code === code);
136
+ if (match) return match.label;
137
+ }
138
+ return code;
139
+ }
140
+
141
+ /** Look up "Category › Reason" path for a stored reason code. */
142
+ export function getUserReportReasonPath(code: string): string {
143
+ for (const cat of REPORT_REASON_FUNNEL) {
144
+ const match = cat.reasons.find((r) => r.code === code);
145
+ if (match) return `${cat.label} › ${match.label}`;
146
+ }
147
+ return code;
148
+ }
149
+
150
+ /** Constants used by the evidence upload route. */
151
+ export const USER_REPORT_EVIDENCE_MAX_FILES = 5;
152
+ export const USER_REPORT_EVIDENCE_MAX_BYTES = 25 * 1024 * 1024;
153
+
154
+ /** Max free-text length for `UserReport.details`. */
155
+ export const USER_REPORT_DETAILS_MAX_LENGTH = 4000;
156
+
157
+ // ─── DTOs ────────────────────────────────────────────────────────────────────
158
+
159
+ export interface CreateUserReportRequest {
160
+ reportedId: string;
161
+ reason: UserReportReasonCode;
162
+ details?: string;
163
+ bashEventId?: string;
164
+ }
165
+
166
+ export interface UserReportEvidenceDto {
167
+ id: string;
168
+ url: string;
169
+ mimetype: string;
170
+ sizeBytes: number;
171
+ createdAt: string;
172
+ }
173
+
174
+ interface UserReportUserSummary {
175
+ id: string;
176
+ email: string | null;
177
+ username: string | null;
178
+ givenName: string | null;
179
+ familyName: string | null;
180
+ image: string | null;
181
+ uploadedImage: string | null;
182
+ }
183
+
184
+ export interface UserReportListItem {
185
+ id: string;
186
+ reporterId: string;
187
+ reportedId: string;
188
+ reason: UserReportReasonCode | string;
189
+ details: string;
190
+ status: string;
191
+ createdAt: string;
192
+ reviewerId: string | null;
193
+ reviewedAt: string | null;
194
+ reviewNotes: string | null;
195
+ evidenceCount: number;
196
+ reporter: UserReportUserSummary;
197
+ reported: UserReportUserSummary;
198
+ }
199
+
200
+ export interface UserReportDetail extends Omit<UserReportListItem, "evidenceCount"> {
201
+ evidence: UserReportEvidenceDto[];
202
+ /** Other pending reports against the same reported user. */
203
+ otherPendingReportsAgainstReported: number;
204
+ /** Total active demerits already on the reported user. */
205
+ reportedActiveDemerits: number;
206
+ }
207
+
208
+ /** Action verbs for the moderation dashboard (PATCH /reports/:id). */
209
+ export type ReviewUserReportAction =
210
+ | "ignore"
211
+ | "suspend"
212
+ | "demerit"
213
+ | "deactivate"
214
+ | "delete";
215
+
216
+ interface ReviewIgnore {
217
+ action: "ignore";
218
+ notes?: string;
219
+ }
220
+ interface ReviewSuspend {
221
+ action: "suspend";
222
+ reason: string;
223
+ /** ISO date; if omitted the user is suspended indefinitely. */
224
+ until?: string;
225
+ notes?: string;
226
+ }
227
+ interface ReviewDemerit {
228
+ action: "demerit";
229
+ reason: string;
230
+ /** Defaults to 1 server-side. */
231
+ points?: number;
232
+ notes?: string;
233
+ }
234
+ interface ReviewDeactivate {
235
+ action: "deactivate";
236
+ reason: string;
237
+ notes?: string;
238
+ }
239
+ interface ReviewDelete {
240
+ action: "delete";
241
+ reason: string;
242
+ /** Reviewer must type the reported user's username/email to confirm. */
243
+ confirmation: string;
244
+ notes?: string;
245
+ }
246
+
247
+ export type ReviewUserReportRequest =
248
+ | ReviewIgnore
249
+ | ReviewSuspend
250
+ | ReviewDemerit
251
+ | ReviewDeactivate
252
+ | ReviewDelete;
@@ -6,7 +6,7 @@
6
6
  * doesn't accidentally re-introduce the same class of bug we just fixed in
7
7
  * getFrontendHost (server-only env vars leaking into the browser code path).
8
8
  */
9
- import { getBackendHost } from "../urlUtils.js";
9
+ import { getBackendHost, getFrontendHost } from "../urlUtils.js";
10
10
 
11
11
  type WindowLocationStub = {
12
12
  protocol: string;
@@ -126,3 +126,30 @@ describe("getBackendHost — Capacitor native (iOS / Android)", () => {
126
126
  expect(result).toBe(process.env.REACT_APP_API ?? "http://localhost:3500");
127
127
  });
128
128
  });
129
+
130
+ describe("getFrontendHost — Capacitor native (iOS / Android)", () => {
131
+ it("uses the public web host instead of the WebView localhost origin", () => {
132
+ setWindow(
133
+ {
134
+ protocol: "capacitor:",
135
+ hostname: "localhost",
136
+ host: "localhost",
137
+ port: "",
138
+ },
139
+ { isNativePlatform: () => true }
140
+ );
141
+
142
+ expect(getFrontendHost()).toBe("https://bash.community");
143
+ });
144
+
145
+ it("keeps regular local dev behavior for browser localhost", () => {
146
+ setWindow({
147
+ protocol: "http:",
148
+ hostname: "localhost",
149
+ host: "localhost:3000",
150
+ port: "3000",
151
+ });
152
+
153
+ expect(getFrontendHost()).toBe("http://localhost:3000");
154
+ });
155
+ });
@@ -0,0 +1,75 @@
1
+ type WindowLocationStub = {
2
+ protocol: string;
3
+ hostname: string;
4
+ host: string;
5
+ port: string;
6
+ };
7
+
8
+ type CapacitorStub = { isNativePlatform?: () => boolean };
9
+
10
+ const ORIGINAL_WINDOW = (globalThis as { window?: unknown }).window;
11
+ const ORIGINAL_FRONTEND_URL = process.env.REACT_APP_FRONTEND_URL;
12
+
13
+ function setWindow(loc: WindowLocationStub, capacitor?: CapacitorStub): void {
14
+ (
15
+ globalThis as unknown as {
16
+ window: { location: WindowLocationStub; Capacitor?: CapacitorStub };
17
+ }
18
+ ).window = {
19
+ location: loc,
20
+ ...(capacitor ? { Capacitor: capacitor } : {}),
21
+ };
22
+ }
23
+
24
+ function clearWindow(): void {
25
+ if (ORIGINAL_WINDOW === undefined) {
26
+ delete (globalThis as { window?: unknown }).window;
27
+ } else {
28
+ (globalThis as { window?: unknown }).window = ORIGINAL_WINDOW;
29
+ }
30
+ }
31
+
32
+ afterEach(() => {
33
+ clearWindow();
34
+ jest.resetModules();
35
+ if (ORIGINAL_FRONTEND_URL === undefined) {
36
+ delete process.env.REACT_APP_FRONTEND_URL;
37
+ } else {
38
+ process.env.REACT_APP_FRONTEND_URL = ORIGINAL_FRONTEND_URL;
39
+ }
40
+ });
41
+
42
+ describe("getFrontendHost — native public URL contract", () => {
43
+ it("returns bash.community for a Capacitor localhost WebView", async () => {
44
+ setWindow(
45
+ {
46
+ protocol: "capacitor:",
47
+ hostname: "localhost",
48
+ host: "localhost",
49
+ port: "",
50
+ },
51
+ { isNativePlatform: () => true }
52
+ );
53
+
54
+ const { getFrontendHost } = await import("../urlUtils.js");
55
+
56
+ expect(getFrontendHost()).toBe("https://bash.community");
57
+ });
58
+
59
+ it("honors REACT_APP_FRONTEND_URL for native QA builds", async () => {
60
+ process.env.REACT_APP_FRONTEND_URL = "https://qa.bash.community";
61
+ setWindow(
62
+ {
63
+ protocol: "capacitor:",
64
+ hostname: "localhost",
65
+ host: "localhost",
66
+ port: "",
67
+ },
68
+ { isNativePlatform: () => true }
69
+ );
70
+
71
+ const { getFrontendHost } = await import("../urlUtils.js");
72
+
73
+ expect(getFrontendHost()).toBe("https://qa.bash.community");
74
+ });
75
+ });