dubs-server 1.0.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 (304) hide show
  1. package/.claude/settings.local.json +280 -0
  2. package/CLAUDE.md +46 -0
  3. package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
  4. package/CURRENT_SESSION.md +171 -0
  5. package/CURRENT_SESSION_DRAW.md +516 -0
  6. package/MARCH_MADNESS_SURVIVOR.md +254 -0
  7. package/PANDA.md +166 -0
  8. package/Procfile +4 -0
  9. package/README.md +476 -0
  10. package/controllers/livescoresController.js +376 -0
  11. package/controllers/pickemController.js +554 -0
  12. package/controllers/survivorAdminController.js +887 -0
  13. package/controllers/survivorController.js +623 -0
  14. package/cron/oracleMonitor.js +77 -0
  15. package/cron/pickemOracleMonitor.js +73 -0
  16. package/data/jackpot-history.json +952 -0
  17. package/data/ncaaTeams.js +406 -0
  18. package/documentation/API_SECURITY_GUIDE.md +327 -0
  19. package/documentation/ARCADE_API.md +593 -0
  20. package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
  21. package/documentation/ARCADE_QUICKSTART.md +242 -0
  22. package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
  23. package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
  24. package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
  25. package/documentation/CLAIM_STATUS_FIX.md +67 -0
  26. package/documentation/CLI_TOOL_GUIDE.md +372 -0
  27. package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
  28. package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
  29. package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
  30. package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
  31. package/documentation/DATABASE_QUERIES.md +269 -0
  32. package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
  33. package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
  34. package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
  35. package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
  36. package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
  37. package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
  38. package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
  39. package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
  40. package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
  41. package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
  42. package/documentation/HEROKU_DEPLOYMENT.md +134 -0
  43. package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
  44. package/documentation/JACKPOT_API.md +521 -0
  45. package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
  46. package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
  47. package/documentation/JWT_QUICK_SETUP.md +268 -0
  48. package/documentation/JWT_TESTING_GUIDE.md +404 -0
  49. package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
  50. package/documentation/KEEPER_SETUP.md +206 -0
  51. package/documentation/KEEPER_STATE_MACHINE.md +423 -0
  52. package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
  53. package/documentation/LOCAL_VOTING_TEST.md +279 -0
  54. package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
  55. package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
  56. package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
  57. package/documentation/PNL_TRACKING_SETUP.md +189 -0
  58. package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
  59. package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
  60. package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
  61. package/documentation/QUICK_AUTH_SETUP.md +99 -0
  62. package/documentation/QUICK_DEPLOY.md +224 -0
  63. package/documentation/QUICK_FIX.md +114 -0
  64. package/documentation/QUICK_START.md +152 -0
  65. package/documentation/REFEREE_MODE_GUIDE.md +392 -0
  66. package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
  67. package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
  68. package/documentation/RUN_MIGRATION_NOW.md +39 -0
  69. package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
  70. package/documentation/SETUP_GUIDE.md +184 -0
  71. package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
  72. package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
  73. package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
  74. package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
  75. package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
  76. package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
  77. package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
  78. package/ecosystem.config.js +65 -0
  79. package/env.template +125 -0
  80. package/middleware/apiKeyAuth.js +136 -0
  81. package/middleware/authenticate.js +214 -0
  82. package/middleware/developerUserAuth.js +76 -0
  83. package/middleware/socketAuth.js +69 -0
  84. package/package.json +49 -0
  85. package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
  86. package/postman/Dubs-API-v1.postman_collection.json +205 -0
  87. package/postman/Dubs_Developer_API.postman_collection.json +662 -0
  88. package/postman/QUICKSTART.md +118 -0
  89. package/postman/QUICK_REFERENCE.md +246 -0
  90. package/postman/README.md +71 -0
  91. package/postman/VOTING_API_GUIDE.md +426 -0
  92. package/refactor/Animations.md +148 -0
  93. package/refactor/Chat.md +252 -0
  94. package/routes/actionsRoutes.js +699 -0
  95. package/routes/adminRoutes.js +370 -0
  96. package/routes/analyticsRoutes.js +1262 -0
  97. package/routes/arcadeRoutes.js +557 -0
  98. package/routes/authRoutes.js +2310 -0
  99. package/routes/avatarRoutes.js +85 -0
  100. package/routes/botRoutes.js +211 -0
  101. package/routes/chatRoutes.js +377 -0
  102. package/routes/cryptoPriceRoutes.js +105 -0
  103. package/routes/developerRoutes.js +4201 -0
  104. package/routes/deviceRoutes.js +214 -0
  105. package/routes/dmRoutes.js +167 -0
  106. package/routes/esportsRoutes.js +806 -0
  107. package/routes/exchangeRateRoutes.js +233 -0
  108. package/routes/gamesRoutes.js +3028 -0
  109. package/routes/jackpotRoutes.js +754 -0
  110. package/routes/keeperMonitoringRoutes.js +156 -0
  111. package/routes/keeperWebhookRoutes.js +466 -0
  112. package/routes/livescoresRoutes.js +31 -0
  113. package/routes/pickemAdminRoutes.js +199 -0
  114. package/routes/pickemRoutes.js +231 -0
  115. package/routes/playerStatsRoutes.js +147 -0
  116. package/routes/portfolioRoutes.js +217 -0
  117. package/routes/promoRoutes.js +418 -0
  118. package/routes/referralEarningsRoutes.js +392 -0
  119. package/routes/socialRoutes.js +459 -0
  120. package/routes/sportsRoutes.js +1271 -0
  121. package/routes/survivorAdminRoutes.js +345 -0
  122. package/routes/survivorRoutes.js +756 -0
  123. package/routes/uploadRoutes.js +256 -0
  124. package/routes/userProfileRoutes.js +244 -0
  125. package/routes/whatsNewRoutes.js +331 -0
  126. package/scripts/.claude/settings.local.json +15 -0
  127. package/scripts/README.md +170 -0
  128. package/scripts/RESTART_EVERYTHING.sh +104 -0
  129. package/scripts/add-claim-columns.sql +48 -0
  130. package/scripts/add-crypto-prices-cache.sql +27 -0
  131. package/scripts/add-exchange-rates-cache.sql +40 -0
  132. package/scripts/add-game-invite-column.sql +23 -0
  133. package/scripts/add-game-invite-notification.sql +33 -0
  134. package/scripts/add-game-invite-telegram-pref.sql +16 -0
  135. package/scripts/add-game-joined-notification.sql +16 -0
  136. package/scripts/add-game-joined-pref.js +40 -0
  137. package/scripts/add-game-joined-preference.sql +6 -0
  138. package/scripts/add-game-start-notifications.sql +41 -0
  139. package/scripts/add-notification-flags-to-games.sql +55 -0
  140. package/scripts/add-pending-game-dismissals.sql +19 -0
  141. package/scripts/add-preferred-currency.sql +34 -0
  142. package/scripts/add-winner-columns.js +61 -0
  143. package/scripts/add_mention_system.sql +53 -0
  144. package/scripts/add_payment_system.sql +96 -0
  145. package/scripts/add_sports_event_id_column.sql +22 -0
  146. package/scripts/analyze-cohort-data-heroku.js +276 -0
  147. package/scripts/analyze-cohort-data.js +295 -0
  148. package/scripts/analyze-prod-cohorts.sh +10 -0
  149. package/scripts/backfill-matchup-images.js +245 -0
  150. package/scripts/backfill-missing-signatures.js +175 -0
  151. package/scripts/backfill-referral-earnings.js +202 -0
  152. package/scripts/check-chat-schema.js +130 -0
  153. package/scripts/check-db.sh +14 -0
  154. package/scripts/check_oracle_in_game.js +54 -0
  155. package/scripts/cleanup-database.js +193 -0
  156. package/scripts/clear-notification-cache.js +85 -0
  157. package/scripts/convert-mnemonic.js +50 -0
  158. package/scripts/create-users-table.sql +44 -0
  159. package/scripts/debug-cohort-counts.js +248 -0
  160. package/scripts/debug-winner-calc.js +84 -0
  161. package/scripts/deploy-payment-system.sh +118 -0
  162. package/scripts/deploy-to-heroku.sh +63 -0
  163. package/scripts/diagnose-locked-round.js +143 -0
  164. package/scripts/dubs-cli.js +720 -0
  165. package/scripts/dump-account.js +65 -0
  166. package/scripts/find-vrf-offset.js +48 -0
  167. package/scripts/fix-chat-notifications-constraint.sql +122 -0
  168. package/scripts/fix-claim-columns.js +124 -0
  169. package/scripts/fix-constraint-now.js +44 -0
  170. package/scripts/fix-lock-timestamps.js +96 -0
  171. package/scripts/fix-locked-round.sh +126 -0
  172. package/scripts/fix-missing-badges.sql +91 -0
  173. package/scripts/fix-payment-notifications.sql +41 -0
  174. package/scripts/force-new-round.js +55 -0
  175. package/scripts/force-resolve-and-claim.js +278 -0
  176. package/scripts/important/README.md +115 -0
  177. package/scripts/important/authority-force-lock.js +197 -0
  178. package/scripts/important/authority-resolve-game.js +267 -0
  179. package/scripts/important/check-game-status.js +373 -0
  180. package/scripts/important/list-pending-games-by-version.js +270 -0
  181. package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
  182. package/scripts/initialize-jackpot.js +111 -0
  183. package/scripts/jackpot/.claude/settings.local.json +10 -0
  184. package/scripts/jackpot/force-reset.js +84 -0
  185. package/scripts/jackpot/initialize-mainnet.js +100 -0
  186. package/scripts/jackpot/keeper.js +742 -0
  187. package/scripts/jackpot/status.js +107 -0
  188. package/scripts/jackpot/update-round-duration.js +143 -0
  189. package/scripts/keeper-bot.js +112 -0
  190. package/scripts/list-pending-games.js +131 -0
  191. package/scripts/migrate-chat-v2.js +127 -0
  192. package/scripts/migrate-chat-winners.js +84 -0
  193. package/scripts/migrate-chat.sh +17 -0
  194. package/scripts/migrate-game-invite.js +83 -0
  195. package/scripts/migrate-heroku-game-notifications.sh +159 -0
  196. package/scripts/migrations/001_analytics_tables.sql +422 -0
  197. package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
  198. package/scripts/migrations/003_referral_earnings.sql +208 -0
  199. package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
  200. package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
  201. package/scripts/migrations/005_push_notifications.sql +55 -0
  202. package/scripts/migrations/006_add_draw_team_players.sql +28 -0
  203. package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
  204. package/scripts/migrations/007_add_gif_url.sql +8 -0
  205. package/scripts/migrations/008_add_connect4_columns.sql +139 -0
  206. package/scripts/migrations/008_add_pool_tracking.sql +22 -0
  207. package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
  208. package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
  209. package/scripts/migrations/011_create_developer_tables.sql +67 -0
  210. package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
  211. package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
  212. package/scripts/migrations/013_add_network_mode.sql +18 -0
  213. package/scripts/migrations/014_create_developer_app_users.sql +19 -0
  214. package/scripts/migrations/015_add_ui_config.sql +4 -0
  215. package/scripts/migrations/016_add_resolution_secret.sql +4 -0
  216. package/scripts/migrations/017_add_external_game_id.sql +3 -0
  217. package/scripts/migrations/018_create_pickem_tables.sql +115 -0
  218. package/scripts/migrations/019_expo_push_tokens.sql +19 -0
  219. package/scripts/migrations/create_whats_new_tables.sql +88 -0
  220. package/scripts/migrations/drop_live_games_tables.sql +34 -0
  221. package/scripts/open-jackpot-round.js +85 -0
  222. package/scripts/purge-all-data.sh +329 -0
  223. package/scripts/purge-all-data.sql +142 -0
  224. package/scripts/purge-heroku-data.sh +149 -0
  225. package/scripts/purge-heroku-data.sql +62 -0
  226. package/scripts/rebuild-heroku-database.sh +113 -0
  227. package/scripts/recover-funds.js +357 -0
  228. package/scripts/regenerate-epl-images.js +278 -0
  229. package/scripts/resize-s3-matchup-images.js +374 -0
  230. package/scripts/resolve-direct.js +88 -0
  231. package/scripts/resolve-mock-game.js +124 -0
  232. package/scripts/resolve-pickem-game.js +55 -0
  233. package/scripts/resolve-round-manual.js +83 -0
  234. package/scripts/resolve-stuck-game.js +382 -0
  235. package/scripts/resolve-stuck-round.js +42 -0
  236. package/scripts/run-connect4-migration.sh +16 -0
  237. package/scripts/run-mention-migration.sh +32 -0
  238. package/scripts/run-payment-migration.sh +51 -0
  239. package/scripts/run-preferred-currency-migration.sh +31 -0
  240. package/scripts/run-referral-earnings-migration.sh +32 -0
  241. package/scripts/run-survivor-outcome-migration.sh +16 -0
  242. package/scripts/seed-test-users.js +346 -0
  243. package/scripts/setup-auth-tables.js +78 -0
  244. package/scripts/setup-complete-database.sql +992 -0
  245. package/scripts/setup-database-fresh.sh +359 -0
  246. package/scripts/setup-heroku-keeper.sh +48 -0
  247. package/scripts/setup-keeper-database.js +83 -0
  248. package/scripts/setup-keeper-state-db.sql +110 -0
  249. package/scripts/setup-oracle.sh +39 -0
  250. package/scripts/setup-pnl-tracking.js +111 -0
  251. package/scripts/start-devnet.sh +14 -0
  252. package/scripts/test-arcade-devnet.sh +160 -0
  253. package/scripts/test-arcade-match.sh +109 -0
  254. package/scripts/test-automatic-mode.sh +239 -0
  255. package/scripts/test-connect4-cancel-claim.js +370 -0
  256. package/scripts/test-connect4-e2e.js +369 -0
  257. package/scripts/test-connect4-resolve.js +369 -0
  258. package/scripts/test-game-state-endpoint.js +136 -0
  259. package/scripts/test-invite-notification.js +86 -0
  260. package/scripts/test-jackpot-api.sh +71 -0
  261. package/scripts/test-poll-confirmation.js +267 -0
  262. package/scripts/test-resolve-game.js +271 -0
  263. package/scripts/test-resolve-signature.js +223 -0
  264. package/scripts/test-signature-preservation.js +124 -0
  265. package/scripts/test-state-machine.js +291 -0
  266. package/scripts/test-webhook-receiver.js +60 -0
  267. package/scripts/update-notification-constraint.js +52 -0
  268. package/scripts/verify-account-layout.js +145 -0
  269. package/scripts/verify-winner-algorithm.js +278 -0
  270. package/server.js +5259 -0
  271. package/services/arcadeMatchService.js +763 -0
  272. package/services/automaticGameOracle.js +1596 -0
  273. package/services/chatService.js +1612 -0
  274. package/services/connect4GameService.js +1049 -0
  275. package/services/connect4NotificationService.js +374 -0
  276. package/services/cryptoPriceService.js +223 -0
  277. package/services/customGameResolver.js +260 -0
  278. package/services/db.js +79 -0
  279. package/services/directMessageService.js +389 -0
  280. package/services/discordNotifications.js +160 -0
  281. package/services/exchangeRateService.js +289 -0
  282. package/services/expoPushService.js +314 -0
  283. package/services/gamesCacheService.js +539 -0
  284. package/services/jackpotHistory.js +331 -0
  285. package/services/jackpotService.js +856 -0
  286. package/services/keeperStateService.js +355 -0
  287. package/services/matchupImageService.js +591 -0
  288. package/services/notificationCacheService.js +407 -0
  289. package/services/pickemOracle.js +440 -0
  290. package/services/playerStatsService.js +389 -0
  291. package/services/portfolioService.js +555 -0
  292. package/services/promoService.js +757 -0
  293. package/services/promoTreasuryService.js +239 -0
  294. package/services/pushNotifications.js +353 -0
  295. package/services/redisService.js +422 -0
  296. package/services/referralEarningsService.js +728 -0
  297. package/services/s3Service.js +396 -0
  298. package/services/socialService.js +1202 -0
  299. package/services/survivorOracle.js +469 -0
  300. package/services/survivorSimulator.js +475 -0
  301. package/services/telegramNotifications.js +461 -0
  302. package/services/userProfileStatsService.js +1185 -0
  303. package/services/whatsNewService.js +388 -0
  304. package/utils/urlHelper.js +95 -0
@@ -0,0 +1,396 @@
1
+ /**
2
+ * 📸 S3 Service for Avatar and Matchup Image Uploads
3
+ */
4
+
5
+ const { S3Client, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
6
+ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
7
+ const sharp = require('sharp');
8
+
9
+ class S3Service {
10
+ constructor() {
11
+ // Check for AWS credentials
12
+ this.credentialsAvailable = !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY);
13
+
14
+ if (!this.credentialsAvailable) {
15
+ console.warn('⚠️ AWS credentials not set - uploads will fail');
16
+ console.warn(' Add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to .env');
17
+ this.client = null;
18
+ return;
19
+ }
20
+
21
+ // Region must match bucket location: dev in us-east-1, prod in us-east-2
22
+ this.region = process.env.NODE_ENV === 'production'
23
+ ? 'us-east-2'
24
+ : 'us-east-1';
25
+
26
+ // Bucket configuration based on environment
27
+ // Avatars bucket (existing)
28
+ this.bucketName = process.env.NODE_ENV === 'production'
29
+ ? 'dubs-avatars-prod'
30
+ : 'dubs-avatars-dev';
31
+
32
+ // What's New bucket (dedicated for feature announcements)
33
+ this.whatsNewBucketName = process.env.NODE_ENV === 'production'
34
+ ? 'dubs-whats-new-prod'
35
+ : 'dubs-whats-new-dev';
36
+
37
+ // S3 Client must use the same region as the bucket
38
+ this.client = new S3Client({
39
+ region: this.region,
40
+ credentials: {
41
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
42
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
43
+ },
44
+ });
45
+
46
+ console.log(`📸 S3 Service initialized:`);
47
+ console.log(` - Avatars: ${this.bucketName} (${this.region})`);
48
+ console.log(` - What's New: ${this.whatsNewBucketName} (${this.region})`);
49
+ }
50
+
51
+ /**
52
+ * Generate presigned URL for direct browser upload
53
+ */
54
+ async getUploadUrl(walletAddress, fileExtension) {
55
+ if (!this.credentialsAvailable || !this.client) {
56
+ throw new Error('AWS credentials not configured. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env file');
57
+ }
58
+
59
+ const fileName = `${walletAddress}-${Date.now()}.${fileExtension}`;
60
+ const key = `avatars/${fileName}`;
61
+
62
+ const command = new PutObjectCommand({
63
+ Bucket: this.bucketName,
64
+ Key: key,
65
+ ContentType: this.getContentType(fileExtension),
66
+ // Public read access handled by bucket policy
67
+ });
68
+
69
+ // Generate presigned URL valid for 5 minutes
70
+ const uploadUrl = await getSignedUrl(this.client, command, { expiresIn: 300 });
71
+
72
+ // Public URL for accessing the file
73
+ const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
74
+
75
+ return {
76
+ uploadUrl,
77
+ publicUrl,
78
+ key,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Get content type based on file extension
84
+ */
85
+ getContentType(extension) {
86
+ const types = {
87
+ 'jpg': 'image/jpeg',
88
+ 'jpeg': 'image/jpeg',
89
+ 'png': 'image/png',
90
+ 'gif': 'image/gif',
91
+ 'webp': 'image/webp',
92
+ };
93
+ return types[extension.toLowerCase()] || 'application/octet-stream';
94
+ }
95
+
96
+ /**
97
+ * Validate file extension
98
+ */
99
+ isValidFileType(extension) {
100
+ const allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
101
+ return allowed.includes(extension.toLowerCase());
102
+ }
103
+
104
+ /**
105
+ * Normalize team name for use in file keys
106
+ * Converts to lowercase, replaces spaces with underscores, removes special chars
107
+ */
108
+ normalizeTeamName(teamName) {
109
+ return teamName
110
+ .toLowerCase()
111
+ .trim()
112
+ .replace(/[^a-z0-9\s]/g, '') // Remove special characters
113
+ .replace(/\s+/g, '_') // Replace spaces with underscores
114
+ .replace(/_+/g, '_') // Collapse multiple underscores
115
+ .replace(/^_|_$/g, ''); // Remove leading/trailing underscores
116
+ }
117
+
118
+ /**
119
+ * Generate a team-based key for matchup images
120
+ * Format: matchups/{league}/{away_team}_{home_team}.jpg
121
+ * This allows reusing the same image for all games with the same teams
122
+ */
123
+ getMatchupImageKey(awayTeam, homeTeam, league) {
124
+ const normalizedAway = this.normalizeTeamName(awayTeam);
125
+ const normalizedHome = this.normalizeTeamName(homeTeam);
126
+ const normalizedLeague = league.toUpperCase();
127
+ return `matchups/${normalizedLeague}/${normalizedAway}_${normalizedHome}.jpg`;
128
+ }
129
+
130
+ /**
131
+ * Check if a matchup image already exists in S3
132
+ */
133
+ async matchupImageExists(key) {
134
+ if (!this.credentialsAvailable || !this.client) {
135
+ return false;
136
+ }
137
+
138
+ try {
139
+ const command = new HeadObjectCommand({
140
+ Bucket: this.bucketName,
141
+ Key: key,
142
+ });
143
+ await this.client.send(command);
144
+ return true;
145
+ } catch (err) {
146
+ if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
147
+ return false;
148
+ }
149
+ // For other errors, log but assume it doesn't exist
150
+ console.warn(`[S3] Error checking if image exists: ${err.message}`);
151
+ return false;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Upload a matchup image directly from buffer
157
+ * Used for server-side generated matchup images
158
+ * Optimizes image size by resizing to half dimensions and converting to JPEG for better compression
159
+ * Uses team-based keys to allow image reuse across games with the same teams
160
+ *
161
+ * @param {string} awayTeam - Away team name
162
+ * @param {string} homeTeam - Home team name
163
+ * @param {string} league - League abbreviation (NHL, NBA, NFL, MLB)
164
+ * @param {Buffer} imageBuffer - PNG image buffer (from canvas)
165
+ * @returns {Promise<{publicUrl: string, key: string, wasReused: boolean}>}
166
+ */
167
+ async uploadMatchupImage(awayTeam, homeTeam, league, imageBuffer, forceOverwrite = false) {
168
+ if (!this.credentialsAvailable || !this.client) {
169
+ throw new Error('AWS credentials not configured');
170
+ }
171
+
172
+ // Generate team-based key (reusable across all games with same teams)
173
+ const key = this.getMatchupImageKey(awayTeam, homeTeam, league);
174
+
175
+ // Check if image already exists (skip check if forceOverwrite is true)
176
+ if (!forceOverwrite) {
177
+ const exists = await this.matchupImageExists(key);
178
+ if (exists) {
179
+ const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
180
+ console.log(`[S3] ♻️ Reusing existing matchup image: ${key}`);
181
+ return {
182
+ publicUrl,
183
+ key,
184
+ wasReused: true,
185
+ };
186
+ }
187
+ } else {
188
+ console.log(`[S3] 🔄 Force overwriting matchup image: ${key}`);
189
+ }
190
+
191
+ console.log(`[S3] Optimizing matchup image: ${key} (original: ${imageBuffer.length} bytes)`);
192
+
193
+ // Optimize image: convert PNG to JPEG without resizing (maintain full quality)
194
+ let optimizedBuffer;
195
+ try {
196
+ // Get original dimensions
197
+ const metadata = await sharp(imageBuffer).metadata();
198
+ const originalWidth = metadata.width;
199
+ const originalHeight = metadata.height;
200
+
201
+ // Convert to JPEG without resizing (maintain full 600x315 quality)
202
+ optimizedBuffer = await sharp(imageBuffer)
203
+ .jpeg({
204
+ quality: 90, // High quality for crisp images
205
+ mozjpeg: true, // Use mozjpeg encoder for better compression
206
+ })
207
+ .toBuffer();
208
+
209
+ const sizeSaved = imageBuffer.length - optimizedBuffer.length;
210
+ const percentSaved = ((sizeSaved / imageBuffer.length) * 100).toFixed(1);
211
+
212
+ console.log(`[S3] Optimized: ${originalWidth}x${originalHeight} (PNG → JPEG, no resize)`);
213
+ console.log(`[S3] Size: ${imageBuffer.length} bytes → ${optimizedBuffer.length} bytes (saved ${percentSaved}%)`);
214
+ } catch (optimizeError) {
215
+ console.warn(`[S3] ⚠️ Image optimization failed, using original: ${optimizeError.message}`);
216
+ optimizedBuffer = imageBuffer; // Fallback to original if optimization fails
217
+ }
218
+
219
+ const command = new PutObjectCommand({
220
+ Bucket: this.bucketName,
221
+ Key: key,
222
+ Body: optimizedBuffer,
223
+ ContentType: 'image/jpeg',
224
+ CacheControl: 'public, max-age=31536000', // Cache for 1 year (images don't change)
225
+ // NOTE: ACL won't work because bucket has "Object Ownership: Bucket owner enforced"
226
+ // Public access must be granted via bucket policy. Add this policy in AWS Console:
227
+ // {
228
+ // "Version": "2012-10-17",
229
+ // "Statement": [{
230
+ // "Sid": "PublicReadMatchups",
231
+ // "Effect": "Allow",
232
+ // "Principal": "*",
233
+ // "Action": "s3:GetObject",
234
+ // "Resource": "arn:aws:s3:::dubs-avatars-prod/matchups/*"
235
+ // }]
236
+ // }
237
+ });
238
+
239
+ await this.client.send(command);
240
+
241
+ // Add cache-busting timestamp to URL to ensure browsers fetch the new image
242
+ // This is important when overwriting existing images (e.g., EPL regeneration)
243
+ const cacheBuster = Date.now();
244
+ const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}?v=${cacheBuster}`;
245
+
246
+ console.log(`[S3] ✅ Matchup image uploaded: ${publicUrl} (${optimizedBuffer.length} bytes)`);
247
+
248
+ return {
249
+ publicUrl,
250
+ key,
251
+ wasReused: false,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Check if S3 credentials are available
257
+ */
258
+ isConfigured() {
259
+ return this.credentialsAvailable && this.client !== null;
260
+ }
261
+
262
+ /**
263
+ * Get the S3 key for a profile OG image
264
+ * @param {string} username - Username
265
+ * @param {string} variant - 'default' or 'twitter'
266
+ */
267
+ getProfileOGImageKey(username, variant = 'default') {
268
+ // Normalize username for safe file naming
269
+ const normalizedUsername = username
270
+ .toLowerCase()
271
+ .trim()
272
+ .replace(/[^a-z0-9_-]/g, '_');
273
+ const suffix = variant === 'twitter' ? '-twitter' : '';
274
+ return `og-profiles/${normalizedUsername}${suffix}.png`;
275
+ }
276
+
277
+ /**
278
+ * Get the public URL for a profile OG image
279
+ * @param {string} username - Username
280
+ * @param {string} variant - 'default' or 'twitter'
281
+ */
282
+ getProfileOGImageUrl(username, variant = 'default') {
283
+ const key = this.getProfileOGImageKey(username, variant);
284
+ return `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
285
+ }
286
+
287
+ /**
288
+ * Check if a profile OG image exists in S3
289
+ * @param {string} username - Username
290
+ * @param {string} variant - 'default' or 'twitter'
291
+ */
292
+ async profileOGImageExists(username, variant = 'default') {
293
+ if (!this.credentialsAvailable || !this.client) {
294
+ return false;
295
+ }
296
+
297
+ try {
298
+ const key = this.getProfileOGImageKey(username, variant);
299
+ const command = new HeadObjectCommand({
300
+ Bucket: this.bucketName,
301
+ Key: key,
302
+ });
303
+ await this.client.send(command);
304
+ return true;
305
+ } catch (err) {
306
+ if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
307
+ return false;
308
+ }
309
+ console.warn(`[S3] Error checking if profile OG image exists: ${err.message}`);
310
+ return false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Upload a profile OG image to S3
316
+ * Used for the beautiful ShareablePNLCard captured via html2canvas
317
+ *
318
+ * @param {string} username - Username for the profile
319
+ * @param {Buffer} imageBuffer - PNG image buffer
320
+ * @param {string} variant - 'default' or 'twitter' (1200x628 for Twitter cards)
321
+ * @returns {Promise<{publicUrl: string, key: string}>}
322
+ */
323
+ async uploadProfileOGImage(username, imageBuffer, variant = 'default') {
324
+ if (!this.credentialsAvailable || !this.client) {
325
+ throw new Error('AWS credentials not configured');
326
+ }
327
+
328
+ const key = this.getProfileOGImageKey(username, variant);
329
+
330
+ console.log(`[S3] Uploading profile OG image (${variant}): ${key} (${imageBuffer.length} bytes)`);
331
+
332
+ const command = new PutObjectCommand({
333
+ Bucket: this.bucketName,
334
+ Key: key,
335
+ Body: imageBuffer,
336
+ ContentType: 'image/png',
337
+ CacheControl: 'public, max-age=3600', // Cache for 1 hour (profiles change more often than matchups)
338
+ });
339
+
340
+ await this.client.send(command);
341
+
342
+ const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
343
+
344
+ console.log(`[S3] ✅ Profile OG image (${variant}) uploaded: ${publicUrl}`);
345
+
346
+ return {
347
+ publicUrl,
348
+ key,
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Upload a What's New GIF to S3
354
+ * Uses dedicated whats-new bucket
355
+ *
356
+ * @param {Buffer} imageBuffer - GIF/image buffer
357
+ * @param {string} originalFilename - Original filename for extension detection
358
+ * @param {string} contentType - MIME type of the file
359
+ * @returns {Promise<{publicUrl: string, key: string}>}
360
+ */
361
+ async uploadWhatsNewGif(imageBuffer, originalFilename, contentType) {
362
+ if (!this.credentialsAvailable || !this.client) {
363
+ throw new Error('AWS credentials not configured');
364
+ }
365
+
366
+ // Generate unique filename
367
+ const extension = originalFilename.split('.').pop().toLowerCase() || 'gif';
368
+ const timestamp = Date.now();
369
+ const randomId = Math.random().toString(36).substring(2, 8);
370
+ const key = `${timestamp}-${randomId}.${extension}`;
371
+
372
+ console.log(`[S3] Uploading What's New GIF to ${this.whatsNewBucketName}: ${key} (${imageBuffer.length} bytes)`);
373
+
374
+ const command = new PutObjectCommand({
375
+ Bucket: this.whatsNewBucketName,
376
+ Key: key,
377
+ Body: imageBuffer,
378
+ ContentType: contentType || this.getContentType(extension),
379
+ CacheControl: 'public, max-age=31536000', // Cache for 1 year
380
+ });
381
+
382
+ await this.client.send(command);
383
+
384
+ const publicUrl = `https://${this.whatsNewBucketName}.s3.${this.region}.amazonaws.com/${key}`;
385
+
386
+ console.log(`[S3] ✅ What's New GIF uploaded: ${publicUrl}`);
387
+
388
+ return {
389
+ publicUrl,
390
+ key,
391
+ };
392
+ }
393
+ }
394
+
395
+ module.exports = S3Service;
396
+