From 3817abdb4dda92f877a0353be7cabfff5961ed01 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:39:40 -0700 Subject: [PATCH 1/6] MER-192 adding retention data as well as more capable pivot management dashboard --- backend/coverage/coverage-summary.json | 10 + backend/coverage/lcov-report/base.css | 224 + .../coverage/lcov-report/block-navigation.js | 87 + backend/coverage/lcov-report/favicon.png | Bin 0 -> 445 bytes backend/coverage/lcov-report/index.html | 131 + .../lcov-report/middlewares/index.html | 146 + .../pivotReferralValidateRateLimit.js.html | 187 + .../middlewares/requireAdmin.js.html | 250 + .../middlewares/verifyToken.js.html | 712 +++ backend/coverage/lcov-report/prettify.css | 1 + backend/coverage/lcov-report/prettify.js | 2 + .../lcov-report/routes/adminRoutes.js.html | 1525 ++++++ .../lcov-report/routes/authRoutes.js.html | 4555 +++++++++++++++++ .../coverage/lcov-report/routes/index.html | 191 + .../routes/orgBudgetRoutes.js.html | 781 +++ .../routes/pivotAdminRoutes.js.html | 1693 ++++++ .../lcov-report/routes/pivotRoutes.js.html | 2053 ++++++++ .../lcov-report/routes/userRoutes.js.html | 2827 ++++++++++ .../services/adminTenantEventsService.js.html | 529 ++ .../adminTenantSummaryService.js.html | 205 + .../services/atlasPolicyService.js.html | 748 +++ .../services/authGlobalService.js.html | 673 +++ .../services/budgetService.js.html | 2389 +++++++++ .../services/discordWebookService.js.html | 196 + .../services/feedbackService.js.html | 1132 ++++ .../services/getGlobalModelService.js.html | 253 + .../services/imageUploadService.js.html | 601 +++ .../coverage/lcov-report/services/index.html | 161 + .../services/notificationService.js.html | 4423 ++++++++++++++++ .../pivotAdminOverviewService.js.html | 667 +++ .../services/pivotCatalogPurgeService.js.html | 568 ++ .../services/pivotConfigService.js.html | 262 + .../services/pivotFeedService.js.html | 2134 ++++++++ .../services/pivotFeedbackService.js.html | 826 +++ .../services/pivotFriendService.js.html | 1045 ++++ .../pivotIngestDuplicateService.js.html | 739 +++ .../pivotIngestPreviewService.js.html | 3553 +++++++++++++ .../pivotIngestPublishService.js.html | 2098 ++++++++ .../services/pivotIntentService.js.html | 1426 ++++++ .../services/pivotLabEventsService.js.html | 547 ++ .../services/pivotLabNotesService.js.html | 280 + .../services/pivotProfileService.js.html | 292 ++ .../services/pivotReferralCodeService.js.html | 1552 ++++++ .../services/pivotRetentionService.js.html | 415 ++ .../services/pivotTagCatalogService.js.html | 649 +++ .../services/pivotTagSuggestService.js.html | 1135 ++++ .../services/pivotTmdbService.js.html | 835 +++ .../services/pivotWeeklyDropService.js.html | 1018 ++++ .../pivotWeeklySnapshotService.js.html | 820 +++ .../lcov-report/services/resendClient.js.html | 118 + .../reservationMetricsService.js.html | 364 ++ .../resourceReservationService.js.html | 700 +++ .../services/rootOperatorUsersService.js.html | 640 +++ .../services/tenantConfigService.js.html | 1294 +++++ .../services/tenantKeyRenameService.js.html | 652 +++ .../lcov-report/sort-arrow-sprite.png | Bin 0 -> 138 bytes backend/coverage/lcov-report/sorter.js | 210 + .../lcov-report/utilities/cookieUtils.js.html | 136 + .../lcov-report/utilities/friendUtils.js.html | 562 ++ .../coverage/lcov-report/utilities/index.html | 161 + .../utilities/pivotDropSchedule.js.html | 691 +++ .../utilities/pivotIsoWeek.js.html | 322 ++ .../lcov-report/utilities/pivotLogger.js.html | 388 ++ .../utilities/pivotMovieMetadata.js.html | 556 ++ .../utilities/pivotTimeSlots.js.html | 532 ++ .../utilities/semesterHelpers.js.html | 859 ++++ .../utilities/sessionUtils.js.html | 1042 ++++ .../utilities/timeBlockHelper.js.html | 838 +++ backend/coverage/lcov.info | 1610 ++++++ backend/migrations/seedPivotFeedEvents.js | 69 +- backend/migrations/sendPivotWeeklyPush.js | 15 + backend/routes/pivotAdminRoutes.js | 143 +- backend/routes/pivotRoutes.js | 101 +- backend/schemas/pivotEventIntent.js | 6 + backend/schemas/pivotWeeklySnapshot.js | 20 + backend/services/pivotAdminOverviewService.js | 13 + backend/services/pivotFeedService.js | 144 +- backend/services/pivotIngestPublishService.js | 267 +- backend/services/pivotIntentService.js | 175 +- backend/services/pivotLabEventsService.js | 74 +- backend/services/pivotRetentionService.js | 110 + backend/services/pivotTmdbService.js | 250 + backend/services/pivotWeeklyDropService.js | 15 + .../services/pivotWeeklySnapshotService.js | 52 +- .../pivotAdminRoutes.outcomes.test.js | 55 + .../pivotRoutes.outcomes.test.js | 1 + .../unit/pivotAdminOverviewService.test.js | 17 +- backend/tests/unit/pivotFeedService.test.js | 59 +- .../unit/pivotIngestPublishService.test.js | 179 +- backend/tests/unit/pivotIntentService.test.js | 110 +- backend/tests/unit/pivotIsoWeek.test.js | 40 +- .../tests/unit/pivotLabEventsService.test.js | 116 + backend/tests/unit/pivotLogger.test.js | 96 + backend/tests/unit/pivotMovieMetadata.test.js | 139 + .../tests/unit/pivotRetentionService.test.js | 131 + backend/tests/unit/pivotTimeSlots.test.js | 62 + .../tests/unit/pivotWeeklyDropService.test.js | 45 + .../unit/pivotWeeklySnapshotService.test.js | 21 +- backend/utilities/pivotDropSchedule.js | 17 +- backend/utilities/pivotIsoWeek.js | 55 + backend/utilities/pivotLogger.js | 101 + backend/utilities/pivotMovieMetadata.js | 157 + backend/utilities/pivotTimeSlots.js | 149 + .../PivotLab/PivotCatalogEventEditModal.jsx | 529 ++ .../PivotLab/PivotCatalogEventEditModal.scss | 41 + .../PivotLab/PivotLabOverview.jsx | 346 ++ .../PlatformAdmin/PivotLab/PivotLabPage.jsx | 1693 ++++-- .../PlatformAdmin/PivotLab/PivotLabPage.scss | 474 +- .../PivotLab/PivotManualImportModal.jsx | 942 ++++ .../PivotLab/PivotManualImportModal.scss | 433 ++ .../PivotLab/PivotTagMultiSelect.jsx | 10 +- .../PivotLab/PivotTmdbLookup.jsx | 216 + .../PivotLab/PivotTmdbLookup.scss | 191 + .../PlatformAdmin/PivotLab/pivotTmdbClient.js | 149 + frontend/src/utils/pivotIsoWeek.js | 26 + 115 files changed, 66700 insertions(+), 575 deletions(-) create mode 100644 backend/coverage/coverage-summary.json create mode 100644 backend/coverage/lcov-report/base.css create mode 100644 backend/coverage/lcov-report/block-navigation.js create mode 100644 backend/coverage/lcov-report/favicon.png create mode 100644 backend/coverage/lcov-report/index.html create mode 100644 backend/coverage/lcov-report/middlewares/index.html create mode 100644 backend/coverage/lcov-report/middlewares/pivotReferralValidateRateLimit.js.html create mode 100644 backend/coverage/lcov-report/middlewares/requireAdmin.js.html create mode 100644 backend/coverage/lcov-report/middlewares/verifyToken.js.html create mode 100644 backend/coverage/lcov-report/prettify.css create mode 100644 backend/coverage/lcov-report/prettify.js create mode 100644 backend/coverage/lcov-report/routes/adminRoutes.js.html create mode 100644 backend/coverage/lcov-report/routes/authRoutes.js.html create mode 100644 backend/coverage/lcov-report/routes/index.html create mode 100644 backend/coverage/lcov-report/routes/orgBudgetRoutes.js.html create mode 100644 backend/coverage/lcov-report/routes/pivotAdminRoutes.js.html create mode 100644 backend/coverage/lcov-report/routes/pivotRoutes.js.html create mode 100644 backend/coverage/lcov-report/routes/userRoutes.js.html create mode 100644 backend/coverage/lcov-report/services/adminTenantEventsService.js.html create mode 100644 backend/coverage/lcov-report/services/adminTenantSummaryService.js.html create mode 100644 backend/coverage/lcov-report/services/atlasPolicyService.js.html create mode 100644 backend/coverage/lcov-report/services/authGlobalService.js.html create mode 100644 backend/coverage/lcov-report/services/budgetService.js.html create mode 100644 backend/coverage/lcov-report/services/discordWebookService.js.html create mode 100644 backend/coverage/lcov-report/services/feedbackService.js.html create mode 100644 backend/coverage/lcov-report/services/getGlobalModelService.js.html create mode 100644 backend/coverage/lcov-report/services/imageUploadService.js.html create mode 100644 backend/coverage/lcov-report/services/index.html create mode 100644 backend/coverage/lcov-report/services/notificationService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotAdminOverviewService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotCatalogPurgeService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotConfigService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotFeedService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotFeedbackService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotFriendService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotIngestDuplicateService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotIngestPreviewService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotIngestPublishService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotIntentService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotLabEventsService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotLabNotesService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotProfileService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotReferralCodeService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotRetentionService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotTagCatalogService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotTagSuggestService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotTmdbService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotWeeklyDropService.js.html create mode 100644 backend/coverage/lcov-report/services/pivotWeeklySnapshotService.js.html create mode 100644 backend/coverage/lcov-report/services/resendClient.js.html create mode 100644 backend/coverage/lcov-report/services/reservationMetricsService.js.html create mode 100644 backend/coverage/lcov-report/services/resourceReservationService.js.html create mode 100644 backend/coverage/lcov-report/services/rootOperatorUsersService.js.html create mode 100644 backend/coverage/lcov-report/services/tenantConfigService.js.html create mode 100644 backend/coverage/lcov-report/services/tenantKeyRenameService.js.html create mode 100644 backend/coverage/lcov-report/sort-arrow-sprite.png create mode 100644 backend/coverage/lcov-report/sorter.js create mode 100644 backend/coverage/lcov-report/utilities/cookieUtils.js.html create mode 100644 backend/coverage/lcov-report/utilities/friendUtils.js.html create mode 100644 backend/coverage/lcov-report/utilities/index.html create mode 100644 backend/coverage/lcov-report/utilities/pivotDropSchedule.js.html create mode 100644 backend/coverage/lcov-report/utilities/pivotIsoWeek.js.html create mode 100644 backend/coverage/lcov-report/utilities/pivotLogger.js.html create mode 100644 backend/coverage/lcov-report/utilities/pivotMovieMetadata.js.html create mode 100644 backend/coverage/lcov-report/utilities/pivotTimeSlots.js.html create mode 100644 backend/coverage/lcov-report/utilities/semesterHelpers.js.html create mode 100644 backend/coverage/lcov-report/utilities/sessionUtils.js.html create mode 100644 backend/coverage/lcov-report/utilities/timeBlockHelper.js.html create mode 100644 backend/coverage/lcov.info create mode 100644 backend/services/pivotRetentionService.js create mode 100644 backend/services/pivotTmdbService.js create mode 100644 backend/tests/unit/pivotLabEventsService.test.js create mode 100644 backend/tests/unit/pivotLogger.test.js create mode 100644 backend/tests/unit/pivotMovieMetadata.test.js create mode 100644 backend/tests/unit/pivotRetentionService.test.js create mode 100644 backend/tests/unit/pivotTimeSlots.test.js create mode 100644 backend/utilities/pivotLogger.js create mode 100644 backend/utilities/pivotMovieMetadata.js create mode 100644 backend/utilities/pivotTimeSlots.js create mode 100644 frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx create mode 100644 frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.scss create mode 100644 frontend/src/pages/PlatformAdmin/PivotLab/PivotLabOverview.jsx create mode 100644 frontend/src/pages/PlatformAdmin/PivotLab/PivotManualImportModal.jsx create mode 100644 frontend/src/pages/PlatformAdmin/PivotLab/PivotManualImportModal.scss create mode 100644 frontend/src/pages/PlatformAdmin/PivotLab/PivotTmdbLookup.jsx create mode 100644 frontend/src/pages/PlatformAdmin/PivotLab/PivotTmdbLookup.scss create mode 100644 frontend/src/pages/PlatformAdmin/PivotLab/pivotTmdbClient.js diff --git a/backend/coverage/coverage-summary.json b/backend/coverage/coverage-summary.json new file mode 100644 index 00000000..f0ecfad0 --- /dev/null +++ b/backend/coverage/coverage-summary.json @@ -0,0 +1,10 @@ +{"total": {"lines":{"total":704,"covered":291,"skipped":0,"pct":41.33},"statements":{"total":718,"covered":296,"skipped":0,"pct":41.22},"functions":{"total":113,"covered":48,"skipped":0,"pct":42.47},"branches":{"total":608,"covered":193,"skipped":0,"pct":31.74},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/Users/james/Documents/Code/Meridian-Mono/Meridian/backend/services/feedbackService.js": {"lines":{"total":78,"covered":2,"skipped":0,"pct":2.56},"functions":{"total":14,"covered":0,"skipped":0,"pct":0},"statements":{"total":78,"covered":2,"skipped":0,"pct":2.56},"branches":{"total":27,"covered":0,"skipped":0,"pct":0}} +,"/Users/james/Documents/Code/Meridian-Mono/Meridian/backend/services/pivotFeedService.js": {"lines":{"total":238,"covered":217,"skipped":0,"pct":91.17},"functions":{"total":31,"covered":31,"skipped":0,"pct":100},"statements":{"total":241,"covered":220,"skipped":0,"pct":91.28},"branches":{"total":198,"covered":148,"skipped":0,"pct":74.74}} +,"/Users/james/Documents/Code/Meridian-Mono/Meridian/backend/services/pivotFeedbackService.js": {"lines":{"total":82,"covered":8,"skipped":0,"pct":9.75},"functions":{"total":14,"covered":0,"skipped":0,"pct":0},"statements":{"total":89,"covered":8,"skipped":0,"pct":8.98},"branches":{"total":61,"covered":0,"skipped":0,"pct":0}} +,"/Users/james/Documents/Code/Meridian-Mono/Meridian/backend/services/pivotIntentService.js": {"lines":{"total":127,"covered":10,"skipped":0,"pct":7.87},"functions":{"total":13,"covered":0,"skipped":0,"pct":0},"statements":{"total":127,"covered":10,"skipped":0,"pct":7.87},"branches":{"total":130,"covered":0,"skipped":0,"pct":0}} +,"/Users/james/Documents/Code/Meridian-Mono/Meridian/backend/utilities/pivotIsoWeek.js": {"lines":{"total":29,"covered":3,"skipped":0,"pct":10.34},"functions":{"total":6,"covered":1,"skipped":0,"pct":16.66},"statements":{"total":29,"covered":3,"skipped":0,"pct":10.34},"branches":{"total":11,"covered":2,"skipped":0,"pct":18.18}} +,"/Users/james/Documents/Code/Meridian-Mono/Meridian/backend/utilities/pivotLogger.js": {"lines":{"total":35,"covered":9,"skipped":0,"pct":25.71},"functions":{"total":9,"covered":3,"skipped":0,"pct":33.33},"statements":{"total":35,"covered":9,"skipped":0,"pct":25.71},"branches":{"total":28,"covered":9,"skipped":0,"pct":32.14}} +,"/Users/james/Documents/Code/Meridian-Mono/Meridian/backend/utilities/pivotMovieMetadata.js": {"lines":{"total":62,"covered":11,"skipped":0,"pct":17.74},"functions":{"total":11,"covered":3,"skipped":0,"pct":27.27},"statements":{"total":63,"covered":11,"skipped":0,"pct":17.46},"branches":{"total":101,"covered":8,"skipped":0,"pct":7.92}} +,"/Users/james/Documents/Code/Meridian-Mono/Meridian/backend/utilities/pivotTimeSlots.js": {"lines":{"total":53,"covered":31,"skipped":0,"pct":58.49},"functions":{"total":15,"covered":10,"skipped":0,"pct":66.66},"statements":{"total":56,"covered":33,"skipped":0,"pct":58.92},"branches":{"total":52,"covered":26,"skipped":0,"pct":50}} +} diff --git a/backend/coverage/lcov-report/base.css b/backend/coverage/lcov-report/base.css new file mode 100644 index 00000000..f418035b --- /dev/null +++ b/backend/coverage/lcov-report/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/backend/coverage/lcov-report/block-navigation.js b/backend/coverage/lcov-report/block-navigation.js new file mode 100644 index 00000000..530d1ed2 --- /dev/null +++ b/backend/coverage/lcov-report/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/backend/coverage/lcov-report/favicon.png b/backend/coverage/lcov-report/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c1525b811a167671e9de1fa78aab9f5c0b61cef7 GIT binary patch literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 41.22% + Statements + 296/718 +
+ + +
+ 31.74% + Branches + 193/608 +
+ + +
+ 42.47% + Functions + 48/113 +
+ + +
+ 41.33% + Lines + 291/704 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
services +
+
44.85%240/53535.57%148/41643.05%31/7245.14%237/525
utilities +
+
30.6%56/18323.43%45/19241.46%17/4130.16%54/179
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/middlewares/index.html b/backend/coverage/lcov-report/middlewares/index.html new file mode 100644 index 00000000..b21550f4 --- /dev/null +++ b/backend/coverage/lcov-report/middlewares/index.html @@ -0,0 +1,146 @@ + + + + + + Code coverage report for middlewares + + + + + + + + + +
+
+

All files middlewares

+
+ +
+ 32.57% + Statements + 43/132 +
+ + +
+ 13.27% + Branches + 15/113 +
+ + +
+ 45.45% + Functions + 5/11 +
+ + +
+ 33.07% + Lines + 43/130 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
pivotReferralValidateRateLimit.js +
+
92.85%13/1466.66%6/9100%1/192.85%13/14
requireAdmin.js +
+
11.53%3/260%0/350%0/111.53%3/26
verifyToken.js +
+
29.34%27/9213.04%9/6944.44%4/930%27/90
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/middlewares/pivotReferralValidateRateLimit.js.html b/backend/coverage/lcov-report/middlewares/pivotReferralValidateRateLimit.js.html new file mode 100644 index 00000000..5874b1d3 --- /dev/null +++ b/backend/coverage/lcov-report/middlewares/pivotReferralValidateRateLimit.js.html @@ -0,0 +1,187 @@ + + + + + + Code coverage report for middlewares/pivotReferralValidateRateLimit.js + + + + + + + + + +
+
+

All files / middlewares pivotReferralValidateRateLimit.js

+
+ +
+ 92.85% + Statements + 13/14 +
+ + +
+ 66.66% + Branches + 6/9 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 92.85% + Lines + 13/14 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35  +1x +1x +  +1x +  +  +2x +2x +2x +  +2x +1x +1x +  +  +2x +  +2x +  +  +  +  +  +  +  +2x +  +  +1x +  +  +  +  + 
/** In-memory rate limit for unauthenticated referral validation (20 req/min/IP). */
+const WINDOW_MS = 60 * 1000;
+const MAX_REQUESTS_PER_WINDOW = 20;
+ 
+const buckets = new Map();
+ 
+function pivotReferralValidateRateLimit(req, res, next) {
+  const ip = req.ip || req.socket?.remoteAddress || 'unknown';
+  const now = Date.now();
+  let bucket = buckets.get(ip);
+ 
+  if (!bucket || now - bucket.windowStart >= WINDOW_MS) {
+    bucket = { windowStart: now, count: 0 };
+    buckets.set(ip, bucket);
+  }
+ 
+  bucket.count += 1;
+ 
+  Iif (bucket.count > MAX_REQUESTS_PER_WINDOW) {
+    return res.status(429).json({
+      success: false,
+      message: 'Too many validation attempts. Please try again in a minute.',
+      code: 'REFERRAL_VALIDATE_RATE_LIMIT',
+    });
+  }
+ 
+  return next();
+}
+ 
+module.exports = {
+  pivotReferralValidateRateLimit,
+  WINDOW_MS,
+  MAX_REQUESTS_PER_WINDOW,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/middlewares/requireAdmin.js.html b/backend/coverage/lcov-report/middlewares/requireAdmin.js.html new file mode 100644 index 00000000..e211c63d --- /dev/null +++ b/backend/coverage/lcov-report/middlewares/requireAdmin.js.html @@ -0,0 +1,250 @@ + + + + + + Code coverage report for middlewares/requireAdmin.js + + + + + + + + + +
+
+

All files / middlewares requireAdmin.js

+
+ +
+ 11.53% + Statements + 3/26 +
+ + +
+ 0% + Branches + 0/35 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 11.53% + Lines + 3/26 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +561x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
const getModels = require('../services/getModelService');
+const getGlobalModels = require('../services/getGlobalModelService');
+ 
+/**
+ * Middleware: require platform admin OR tenant admin/root.
+ * Use after verifyToken. Allows platform admins to access any tenant without a local User.
+ */
+async function requireAdmin(req, res, next) {
+    if (!req.user) {
+        return res.status(401).json({ message: 'Authentication required' });
+    }
+ 
+    const platformRoles = req.user.platformRoles || [];
+    let isAdmin = platformRoles.includes('platform_admin') || platformRoles.includes('root');
+ 
+    if (req.user.globalUserId) {
+        const { PlatformRole } = getGlobalModels(req, 'PlatformRole');
+        const pr = await PlatformRole.findOne({ globalUserId: req.user.globalUserId });
+        if (pr && pr.roles && (pr.roles.includes('platform_admin') || pr.roles.includes('root'))) {
+            isAdmin = true;
+        }
+    }
+ 
+    let tenantUser = null;
+    if (req.user.userId) {
+        const { User } = getModels(req, 'User');
+        tenantUser = await User.findById(req.user.userId);
+        if (!isAdmin && tenantUser && tenantUser.roles && (tenantUser.roles.includes('admin') || tenantUser.roles.includes('root'))) {
+            isAdmin = true;
+        }
+    }
+ 
+    if (!isAdmin) {
+        return res.status(403).json({ message: 'Forbidden' });
+    }
+ 
+    const mfaConfiguredByToken = Boolean(req.user.mfaConfigured);
+    const mfaConfiguredByDb = Boolean(
+        (tenantUser?.adminMfa?.totp?.enabled && tenantUser?.adminMfa?.totp?.secret) ||
+        (Array.isArray(tenantUser?.adminMfa?.passkeys) && tenantUser.adminMfa.passkeys.length > 0)
+    );
+    const mfaConfigured = mfaConfiguredByToken || mfaConfiguredByDb;
+    if (mfaConfigured && !req.user.mfaVerified) {
+        return res.status(403).json({
+            message: 'Admin MFA is required',
+            code: 'ADMIN_MFA_REQUIRED',
+            mfaConfigured: mfaConfigured,
+            mfaVerified: Boolean(req.user.mfaVerified),
+        });
+    }
+ 
+    return next();
+}
+ 
+module.exports = { requireAdmin };
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/middlewares/verifyToken.js.html b/backend/coverage/lcov-report/middlewares/verifyToken.js.html new file mode 100644 index 00000000..5ec8dde6 --- /dev/null +++ b/backend/coverage/lcov-report/middlewares/verifyToken.js.html @@ -0,0 +1,712 @@ + + + + + + Code coverage report for middlewares/verifyToken.js + + + + + + + + + +
+
+

All files / middlewares verifyToken.js

+
+ +
+ 29.34% + Statements + 27/92 +
+ + +
+ 13.04% + Branches + 9/69 +
+ + +
+ 44.44% + Functions + 4/9 +
+ + +
+ 30% + Lines + 27/90 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +2102x +2x +  +  +6x +  +2x +2x +  +2x +2x +2x +  +  +  +  +  +  +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +  +  +  +  +  +2x +6x +  +  +6x +  +  +  +  +  +  +  +  +6x +6x +6x +6x +6x +6x +6x +6x +  +  +  +  +  +  +  +  +  +  +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +2x + 
const jwt = require('jsonwebtoken');
+const authGlobalService = require('../services/authGlobalService');
+ 
+function getModels(req, ...names) {
+    return require('../services/getModelService')(req, ...names);
+}
+const { validateSession } = require('../utilities/sessionUtils');
+const { getCookieDomain } = require('../utilities/cookieUtils');
+ 
+const ACCESS_TOKEN_EXPIRY_MINUTES = 15;
+const ACCESS_TOKEN_EXPIRY = `${ACCESS_TOKEN_EXPIRY_MINUTES}m`;
+const ACCESS_TOKEN_EXPIRY_MS = ACCESS_TOKEN_EXPIRY_MINUTES * 60 * 1000;
+ 
+/**
+ * Resolve req.user from decoded JWT: for new tokens (globalUserId) resolve tenant user from TenantMembership;
+ * for legacy tokens (userId only) pass through.
+ */
+async function resolveRequestUser(req, decodedToken) {
+    Iif (decodedToken.globalUserId) {
+        const { tenantUserId, tenantUser } = await authGlobalService.resolveTenantUserForRequest(req, decodedToken.globalUserId);
+        const roles = tenantUser && tenantUser.roles ? tenantUser.roles : (decodedToken.roles || ['user']);
+        req.user = {
+            globalUserId: decodedToken.globalUserId,
+            userId: tenantUserId,
+            tenantUserId,
+            roles,
+            platformRoles: decodedToken.platformRoles || [],
+            mfaConfigured: Boolean(decodedToken.mfaConfigured),
+            mfaVerified: Boolean(decodedToken.mfaVerified),
+        };
+        return;
+    }
+    // Legacy token: userId and roles only
+    req.user = {
+        userId: decodedToken.userId,
+        roles: decodedToken.roles || ['user'],
+    };
+}
+ 
+const verifyToken = async (req, res, next) => {
+    const token = req.cookies.accessToken ||
+        (req.headers['authorization'] && req.headers['authorization'].split(' ')[1]);
+ 
+    Iif (token == null) {
+        console.log('No token provided');
+        return res.status(401).json({
+            success: false,
+            message: 'No access token provided',
+            code: 'NO_TOKEN',
+        });
+    }
+ 
+    try {
+        const decodedToken = jwt.verify(token, process.env.JWT_SECRET);
+        await resolveRequestUser(req, decodedToken);
+        Eif (req.user?.userId) {
+            try {
+                const { User } = getModels(req, 'User');
+                const accountUser = await User.findById(req.user.userId).select('accessSuspended').lean();
+                Iif (accountUser?.accessSuspended) {
+                    return res.status(403).json({
+                        success: false,
+                        message: 'This account has been suspended.',
+                        code: 'ACCOUNT_SUSPENDED',
+                    });
+                }
+            } catch (checkErr) {
+                console.error('[verifyToken] accessSuspended check failed:', checkErr);
+            }
+        }
+        return next();
+    } catch (err) {
+        if (err.name === 'TokenExpiredError') {
+            return res.status(401).json({
+                success: false,
+                message: 'Access token expired',
+                code: 'TOKEN_EXPIRED',
+            });
+        }
+        return res.status(403).json({
+            success: false,
+            message: 'Invalid access token',
+            code: 'INVALID_TOKEN',
+        });
+    }
+};
+ 
+function authorizeRoles(...allowedRoles) {
+    return (req, res, next) => {
+        const { roles } = req.user || {};
+        if (!roles || !allowedRoles.some(role => roles.includes(role))) {
+            return res.status(403).json({ message: 'Forbidden' });
+        }
+        next();
+    };
+}
+ 
+/**
+ * Creates verifyTokenOptional middleware.
+ * @param {Object} [options]
+ * @param {boolean} [options.requireAuthWhenTokenPresent] - When true, if a token was present but
+ *   could not be authenticated (expired + refresh failed, or invalid), return 401 so the client
+ *   can retry after refreshing. When false/omitted, proceed without req.user (backwards compatible).
+ */
+function createVerifyTokenOptional(options = {}) {
+  const requireAuthWhenTokenPresent = options.requireAuthWhenTokenPresent === true;
+ 
+  return async (req, res, next) => {
+    const token = req.cookies.accessToken ||
+      (req.headers['authorization'] && req.headers['authorization'].split(' ')[1]);
+    const refreshToken = req.cookies.refreshToken;
+ 
+    const tryRefresh = async () => {
+      if (!refreshToken) {
+        console.log('[Auth] tryRefresh: no refresh token in cookies');
+        return false;
+      }
+      try {
+        const validation = await validateSession(refreshToken, req);
+        if (!validation.valid) {
+          console.log('[Auth] tryRefresh: session invalid:', validation.error);
+          return false;
+        }
+        const { user, globalUser } = validation;
+        if (globalUser) {
+          const platformRoles = await authGlobalService.getPlatformRolesForGlobalUser(req, globalUser._id);
+          await authGlobalService.issueTokens(req, res, globalUser, user, platformRoles, {
+            mfaConfigured: Boolean(validation.decoded?.mfaConfigured),
+            mfaVerified: Boolean(validation.decoded?.mfaVerified),
+          });
+          req.user = {
+            globalUserId: globalUser._id,
+            userId: user ? user._id : null,
+            tenantUserId: user ? user._id : null,
+            roles: user ? (user.roles || ['user']) : ['user'],
+            platformRoles: platformRoles || [],
+            mfaConfigured: Boolean(validation.decoded?.mfaConfigured),
+            mfaVerified: Boolean(validation.decoded?.mfaVerified),
+          };
+        } else if (user) {
+          const cookieOptions = {
+            httpOnly: true,
+            secure: process.env.NODE_ENV === 'production',
+            sameSite: 'strict',
+            maxAge: ACCESS_TOKEN_EXPIRY_MS,
+            path: '/',
+          };
+          const domain = getCookieDomain(req);
+          if (domain) cookieOptions.domain = domain;
+          const newAccessToken = jwt.sign(
+            { userId: user._id, roles: user.roles },
+            process.env.JWT_SECRET,
+            { expiresIn: ACCESS_TOKEN_EXPIRY }
+          );
+          res.cookie('accessToken', newAccessToken, cookieOptions);
+          req.user = { userId: user._id, roles: user.roles };
+        } else {
+          return false;
+        }
+        console.log('[Auth] Token refreshed successfully for user:', user ? user._id : globalUser?._id);
+        return true;
+      } catch (refreshError) {
+        console.log('[Auth] Refresh failed:', refreshError.message);
+        return false;
+      }
+    };
+ 
+    if (token == null) {
+      console.log('[Auth] No access token, attempting refresh from refreshToken cookie');
+      const refreshed = await tryRefresh();
+      console.log('[Auth] Refresh result:', refreshed ? 'success' : 'failed');
+      return next();
+    }
+ 
+    try {
+      const decodedToken = jwt.verify(token, process.env.JWT_SECRET);
+      await resolveRequestUser(req, decodedToken);
+      return next();
+    } catch (err) {
+      if (err.name !== 'TokenExpiredError') {
+        if (requireAuthWhenTokenPresent) {
+          return res.status(401).json({
+            success: false,
+            message: 'Invalid access token',
+            code: 'INVALID_TOKEN'
+          });
+        }
+        return next();
+      }
+      const refreshed = await tryRefresh();
+      if (refreshed) {
+        return next();
+      }
+      if (requireAuthWhenTokenPresent && !req.user) {
+        return res.status(401).json({
+          success: false,
+          message: 'Access token expired',
+          code: 'TOKEN_EXPIRED'
+        });
+      }
+      return next();
+    }
+  };
+}
+ 
+const verifyTokenOptional = createVerifyTokenOptional();
+verifyTokenOptional.withOptions = createVerifyTokenOptional;
+ 
+module.exports = { verifyToken, verifyTokenOptional, authorizeRoles, resolveRequestUser };
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/prettify.css b/backend/coverage/lcov-report/prettify.css new file mode 100644 index 00000000..b317a7cd --- /dev/null +++ b/backend/coverage/lcov-report/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/backend/coverage/lcov-report/prettify.js b/backend/coverage/lcov-report/prettify.js new file mode 100644 index 00000000..b3225238 --- /dev/null +++ b/backend/coverage/lcov-report/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/backend/coverage/lcov-report/routes/adminRoutes.js.html b/backend/coverage/lcov-report/routes/adminRoutes.js.html new file mode 100644 index 00000000..3bf9a51e --- /dev/null +++ b/backend/coverage/lcov-report/routes/adminRoutes.js.html @@ -0,0 +1,1525 @@ + + + + + + Code coverage report for routes/adminRoutes.js + + + + + + + + + +
+
+

All files / routes adminRoutes.js

+
+ +
+ 18.85% + Statements + 46/244 +
+ + +
+ 0% + Branches + 0/88 +
+ + +
+ 3.7% + Functions + 1/27 +
+ + +
+ 19.74% + Lines + 46/233 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +4811x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +1x +  +1x +1x +1x +1x +  +1x +2x +  +2x +1x +1x +  +1x +1x +1x +1x +  +  +1x +1x +  +1x +  +1x +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
const express = require('express');
+const jwt = require('jsonwebtoken');
+const { randomUUID } = require('crypto');
+const router = express.Router();
+const { verifyToken, authorizeRoles } = require('../middlewares/verifyToken');
+const { requireAdmin } = require('../middlewares/requireAdmin');
+const mongoose = require('mongoose');
+const { connectToDatabase } = require('../connectionsManager');
+const { getConnections, disconnectSocket, disconnectAll } = require('../socket');
+const { createSession } = require('../utilities/sessionUtils');
+const { getCookieDomain } = require('../utilities/cookieUtils');
+const getGlobalModels = require('../services/getGlobalModelService');
+const {
+  DEFAULT_TENANTS,
+  normalizeTenantRows,
+  normalizeTenantOverride,
+  mergeTenantRows,
+} = require('../constants/defaultTenants');
+ 
+const ACCESS_TOKEN_EXPIRY = '1m';
+const REFRESH_TOKEN_EXPIRY = '30d';
+const ACCESS_TOKEN_EXPIRY_MS = 60 * 1000;
+const REFRESH_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
+ 
+router.get('/health', async (req, res) => {
+  try {
+     // Start timer
+     const mongooseConn = await connectToDatabase(req.school);
+     console.log(req.school);
+     const nativeDb = mongooseConn.db;
+ 
+     const start = performance.now();
+     const dbStatus = await nativeDb.admin().ping();
+     const end = performance.now();
+     const latencyMs = (end - start).toFixed(2);
+ 
+ 
+    const cronJobLastRun = new Date(); // implement this from your cron logs
+    const cronStatus = Date.now() - new Date(cronJobLastRun).getTime() < 5 * 60 * 1000;
+ 
+    const authSystemHealthy = true; // Add logic if needed
+ 
+    res.json({
+        statuses:{
+            backend: { status: true, uptime: process.uptime() },
+            database: { status: dbStatus.ok === 1, latency: latencyMs }, 
+            cronJobs: { status: cronStatus, lastRun: cronJobLastRun },
+            auth: { status: authSystemHealthy },
+            frontend: { status: true, build: 'v1.2.3', deployedAt: '2025-04-20T16:00:00Z' } // example static
+        },
+        subDomain: req.school
+    });
+  } catch (err) {
+    console.log(err.message);
+    res.status(500).json({ error: 'Site health check failed', details: err.message });
+  }
+});
+ 
+router.get('/api/tenant-config', async (req, res) => {
+  try {
+    const { TenantConfig } = getGlobalModels(req, 'TenantConfig');
+    const doc = await TenantConfig.findOne({ configKey: 'default' }).lean();
+    const tenants = mergeTenantRows(DEFAULT_TENANTS, doc?.tenants || []);
+    res.json({
+      success: true,
+      data: {
+        tenants,
+        updatedAt: doc?.updatedAt || null,
+      },
+    });
+  } catch (err) {
+    console.error('GET /api/tenant-config failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+async function getLastCronRun() {
+  // Replace with actual query from your cron log collection
+  return new Date(); // stubbed
+}
+ 
+async function checkExternalApi() {
+  try {
+    const response = await fetch('https://api.stripe.com'); // use HEAD or lightweight endpoint
+    return response.ok;
+  } catch {
+    return false;
+  }
+}
+ 
+/**
+ * WebSocket connections – admin only.
+ * List and manage open Socket.IO connections to help with server load.
+ */
+router.get('/websocket-connections', verifyToken, requireAdmin, (req, res) => {
+  try {
+    const connections = getConnections();
+    res.json({
+      success: true,
+      count: connections.length,
+      connections: connections.map((c) => ({
+        ...c,
+        connectedAt: new Date(c.connectedAt).toISOString(),
+      })),
+    });
+  } catch (err) {
+    console.error('GET /websocket-connections failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+router.post('/websocket-connections/:socketId/disconnect', verifyToken, requireAdmin, (req, res) => {
+  try {
+    const { socketId } = req.params;
+    const ok = disconnectSocket(socketId);
+    if (!ok) {
+      return res.status(404).json({ success: false, message: 'Socket not found or already disconnected' });
+    }
+    res.json({ success: true, message: 'Socket disconnected' });
+  } catch (err) {
+    console.error('POST /websocket-connections/:socketId/disconnect failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+router.post('/websocket-connections/disconnect-all', verifyToken, requireAdmin, (req, res) => {
+  try {
+    const count = disconnectAll();
+    res.json({ success: true, message: `Disconnected ${count} connection(s)`, count });
+  } catch (err) {
+    console.error('POST /websocket-connections/disconnect-all failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+/**
+ * Admin impersonation – log in as another user (admin/root only).
+ * POST /admin/impersonate
+ * Body: { identifier: string } – username, email, or user _id
+ */
+router.post('/admin/impersonate', verifyToken, requireAdmin, async (req, res) => {
+  try {
+    const { identifier } = req.body;
+    if (!identifier || typeof identifier !== 'string') {
+      return res.status(400).json({ success: false, message: 'identifier (username, email, or user id) is required' });
+    }
+ 
+    const getModels = require('../services/getModelService');
+    const { User } = getModels(req, 'User');
+ 
+    const trimmed = identifier.trim();
+    const isObjectId = mongoose.Types.ObjectId.isValid(trimmed) && String(new mongoose.Types.ObjectId(trimmed)) === trimmed;
+    const isEmail = trimmed.includes('@');
+ 
+    let targetUser;
+    if (isObjectId) {
+      targetUser = await User.findById(trimmed);
+    } else if (isEmail) {
+      targetUser = await User.findOne({ email: trimmed.toLowerCase() });
+    } else {
+      targetUser = await User.findOne({ username: { $regex: new RegExp(`^${trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } });
+    }
+ 
+    if (!targetUser) {
+      return res.status(404).json({ success: false, message: 'User not found' });
+    }
+ 
+    const accessToken = jwt.sign(
+      { userId: targetUser._id, roles: targetUser.roles },
+      process.env.JWT_SECRET,
+      { expiresIn: ACCESS_TOKEN_EXPIRY }
+    );
+ 
+    const refreshToken = jwt.sign(
+      { userId: targetUser._id, type: 'refresh', jti: randomUUID() },
+      process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
+      { expiresIn: REFRESH_TOKEN_EXPIRY }
+    );
+ 
+    await createSession(targetUser._id, refreshToken, req);
+ 
+    const cookieOpts = {
+      httpOnly: true,
+      secure: process.env.NODE_ENV === 'production',
+      sameSite: 'strict',
+      maxAge: ACCESS_TOKEN_EXPIRY_MS,
+      path: '/'
+    };
+    const domain = getCookieDomain(req);
+    if (domain) cookieOpts.domain = domain;
+    res.cookie('accessToken', accessToken, cookieOpts);
+    res.cookie('refreshToken', refreshToken, { ...cookieOpts, maxAge: REFRESH_TOKEN_EXPIRY_MS });
+ 
+    const userObj = targetUser.toObject ? targetUser.toObject() : targetUser;
+    delete userObj.password;
+ 
+    console.log(`POST: /admin/impersonate - Admin ${req.user.userId} logged in as ${targetUser.username} (${targetUser._id})`);
+ 
+    res.status(200).json({
+      success: true,
+      message: `Logged in as ${targetUser.username}`,
+      data: { user: userObj }
+    });
+  } catch (err) {
+    console.error('POST /admin/impersonate failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+/**
+ * Get recent analytics events for a user (admin/root only).
+ * GET /admin/user/:userId/analytics?limit=50
+ */
+router.get('/admin/user/:userId/analytics', verifyToken, requireAdmin, async (req, res) => {
+  try {
+    const { userId } = req.params;
+    const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100);
+ 
+    if (!mongoose.Types.ObjectId.isValid(userId)) {
+      return res.status(400).json({ success: false, message: 'Invalid user ID' });
+    }
+ 
+    const getModels = require('../services/getModelService');
+    const { AnalyticsEvent } = getModels(req, 'AnalyticsEvent');
+ 
+    const events = await AnalyticsEvent.find(
+      { user_id: new mongoose.Types.ObjectId(userId) },
+      { event_id: 1, event: 1, ts: 1, platform: 1, context: 1, properties: 1 }
+    )
+      .sort({ ts: -1 })
+      .limit(limit)
+      .lean();
+ 
+    res.json({
+      success: true,
+      data: events
+    });
+  } catch (err) {
+    console.error('GET /admin/user/:userId/analytics failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+/**
+ * GET /admin/platform-admins – list platform admins (GlobalUsers with platform_admin role)
+ */
+router.get('/admin/platform-admins', verifyToken, requireAdmin, async (req, res) => {
+  try {
+    const { PlatformRole, GlobalUser } = getGlobalModels(req, 'PlatformRole', 'GlobalUser');
+    const roles = await PlatformRole.find({ roles: 'platform_admin' }).lean();
+    const globalUserIds = roles.map(r => r.globalUserId);
+    const users = await GlobalUser.find({ _id: { $in: globalUserIds } }).select('email name picture createdAt').lean();
+    const byId = users.reduce((acc, u) => { acc[u._id.toString()] = u; return acc; }, {});
+    const list = roles.map(r => ({
+      globalUserId: r.globalUserId,
+      email: byId[r.globalUserId.toString()]?.email,
+      name: byId[r.globalUserId.toString()]?.name,
+      picture: byId[r.globalUserId.toString()]?.picture,
+    }));
+    res.json({ success: true, data: list });
+  } catch (err) {
+    console.error('GET /admin/platform-admins failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+/**
+ * POST /admin/platform-admins – add platform admin by email or globalUserId
+ * Body: { email?: string, globalUserId?: string }
+ */
+router.post('/admin/platform-admins', verifyToken, requireAdmin, async (req, res) => {
+  try {
+    const { email, globalUserId } = req.body;
+    const { PlatformRole, GlobalUser } = getGlobalModels(req, 'PlatformRole', 'GlobalUser');
+ 
+    let globalUser;
+    if (globalUserId) {
+      globalUser = await GlobalUser.findById(globalUserId);
+    } else if (email) {
+      globalUser = await GlobalUser.findOne({ email: String(email).trim().toLowerCase() });
+    }
+    if (!globalUser) {
+      return res.status(404).json({ success: false, message: 'Global user not found. Add by email or globalUserId.' });
+    }
+ 
+    let pr = await PlatformRole.findOne({ globalUserId: globalUser._id });
+    if (!pr) {
+      pr = new PlatformRole({ globalUserId: globalUser._id, roles: [] });
+    }
+    if (!pr.roles.includes('platform_admin')) {
+      pr.roles.push('platform_admin');
+      await pr.save();
+    }
+    console.log(`Platform admin added: ${globalUser.email} by ${req.user.userId || req.user.globalUserId}`);
+    res.json({ success: true, data: { globalUserId: globalUser._id, email: globalUser.email } });
+  } catch (err) {
+    console.error('POST /admin/platform-admins failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+/**
+ * POST /admin/migrate-classroom-building-refs
+ * One-shot (per tenant DB): create Building docs from distinct legacy Classroom.building strings,
+ * then replace those strings with ObjectId refs. Guarded by admin_migration_runs; optional body.force to re-run.
+ */
+router.post('/admin/migrate-classroom-building-refs', verifyToken, requireAdmin, async (req, res) => {
+  try {
+    const { runMigrateClassroomBuildingRefs } = require('../migrations/migrateClassroomBuildingRefs');
+    const force = Boolean(req.body?.force);
+    const data = await runMigrateClassroomBuildingRefs(req.db, { force });
+    console.log('POST /admin/migrate-classroom-building-refs completed:', data);
+    res.json({ success: true, data });
+  } catch (err) {
+    console.error('POST /admin/migrate-classroom-building-refs failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+/**
+ * POST /admin/migrate-org-owner-roles
+ * Ensure every org owner has an active OrgMember record with owner role.
+ * Also repairs existing owner memberships that drifted away from owner role.
+ */
+router.post('/admin/migrate-org-owner-roles', verifyToken, requireAdmin, async (req, res) => {
+  try {
+    const getModels = require('../services/getModelService');
+    const { Org, OrgMember } = getModels(req, 'Org', 'OrgMember');
+ 
+    const orgs = await Org.find({}, { _id: 1, owner: 1 }).lean();
+    let createdOwnerMemberships = 0;
+    let repairedOwnerMemberships = 0;
+ 
+    for (const org of orgs) {
+      if (!org?.owner) continue;
+      const ownerId = org.owner;
+      const ownerMembership = await OrgMember.findOne({ org_id: org._id, user_id: ownerId });
+ 
+      if (!ownerMembership) {
+        await OrgMember.create({
+          org_id: org._id,
+          user_id: ownerId,
+          role: 'owner',
+          roles: ['owner'],
+          status: 'active',
+          assignedBy: ownerId
+        });
+        createdOwnerMemberships += 1;
+        continue;
+      }
+ 
+      const rolesArray = Array.isArray(ownerMembership.roles) ? ownerMembership.roles : [];
+      const hasOwnerRoleField = ownerMembership.role === 'owner';
+      const hasOwnerInRolesArray = rolesArray.includes('owner');
+      const isActive = ownerMembership.status === 'active';
+      if (!hasOwnerRoleField || !hasOwnerInRolesArray || !isActive) {
+        const repairedRoles = [
+          'owner',
+          ...(rolesArray.filter((roleName) => roleName && roleName !== 'owner'))
+        ];
+        ownerMembership.role = 'owner';
+        ownerMembership.roles = repairedRoles;
+        ownerMembership.status = 'active';
+        await ownerMembership.save();
+        repairedOwnerMemberships += 1;
+      }
+    }
+ 
+    const data = {
+      orgsScanned: orgs.length,
+      createdOwnerMemberships,
+      repairedOwnerMemberships
+    };
+    console.log('POST /admin/migrate-org-owner-roles completed:', data);
+    res.json({ success: true, data });
+  } catch (err) {
+    console.error('POST /admin/migrate-org-owner-roles failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+router.get('/admin/tenant-config', verifyToken, requireAdmin, async (req, res) => {
+  if (!req.user.platformRoles?.includes('platform_admin')) {
+    return res.status(403).json({ success: false, message: 'Platform admin required.' });
+  }
+  try {
+    const { TenantConfig } = getGlobalModels(req, 'TenantConfig');
+    const doc = await TenantConfig.findOne({ configKey: 'default' }).lean();
+    const tenants = mergeTenantRows(DEFAULT_TENANTS, doc?.tenants || []);
+    res.json({
+      success: true,
+      data: {
+        tenants,
+        updatedAt: doc?.updatedAt || null,
+      },
+    });
+  } catch (err) {
+    console.error('GET /admin/tenant-config failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+router.put('/admin/tenant-config', verifyToken, requireAdmin, async (req, res) => {
+  if (!req.user.platformRoles?.includes('platform_admin')) {
+    return res.status(403).json({ success: false, message: 'Platform admin required.' });
+  }
+  try {
+    if (!Array.isArray(req.body?.tenants)) {
+      return res.status(400).json({ success: false, message: 'tenants array is required.' });
+    }
+    const incoming = normalizeTenantRows(req.body.tenants);
+    const incomingByKey = new Map(incoming.map((row) => [row.tenantKey, row]));
+    const { TenantConfig } = getGlobalModels(req, 'TenantConfig');
+    const existingDoc = await TenantConfig.findOne({ configKey: 'default' }).lean();
+    const storedRows = existingDoc?.tenants || [];
+    const storedDynamic = storedRows.filter(
+      (row) => !DEFAULT_TENANTS.some((base) => base.tenantKey === row.tenantKey)
+    );
+    const defaultOverrides = DEFAULT_TENANTS.map((row) => {
+      const update = incomingByKey.get(row.tenantKey);
+      if (!update) return null;
+      return normalizeTenantOverride({
+        tenantKey: row.tenantKey,
+        status: update.status || row.status,
+        statusMessage: update.statusMessage || '',
+      });
+    }).filter(Boolean);
+    const nextStored = [...storedDynamic, ...defaultOverrides];
+    const nextTenants = mergeTenantRows(DEFAULT_TENANTS, nextStored);
+    const activeCount = nextTenants.filter((tenant) => tenant.status === 'active').length;
+    if (activeCount < 1) {
+      return res.status(400).json({
+        success: false,
+        message: 'At least one tenant must remain active.',
+        code: 'AT_LEAST_ONE_ACTIVE_TENANT_REQUIRED',
+      });
+    }
+ 
+    const updatedBy = req.user.globalUserId || req.user.userId || null;
+    const doc = await TenantConfig.findOneAndUpdate(
+      { configKey: 'default' },
+      { $set: { tenants: nextStored, updatedBy } },
+      { new: true, upsert: true, setDefaultsOnInsert: true }
+    ).lean();
+ 
+    res.json({
+      success: true,
+      data: {
+        tenants: mergeTenantRows(DEFAULT_TENANTS, doc?.tenants || []),
+        updatedAt: doc?.updatedAt || null,
+      },
+    });
+  } catch (err) {
+    console.error('PUT /admin/tenant-config failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+/**
+ * DELETE /admin/platform-admins/:globalUserId – remove platform_admin role
+ */
+router.delete('/admin/platform-admins/:globalUserId', verifyToken, requireAdmin, async (req, res) => {
+  try {
+    const { globalUserId } = req.params;
+    const { PlatformRole } = getGlobalModels(req, 'PlatformRole');
+    const pr = await PlatformRole.findOne({ globalUserId });
+    if (!pr) {
+      return res.status(404).json({ success: false, message: 'Platform role not found' });
+    }
+    pr.roles = (pr.roles || []).filter(r => r !== 'platform_admin' && r !== 'root');
+    await pr.save();
+    console.log(`Platform admin removed: ${globalUserId} by ${req.user.userId || req.user.globalUserId}`);
+    res.json({ success: true });
+  } catch (err) {
+    console.error('DELETE /admin/platform-admins failed:', err);
+    res.status(500).json({ success: false, message: err.message });
+  }
+});
+ 
+module.exports = router;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/routes/authRoutes.js.html b/backend/coverage/lcov-report/routes/authRoutes.js.html new file mode 100644 index 00000000..31ad4395 --- /dev/null +++ b/backend/coverage/lcov-report/routes/authRoutes.js.html @@ -0,0 +1,4555 @@ + + + + + + Code coverage report for routes/authRoutes.js + + + + + + + + + +
+
+

All files / routes authRoutes.js

+
+ +
+ 15.12% + Statements + 98/648 +
+ + +
+ 6.19% + Branches + 21/339 +
+ + +
+ 9.09% + Functions + 4/44 +
+ + +
+ 15.32% + Lines + 97/633 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +1157 +1158 +1159 +1160 +1161 +1162 +1163 +1164 +1165 +1166 +1167 +1168 +1169 +1170 +1171 +1172 +1173 +1174 +1175 +1176 +1177 +1178 +1179 +1180 +1181 +1182 +1183 +1184 +1185 +1186 +1187 +1188 +1189 +1190 +1191 +1192 +1193 +1194 +1195 +1196 +1197 +1198 +1199 +1200 +1201 +1202 +1203 +1204 +1205 +1206 +1207 +1208 +1209 +1210 +1211 +1212 +1213 +1214 +1215 +1216 +1217 +1218 +1219 +1220 +1221 +1222 +1223 +1224 +1225 +1226 +1227 +1228 +1229 +1230 +1231 +1232 +1233 +1234 +1235 +1236 +1237 +1238 +1239 +1240 +1241 +1242 +1243 +1244 +1245 +1246 +1247 +1248 +1249 +1250 +1251 +1252 +1253 +1254 +1255 +1256 +1257 +1258 +1259 +1260 +1261 +1262 +1263 +1264 +1265 +1266 +1267 +1268 +1269 +1270 +1271 +1272 +1273 +1274 +1275 +1276 +1277 +1278 +1279 +1280 +1281 +1282 +1283 +1284 +1285 +1286 +1287 +1288 +1289 +1290 +1291 +1292 +1293 +1294 +1295 +1296 +1297 +1298 +1299 +1300 +1301 +1302 +1303 +1304 +1305 +1306 +1307 +1308 +1309 +1310 +1311 +1312 +1313 +1314 +1315 +1316 +1317 +1318 +1319 +1320 +1321 +1322 +1323 +1324 +1325 +1326 +1327 +1328 +1329 +1330 +1331 +1332 +1333 +1334 +1335 +1336 +1337 +1338 +1339 +1340 +1341 +1342 +1343 +1344 +1345 +1346 +1347 +1348 +1349 +1350 +1351 +1352 +1353 +1354 +1355 +1356 +1357 +1358 +1359 +1360 +1361 +1362 +1363 +1364 +1365 +1366 +1367 +1368 +1369 +1370 +1371 +1372 +1373 +1374 +1375 +1376 +1377 +1378 +1379 +1380 +1381 +1382 +1383 +1384 +1385 +1386 +1387 +1388 +1389 +1390 +1391 +1392 +1393 +1394 +1395 +1396 +1397 +1398 +1399 +1400 +1401 +1402 +1403 +1404 +1405 +1406 +1407 +1408 +1409 +1410 +1411 +1412 +1413 +1414 +1415 +1416 +1417 +1418 +1419 +1420 +1421 +1422 +1423 +1424 +1425 +1426 +1427 +1428 +1429 +1430 +1431 +1432 +1433 +1434 +1435 +1436 +1437 +1438 +1439 +1440 +1441 +1442 +1443 +1444 +1445 +1446 +1447 +1448 +1449 +1450 +1451 +1452 +1453 +1454 +1455 +1456 +1457 +1458 +1459 +1460 +1461 +1462 +1463 +1464 +1465 +1466 +1467 +1468 +1469 +1470 +1471 +1472 +1473 +1474 +1475 +1476 +1477 +1478 +1479 +1480 +1481 +1482 +1483 +1484 +1485 +1486 +1487 +1488 +1489 +1490 +14911x +1x +1x +1x +1x +1x +1x +  +1x +1x +  +1x +1x +1x +  +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +1x +1x +1x +1x +  +  +1x +  +  +1x +1x +  +1x +1x +1x +1x +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +3x +  +  +  +1x +  +4x +  +  +  +  +  +  +  +  +  +4x +4x +  +4x +4x +  +4x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +3x +1x +1x +  +  +  +  +  +2x +2x +  +2x +1x +  +  +1x +  +  +  +  +  +1x +  +  +1x +  +1x +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +1x +  +1x +1x +  +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x
const express = require('express');
+const bcrypt = require('bcrypt');
+const jwt = require('jsonwebtoken');
+const axios = require('axios');
+require('dotenv').config();
+const { sendDiscordMessage } = require('../services/discordWebookService');
+const { isProfane } = require('../services/profanityFilterService');
+ 
+const router = express.Router();
+const { verifyToken } = require('../middlewares/verifyToken.js');
+ 
+const { authenticateWithGoogle, authenticateWithApple, loginUser, registerUser, authenticateWithGoogleIdToken } = require('../services/userServices.js');
+const { sendUserRegisteredEvent } = require('../inngest/events.js');
+const getGlobalModels = require('../services/getGlobalModelService.js');
+ 
+function getModels(req, ...names) {
+    return require('../services/getModelService.js')(req, ...names);
+}
+ 
+async function bindAuthTenant(req) {
+    const { connectToDatabase } = require('../connectionsManager');
+    const { getTenantByKey } = require('../services/tenantConfigService');
+ 
+    let tenantKey = null;
+    if (req.school === 'www') {
+        tenantKey = req.body?.school ? String(req.body.school).trim().toLowerCase() : null;
+        if (!tenantKey) {
+            return {
+                status: 400,
+                body: {
+                    success: false,
+                    message: 'Please select your school or use your school’s login page.',
+                    code: 'SCHOOL_REQUIRED',
+                },
+            };
+        }
+    } else {
+        const override = req.headers['x-tenant'] || req.body?.tenantKey || req.body?.school;
+        tenantKey = override ? String(override).trim().toLowerCase() : req.school;
+    }
+ 
+    if (!tenantKey || tenantKey === 'www') {
+        return {
+            status: 400,
+            body: {
+                success: false,
+                message: 'Tenant is required for sign-in.',
+                code: 'TENANT_REQUIRED',
+            },
+        };
+    }
+ 
+    const tenant = await getTenantByKey(req, tenantKey);
+    if (!tenant) {
+        return {
+            status: 404,
+            body: {
+                success: false,
+                message: `Unknown tenant "${tenantKey}".`,
+                code: 'TENANT_NOT_FOUND',
+            },
+        };
+    }
+ 
+    const dbRouteKey = tenant.subdomain || tenant.tenantKey;
+    req.school = tenant.tenantKey;
+    req.db = await connectToDatabase(dbRouteKey);
+    return null;
+}
+const { getFriendRequests } = require('../utilities/friendUtils');
+const { createSession, validateSession, deleteSession, deleteAllUserSessions, getUserSessions, getUserSessionsForGlobalUser, deleteSessionById, deleteSessionByIdForGlobalUser, revokeAllOtherSessionsForGlobalUser } = require('../utilities/sessionUtils');
+const { getCookieDomain } = require('../utilities/cookieUtils');
+const authGlobalService = require('../services/authGlobalService');
+const {
+    isAdminLevelAccount,
+    getMfaStatus,
+    buildTokenMfaClaims,
+    createPendingMfaToken,
+    verifyPendingMfaToken,
+    getMfaPendingCookieOptions,
+    getPendingMfaTokenFromRequest,
+    createTotpEnrollment,
+    enableTotpEnrollment,
+    verifyTotpForLogin,
+    disableTotp,
+    getPasskeySummary,
+    generatePasskeyRegistration,
+    verifyPasskeyRegistration,
+    generatePasskeyAuthentication,
+    verifyPasskeyAuthentication,
+    removePasskey,
+} = require('../services/adminMfaService');
+ 
+const { getResend } = require('../services/resendClient');
+const { render } = require('@react-email/render')
+const React = require('react');
+const ForgotEmail = require('../emails/ForgotEmail').default;
+ 
+// Store verification codes temporarily (in production, use Redis or similar)
+const verificationCodes = new Map();
+ 
+ 
+const ACCESS_TOKEN_EXPIRY_MINUTES = 15;
+const REFRESH_TOKEN_EXPIRY_DAYS = 30;
+// Token configuration
+const ACCESS_TOKEN_EXPIRY = `${ACCESS_TOKEN_EXPIRY_MINUTES}m`;
+const REFRESH_TOKEN_EXPIRY = `${REFRESH_TOKEN_EXPIRY_DAYS}d`;  // 2 days
+const ACCESS_TOKEN_EXPIRY_MS = ACCESS_TOKEN_EXPIRY_MINUTES * 60 * 1000;
+const REFRESH_TOKEN_EXPIRY_MS = REFRESH_TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000; // 2 days in milliseconds
+const ADMIN_MFA_PENDING_COOKIE = 'adminMfaPending';
+ 
+// Check if request is from mobile client (token-based auth instead of cookies)
+const isMobileClient = (req) => req.headers['x-client'] === 'mobile';
+ 
+function setPendingMfaCookie(req, res, pendingToken) {
+    res.cookie(ADMIN_MFA_PENDING_COOKIE, pendingToken, getMfaPendingCookieOptions(req));
+}
+ 
+function clearPendingMfaCookie(req, res) {
+    const clearOpts = { ...getMfaPendingCookieOptions(req) };
+    delete clearOpts.maxAge;
+    res.clearCookie(ADMIN_MFA_PENDING_COOKIE, clearOpts);
+}
+ 
+async function getCurrentTenantAdminUser(req) {
+    if (!req.user || !req.user.userId) {
+        throw new Error('A tenant admin account is required for MFA management');
+    }
+    const { User } = getModels(req, 'User');
+    const user = await User.findById(req.user.userId);
+    if (!user) {
+        throw new Error('User not found');
+    }
+    if (!isAdminLevelAccount(user, req.user.platformRoles || [])) {
+        throw new Error('Admin access required');
+    }
+    return user;
+}
+ 
+async function completeLoginWithAdminMfa(req, res, globalUser, tenantUser, platformRoles, message) {
+    if (tenantUser?.accessSuspended) {
+        return {
+            status: 403,
+            body: {
+                success: false,
+                message: 'This account has been suspended.',
+                code: 'ACCOUNT_SUSPENDED',
+            },
+        };
+    }
+    const isAdmin = isAdminLevelAccount(tenantUser, platformRoles);
+    const mfaStatus = getMfaStatus(tenantUser);
+ 
+    if (isAdmin && mfaStatus.configured) {
+        const pendingToken = createPendingMfaToken({
+            globalUserId: globalUser._id.toString(),
+            tenantUserId: tenantUser?._id?.toString() || null,
+            school: req.school,
+            platformRoles: platformRoles || [],
+        });
+        setPendingMfaCookie(req, res, pendingToken);
+        const data = {
+            requiresMfa: true,
+            methods: mfaStatus.methods,
+            school: req.school,
+        };
+        if (isMobileClient(req)) data.mfaToken = pendingToken;
+        return {
+            status: 200,
+            body: {
+                success: true,
+                message: 'Additional verification required for admin account',
+                data,
+            },
+        };
+    }
+ 
+    const tokenMfaClaims = buildTokenMfaClaims({
+        isAdminLevel: isAdmin,
+        mfaConfigured: mfaStatus.configured,
+        mfaVerified: !isAdmin,
+    });
+    const tokens = await authGlobalService.issueTokens(req, res, globalUser, tenantUser, platformRoles, tokenMfaClaims);
+    clearPendingMfaCookie(req, res);
+ 
+    const responseData = {
+        user: tenantUser,
+        adminMfaSetupRequired: isAdmin && !mfaStatus.configured,
+    };
+    if (isMobileClient(req)) {
+        responseData.accessToken = tokens.accessToken;
+        responseData.refreshToken = tokens.refreshToken;
+    }
+ 
+    return {
+        status: 200,
+        body: {
+            success: true,
+            message,
+            data: responseData,
+        },
+    };
+}
+ 
+function validateUsername(username) { //keeping logic external, for easier testing
+    // Define the regex pattern
+    const regex = /^[a-zA-Z0-9]{3,20}$/;
+  
+    // Test the username against the regex pattern
+    return regex.test(username);
+}
+ 
+// Registration endpoint
+router.post('/register', async (req, res) => {
+    // When on www, require school in body so we use the correct tenant DB (landing is www-only; app is tenant-only)
+    Iif (req.school === 'www') {
+        const school = (req.body && req.body.school) ? String(req.body.school).trim().toLowerCase() : null;
+        if (!school) {
+            return res.status(400).json({ success: false, message: 'Please select your school or use your school’s login page (e.g. rpi.meridian.study).', code: 'SCHOOL_REQUIRED' });
+        }
+        req.school = school;
+        req.db = await require('../connectionsManager').connectToDatabase(school);
+    }
+ 
+    // Extract user details from request body
+    const { username, email, password, invite_token: bodyInviteToken } = req.body;
+    const inviteToken = bodyInviteToken || req.cookies?.org_invite_token;
+ 
+    try {
+        const { User, OrgInvite, OrgMember } = getModels(req, 'User', 'OrgInvite', 'OrgMember');
+ 
+        if (inviteToken) {
+            const invite = await OrgInvite.findOne({ token: inviteToken, status: 'pending' });
+            Eif (invite && new Date() <= invite.expires_at) {
+                const inviteEmail = invite.email?.toLowerCase();
+                const regEmail = String(email).trim().toLowerCase();
+                Eif (inviteEmail !== regEmail) {
+                    return res.status(400).json({
+                        success: false,
+                        message: 'Please register with the same email address the invitation was sent to.',
+                        code: 'INVITE_EMAIL_MISMATCH'
+                    });
+                }
+            }
+        }
+ 
+        Iif (!validateUsername(username)) {
+            console.log(`POST: /register registration of ${username} failed`);
+            return res.status(405).json({
+                success: false,
+                message: 'Username has illegal chars'
+            });
+        }
+        if(isProfane(username)){
+            console.log(`POST: /register registration of ${username} failed`);
+            return res.status(405).json({
+                success: false,
+                message: 'Username does not abide by community standards'
+            });
+        }
+ 
+        const existingUsername = await User.findOne({ username });
+        const existingEmail = await User.findOne({ email });
+ 
+        if (existingUsername || existingEmail) {
+            const message = existingUsername && existingEmail ? 'Email and username are taken'
+                : existingEmail ? 'Email is taken'
+                    : 'Username is taken';
+            return res.status(400).json({ success: false, message });
+        }
+ 
+        // condition ? if true : if false;
+ 
+        // Create and save the new user
+        const user = new User({
+            username: username, email: email, password: password,
+        });
+        await user.save();
+ 
+        const { runAutoClaimAsync } = require('../services/autoClaimEventRegistrationsService');
+        runAutoClaimAsync(req, user._id.toString(), user.email);
+ 
+        // Process org invite if present
+        Iif (inviteToken) {
+            const invite = await OrgInvite.findOne({ token: inviteToken, status: 'pending' });
+            if (invite && new Date() <= invite.expires_at) {
+                const inviteEmail = invite.email?.toLowerCase();
+                const userEmail = user.email?.toLowerCase();
+                if (inviteEmail === userEmail) {
+                    const existingMember = await OrgMember.findOne({ org_id: invite.org_id, user_id: user._id });
+                    if (!existingMember) {
+                        const member = new OrgMember({
+                            org_id: invite.org_id,
+                            user_id: user._id,
+                            role: invite.role,
+                            status: 'active',
+                            assignedBy: invite.invited_by
+                        });
+                        await member.save();
+                        if (!user.clubAssociations) user.clubAssociations = [];
+                        if (!user.clubAssociations.some(c => c.toString() === invite.org_id)) {
+                            user.clubAssociations.push(invite.org_id);
+                            await user.save();
+                        }
+                        invite.status = 'accepted';
+                        invite.user_id = user._id;
+                        await invite.save();
+                        const { checkAndAutoApproveOrg } = require('../services/orgApprovalService');
+                        await checkAndAutoApproveOrg(req, invite.org_id);
+                    }
+                }
+                res.clearCookie('org_invite_token', { path: '/' });
+            }
+        }
+ 
+        const globalUser = await authGlobalService.getOrCreateGlobalUser(req, user);
+        await authGlobalService.getOrCreateTenantMembership(req, globalUser._id, user);
+        const platformRoles = await authGlobalService.getPlatformRolesForGlobalUser(req, globalUser._id);
+        const tokenMfaClaims = buildTokenMfaClaims({
+            isAdminLevel: false,
+            mfaConfigured: false,
+            mfaVerified: true,
+        });
+        const tokens = await authGlobalService.issueTokens(req, res, globalUser, user, platformRoles, tokenMfaClaims);
+ 
+        console.log(`POST: /register new user ${username}`);
+        sendDiscordMessage(`New user registered`, `user ${username} registered`, "newUser");
+ 
+        const responseData = { user: user };
+        Iif (isMobileClient(req)) {
+            responseData.accessToken = tokens.accessToken;
+            responseData.refreshToken = tokens.refreshToken;
+        }
+        res.status(201).json({
+            success: true,
+            message: 'User registered successfully',
+            data: responseData
+        });
+    } catch (error) {
+        console.log(`POST: /register registration of ${username} failed`)
+        console.log(error)
+        res.status(500).json({
+            success: false,
+            message: 'Error registering new user'
+        });
+    }
+});
+ 
+// console.log(`POST: /register registration of ${username} failed`)
+// res.status(405).json({
+//     success: false,
+//     message: 'Username has illegal chars'
+// });
+ 
+// Login endpoint
+router.post('/login', async (req, res) => {
+    if (req.school === 'www') {
+        const school = (req.body && req.body.school) ? String(req.body.school).trim().toLowerCase() : null;
+        if (!school) {
+            return res.status(400).json({ success: false, message: 'Please select your school or use your school’s login page (e.g. rpi.meridian.study).', code: 'SCHOOL_REQUIRED' });
+        }
+        req.school = school;
+        req.db = await require('../connectionsManager').connectToDatabase(school);
+    }
+ 
+    const { email, password } = req.body;
+ 
+    try {
+        //check if it is an email or username, case insensitive for email
+        const { user } = await loginUser({ email, password, req });
+ 
+        const globalUser = await authGlobalService.getOrCreateGlobalUser(req, user);
+        await authGlobalService.getOrCreateTenantMembership(req, globalUser._id, user);
+        const platformRoles = await authGlobalService.getPlatformRolesForGlobalUser(req, globalUser._id);
+        const loginResult = await completeLoginWithAdminMfa(req, res, globalUser, user, platformRoles, 'Logged in successfully');
+        console.log(`POST: /login user ${user.username} logged in`);
+        res.status(loginResult.status).json(loginResult.body);
+    } catch (error) {
+        console.log(`POST: /login login user failed`)
+        res.status(500).json({
+            success: false,
+            message: error.message
+        });
+    }
+});
+ 
+// Refresh token endpoint
+router.post('/refresh-token', async (req, res) => {
+    // Accept refresh token from cookie (web) or header (mobile)
+    let refreshToken = req.cookies.refreshToken;
+    if (!refreshToken && req.headers['x-client'] === 'mobile') {
+        const authHeader = req.headers['authorization'];
+        if (authHeader && authHeader.startsWith('Bearer ')) {
+            refreshToken = authHeader.substring(7);
+        } else if (req.headers['x-refresh-token']) {
+            refreshToken = req.headers['x-refresh-token'];
+        }
+    }
+    
+    if (!refreshToken) {
+        console.log('POST: /refresh-token 403 no refresh token provided');
+        return res.status(403).json({
+            success: false,
+            message: 'No refresh token provided'
+        });
+    }
+ 
+    try {
+        // Validate session using session utilities (supports both global and legacy tokens)
+        const validation = await validateSession(refreshToken, req);
+ 
+        if (!validation.valid) {
+            console.log('POST: /refresh-token 401', validation.error);
+            return res.status(401).json({
+                success: false,
+                message: validation.error || 'Invalid refresh token'
+            });
+        }
+ 
+        const { user, globalUser } = validation;
+        const isMobile = isMobileClient(req);
+ 
+        if (user?.accessSuspended) {
+            return res.status(403).json({
+                success: false,
+                message: 'This account has been suspended.',
+                code: 'ACCOUNT_SUSPENDED',
+            });
+        }
+ 
+        if (globalUser) {
+            const platformRoles = await authGlobalService.getPlatformRolesForGlobalUser(req, globalUser._id);
+            const tokens = await authGlobalService.issueTokens(
+                req,
+                res,
+                globalUser,
+                user,
+                platformRoles,
+                {
+                    mfaConfigured: Boolean(validation.decoded?.mfaConfigured),
+                    mfaVerified: Boolean(validation.decoded?.mfaVerified),
+                },
+            );
+            const response = { success: true, message: 'Token refreshed successfully' };
+            if (isMobile) response.accessToken = tokens.accessToken;
+            return res.json(response);
+        }
+ 
+        const newAccessToken = jwt.sign(
+            { userId: user._id, roles: user.roles },
+            process.env.JWT_SECRET,
+            { expiresIn: ACCESS_TOKEN_EXPIRY }
+        );
+        const cookieOptions = {
+            httpOnly: true,
+            secure: process.env.NODE_ENV === 'production',
+            sameSite: 'strict',
+            maxAge: ACCESS_TOKEN_EXPIRY_MS,
+            path: '/'
+        };
+        const domain = getCookieDomain(req);
+        if (domain) cookieOptions.domain = domain;
+        if (!isMobile) {
+            res.cookie('accessToken', newAccessToken, cookieOptions);
+        }
+ 
+        console.log(`POST: /refresh-token user ${user.username}`);
+        const response = { success: true, message: 'Token refreshed successfully' };
+        if (isMobile) response.accessToken = newAccessToken;
+        res.json(response);
+    } catch (error) {
+        console.log('POST: /refresh-token 401 refresh token failed', error.message);
+        
+        // Check if it's a token expiration error
+        if (error.name === 'TokenExpiredError') {
+            console.log('⏰ Refresh token expired');
+            return res.status(401).json({
+                success: false,
+                message: 'Refresh token expired',
+                code: 'REFRESH_TOKEN_EXPIRED'
+            });
+        }
+        
+        // Check if it's an invalid token error
+        if (error.name === 'JsonWebTokenError') {
+            console.log('POST: /refresh-token 401 invalid refresh token');
+            return res.status(401).json({
+                success: false,
+                message: 'Invalid refresh token',
+                code: 'INVALID_REFRESH_TOKEN'
+            });
+        }
+        
+        console.log('POST: /refresh-token 401 refresh token failed', error.message);
+        res.status(401).json({
+            success: false,
+            message: 'Invalid refresh token',
+            code: 'REFRESH_FAILED'
+        });
+    }
+});
+ 
+// Logout endpoint
+router.post('/logout', async (req, res) => {
+    let refreshToken = req.cookies.refreshToken;
+    if (!refreshToken && req.headers['x-client'] === 'mobile') {
+        const authHeader = req.headers['authorization'];
+        if (authHeader && authHeader.startsWith('Bearer ')) {
+            refreshToken = authHeader.substring(7);
+        } else if (req.headers['x-refresh-token']) {
+            refreshToken = req.headers['x-refresh-token'];
+        }
+    }
+    
+    if (refreshToken) {
+        try {
+            // Delete the specific session instead of clearing user's refreshToken
+            await deleteSession(refreshToken, req);
+        } catch (error) {
+            console.log('Error deleting session:', error);
+        }
+    }
+ 
+    // Clear both cookies (no-op for mobile, but harmless)
+    const clearOpts = {
+        httpOnly: true,
+        secure: process.env.NODE_ENV === 'production',
+        sameSite: 'strict',
+        path: '/'
+    };
+    const domain = getCookieDomain(req);
+    if (domain) clearOpts.domain = domain;
+    res.clearCookie('accessToken', clearOpts);
+    res.clearCookie('refreshToken', clearOpts);
+    res.clearCookie(ADMIN_MFA_PENDING_COOKIE, clearOpts);
+ 
+    console.log(`POST: /logout user logged out`);
+    res.json({ success: true, message: 'Logged out successfully' });
+});
+ 
+router.get('/validate-token', verifyToken, async (req, res) => {
+    try {
+        // On www we only return communities (no tenant DB); frontend uses this to redirect to tenant.
+        if (req.school === 'www') {
+            if (!req.user || !req.user.globalUserId) {
+                return res.json({ success: true, message: 'Token is valid', data: { user: null, communities: [], platformRoles: req.user?.platformRoles || [] } });
+            }
+            const { TenantMembership } = getGlobalModels(req, 'TenantMembership');
+            const memberships = await TenantMembership.find({ globalUserId: req.user.globalUserId, status: 'active' }).lean();
+            const communities = memberships.map(m => m.tenantKey);
+            let platformRoles = req.user.platformRoles || [];
+            if (!platformRoles.length) {
+              const { PlatformRole } = getGlobalModels(req, 'PlatformRole');
+              const pr = await PlatformRole.findOne({ globalUserId: req.user.globalUserId }).lean();
+              platformRoles = pr?.roles || [];
+            }
+            return res.json({
+              success: true,
+              message: 'Token is valid',
+              data: { user: null, communities, platformRoles },
+            });
+        }
+ 
+        const { User, Friendship } = getModels(req, 'User', 'Friendship');
+        const orgInviteService = require('../services/orgInviteService');
+ 
+        const user = await User.findById(req.user.userId)
+            .select('-password -refreshToken') // Add fields you want to exclude
+            .lean()
+            .populate('clubAssociations'); 
+            
+        if (!user) {
+            if (req.user.globalUserId) {
+                const { TenantMembership } = getGlobalModels(req, 'TenantMembership');
+                const memberships = await TenantMembership.find({ globalUserId: req.user.globalUserId, status: 'active' }).lean();
+                const communities = memberships.map(m => m.tenantKey);
+                return res.json({
+                    success: true,
+                    message: 'Token is valid',
+                    data: { user: null, communities }
+                });
+            }
+            console.log(`GET: /validate-token token is invalid`);
+            return res.status(404).json({ success: false, message: 'User not found' });
+        }
+ 
+        // Fetch friend requests (both sent and received) using utility
+        const friendRequests = await getFriendRequests(Friendship, req.user.userId, {
+            receivedFields: 'username name picture _id',
+            sentFields: 'username name picture _id',
+            lean: true
+        });
+ 
+        // Fetch pending org invites for this user
+        const pendingOrgInvites = await orgInviteService.getPendingForUser(req);
+ 
+        // Domains where this user is an active member of a stakeholder role (domain dashboard entry points)
+        try {
+            const { StakeholderRole, Domain } = getModels(req, 'StakeholderRole', 'Domain');
+            const roles = await StakeholderRole.find({
+                members: {
+                    $elemMatch: {
+                        userId: user._id,
+                        isActive: { $ne: false }
+                    }
+                },
+                isActive: true
+            })
+                .select('domainId')
+                .lean();
+ 
+            const rawDomainIds = [...new Set((roles || []).map((r) => r.domainId).filter(Boolean))];
+            const domainDocs =
+                rawDomainIds.length > 0
+                    ? await Domain.find({ _id: { $in: rawDomainIds }, isActive: true }).select('name').lean()
+                    : [];
+            const nameById = new Map(domainDocs.map((d) => [String(d._id), d.name]));
+ 
+            user.stakeholderDomainDashboards = rawDomainIds.map((id) => ({
+                domainId: String(id),
+                domainName: nameById.get(String(id)) || null
+            }));
+        } catch (e) {
+            console.warn('validate-token: stakeholder domain dashboards skipped', e?.message || e);
+            user.stakeholderDomainDashboards = [];
+        }
+ 
+        console.log(`GET: /validate-token token is valid for user ${user.username}`)
+        const data = {
+            user: user,
+            friendRequests: friendRequests,
+            pendingOrgInvites: pendingOrgInvites,
+            platformRoles: req.user.platformRoles || [],
+        };
+        if (req.user.globalUserId) {
+            const { TenantMembership, PlatformRole } = getGlobalModels(req, 'TenantMembership', 'PlatformRole');
+            const memberships = await TenantMembership.find({ globalUserId: req.user.globalUserId, status: 'active' }).lean();
+            data.communities = memberships.map(m => m.tenantKey);
+            if (!data.platformRoles.length) {
+              const pr = await PlatformRole.findOne({ globalUserId: req.user.globalUserId }).lean();
+              data.platformRoles = pr?.roles || [];
+            }
+        }
+        res.json({
+            success: true,
+            message: 'Token is valid',
+            data
+        });
+    } catch (error) {
+        console.log(`GET: /validate-token token is invalid`, error)
+        res.status(500).json({
+            success: false,
+            message: 'Error fetching user details',
+            error: error.message
+        });
+    }
+});
+ 
+/**
+ * POST /join-tenant – create tenant user + membership for current school (global user with no local account).
+ * Requires global identity (logged in with new JWT shape). Body empty; tenant from req.school.
+ */
+router.post('/join-tenant', verifyToken, async (req, res) => {
+    try {
+        if (!req.user.globalUserId) {
+            return res.status(400).json({
+                success: false,
+                message: 'Global identity required. Log in again to get a full account.'
+            });
+        }
+        const { TenantMembership, GlobalUser } = getGlobalModels(req, 'TenantMembership', 'GlobalUser');
+        const existing = await TenantMembership.findOne({ globalUserId: req.user.globalUserId, tenantKey: req.school, status: 'active' });
+        if (existing) {
+            const { User } = getModels(req, 'User');
+            const user = await User.findById(existing.tenantUserId).lean();
+            return res.json({
+                success: true,
+                message: 'Already a member',
+                data: { user, alreadyMember: true }
+            });
+        }
+        const globalUser = await GlobalUser.findById(req.user.globalUserId);
+        if (!globalUser) {
+            return res.status(400).json({ success: false, message: 'Global user not found.' });
+        }
+        const { User } = getModels(req, 'User');
+        const email = globalUser.email.toLowerCase();
+        let tenantUser = await User.findOne({ email });
+        if (!tenantUser) {
+            const base = email.split('@')[0].replace(/\W/g, '');
+            let username = base + '_' + req.school;
+            let exists = await User.findOne({ username });
+            let suffix = 0;
+            while (exists) {
+                username = base + '_' + req.school + '_' + (suffix++);
+                exists = await User.findOne({ username });
+            }
+            tenantUser = new User({
+                email,
+                name: globalUser.name,
+                picture: globalUser.picture,
+                googleId: globalUser.googleId,
+                appleId: globalUser.appleId,
+                samlId: globalUser.samlId,
+                samlProvider: globalUser.samlProvider,
+                username,
+                roles: ['user'],
+                clubAssociations: []
+            });
+            await tenantUser.save();
+        }
+        const membership = new TenantMembership({
+            globalUserId: globalUser._id,
+            tenantKey: req.school,
+            tenantUserId: tenantUser._id,
+            status: 'active'
+        });
+        await membership.save();
+        const platformRoles = await authGlobalService.getPlatformRolesForGlobalUser(req, globalUser._id);
+        const mfaStatus = getMfaStatus(tenantUser);
+        const tokenMfaClaims = buildTokenMfaClaims({
+            isAdminLevel: isAdminLevelAccount(tenantUser, platformRoles),
+            mfaConfigured: mfaStatus.configured,
+            mfaVerified: false,
+        });
+        await authGlobalService.issueTokens(req, res, globalUser, tenantUser, platformRoles, tokenMfaClaims);
+        const userObj = tenantUser.toObject ? tenantUser.toObject() : tenantUser;
+        res.json({
+            success: true,
+            message: 'Joined tenant',
+            data: { user: userObj }
+        });
+    } catch (error) {
+        console.error('POST /join-tenant failed', error);
+        res.status(500).json({ success: false, message: error.message });
+    }
+});
+ 
+router.post('/verify-email', async (req, res) => {
+    const { email } = req.body;
+    const apiKey = process.env.HUNTER_API; // Replace with your actual API key
+    
+    if(process.env.NODE_ENV === 'development'){
+        return res.status(200).json({success:true});
+    }
+ 
+    try {
+      const response = await axios(`https://api.hunter.io/v2/email-verifier?email=${email}&api_key=${apiKey}`);
+      res.json(response.data);
+    } catch (error) {
+      res.status(500).json({ error: 'Error verifying email' });
+    }
+  });
+ 
+router.post('/google-login', async (req, res) => {
+    const tenantError = await bindAuthTenant(req);
+    if (tenantError) {
+        return res.status(tenantError.status).json(tenantError.body);
+    }
+ 
+    const { code, codeVerifier, isRegister, url, idToken } = req.body;
+ 
+    // Handle two different flows:
+    // 1. ID Token flow (native SDKs) - no PKCE needed
+    // 2. Authorization code flow (expo-auth-session) - requires PKCE
+    
+    if (!code && !idToken) {
+        return res.status(400).json({
+            success: false,
+            message: 'No authorization code or ID token provided'
+        });
+    }
+ 
+    try {
+        let user;
+        
+        // Use ID token flow if idToken is provided (native SDKs)
+        if (idToken) {
+            console.log('Using Google ID token authentication flow');
+            const result = await authenticateWithGoogleIdToken(idToken, url, req);
+            user = result.user;
+        } else {
+            // Use authorization code flow (expo-auth-session with PKCE)
+            console.log('Using Google authorization code authentication flow');
+            const result = await authenticateWithGoogle(code, isRegister, url, req, codeVerifier);
+            user = result.user;
+        }
+        
+        const globalUser = await authGlobalService.getOrCreateGlobalUser(req, user);
+        await authGlobalService.getOrCreateTenantMembership(req, globalUser._id, user);
+        const platformRoles = await authGlobalService.getPlatformRolesForGlobalUser(req, globalUser._id);
+        const loginResult = await completeLoginWithAdminMfa(req, res, globalUser, user, platformRoles, 'Google login successful');
+        res.status(loginResult.status).json(loginResult.body);
+ 
+    } catch (error) {
+        if (error.message === 'Email already exists') {
+            return res.status(409).json({
+                success: false,
+                message: 'Email already exists'
+            });
+        }
+        console.log('Google login failed:', error);
+        res.status(500).json({
+            success: false,
+            message: `Google login failed, error: ${error.message}`
+        });
+    }
+});
+ 
+router.post('/apple-login', async (req, res) => {
+    const tenantError = await bindAuthTenant(req);
+    if (tenantError) {
+        return res.status(tenantError.status).json(tenantError.body);
+    }
+ 
+    const { idToken, user } = req.body;
+ 
+    if (!idToken) {
+        return res.status(400).json({
+            success: false,
+            message: 'No ID token provided'
+        });
+    }
+ 
+    try {
+        const { user: authenticatedUser } = await authenticateWithApple(idToken, user, req);
+ 
+        const globalUser = await authGlobalService.getOrCreateGlobalUser(req, authenticatedUser);
+        await authGlobalService.getOrCreateTenantMembership(req, globalUser._id, authenticatedUser);
+        const platformRoles = await authGlobalService.getPlatformRolesForGlobalUser(req, globalUser._id);
+        const loginResult = await completeLoginWithAdminMfa(req, res, globalUser, authenticatedUser, platformRoles, 'Apple login successful');
+        res.status(loginResult.status).json(loginResult.body);
+ 
+    } catch (error) {
+        if (error.message === 'Email already exists') {
+            return res.status(409).json({
+                success: false,
+                message: 'Email already exists'
+            });
+        }
+        console.log('Apple login failed:', error);
+        res.status(500).json({
+            success: false,
+            message: `Apple login failed, error: ${error.message}`
+        });
+    }
+});
+ 
+// Apple Sign In callback endpoint (handles POST from Apple)
+router.post('/auth/apple/callback', async (req, res) => {
+    const idToken = req.body.id_token || req.body.idToken;
+    const user = req.body.user; // JSON string with user info
+    const state = req.body.state; // Contains redirect destination
+    
+    if (!idToken) {
+        const frontendUrl = process.env.NODE_ENV === 'production' 
+            ? 'https://meridian.study'
+            : 'http://localhost:3000';
+        return res.redirect(`${frontendUrl}/login?error=no_token`);
+    }
+ 
+    try {
+        // Parse user info if provided
+        let userInfo = null;
+        if (user) {
+            try {
+                userInfo = typeof user === 'string' ? JSON.parse(decodeURIComponent(user)) : user;
+            } catch (e) {
+                console.error('Failed to parse user info:', e);
+            }
+        }
+ 
+        const { user: authenticatedUser } = await authenticateWithApple(idToken, userInfo, req);
+ 
+        const globalUser = await authGlobalService.getOrCreateGlobalUser(req, authenticatedUser);
+        await authGlobalService.getOrCreateTenantMembership(req, globalUser._id, authenticatedUser);
+        const platformRoles = await authGlobalService.getPlatformRolesForGlobalUser(req, globalUser._id);
+        const loginResult = await completeLoginWithAdminMfa(req, res, globalUser, authenticatedUser, platformRoles, 'Apple login successful');
+ 
+        // Determine redirect destination
+        const frontendUrl = process.env.NODE_ENV === 'production' 
+            ? 'https://meridian.study'
+            : 'http://localhost:3000';
+        
+        let redirectTo = '/events-dashboard';
+        
+        // Parse state if provided
+        if (state) {
+            try {
+                const stateData = typeof state === 'string' ? JSON.parse(decodeURIComponent(state)) : state;
+                if (stateData && stateData.redirect) {
+                    redirectTo = stateData.redirect;
+                }
+            } catch (e) {
+                // If state is just a path, use it directly
+                if (typeof state === 'string' && state.startsWith('/')) {
+                    redirectTo = state;
+                }
+            }
+        }
+        
+        // Redirect admin users to admin dashboard
+        if (authenticatedUser.roles && authenticatedUser.roles.includes('admin')) {
+            redirectTo = '/admin';
+        }
+ 
+        if (loginResult.body?.data?.requiresMfa) {
+            redirectTo = '/login?mfa=required';
+        }
+ 
+        // Redirect to frontend
+        res.redirect(`${frontendUrl}${redirectTo}`);
+ 
+    } catch (error) {
+        console.log('Apple callback failed:', error);
+        const frontendUrl = process.env.NODE_ENV === 'production' 
+            ? 'https://meridian.study'
+            : 'http://localhost:3000';
+        
+        let errorMessage = 'Apple authentication failed';
+        if (error.message === 'Email already exists') {
+            errorMessage = 'email_exists';
+        }
+        
+        res.redirect(`${frontendUrl}/login?error=${encodeURIComponent(errorMessage)}`);
+    }
+});
+ 
+// Forgot password endpoint
+router.post('/forgot-password', async (req, res) => {
+    const { email } = req.body;
+ 
+    try {
+        const { User } = getModels(req, 'User');
+        
+        // Find user by email
+        const user = await User.findOne({ email });
+        
+        if (!user) {
+            return res.status(404).json({
+                success: false,
+                message: 'No account found with this email address'
+            });
+        }
+ 
+        // Generate a 6-digit verification code
+        const verificationCode = Math.floor(100000 + Math.random() * 900000).toString();
+        
+        // Store the code with the user ID and an expiration time (30 minutes)
+        verificationCodes.set(email, {
+            code: verificationCode,
+            userId: user._id,
+            expiresAt: Date.now() + 30 * 60 * 1000 // 30 minutes
+        });
+ 
+        // Send email with verification code
+        const emailHTML = await render(React.createElement(ForgotEmail, { 
+            name: user.username, 
+            code: verificationCode 
+        }));
+ 
+        const resend = getResend();
+        if (!resend) {
+            return res.status(503).json({ success: false, message: 'Email service not configured' });
+        }
+        const { data, error } = await resend.emails.send({
+            from: "Meridian Support <support@meridian.study>",
+            to: [email],
+            subject: "Password Reset Code",
+            html: emailHTML,
+        });
+ 
+        if (error) {
+            console.log('POST: /forgot-password email sending error', error);
+            return res.status(500).json({ 
+                success: false, 
+                message: 'Error sending reset email', 
+                error: error.message 
+            });
+        }
+ 
+        console.log(`POST: /forgot-password verification code sent to ${email}`);
+        res.status(200).json({ 
+            success: true, 
+            message: 'Password reset code sent successfully' 
+        });
+    } catch (error) {
+        console.log(`POST: /forgot-password failed`, error);
+        res.status(500).json({
+            success: false,
+            message: 'Error processing password reset request',
+            error: error.message
+        });
+    }
+});
+ 
+// Verify code endpoint
+router.post('/verify-code', async (req, res) => {
+    const { email, code } = req.body;
+ 
+    try {
+        // Check if the code exists and is valid
+        const storedData = verificationCodes.get(email);
+        
+        if (!storedData) {
+            return res.status(400).json({
+                success: false,
+                message: 'No verification code found for this email'
+            });
+        }
+        
+        if (storedData.code !== code) {
+            return res.status(400).json({
+                success: false,
+                message: 'Invalid verification code'
+            });
+        }
+        
+        if (Date.now() > storedData.expiresAt) {
+            verificationCodes.delete(email);
+            return res.status(400).json({
+                success: false,
+                message: 'Verification code has expired. Please request a new one.'
+            });
+        }
+ 
+        console.log(`POST: /verify-code code verified for ${email}`);
+        res.status(200).json({
+            success: true,
+            message: 'Verification code is valid'
+        });
+    } catch (error) {
+        console.log(`POST: /verify-code failed`, error);
+        res.status(500).json({
+            success: false,
+            message: 'Error verifying code',
+            error: error.message
+        });
+    }
+});
+ 
+// Reset password endpoint
+router.post('/reset-password', async (req, res) => {
+    const { email, code, newPassword } = req.body;
+ 
+    try {
+        // Check if the code exists and is valid
+        const storedData = verificationCodes.get(email);
+        
+        if (!storedData) {
+            return res.status(400).json({
+                success: false,
+                message: 'No verification code found for this email'
+            });
+        }
+        
+        if (storedData.code !== code) {
+            return res.status(400).json({
+                success: false,
+                message: 'Invalid verification code'
+            });
+        }
+        
+        if (Date.now() > storedData.expiresAt) {
+            verificationCodes.delete(email);
+            return res.status(400).json({
+                success: false,
+                message: 'Verification code has expired. Please request a new one.'
+            });
+        }
+ 
+        const { User } = getModels(req, 'User');
+        
+        // Find user by ID
+        const user = await User.findById(storedData.userId);
+        
+        if (!user) {
+            return res.status(404).json({
+                success: false,
+                message: 'User not found'
+            });
+        }
+ 
+        // Update password
+        user.password = newPassword;
+        await user.save();
+ 
+        // Remove the used code
+        verificationCodes.delete(email);
+ 
+        console.log(`POST: /reset-password password reset for user ${user.username}`);
+        res.status(200).json({
+            success: true,
+            message: 'Password reset successfully'
+        });
+    } catch (error) {
+        console.log(`POST: /reset-password failed`, error);
+        res.status(500).json({
+            success: false,
+            message: 'Error resetting password',
+            error: error.message
+        });
+    }
+});
+ 
+async function resolvePendingMfaContext(req) {
+    const pendingMfaToken = getPendingMfaTokenFromRequest(req);
+    if (!pendingMfaToken) {
+        throw new Error('No pending MFA challenge found');
+    }
+    const payload = verifyPendingMfaToken(pendingMfaToken);
+    if (!payload || payload.school !== req.school) {
+        throw new Error('Invalid MFA challenge for this tenant');
+    }
+    const { User } = getModels(req, 'User');
+    const { GlobalUser } = getGlobalModels(req, 'GlobalUser');
+    const tenantUser = await User.findById(payload.tenantUserId);
+    if (!tenantUser) {
+        throw new Error('Admin account no longer exists');
+    }
+    const globalUser = await GlobalUser.findById(payload.globalUserId);
+    if (!globalUser) {
+        throw new Error('Global identity no longer exists');
+    }
+    const platformRoles = Array.isArray(payload.platformRoles) ? payload.platformRoles : [];
+    if (!isAdminLevelAccount(tenantUser, platformRoles)) {
+        throw new Error('MFA challenge is not valid for this account');
+    }
+    return {
+        pendingMfaToken,
+        platformRoles,
+        tenantUser,
+        globalUser,
+    };
+}
+ 
+router.get('/mfa/admin/status', verifyToken, async (req, res) => {
+    try {
+        const adminUser = await getCurrentTenantAdminUser(req);
+        const status = getMfaStatus(adminUser);
+        res.json({
+            success: true,
+            data: {
+                ...status,
+                passkeys: getPasskeySummary(adminUser),
+            },
+        });
+    } catch (error) {
+        const code = error.message === 'Admin access required' ? 403 : 400;
+        res.status(code).json({ success: false, message: error.message });
+    }
+});
+ 
+router.post('/mfa/admin/totp/setup', verifyToken, async (req, res) => {
+    try {
+        const adminUser = await getCurrentTenantAdminUser(req);
+        const setup = await createTotpEnrollment(adminUser);
+        res.json({
+            success: true,
+            message: 'Authenticator setup started',
+            data: setup,
+        });
+    } catch (error) {
+        const code = error.message === 'Admin access required' ? 403 : 400;
+        res.status(code).json({ success: false, message: error.message });
+    }
+});
+ 
+router.post('/mfa/admin/totp/enable', verifyToken, async (req, res) => {
+    try {
+        const { code } = req.body || {};
+        if (!code) {
+            return res.status(400).json({ success: false, message: 'Authenticator code is required' });
+        }
+        const adminUser = await getCurrentTenantAdminUser(req);
+        await enableTotpEnrollment(adminUser, code);
+        res.json({
+            success: true,
+            message: 'Authenticator app enabled for admin MFA',
+            data: getMfaStatus(adminUser),
+        });
+    } catch (error) {
+        const code = error.message === 'Admin access required' ? 403 : 400;
+        res.status(code).json({ success: false, message: error.message });
+    }
+});
+ 
+router.delete('/mfa/admin/totp', verifyToken, async (req, res) => {
+    try {
+        const { code } = req.body || {};
+        if (!code) {
+            return res.status(400).json({ success: false, message: 'Current authenticator code is required' });
+        }
+        const adminUser = await getCurrentTenantAdminUser(req);
+        await disableTotp(adminUser, code);
+        res.json({
+            success: true,
+            message: 'Authenticator app removed from admin MFA',
+            data: getMfaStatus(adminUser),
+        });
+    } catch (error) {
+        const code = error.message === 'Admin access required' ? 403 : 400;
+        res.status(code).json({ success: false, message: error.message });
+    }
+});
+ 
+router.post('/mfa/admin/passkey/registration-options', verifyToken, async (req, res) => {
+    try {
+        const adminUser = await getCurrentTenantAdminUser(req);
+        const options = await generatePasskeyRegistration(req, adminUser);
+        res.json({
+            success: true,
+            data: {
+                options,
+            },
+        });
+    } catch (error) {
+        const code = error.message === 'Admin access required' ? 403 : 400;
+        res.status(code).json({ success: false, message: error.message });
+    }
+});
+ 
+router.post('/mfa/admin/passkey/register', verifyToken, async (req, res) => {
+    try {
+        const { credential, nickname } = req.body || {};
+        if (!credential) {
+            return res.status(400).json({ success: false, message: 'Passkey credential is required' });
+        }
+        const adminUser = await getCurrentTenantAdminUser(req);
+        await verifyPasskeyRegistration(req, adminUser, credential, nickname);
+        res.json({
+            success: true,
+            message: 'Passkey added for admin MFA',
+            data: {
+                ...getMfaStatus(adminUser),
+                passkeys: getPasskeySummary(adminUser),
+            },
+        });
+    } catch (error) {
+        const code = error.message === 'Admin access required' ? 403 : 400;
+        res.status(code).json({ success: false, message: error.message });
+    }
+});
+ 
+router.delete('/mfa/admin/passkeys/:credentialId', verifyToken, async (req, res) => {
+    try {
+        const { credentialId } = req.params;
+        const adminUser = await getCurrentTenantAdminUser(req);
+        const removed = await removePasskey(adminUser, credentialId);
+        if (!removed) {
+            return res.status(404).json({ success: false, message: 'Passkey not found' });
+        }
+        res.json({
+            success: true,
+            message: 'Passkey removed',
+            data: {
+                ...getMfaStatus(adminUser),
+                passkeys: getPasskeySummary(adminUser),
+            },
+        });
+    } catch (error) {
+        const code = error.message === 'Admin access required' ? 403 : 400;
+        res.status(code).json({ success: false, message: error.message });
+    }
+});
+ 
+router.get('/mfa/pending', async (req, res) => {
+    try {
+        const context = await resolvePendingMfaContext(req);
+        const status = getMfaStatus(context.tenantUser);
+        res.json({
+            success: true,
+            data: {
+                requiresMfa: true,
+                methods: status.methods,
+                school: req.school,
+            },
+        });
+    } catch (error) {
+        res.status(401).json({ success: false, message: error.message });
+    }
+});
+ 
+router.post('/mfa/passkey/authentication-options', async (req, res) => {
+    try {
+        const context = await resolvePendingMfaContext(req);
+        const status = getMfaStatus(context.tenantUser);
+        if (!status.methods.includes('passkey')) {
+            return res.status(400).json({ success: false, message: 'Passkey MFA is not configured for this account' });
+        }
+        const options = await generatePasskeyAuthentication(req, context.tenantUser, context.pendingMfaToken);
+        res.json({
+            success: true,
+            data: { options },
+        });
+    } catch (error) {
+        res.status(401).json({ success: false, message: error.message });
+    }
+});
+ 
+router.post('/mfa/verify-totp', async (req, res) => {
+    try {
+        const { code } = req.body || {};
+        if (!code) {
+            return res.status(400).json({ success: false, message: 'Authenticator code is required' });
+        }
+        const context = await resolvePendingMfaContext(req);
+        const status = getMfaStatus(context.tenantUser);
+        if (!status.methods.includes('totp')) {
+            return res.status(400).json({ success: false, message: 'Authenticator app MFA is not configured for this account' });
+        }
+        const isValid = await verifyTotpForLogin(context.tenantUser, code);
+        if (!isValid) {
+            return res.status(401).json({ success: false, message: 'Invalid authenticator code' });
+        }
+ 
+        const tokenMfaClaims = buildTokenMfaClaims({
+            isAdminLevel: true,
+            mfaConfigured: true,
+            mfaVerified: true,
+        });
+        const tokens = await authGlobalService.issueTokens(
+            req,
+            res,
+            context.globalUser,
+            context.tenantUser,
+            context.platformRoles,
+            tokenMfaClaims,
+        );
+        clearPendingMfaCookie(req, res);
+        const data = { user: context.tenantUser };
+        if (isMobileClient(req)) {
+            data.accessToken = tokens.accessToken;
+            data.refreshToken = tokens.refreshToken;
+        }
+        res.json({
+            success: true,
+            message: 'Admin MFA verification successful',
+            data,
+        });
+    } catch (error) {
+        res.status(401).json({ success: false, message: error.message });
+    }
+});
+ 
+router.post('/mfa/verify-passkey', async (req, res) => {
+    try {
+        const { credential } = req.body || {};
+        if (!credential) {
+            return res.status(400).json({ success: false, message: 'Passkey assertion is required' });
+        }
+        const context = await resolvePendingMfaContext(req);
+        const status = getMfaStatus(context.tenantUser);
+        if (!status.methods.includes('passkey')) {
+            return res.status(400).json({ success: false, message: 'Passkey MFA is not configured for this account' });
+        }
+ 
+        await verifyPasskeyAuthentication(req, context.tenantUser, context.pendingMfaToken, credential);
+        const tokenMfaClaims = buildTokenMfaClaims({
+            isAdminLevel: true,
+            mfaConfigured: true,
+            mfaVerified: true,
+        });
+        const tokens = await authGlobalService.issueTokens(
+            req,
+            res,
+            context.globalUser,
+            context.tenantUser,
+            context.platformRoles,
+            tokenMfaClaims,
+        );
+        clearPendingMfaCookie(req, res);
+        const data = { user: context.tenantUser };
+        if (isMobileClient(req)) {
+            data.accessToken = tokens.accessToken;
+            data.refreshToken = tokens.refreshToken;
+        }
+        res.json({
+            success: true,
+            message: 'Admin MFA verification successful',
+            data,
+        });
+    } catch (error) {
+        res.status(401).json({ success: false, message: error.message });
+    }
+});
+ 
+// Get all active sessions for the current user
+router.get('/sessions', verifyToken, async (req, res) => {
+    try {
+        const sessions = req.user.globalUserId
+            ? await getUserSessionsForGlobalUser(req.user.globalUserId, req)
+            : await getUserSessions(req.user.userId, req);
+ 
+        // Format sessions for response (exclude sensitive info)
+        const formattedSessions = sessions.map(session => ({
+            id: session._id,
+            deviceInfo: session.deviceInfo,
+            clientType: session.clientType,
+            ipAddress: session.ipAddress,
+            lastUsed: session.lastUsed,
+            createdAt: session.createdAt,
+            expiresAt: session.expiresAt,
+            isCurrent: req.cookies.refreshToken === session.refreshToken
+        }));
+ 
+        const userLabel = req.user.globalUserId ? req.user.globalUserId : req.user.userId;
+        console.log(`GET: /sessions user ${userLabel} retrieved ${formattedSessions.length} sessions`);
+        res.json({
+            success: true,
+            data: {
+                sessions: formattedSessions
+            }
+        });
+    } catch (error) {
+        console.log(`GET: /sessions failed`, error);
+        res.status(500).json({
+            success: false,
+            message: 'Error fetching sessions',
+            error: error.message
+        });
+    }
+});
+ 
+// Revoke a specific session
+router.delete('/sessions/:sessionId', verifyToken, async (req, res) => {
+    try {
+        const { sessionId } = req.params;
+        const deleted = req.user.globalUserId
+            ? await deleteSessionByIdForGlobalUser(sessionId, req.user.globalUserId, req)
+            : await deleteSessionById(sessionId, req.user.userId, req);
+ 
+        if (deleted) {
+            const userLabel = req.user.globalUserId ? req.user.globalUserId : req.user.userId;
+            console.log(`DELETE: /sessions/${sessionId} session revoked by user ${userLabel}`);
+            res.json({
+                success: true,
+                message: 'Session revoked successfully'
+            });
+        } else {
+            res.status(404).json({
+                success: false,
+                message: 'Session not found or you do not have permission to revoke it'
+            });
+        }
+    } catch (error) {
+        console.log(`DELETE: /sessions/${req.params.sessionId} failed`, error);
+        res.status(500).json({
+            success: false,
+            message: 'Error revoking session',
+            error: error.message
+        });
+    }
+});
+ 
+// Revoke all other sessions (keep current one)
+router.post('/sessions/revoke-all-others', verifyToken, async (req, res) => {
+    try {
+        const currentRefreshToken = req.cookies.refreshToken;
+        let revokedCount;
+ 
+        if (req.user.globalUserId) {
+            revokedCount = await revokeAllOtherSessionsForGlobalUser(req.user.globalUserId, currentRefreshToken, req);
+        } else {
+            const allSessions = await getUserSessions(req.user.userId, req);
+            const { Session } = getModels(req, 'Session');
+            await Session.deleteMany({
+                userId: req.user.userId,
+                refreshToken: { $ne: currentRefreshToken }
+            });
+            revokedCount = allSessions.length - 1;
+        }
+ 
+        const userLabel = req.user.globalUserId ? req.user.globalUserId : req.user.userId;
+        console.log(`POST: /sessions/revoke-all-others user ${userLabel} revoked ${revokedCount} other sessions`);
+        res.json({
+            success: true,
+            message: 'All other sessions revoked successfully'
+        });
+    } catch (error) {
+        console.log(`POST: /sessions/revoke-all-others failed`, error);
+        res.status(500).json({
+            success: false,
+            message: 'Error revoking sessions',
+            error: error.message
+        });
+    }
+});
+ 
+ 
+module.exports = router;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/routes/index.html b/backend/coverage/lcov-report/routes/index.html new file mode 100644 index 00000000..061295f2 --- /dev/null +++ b/backend/coverage/lcov-report/routes/index.html @@ -0,0 +1,191 @@ + + + + + + Code coverage report for routes + + + + + + + + + +
+
+

All files routes

+
+ +
+ 28.32% + Statements + 520/1836 +
+ + +
+ 16.16% + Branches + 145/897 +
+ + +
+ 29.05% + Functions + 43/148 +
+ + +
+ 28.79% + Lines + 518/1799 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
adminRoutes.js +
+
18.85%46/2440%0/883.7%1/2719.74%46/233
authRoutes.js +
+
15.12%98/6486.19%21/3399.09%4/4415.32%97/633
orgBudgetRoutes.js +
+
54.31%63/11626.08%12/4658.33%7/1253.91%62/115
pivotAdminRoutes.js +
+
61.58%101/16438.38%38/9982.35%14/1761.58%101/164
pivotRoutes.js +
+
56.93%119/20944%44/10063.63%14/2256.93%119/209
userRoutes.js +
+
20.43%93/45513.33%30/22511.53%3/2620.89%93/445
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/routes/orgBudgetRoutes.js.html b/backend/coverage/lcov-report/routes/orgBudgetRoutes.js.html new file mode 100644 index 00000000..dc6da109 --- /dev/null +++ b/backend/coverage/lcov-report/routes/orgBudgetRoutes.js.html @@ -0,0 +1,781 @@ + + + + + + Code coverage report for routes/orgBudgetRoutes.js + + + + + + + + + +
+
+

All files / routes orgBudgetRoutes.js

+
+ +
+ 54.31% + Statements + 63/116 +
+ + +
+ 26.08% + Branches + 12/46 +
+ + +
+ 58.33% + Functions + 7/12 +
+ + +
+ 53.91% + Lines + 62/115 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +2331x +1x +1x +1x +1x +1x +1x +  +1x +  +  +1x +  +1x +12x +12x +1x +1x +1x +  +  +  +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +4x +4x +4x +3x +  +1x +1x +1x +  +  +  +1x +  +  +  +  +2x +2x +2x +2x +  +  +  +  +  +  +  +  +1x +  +  +  +  +2x +2x +2x +2x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +2x +2x +2x +  +  +  +  +  +  +  +1x +  +1x +1x +1x +  +  +  +  +1x +  +  +  +  +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +1x + 
const express = require('express');
+const getModels = require('../services/getModelService');
+const orgBudgetSchema = require('../schemas/orgBudget');
+const { verifyToken } = require('../middlewares/verifyToken');
+const { requireOrgPermission } = require('../middlewares/orgPermissions');
+const { ORG_PERMISSIONS } = require('../constants/permissions');
+const budgetService = require('../services/budgetService');
+ 
+const router = express.Router();
+ 
+/** Stale unique indexes on org_id/name (pre-schema) collide: all docs lack those fields → duplicate null keys. */
+const orgBudgetIndexesSynced = new WeakSet();
+ 
+router.use(async (req, res, next) => {
+    try {
+        if (!req.db || orgBudgetIndexesSynced.has(req.db)) return next();
+        const OrgBudget = req.db.models.OrgBudget || req.db.model('OrgBudget', orgBudgetSchema, 'orgBudgets');
+        await OrgBudget.syncIndexes();
+        orgBudgetIndexesSynced.add(req.db);
+    } catch (e) {
+        console.warn('[org-budgets] OrgBudget.syncIndexes:', e.message);
+    }
+    next();
+});
+ 
+router.get('/:orgId/budget-templates', verifyToken, requireOrgPermission(ORG_PERMISSIONS.VIEW_FINANCES), async (req, res) => {
+    try {
+        const { orgId } = req.params;
+        const { Org } = getModels(req, 'Org');
+        const org = await Org.findById(orgId).select('orgTypeKey org_name').lean();
+        if (!org) {
+            return res.status(404).json({ success: false, message: 'Organization not found' });
+        }
+        const config = await budgetService.ensureFinanceConfig(req);
+        const lineItemPolicy = await budgetService.getBudgetLineItemPolicy(req);
+        res.status(200).json({
+            success: true,
+            data: {
+                templates: config.budgetTemplates || [],
+                workflowPresets: config.workflowPresets || [],
+                orgTypeKey: org.orgTypeKey || 'default',
+                lineItemPolicy
+            }
+        });
+    } catch (e) {
+        console.error('budget-templates', e);
+        res.status(500).json({ success: false, message: e.message });
+    }
+});
+ 
+router.get('/:orgId/budgets', verifyToken, requireOrgPermission(ORG_PERMISSIONS.VIEW_FINANCES), async (req, res) => {
+    try {
+        const { orgId } = req.params;
+        const data = await budgetService.listBudgetsForOrg(req, orgId);
+        res.status(200).json({ success: true, data });
+    } catch (e) {
+        console.error('list budgets', e);
+        res.status(500).json({ success: false, message: e.message });
+    }
+});
+ 
+router.get(
+    '/:orgId/budgets/:budgetId',
+    verifyToken,
+    requireOrgPermission(ORG_PERMISSIONS.VIEW_FINANCES),
+    async (req, res) => {
+        try {
+            const { orgId, budgetId } = req.params;
+            const data = await budgetService.getBudgetById(req, orgId, budgetId);
+            if (!data) {
+                return res.status(404).json({ success: false, message: 'Budget not found' });
+            }
+            res.status(200).json({ success: true, data });
+        } catch (e) {
+            console.error('get budget', e);
+            res.status(500).json({ success: false, message: e.message });
+        }
+    }
+);
+ 
+router.post('/:orgId/budgets', verifyToken, requireOrgPermission(ORG_PERMISSIONS.MANAGE_FINANCES), async (req, res) => {
+    try {
+        const { orgId } = req.params;
+        const data = await budgetService.createBudget(req, orgId, req.user.userId, req.body || {});
+        res.status(201).json({ success: true, data });
+    } catch (e) {
+        const code = e.statusCode || 500;
+        console.error('create budget', e);
+        res.status(code).json({ success: false, message: e.message });
+    }
+});
+ 
+router.patch(
+    '/:orgId/budgets/:budgetId',
+    verifyToken,
+    requireOrgPermission(ORG_PERMISSIONS.MANAGE_FINANCES),
+    async (req, res) => {
+        try {
+            const { orgId, budgetId } = req.params;
+            const data = await budgetService.updateBudgetDraft(req, orgId, budgetId, req.user.userId, req.body || {});
+            res.status(200).json({ success: true, data });
+        } catch (e) {
+            const code = e.statusCode || 500;
+            console.error('update budget', e);
+            res.status(code).json({ success: false, message: e.message });
+        }
+    }
+);
+ 
+router.post(
+    '/:orgId/budgets/:budgetId/submit',
+    verifyToken,
+    requireOrgPermission(ORG_PERMISSIONS.MANAGE_FINANCES),
+    async (req, res) => {
+        try {
+            const { orgId, budgetId } = req.params;
+            const data = await budgetService.submitBudget(req, orgId, budgetId, req.user.userId);
+            res.status(200).json({ success: true, data });
+        } catch (e) {
+            const code = e.statusCode || 500;
+            console.error('submit budget', e);
+            res.status(code).json({ success: false, message: e.message });
+        }
+    }
+);
+ 
+router.post(
+    '/:orgId/budgets/:budgetId/comments',
+    verifyToken,
+    requireOrgPermission(ORG_PERMISSIONS.MANAGE_FINANCES),
+    async (req, res) => {
+        try {
+            const { orgId, budgetId } = req.params;
+            const data = await budgetService.addComment(req, orgId, budgetId, req.user.userId, req.body || {});
+            res.status(200).json({ success: true, data });
+        } catch (e) {
+            const code = e.statusCode || 500;
+            console.error('budget comment', e);
+            res.status(code).json({ success: false, message: e.message });
+        }
+    }
+);
+ 
+router.put(
+    '/:orgId/budgets/:budgetId/stages/:stageKey/approve',
+    verifyToken,
+    requireOrgPermission(ORG_PERMISSIONS.MANAGE_FINANCES),
+    async (req, res) => {
+        try {
+            const { orgId, budgetId, stageKey } = req.params;
+            const data = await budgetService.approveStageOrg(req, orgId, budgetId, req.user.userId, stageKey);
+            res.status(200).json({ success: true, data });
+        } catch (e) {
+            const code = e.statusCode || 500;
+            console.error('approve org stage', e);
+            res.status(code).json({ success: false, message: e.message });
+        }
+    }
+);
+ 
+router.put(
+    '/:orgId/budgets/:budgetId/stages/:stageKey/reject',
+    verifyToken,
+    requireOrgPermission(ORG_PERMISSIONS.MANAGE_FINANCES),
+    async (req, res) => {
+        try {
+            const { orgId, budgetId, stageKey } = req.params;
+            const data = await budgetService.rejectBudget(
+                req,
+                orgId,
+                budgetId,
+                req.user.userId,
+                { ...(req.body || {}), stageKey },
+                { platformOnly: false }
+            );
+            res.status(200).json({ success: true, data });
+        } catch (e) {
+            const code = e.statusCode || 500;
+            console.error('reject org stage', e);
+            res.status(code).json({ success: false, message: e.message });
+        }
+    }
+);
+ 
+router.put(
+    '/:orgId/budgets/:budgetId/stages/:stageKey/request-revision',
+    verifyToken,
+    requireOrgPermission(ORG_PERMISSIONS.MANAGE_FINANCES),
+    async (req, res) => {
+        try {
+            const { orgId, budgetId, stageKey } = req.params;
+            const data = await budgetService.requestRevision(
+                req,
+                orgId,
+                budgetId,
+                req.user.userId,
+                { ...(req.body || {}), stageKey },
+                { platformOnly: false }
+            );
+            res.status(200).json({ success: true, data });
+        } catch (e) {
+            const code = e.statusCode || 500;
+            console.error('request revision org', e);
+            res.status(code).json({ success: false, message: e.message });
+        }
+    }
+);
+ 
+router.get(
+    '/:orgId/budgets/:budgetId/export',
+    verifyToken,
+    requireOrgPermission(ORG_PERMISSIONS.VIEW_FINANCES),
+    async (req, res) => {
+        try {
+            const { orgId, budgetId } = req.params;
+            const format = (req.query.format || 'json').toLowerCase();
+            const out = await budgetService.exportBudget(req, orgId, budgetId, format);
+            Eif (format === 'csv') {
+                res.setHeader('Content-Type', out.contentType);
+                res.setHeader('Content-Disposition', `attachment; filename="${out.filename}"`);
+                return res.status(200).send(out.body);
+            }
+            return res.status(200).json(out.body);
+        } catch (e) {
+            const code = e.statusCode || 500;
+            console.error('export budget', e);
+            res.status(code).json({ success: false, message: e.message });
+        }
+    }
+);
+ 
+module.exports = router;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/routes/pivotAdminRoutes.js.html b/backend/coverage/lcov-report/routes/pivotAdminRoutes.js.html new file mode 100644 index 00000000..29817d8e --- /dev/null +++ b/backend/coverage/lcov-report/routes/pivotAdminRoutes.js.html @@ -0,0 +1,1693 @@ + + + + + + Code coverage report for routes/pivotAdminRoutes.js + + + + + + + + + +
+
+

All files / routes pivotAdminRoutes.js

+
+ +
+ 61.58% + Statements + 101/164 +
+ + +
+ 38.38% + Branches + 38/99 +
+ + +
+ 82.35% + Functions + 14/17 +
+ + +
+ 61.58% + Lines + 101/164 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +5371x +1x +1x +  +  +  +1x +1x +1x +1x +  +  +  +1x +1x +  +  +  +  +1x +1x +1x +  +  +  +1x +  +  +  +1x +  +  +  +  +  +1x +  +1x +  +1x +  +1x +1x +1x +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +  +  +  +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +2x +  +  +  +  +  +  +  +2x +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +3x +3x +  +  +  +3x +1x +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +  +  +  +  +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
const express = require('express');
+const { verifyToken } = require('../middlewares/verifyToken');
+const { requirePlatformAdmin } = require('../middlewares/requirePlatformAdmin');
+const {
+  rebuildWeeklySnapshot,
+  getWeeklySnapshot,
+} = require('../services/pivotWeeklySnapshotService');
+const { getPivotOverview } = require('../services/pivotAdminOverviewService');
+const { getPivotRetention } = require('../services/pivotRetentionService');
+const { listPivotLabEvents } = require('../services/pivotLabEventsService');
+const {
+  getInterviewNotes,
+  saveInterviewNotes,
+} = require('../services/pivotLabNotesService');
+const { previewIngestUrl } = require('../services/pivotIngestPreviewService');
+const {
+  publishIngestEvent,
+  publishBatchIngestEvents,
+  updateIngestEvent,
+} = require('../services/pivotIngestPublishService');
+const { purgePivotCatalog } = require('../services/pivotCatalogPurgeService');
+const { listPivotTags, seedPivotTagCatalog } = require('../services/pivotTagCatalogService');
+const {
+  suggestPivotEventTags,
+  suggestPivotEventTagsBatch,
+} = require('../services/pivotTagSuggestService');
+const {
+  searchTmdbMovies,
+  fetchTmdbMovieDetails,
+} = require('../services/pivotTmdbService');
+const {
+  pivotRequestLogger,
+  logPivotRouteError,
+  logPivotServiceReject,
+  logPivotServiceSuccess,
+} = require('../utilities/pivotLogger');
+ 
+const router = express.Router();
+ 
+router.use(pivotRequestLogger);
+ 
+router.get('/tags', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await listPivotTags(req);
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /admin/pivot/tags', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot tag catalog.',
+    });
+  }
+});
+ 
+router.post('/tags/seed', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await seedPivotTagCatalog(req);
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /admin/pivot/tags/seed', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to seed pivot tag catalog.',
+    });
+  }
+});
+ 
+router.get('/events', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await listPivotLabEvents(req, {
+      tenantKey: req.query?.tenantKey,
+      batchWeek: req.query?.batchWeek,
+    });
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /admin/pivot/events', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot catalog events.',
+    });
+  }
+});
+ 
+router.get('/interview-notes', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await getInterviewNotes(req, { batchWeek: req.query?.batchWeek });
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /admin/pivot/interview-notes', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load interview notes.',
+    });
+  }
+});
+ 
+router.put('/interview-notes', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await saveInterviewNotes(req, {
+      batchWeek: req.body?.batchWeek ?? req.query?.batchWeek,
+      notes: req.body?.notes,
+    });
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('PUT /admin/pivot/interview-notes', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to save interview notes.',
+    });
+  }
+});
+ 
+router.get('/overview', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await getPivotOverview(req, { batchWeek: req.query?.batchWeek });
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /admin/pivot/overview', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot overview.',
+    });
+  }
+});
+ 
+router.get('/retention', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await getPivotRetention(req, {
+      batchWeek: req.query?.batchWeek,
+      weeks: req.query?.weeks,
+    });
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /admin/pivot/retention', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot retention.',
+    });
+  }
+});
+ 
+router.post('/snapshots/rebuild', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const batchWeek = req.body?.batchWeek ?? req.query?.batchWeek;
+    const result = await rebuildWeeklySnapshot(req, { batchWeek });
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /admin/pivot/snapshots/rebuild', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to rebuild pivot weekly snapshot.',
+    });
+  }
+});
+ 
+router.post('/ingest/suggest-tags', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const events = req.body?.events;
+    const isBatch = Array.isArray(events);
+    console.log('[pivotTagSuggest] route request', {
+      mode: isBatch ? 'batch' : 'single',
+      eventCount: isBatch ? events.length : 1,
+      hasGlobalDb: Boolean(req.globalDb),
+      hasApiKey: Boolean(process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY),
+      model: process.env.CLAUDE_MODEL || process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6',
+    });
+ 
+    const result = isBatch
+      ? await suggestPivotEventTagsBatch(req, events)
+      : await suggestPivotEventTags(req, req.body?.event || req.body);
+ 
+    Iif (result.error) {
+      console.warn('[pivotTagSuggest] route error', {
+        mode: isBatch ? 'batch' : 'single',
+        code: result.code,
+        message: result.error,
+        suggestedCount: result.data?.suggestedCount,
+        failedCount: result.data?.failedCount,
+      });
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+        data: result.data,
+      });
+    }
+ 
+    console.log('[pivotTagSuggest] route success', {
+      mode: isBatch ? 'batch' : 'single',
+      tags: result.data?.tags,
+      suggestedCount: result.data?.suggestedCount,
+      failedCount: result.data?.failedCount,
+    });
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /admin/pivot/ingest/suggest-tags', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to suggest pivot tags.',
+    });
+  }
+});
+ 
+router.get('/tmdb/search', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await searchTmdbMovies({
+      query: req.query?.query,
+      year: req.query?.year,
+    });
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /admin/pivot/tmdb/search', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to search TMDB.',
+    });
+  }
+});
+ 
+router.get('/tmdb/movies/:tmdbId', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await fetchTmdbMovieDetails({ tmdbId: req.params.tmdbId });
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /admin/pivot/tmdb/movies/:tmdbId', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load TMDB movie.',
+    });
+  }
+});
+ 
+router.post('/ingest/preview', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await previewIngestUrl(req, {
+      url: req.body?.url,
+      tenantKey: req.body?.tenantKey,
+    });
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /admin/pivot/ingest/preview', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to preview event import.',
+    });
+  }
+});
+ 
+router.post('/ingest', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await publishIngestEvent(req, {
+      tenantKey: req.body?.tenantKey,
+      url: req.body?.url,
+      batchWeek: req.body?.batchWeek,
+      overrides: req.body?.overrides,
+    });
+    Iif (result.error) {
+      logPivotServiceReject('POST /admin/pivot/ingest', result, req, {
+        tenantKey: req.body?.tenantKey,
+        batchWeek: req.body?.batchWeek,
+        eventName: req.body?.overrides?.name,
+      });
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    logPivotServiceSuccess('POST /admin/pivot/ingest', req, {
+      tenantKey: req.body?.tenantKey,
+      batchWeek: req.body?.batchWeek,
+      eventId: result.data?.event?._id,
+      eventName: result.data?.event?.name,
+    });
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /admin/pivot/ingest', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to publish pivot catalog event.',
+    });
+  }
+});
+ 
+router.post('/ingest/batch', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await publishBatchIngestEvents(req, {
+      tenantKey: req.body?.tenantKey,
+      batchWeek: req.body?.batchWeek,
+      events: req.body?.events,
+    });
+    if (result.error && !result.data?.published?.length) {
+      logPivotServiceReject('POST /admin/pivot/ingest/batch', result, req, {
+        tenantKey: req.body?.tenantKey,
+        batchWeek: req.body?.batchWeek,
+        requestedCount: Array.isArray(req.body?.events) ? req.body.events.length : 0,
+      });
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+        data: result.data,
+      });
+    }
+ 
+    logPivotServiceSuccess('POST /admin/pivot/ingest/batch', req, {
+      tenantKey: req.body?.tenantKey,
+      batchWeek: req.body?.batchWeek,
+      publishedCount: result.data?.publishedCount ?? result.data?.published?.length ?? 0,
+      failedCount: result.data?.failedCount ?? result.data?.failures?.length ?? 0,
+    });
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /admin/pivot/ingest/batch', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to publish pivot catalog events.',
+    });
+  }
+});
+ 
+router.patch('/ingest/:eventId', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await updateIngestEvent(req, {
+      eventId: req.params.eventId,
+      tenantKey: req.body?.tenantKey,
+      overrides: req.body?.overrides,
+    });
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('PATCH /admin/pivot/ingest/:eventId', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to update pivot catalog event.',
+    });
+  }
+});
+ 
+router.post('/dev/purge-catalog', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await purgePivotCatalog(req, {
+      tenantKey: req.body?.tenantKey,
+      confirm: req.body?.confirm,
+      clearSnapshots: req.body?.clearSnapshots,
+    });
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /admin/pivot/dev/purge-catalog', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to purge pivot catalog data.',
+    });
+  }
+});
+ 
+router.get('/snapshots/:batchWeek', verifyToken, requirePlatformAdmin, async (req, res) => {
+  try {
+    const result = await getWeeklySnapshot(req, { batchWeek: req.params.batchWeek });
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /admin/pivot/snapshots/:batchWeek', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot weekly snapshot.',
+    });
+  }
+});
+ 
+module.exports = router;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/routes/pivotRoutes.js.html b/backend/coverage/lcov-report/routes/pivotRoutes.js.html new file mode 100644 index 00000000..97949568 --- /dev/null +++ b/backend/coverage/lcov-report/routes/pivotRoutes.js.html @@ -0,0 +1,2053 @@ + + + + + + Code coverage report for routes/pivotRoutes.js + + + + + + + + + +
+
+

All files / routes pivotRoutes.js

+
+ +
+ 56.93% + Statements + 119/209 +
+ + +
+ 44% + Branches + 44/100 +
+ + +
+ 63.63% + Functions + 14/22 +
+ + +
+ 56.93% + Lines + 119/209 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +6571x +1x +1x +1x +  +  +  +  +  +  +1x +  +  +  +  +1x +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +1x +  +1x +  +  +  +  +  +1x +  +1x +  +1x +  +1x +2x +2x +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +4x +4x +  +  +  +4x +1x +  +  +1x +  +  +  +  +  +  +3x +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +1x +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +1x +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +1x +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +3x +3x +3x +1x +  +  +  +  +  +  +  +2x +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +1x +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
const express = require('express');
+const { body, validationResult } = require('express-validator');
+const { validateReferralCode, redeemReferralCode } = require('../services/pivotReferralCodeService');
+const { getPivotFeed, getPivotEventFriends } = require('../services/pivotFeedService');
+const {
+  recordFeedAction,
+  recordExternalOpen,
+  confirmRegistered,
+  getWeekRecap,
+  resetWeekActions,
+} = require('../services/pivotIntentService');
+const {
+  getPendingEventFeedback,
+  submitEventFeedback,
+  listUserPivotEventFeedback,
+} = require('../services/pivotFeedbackService');
+const { getPivotConfig } = require('../services/pivotConfigService');
+const { listPivotTags } = require('../services/pivotTagCatalogService');
+const {
+  getPivotProfileInterests,
+  updatePivotProfileInterests,
+} = require('../services/pivotProfileService');
+const {
+  searchPivotFriends,
+  sendPivotFriendRequest,
+  listPivotFriends,
+  listPivotFriendRequests,
+  acceptPivotFriendRequest,
+  declinePivotFriendRequest,
+} = require('../services/pivotFriendService');
+const {
+  pivotReferralValidateRateLimit,
+} = require('../middlewares/pivotReferralValidateRateLimit');
+ 
+const { verifyToken } = require('../middlewares/verifyToken');
+const {
+  pivotRequestLogger,
+  logPivotRouteError,
+  logPivotServiceReject,
+  logPivotServiceSuccess,
+} = require('../utilities/pivotLogger');
+ 
+const router = express.Router();
+ 
+router.use(pivotRequestLogger);
+ 
+router.post('/referral/validate', pivotReferralValidateRateLimit, async (req, res) => {
+  try {
+    const result = await validateReferralCode(req, req.body?.code);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/referral/validate', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to validate referral code.',
+    });
+  }
+});
+ 
+router.post('/referral/redeem', verifyToken, async (req, res) => {
+  try {
+    const result = await redeemReferralCode(req, req.body?.code);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/referral/redeem', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to redeem referral code.',
+    });
+  }
+});
+ 
+router.get('/tags', verifyToken, async (req, res) => {
+  try {
+    const result = await listPivotTags(req);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/tags', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot tags.',
+    });
+  }
+});
+ 
+router.get('/profile/interests', verifyToken, async (req, res) => {
+  try {
+    const result = await getPivotProfileInterests(req);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/profile/interests', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot interests.',
+    });
+  }
+});
+ 
+router.put('/profile/interests', verifyToken, async (req, res) => {
+  try {
+    const result = await updatePivotProfileInterests(req, req.body);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('PUT /pivot/profile/interests', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to save pivot interests.',
+    });
+  }
+});
+ 
+router.get('/config', verifyToken, async (req, res) => {
+  try {
+    const result = await getPivotConfig(req, { batchWeek: req.query.batchWeek });
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/config', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot config.',
+    });
+  }
+});
+ 
+router.get('/feed', verifyToken, async (req, res) => {
+  try {
+    const result = await getPivotFeed(req, {
+      batchWeek: req.query.batchWeek,
+      excludeEventIds: req.query.excludeEventIds,
+    });
+    if (result.error) {
+      logPivotServiceReject('GET /pivot/feed', result, req, {
+        batchWeek: req.query.batchWeek,
+      });
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    logPivotServiceSuccess('GET /pivot/feed', req, {
+      batchWeek: result.data?.batchWeek,
+      eventCount: result.data?.events?.length ?? 0,
+      excludeEventIds: req.query.excludeEventIds || undefined,
+    });
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/feed', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot feed.',
+    });
+  }
+});
+ 
+router.post('/feed/action', verifyToken, async (req, res) => {
+  try {
+    const result = await recordFeedAction(req, req.body);
+    if (result.error) {
+      logPivotServiceReject('POST /pivot/feed/action', result, req, {
+        eventId: req.body?.eventId,
+        action: req.body?.action,
+      });
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    logPivotServiceSuccess('POST /pivot/feed/action', req, {
+      eventId: result.data?.eventId,
+      status: result.data?.status,
+      batchWeek: result.data?.batchWeek,
+    });
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/feed/action', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to record pivot intent.',
+    });
+  }
+});
+ 
+router.post('/intent/:eventId/external-open', verifyToken, async (req, res) => {
+  try {
+    const result = await recordExternalOpen(req, req.params.eventId, req.body);
+    Iif (result.error) {
+      logPivotServiceReject('POST /pivot/intent/:eventId/external-open', result, req, {
+        eventId: req.params.eventId,
+      });
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    logPivotServiceSuccess('POST /pivot/intent/:eventId/external-open', req, {
+      eventId: result.data?.eventId,
+      status: result.data?.status,
+      externalOpenCount: result.data?.externalOpenCount,
+    });
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/intent/:eventId/external-open', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to record external open.',
+    });
+  }
+});
+ 
+router.post('/intent/:eventId/registered', verifyToken, async (req, res) => {
+  try {
+    const result = await confirmRegistered(req, req.params.eventId, req.body);
+    if (result.error) {
+      logPivotServiceReject('POST /pivot/intent/:eventId/registered', result, req, {
+        eventId: req.params.eventId,
+        timeSlotId: req.body?.timeSlotId,
+      });
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    logPivotServiceSuccess('POST /pivot/intent/:eventId/registered', req, {
+      eventId: result.data?.eventId,
+      status: result.data?.status,
+      timeSlotId: result.data?.timeSlotId,
+      batchWeek: result.data?.batchWeek,
+    });
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/intent/:eventId/registered', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to confirm registration.',
+    });
+  }
+});
+ 
+router.get('/friends', verifyToken, async (req, res) => {
+  try {
+    const result = await listPivotFriends(req);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/friends', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load friends.',
+    });
+  }
+});
+ 
+router.get('/friends/requests', verifyToken, async (req, res) => {
+  try {
+    const result = await listPivotFriendRequests(req);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/friends/requests', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load friend requests.',
+    });
+  }
+});
+ 
+router.post('/friends/requests/:friendshipId/accept', verifyToken, async (req, res) => {
+  try {
+    const result = await acceptPivotFriendRequest(req, req.params.friendshipId);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/friends/requests/:friendshipId/accept', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to accept friend request.',
+    });
+  }
+});
+ 
+router.post('/friends/requests/:friendshipId/decline', verifyToken, async (req, res) => {
+  try {
+    const result = await declinePivotFriendRequest(req, req.params.friendshipId);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/friends/requests/:friendshipId/decline', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to decline friend request.',
+    });
+  }
+});
+ 
+router.get('/friends/search', verifyToken, async (req, res) => {
+  try {
+    const result = await searchPivotFriends(req, { q: req.query.q });
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/friends/search', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to search friends.',
+    });
+  }
+});
+ 
+router.post('/friends/request', verifyToken, async (req, res) => {
+  try {
+    const result = await sendPivotFriendRequest(req, req.body);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(201).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/friends/request', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to send friend request.',
+    });
+  }
+});
+ 
+router.get('/events/:eventId/friends', verifyToken, async (req, res) => {
+  try {
+    const result = await getPivotEventFriends(req, req.params.eventId);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/events/:eventId/friends', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load event friends.',
+    });
+  }
+});
+ 
+router.get('/week-recap', verifyToken, async (req, res) => {
+  try {
+    const result = await getWeekRecap(req, { batchWeek: req.query.batchWeek });
+    if (result.error) {
+      logPivotServiceReject('GET /pivot/week-recap', result, req, {
+        batchWeek: req.query.batchWeek,
+      });
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    logPivotServiceSuccess('GET /pivot/week-recap', req, {
+      batchWeek: result.data?.batchWeek,
+      eventCount: result.data?.events?.length ?? 0,
+    });
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/week-recap', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load week recap.',
+    });
+  }
+});
+ 
+router.get('/feedback/pending', verifyToken, async (req, res) => {
+  try {
+    const result = await getPendingEventFeedback(req);
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/feedback/pending', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pending feedback.',
+    });
+  }
+});
+ 
+router.post('/feedback', [
+  verifyToken,
+  body('eventId').trim().notEmpty().withMessage('eventId is required'),
+  body('rating').isInt({ min: 1, max: 5 }).withMessage('rating must be 1–5'),
+  body('comment').optional().isString().trim().isLength({ max: 500 }),
+], async (req, res) => {
+  try {
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      return res.status(400).json({
+        success: false,
+        message: 'Validation errors',
+        errors: errors.array(),
+        code: 'VALIDATION_ERROR',
+      });
+    }
+ 
+    const result = await submitEventFeedback(req, req.body);
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/feedback', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to submit feedback.',
+    });
+  }
+});
+ 
+router.get('/dev/feedback', verifyToken, async (req, res) => {
+  if (process.env.NODE_ENV !== 'development') {
+    return res.status(404).json({
+      success: false,
+      message: 'Not found.',
+    });
+  }
+ 
+  try {
+    const result = await listUserPivotEventFeedback(req, {
+      limit: req.query.limit,
+    });
+    if (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('GET /pivot/dev/feedback', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to load pivot feedback.',
+    });
+  }
+});
+ 
+router.post('/dev/reset-week-actions', verifyToken, async (req, res) => {
+  if (process.env.NODE_ENV !== 'development') {
+    return res.status(404).json({
+      success: false,
+      message: 'Not found.',
+    });
+  }
+ 
+  try {
+    const result = await resetWeekActions(req, { batchWeek: req.body?.batchWeek });
+    Iif (result.error) {
+      return res.status(result.status || 400).json({
+        success: false,
+        message: result.error,
+        code: result.code,
+      });
+    }
+ 
+    return res.status(200).json({
+      success: true,
+      data: result.data,
+    });
+  } catch (err) {
+    logPivotRouteError('POST /pivot/dev/reset-week-actions', err, req);
+    return res.status(500).json({
+      success: false,
+      message: 'Unable to reset week actions.',
+    });
+  }
+});
+ 
+module.exports = router;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/routes/userRoutes.js.html b/backend/coverage/lcov-report/routes/userRoutes.js.html new file mode 100644 index 00000000..afc79245 --- /dev/null +++ b/backend/coverage/lcov-report/routes/userRoutes.js.html @@ -0,0 +1,2827 @@ + + + + + + Code coverage report for routes/userRoutes.js + + + + + + + + + +
+
+

All files / routes userRoutes.js

+
+ +
+ 20.43% + Statements + 93/455 +
+ + +
+ 13.33% + Branches + 30/225 +
+ + +
+ 11.53% + Functions + 3/26 +
+ + +
+ 20.89% + Lines + 93/445 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +9151x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +1x +3x +3x +3x +3x +3x +  +  +  +  +3x +3x +3x +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +3x +  +  +3x +3x +3x +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +3x +1x +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +2x +  +  +  +2x +1x +  +  +  +  +  +  +1x +  +  +1x +1x +1x +1x +  +  +  +  +  +1x +  +  +1x +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +2x +2x +2x +2x +  +2x +  +  +  +2x +2x +2x +1x +1x +  +1x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +1x +1x +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +1x +1x +1x +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
const express = require('express');
+const { verifyToken, verifyTokenOptional, authorizeRoles } = require('../middlewares/verifyToken');
+const { requireAdmin } = require('../middlewares/requireAdmin');
+const cron = require('node-cron');
+const axios = require('axios');
+const { isProfane } = require('../services/profanityFilterService');
+const StudyHistory = require('../schemas/studyHistory.js');
+const { findNext } = require('../helpers.js');
+const { sendDiscordMessage } = require('../services/discordWebookService');
+const BadgeGrant = require('../schemas/badgeGrant');
+const getModels = require('../services/getModelService');
+const { uploadImageToS3, deleteAndUploadImageToS3 } = require('../services/imageUploadService');
+const { sendRoomCheckinEvent } = require('../inngest/events');
+const multer = require('multer');
+const path = require('path');
+ 
+const upload = multer({
+    storage: multer.memoryStorage(),
+    limits: {
+        fileSize: 5 * 1024 * 1024, // 5 MB
+    },
+});
+ 
+/** Driver / Mongoose versions differ: code may be number or string, or only message (E11000). */
+function isMongoDuplicateKeyError(err) {
+    if (!err) return false;
+    const c = err.code;
+    if (c === 11000 || c === '11000') return true;
+    if (err.name === 'MongoServerError' && (c === 11000 || c === '11000')) return true;
+    if (String(err.message || '').includes('E11000') && String(err.message || '').includes('duplicate key')) {
+        return true;
+    }
+    if (err.cause && isMongoDuplicateKeyError(err.cause)) return true;
+    return false;
+}
+ 
+function duplicateIndexFieldFromError(err) {
+    const root = err.cause && (err.cause.keyPattern || err.cause.keyValue) ? err.cause : err;
+    if (root.keyPattern && typeof root.keyPattern === 'object') {
+        const keys = Object.keys(root.keyPattern);
+        if (keys.length) return keys[0];
+    }
+    const msg = String(err.message || '');
+    if (/username_1|dup key:\s*\{\s*username:/i.test(msg)) return 'username';
+    if (/email_1|dup key:\s*\{\s*email:/i.test(msg)) return 'email';
+    if (/affiliatedEmail/i.test(msg) && /dup key/i.test(msg)) return 'affiliatedEmail';
+    return null;
+}
+ 
+const router = express.Router();
+ 
+router.post("/update-user", verifyToken, async (req, res) => {
+    const { User } = getModels(req, 'User');
+    const { name, username, classroom, recommendation, onboarded, tags, onboardingResponses, onboardingCompletedSteps } = req.body;
+    try {
+        const user = await User.findById(req.user.userId);
+        Iif (!user) {
+            console.log(`POST: /update-user token is invalid`);
+            return res.status(404).json({ success: false, message: 'User not found', code: 'USER_NOT_FOUND' });
+        }
+ 
+        Eif (Object.prototype.hasOwnProperty.call(req.body, 'name')) {
+            const trimmedName = String(name ?? '').trim();
+            Iif (!trimmedName) {
+                return res.status(400).json({
+                    success: false,
+                    message: 'Please enter your name',
+                    code: 'NAME_REQUIRED',
+                    field: 'name',
+                });
+            }
+            Iif (trimmedName.length > 120) {
+                return res.status(400).json({
+                    success: false,
+                    message: 'Name must be 120 characters or less',
+                    code: 'NAME_TOO_LONG',
+                    field: 'name',
+                });
+            }
+            user.name = trimmedName;
+        }
+ 
+        Eif (Object.prototype.hasOwnProperty.call(req.body, 'username')) {
+            const trimmedUsername = String(username ?? '').trim();
+            Iif (!trimmedUsername) {
+                return res.status(400).json({
+                    success: false,
+                    message: 'Please choose a username',
+                    code: 'USERNAME_REQUIRED',
+                    field: 'username',
+                });
+            }
+            Iif (trimmedUsername.length < 3) {
+                return res.status(400).json({
+                    success: false,
+                    message: 'Username must be at least 3 characters',
+                    code: 'USERNAME_TOO_SHORT',
+                    field: 'username',
+                });
+            }
+            Iif (trimmedUsername.length > 20) {
+                return res.status(400).json({
+                    success: false,
+                    message: 'Username must be 20 characters or less',
+                    code: 'USERNAME_TOO_LONG',
+                    field: 'username',
+                });
+            }
+            if (!/^[a-zA-Z0-9]+$/.test(trimmedUsername)) {
+                return res.status(400).json({
+                    success: false,
+                    message: 'Username can only use letters and numbers (no spaces or symbols)',
+                    code: 'USERNAME_INVALID',
+                    field: 'username',
+                });
+            }
+            Iif (isProfane(trimmedUsername)) {
+                return res.status(400).json({
+                    success: false,
+                    message: 'Username does not abide by community standards',
+                    code: 'USERNAME_PROFANE',
+                    field: 'username',
+                });
+            }
+            const escaped = trimmedUsername.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+            const conflict = await User.findOne({
+                username: { $regex: new RegExp(`^${escaped}$`, 'i') },
+                _id: { $ne: user._id },
+            });
+            if (conflict) {
+                return res.status(400).json({
+                    success: false,
+                    message: 'This username is already taken',
+                    code: 'USERNAME_TAKEN',
+                    field: 'username',
+                });
+            }
+            user.username = trimmedUsername;
+        }
+ 
+        user.classroomPreferences = classroom ? classroom : user.classroomPreferences;
+        user.recommendationPreferences = recommendation ? recommendation : user.recommendationPreferences;
+        user.onboarded = onboarded ? onboarded : user.onboarded;
+        Iif (Array.isArray(tags)) {
+            user.tags = tags
+                .map((t) => String(t || '').trim())
+                .filter(Boolean)
+                .slice(0, 30);
+        }
+        Iif (onboardingResponses && typeof onboardingResponses === 'object' && !Array.isArray(onboardingResponses)) {
+            user.onboardingResponses = onboardingResponses;
+        }
+        Iif (Array.isArray(onboardingCompletedSteps)) {
+            user.onboardingCompletedSteps = onboardingCompletedSteps
+                .map((stepId) => String(stepId || '').trim())
+                .filter(Boolean)
+                .slice(0, 300);
+        }
+ 
+        await user.save();
+        console.log(`POST: /update-user ${req.user.userId} successful`);
+        return res.status(200).json({ success: true, message: 'User updated successfully' });
+    } catch (error) {
+        console.log(`POST: /update-user ${req.user.userId} failed`, error?.message || error);
+        if (isMongoDuplicateKeyError(error)) {
+            const dupField = duplicateIndexFieldFromError(error);
+            if (dupField === 'username') {
+                return res.status(400).json({
+                    success: false,
+                    message: 'This username is already taken',
+                    code: 'USERNAME_TAKEN',
+                    field: 'username',
+                });
+            }
+            return res.status(400).json({
+                success: false,
+                message: 'This value is already in use',
+                code: 'DUPLICATE_KEY',
+            });
+        }
+        if (error.name === 'ValidationError') {
+            const keys = Object.keys(error.errors || {});
+            const firstKey = keys[0];
+            const firstMsg = firstKey ? error.errors[firstKey].message : error.message;
+            return res.status(400).json({
+                success: false,
+                message: firstMsg || 'Invalid profile data',
+                code: 'VALIDATION_ERROR',
+                field: firstKey || undefined,
+            });
+        }
+        return res.status(500).json({
+            success: false,
+            message: 'Something went wrong. Please try again.',
+            code: 'SERVER_ERROR',
+        });
+    }
+});
+ 
+// check if username is available
+router.post("/check-username", verifyToken, async (req, res) => {
+    const { User } = getModels(req, 'User');
+    const { username } = req.body;
+    const userId = req.user.userId;
+    try {
+        //check if username is taken, regardless of casing
+        Iif (isProfane(username)) {
+            console.log(`POST: /check-username ${username} is profane`)
+            return res.status(200).json({ success: false, message: 'Username does not abide by community standards' });
+        }
+        const reqUser = await User.findById(userId);
+        const user = await User.findOne({ username: { $regex: new RegExp(username, "i") } });
+        if (user && user._id.toString() !== userId) {
+            console.log(`POST: /check-username ${username} is taken`)
+            return res.status(200).json({ success: false, message: 'Username is taken' });
+        }
+        console.log(`POST: /check-username ${username} is available`)
+        return res.status(200).json({ success: true, message: 'Username is available' });
+    } catch (error) {
+        console.log(`POST: /check-username ${username} failed`)
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+router.post("/check-in", verifyToken, async (req, res) => {
+    const { Classroom, Schedule, StudyHistory } = getModels(req, 'Classroom', 'Schedule', 'StudyHistory');
+    const { classroomId } = req.body;
+    try {
+        //check if user is checked in elsewhere in the checked_in array
+        const classrooms = await Classroom.find({ checked_in: { $in: [req.user.userId] } }).populate(
+            'building',
+            'name'
+        );
+ 
+        // const classrooms = await Classroom.find({ checkIns: req.user.userId });
+        if (classrooms.length > 0) {
+            console.log(`POST: /check-in ${req.user.userId} is already checked in`)
+            return res.status(400).json({ success: false, message: 'User is already checked in' });
+        }
+        const classroom = await Classroom.findOne({ _id: classroomId }).populate('building', 'name');
+        classroom.checked_in.push(req.user.userId);
+        await classroom.save();
+        if (req.user.userId !== "65f474445dca7aca4fb5acaf") {
+            sendDiscordMessage(`User check-in`, `user ${req.user.userId} checked in to ${classroom.name}`, "normal");
+        }
+        //create history object, preempt end time using findnext
+        const schedule = await Schedule.findOne({ classroom_id: classroomId });
+        if (schedule) {
+            let endTime = findNext(schedule.weekly_schedule); //time in minutes from midnight
+            endTime = new Date(new Date().setHours(Math.floor(endTime / 60), endTime % 60, 0, 0));
+            const history = new StudyHistory({
+                user_id: req.user.userId,
+                classroom_id: classroomId,
+                start_time: new Date(),
+                end_time: endTime
+            });
+            await history.save();
+        }
+ 
+        const io = req.app.get('io');
+        if (io) {
+            io.to(classroomId).emit('check-in', { classroomId, userId: req.user.userId });
+        }
+ 
+        // Send Inngest event to schedule auto-checkout after 2 hours
+        // await sendRoomCheckinEvent(req.user.userId, classroomId, new Date());
+ 
+        console.log(`POST: /check-in ${req.user.userId} into ${classroom.name} successful`);
+        return res.status(200).json({ 
+            success: true, 
+            message: 'Checked in successfully - auto-checkout scheduled for 2 hours',
+            data: {
+                classroomId,
+                classroomName: classroom.name,
+                checkinTime: new Date().toISOString(),
+                autoCheckoutTime: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString() // 2 hours from now
+            }
+        });
+    } catch (error) {
+        console.log(`POST: /check-in ${req.user.userId} failed`);
+        console.log(error);
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+router.get("/checked-in", verifyToken, async (req, res) => {
+    const { Classroom } = getModels(req, 'Classroom');
+    try {
+        const classrooms = await Classroom.find({ checked_in: { $in: [req.user.userId] } }).populate(
+            'building',
+            'name'
+        );
+        console.log(`GET: /checked-in ${req.user.userId} successful`)
+        return res.status(200).json({ success: true, message: 'Checked in classrooms retrieved', classrooms });
+    } catch (error) {
+        console.log(`GET: /checked-in ${req.user.userId} failed`)
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+router.post("/check-out", verifyToken, async (req, res) => {
+    const { Classroom, Schedule, User, StudyHistory } = getModels(req, 'Classroom', 'Schedule', 'User', 'StudyHistory');
+    const { classroomId } = req.body;
+    try {
+        const classroom = await Classroom.findOne({ _id: classroomId }).populate('building', 'name');
+        classroom.checked_in = classroom.checked_in.filter(userId => userId !== req.user.userId);
+        await classroom.save();
+        const schedule = await Schedule.findOne({ classroom_id: classroomId });
+        if (schedule) {
+            //find latest history object
+            const history = await StudyHistory.findOne({ user_id: req.user.userId, classroom_id: classroomId }).sort({ start_time: -1 });
+            const endTime = new Date();
+            //if time spent is less than 5 minutes, delete history object
+            if (history) {
+                const timeDiff = endTime - history.start_time;
+                if (timeDiff < 300000) {
+                    await history.deleteOne();
+                } else {
+                    //else update end time
+                    history.end_time = endTime;
+                    await history.save();
+                    //update user stats
+                    const user = await User.findOne({ _id: req.user.userId });
+                    user.hours += timeDiff / 3600000;
+                    //find if new classroom visited
+                    const pastHistory = await StudyHistory.findOne({ user_id: req.user.userId, classroom_id: classroomId });
+                    if (!pastHistory) {
+                        user.visited.push(classroomId);
+                    }
+                }
+            }
+        }
+        const io = req.app.get('io');
+        if (io) {
+            io.to(classroomId).emit('check-out', { classroomId, userId: req.user.userId });
+        }
+        console.log(`POST: /check-out ${req.user.userId} from ${classroom.name} successful`);
+        if (req.user.userId !== "65f474445dca7aca4fb5acaf") {
+            sendDiscordMessage(`User check-out`, `user ${req.user.userId} checked out of ${classroom.name}`, "normal");
+        }
+        return res.status(200).json({ success: true, message: 'Checked out successfully' });
+    } catch (error) {
+        console.log(`POST: /check-out ${req.user.userId} failed`);
+        console.log(error);
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+router.get("/get-developer", verifyToken, async (req, res) => {
+    const { Developer } = getModels(req, 'Developer');
+    try {
+        const developer = await Developer.findOne({ user_id: req.user.userId });
+        console.log(`GET: /get-developer ${req.user.userId} successful`);
+        if (!developer) {
+            return res.status(204).json({ success: false, message: 'Developer not found' });
+        }
+        return res.status(200).json({ success: true, message: 'Developer retrieved', developer });
+ 
+    } catch (error) {
+        console.log(`GET: /get-developer ${req.user.userId} failed`)
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+router.post("/update-developer", verifyToken, async (req, res) => {
+    const { Developer, User } = getModels(req, 'Developer', 'User');
+    const { type, commitment, goals, skills } = req.body;
+    try {
+        const developer = await Developer.findOne({ userId: req.user.userId });
+        const user = await User.findById(req.user.userId);
+ 
+        if (!developer) {
+            //craete developer
+            const newDeveloper = new Developer({
+                user_id: req.user.userId,
+                name: user.name,
+                type,
+                commitment,
+                goals,
+                skills
+            });
+            await newDeveloper.save();
+            user.developer = type;
+            user.tags.push("developer");
+            await user.save();
+            console.log(`POST: /update-developer ${req.user.userId} successful`);
+            return res.status(200).json({ success: true, message: 'Developer created successfully' });
+        }
+        developer.name = name ? name : developer.name;
+        developer.type = type ? type : developer.type;
+        developer.commitment = commitment ? commitment : developer.commitment;
+        developer.goals = goals ? goals : developer.goals;
+        developer.skills = skills ? skills : developer.skills;
+        await developer.save();
+        console.log(`POST: /update-developer ${req.user.userId} successful`);
+        return res.status(200).json({ success: true, message: 'Developer updated successfully' });
+    } catch (error) {
+        console.log(`POST: /update-developer ${req.user.userId} failed`)
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+router.get("/get-user", async (req, res) => {
+    const { User } = getModels(req, 'User');
+    const userId = req.query.userId;
+    try {
+        const user = await User.findById(userId);
+        console.log(`GET: /get-user ${req.query.userId} successful`);
+        return res.status(200).json({ success: true, message: 'User retrieved', user });
+    } catch (error) {
+        console.log(`GET: /get-user ${req.query.userId} failed`)
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+//route to get mulitple users, specified in array
+router.get("/get-users", async (req, res) => {
+    const { User } = getModels(req, 'User');
+    const userIds = req.query.userIds;
+    try {
+        const users = await User.find({ _id: { $in: userIds } });
+        console.log(`GET: /get-users ${req.query.userId} successful`);
+        return res.status(200).json({ success: true, message: 'Users retrieved', users });
+    } catch (error) {
+        console.log(`GET: /get-users ${req.query.userId} failed`)
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+// Advanced user search with configurable filters
+router.get("/search-users", verifyToken, async (req, res) => {
+    const { User } = getModels(req, 'User');
+    const { 
+        query, 
+        roles, 
+        tags, 
+        limit = 20, 
+        skip = 0,
+        sortBy = 'username',
+        sortOrder = 'asc',
+        excludeIds = []
+    } = req.query;
+    
+    try {
+        // Build the search query
+        let searchQuery = {};
+        
+        // Text search on username, name, or email
+        if (query) {
+            // Escape regex special characters to prevent regex errors
+            const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+            searchQuery.$or = [
+                { username: { $regex: new RegExp(escapedQuery, 'i') } },
+                { name: { $regex: new RegExp(escapedQuery, 'i') } },
+                { email: { $regex: new RegExp(escapedQuery, 'i') } }
+            ];
+        }
+        
+        // Filter by roles if provided
+        if (roles) {
+            const roleArray = Array.isArray(roles) ? roles : [roles];
+            searchQuery.roles = { $in: roleArray };
+        }
+        
+        // Filter by tags if provided
+        if (tags) {
+            const tagArray = Array.isArray(tags) ? tags : [tags];
+            searchQuery.tags = { $in: tagArray };
+        }
+        
+        // Exclude specific user IDs if provided
+        if (excludeIds && excludeIds.length > 0) {
+            let excludeArray;
+            try {
+                // Try to parse as JSON if it's a string
+                excludeArray = typeof excludeIds === 'string' ? JSON.parse(excludeIds) : excludeIds;
+                // Ensure it's an array
+                excludeArray = Array.isArray(excludeArray) ? excludeArray : [excludeArray];
+            } catch (error) {
+                console.error('Error parsing excludeIds:', error);
+                excludeArray = [excludeIds];
+            }
+            searchQuery._id = { $nin: excludeArray };
+        }
+        
+        // Build sort object
+        const sort = {};
+        sort[sortBy] = sortOrder === 'asc' ? 1 : -1;
+        
+        // Execute the query with pagination
+        const users = await User.find(searchQuery)
+            .sort(sort)
+            .limit(parseInt(limit))
+            .skip(parseInt(skip))
+            .select('-password -googleId'); // Exclude sensitive fields
+        
+        // Get total count for pagination
+        const total = await User.countDocuments(searchQuery);
+        
+        console.log(`GET: /search-users successful`);
+        return res.status(200).json({ 
+            success: true, 
+            message: 'Users found', 
+            data: users,
+            pagination: {
+                total,
+                limit: parseInt(limit),
+                skip: parseInt(skip)
+            }
+        });
+    } catch (error) {
+        console.log(`GET: /search-users failed`, error);
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+router.post('/create-badge-grant', verifyToken, requireAdmin, async (req, res) => {
+    const { BadgeGrant } = getModels(req, 'BadgeGrant');
+    try {
+        const { badgeContent, badgeColor, daysValid } = req.body;
+ 
+        // Input validation
+        if (!badgeContent || !badgeColor || !daysValid) {
+            return res.status(400).json({ error: 'All fields are required' });
+        }
+ 
+        const validFrom = new Date();
+        const validTo = new Date();
+        validTo.setDate(validTo.getDate() + daysValid);
+ 
+        const badgeGrant = new BadgeGrant({
+            badgeContent,
+            badgeColor,
+            validFrom,
+            validTo,
+        });
+ 
+        await badgeGrant.save();
+ 
+        res.status(201).json({
+            message: 'Badge grant created successfully',
+            hash: badgeGrant.hash,
+            validFrom,
+            validTo,
+        });
+    } catch (error) {
+        console.error('Error creating badge grant:', error);
+        res.status(500).json({ error: 'Server error' });
+    }
+});
+ 
+router.post('/grant-badge', verifyToken, async (req, res) => {
+    const { BadgeGrant, User } = getModels(req, 'BadgeGrant', 'User');
+    try {
+        const { hash } = req.body;
+        const userId = req.user.userId;
+ 
+        if (!hash) {
+            return res.status(400).json({ error: 'Hash is required' });
+        }
+ 
+        const badgeGrant = await BadgeGrant.findOne({ hash });
+ 
+        if (!badgeGrant) {
+            return res.status(404).json({ error: 'Invalid badge grant' });
+        }
+ 
+        const currentDate = new Date();
+ 
+        //check if the today's date is within the valid period
+        if (currentDate < badgeGrant.validFrom || currentDate > badgeGrant.validTo) {
+            return res.status(400).json({ error: 'Badge grant is not valid at this time' });
+        }
+ 
+        const user = await User.findById(userId);
+ 
+        if (!user) {
+            return res.status(404).json({ error: 'User not found' });
+        }
+        if(user.tags.includes(badgeGrant.badgeContent)){
+            return res.status(406).json({ error: 'You\'ve already been granted this badge' });
+        }
+ 
+        // Append the badge to the user's badges array
+        user.tags.push(badgeGrant.badgeContent);
+ 
+        await user.save();
+        console.log(`POST: /grant-badge ${req.user.userId} successful`);
+ 
+        res.status(200).json({ message: 'Badge granted successfully', badges: user.badges, badge: {badgeContent:badgeGrant.badgeContent, badgeColor: badgeGrant.badgeColor} });
+    } catch (error) {
+        console.error('Error granting badge:', error);
+        res.status(500).json({ error: error});
+    }
+});
+ 
+router.post('/renew-badge-grant', verifyToken, requireAdmin, async (req,res) => {
+    const { BadgeGrant } = getModels(req, 'BadgeGrant');
+    try {
+        const { badgeGrantId, daysValid } = req.body;
+ 
+        if (!badgeGrantId || !daysValid) {
+            return res.status(400).json({ error: 'Badge grant ID and days valid are required' });
+        }
+ 
+        const badgeGrant = await BadgeGrant.findById(badgeGrantId);
+        if (!badgeGrant) {
+            return res.status(404).json({ error: 'Badge grant not found' });
+        }
+ 
+        const validFrom = new Date();
+        const validTo = new Date();
+        validTo.setDate(validTo.getDate() + daysValid);
+ 
+        badgeGrant.validFrom = validFrom;
+        badgeGrant.validTo = validTo;
+ 
+        await badgeGrant.save();
+ 
+        res.status(200).json({
+            message: 'Badge grant renewed successfully',
+            validFrom,
+            validTo,
+        });
+    } catch (error) {
+        console.error('Error renewing badge grant:', error);
+        res.status(500).json({ error: 'Server error' });
+    }
+})
+ 
+router.get('/get-badge-grants', verifyToken, requireAdmin, async (req,res) => {
+    const { User, BadgeGrant } = getModels(req, 'User', 'BadgeGrant');
+    try{
+        const user = await User.findById(req.user.userId);
+        if(!user || !user.roles.includes('admin')){
+            return res.status(403).json({
+                success: false,
+                message: 'You don\'t have permissions to view badge grants'
+            })
+        }
+        const badgeGrants = await BadgeGrant.find({});
+        return res.status(200).json({
+            success:true,
+            badgeGrants
+        })
+    } catch (error){
+        console.error('Error getting badges:', error);
+        res.status(500).json({erorr:error})
+    }
+});
+ 
+router.get('/get-badge-grant/:hash', async (req,res) => {
+    const { BadgeGrant } = getModels(req, 'BadgeGrant');
+    try{
+        const { hash } = req.params;
+        const badgeGrant = await BadgeGrant.findOne({ hash });
+        
+        if(!badgeGrant){
+            return res.status(404).json({
+                success: false,
+                message: 'Badge grant not found'
+            })
+        }
+        
+        return res.status(200).json({
+            success: true,
+            badgeGrant: {
+                badgeContent: badgeGrant.badgeContent,
+                badgeColor: badgeGrant.badgeColor,
+                validFrom: badgeGrant.validFrom,
+                validTo: badgeGrant.validTo
+            }
+        })
+    } catch (error){
+        console.error('Error getting badge grant:', error);
+        res.status(500).json({error: error})
+    }
+});
+ 
+ 
+ 
+router.post("/upload-user-image", verifyToken, upload.single('image'), async (req, res) =>{
+    const { User } = getModels(req, 'User');
+    const file = req.file;
+    console.log('uploading user image');
+    if(!file){
+        console.log(`POST: /upload-user-image ${req.user.userId} no file uploaded`)
+        return res.status(400).json({ success: false, message: 'No file uploaded' });
+    }
+    try{
+        const user = await User.findById(req.user.userId);
+        let imageUrl;
+        if(!user.picture){
+            // For new images, use user ID and timestamp in filename
+            const fileExtension = path.extname(file.originalname);
+            const timestamp = Date.now();
+            const fileName = `${req.user.userId}-${timestamp}${fileExtension}`;
+            imageUrl = await uploadImageToS3(file, "users", fileName);
+            user.picture = imageUrl;
+        } else {
+            // For replacing existing images, use user ID and timestamp in filename
+            const fileExtension = path.extname(file.originalname);
+            const timestamp = Date.now();
+            const fileName = `${req.user.userId}-${timestamp}${fileExtension}`;
+            imageUrl = await deleteAndUploadImageToS3(file, "users", user.picture, fileName);
+            user.picture = imageUrl;
+        }
+        await user.save();
+        console.log(`POST: /upload-user-image ${req.user.userId} successful`);
+        return res.status(200).json({ success: true, message: 'Image uploaded successfully', imageUrl });
+    } catch(error){
+        console.log(`POST: /upload-user-image ${req.user.userId} failed, ${error}`)
+        return res.status(500).json({ success: false, message: 'Internal server error', error });
+    }
+});
+ 
+//add or remove role from user
+ 
+router.post('/manage-roles', verifyToken, requireAdmin, async (req,res) => {
+    const { role, userId } = req.body;
+    const { User } = getModels(req, 'User');
+    try{
+        console.log(`${userId}`);
+        const user = await User.findById(userId);
+        console.log('asd');
+        if(!user){
+            return res.status(404).json({
+                success:false,
+            })
+        } else {
+            const admin = await User.findById(req.user.userId);
+            console.log(admin);
+            if(!admin || !(admin.roles.includes('admin'))){
+                console.log('POST: /manage-roles unauthorized');
+                return res.status(403);
+            } else {
+                //update role
+                if(user.roles.includes(role)){
+                    //remove role
+                    console.log('asd')
+                    user.roles = user.roles.filter((i) => i !== role);
+                    console.log(user.roles);
+                    await user.save();
+                    console.log(`POST: /manage-roles, successfully added ${role}`);
+ 
+                    return res.status(200).json({
+                        success:true,
+                        message:'successfully renoved role from user'
+                    })
+                } else {
+                    console.log('asd')
+                    user.roles.push(role);
+                    console.log(user);
+                    const response = await user.save();
+                    if(response){
+                        console.log(response)
+                    }
+                    console.log('gothere')
+                    console.log(`POST: /manage-roles, successfully added ${role}`);
+                    return res.status(200).json({
+                        success: true,
+                        message: "successfuly aded new role to user"
+                    })
+                }
+            }
+        }
+    } catch (error){
+        console.log(error);
+        return res.status(500).json({
+            success:false,
+            error
+        })
+    }
+})
+ 
+// Get user by username (for development testing)
+router.post('/get-user-by-username', verifyToken, async (req, res) => {
+    const { User } = getModels(req, 'User');
+    const { username } = req.body;
+    
+    try {
+        if (!username) {
+            return res.status(400).json({
+                success: false,
+                message: 'Username is required'
+            });
+        }
+ 
+        const user = await User.findOne({ username: { $regex: new RegExp(username, "i") } });
+        
+        if (!user) {
+            return res.status(404).json({
+                success: false,
+                message: 'User not found'
+            });
+        }
+ 
+        // Return minimal user info for security
+        res.status(200).json({
+            success: true,
+            user: {
+                _id: user._id,
+                username: user.username,
+                name: user.name,
+                email: user.email
+            }
+        });
+    } catch (error) {
+        console.error('Error getting user by username:', error);
+        res.status(500).json({
+            success: false,
+            message: 'Internal server error'
+        });
+    }
+});
+ 
+// Register push notification token
+router.post('/register-push-token', verifyToken, async (req, res) => {
+    const { User } = getModels(req, 'User');
+    const { pushToken, appEdition } = req.body;
+    
+    try {
+        Iif (!pushToken) {
+            return res.status(400).json({
+                success: false,
+                message: 'Push token is required'
+            });
+        }
+ 
+        const user = await User.findById(req.user.userId);
+        Iif (!user) {
+            return res.status(404).json({
+                success: false,
+                message: 'User not found'
+            });
+        }
+ 
+        user.pushToken = pushToken;
+        user.pushAppEdition = appEdition === 'pivot' ? 'pivot' : 'campus';
+        await user.save();
+ 
+        console.log(`POST: /register-push-token ${req.user.userId} successful (edition=${user.pushAppEdition})`);
+        return res.status(200).json({
+            success: true,
+            message: 'Push token registered successfully',
+            data: {
+                appEdition: user.pushAppEdition,
+            },
+        });
+    } catch (error) {
+        console.log(`POST: /register-push-token ${req.user.userId} failed:`, error);
+        return res.status(500).json({
+            success: false,
+            message: 'Error registering push token',
+            error: error.message
+        });
+    }
+});
+ 
+/**
+ * DELETE /delete-account
+ * Permanently deletes the authenticated user's account and associated data.
+ * Required for App Store Guideline 2.1 (account creation apps must offer account deletion).
+ */
+router.delete("/delete-account", verifyToken, async (req, res) => {
+    const userId = req.user.userId;
+    const models = getModels(req, 'User', 'Session', 'Friendship', 'StudyHistory', 'StudySession', 'AvailabilityPoll', 'OrgMember', 'OrgFollower', 'Rating', 'Notification', 'OrgInvite', 'OrgMemberApplication', 'Search', 'RepeatedVisit');
+    const { User, Session, Friendship, StudyHistory, StudySession, AvailabilityPoll, OrgMember, OrgFollower, Rating, Notification, OrgInvite, OrgMemberApplication, Search, RepeatedVisit } = models;
+ 
+    try {
+        const user = await User.findById(userId);
+        if (!user) {
+            return res.status(404).json({ success: false, message: 'User not found' });
+        }
+ 
+        // Delete sessions
+        await Session.deleteMany({ userId });
+        // Delete friendships (user as requester or recipient)
+        await Friendship.deleteMany({ $or: [{ requester: userId }, { recipient: userId }] });
+        // Delete study history
+        await StudyHistory.deleteMany({ user_id: userId });
+        // Delete study sessions created by user
+        await StudySession.deleteMany({ creator: userId });
+        // Delete availability polls created by user
+        await AvailabilityPoll.deleteMany({ creatorType: 'User', creatorId: userId });
+        // Delete org memberships
+        await OrgMember.deleteMany({ user_id: userId });
+        // Delete org follows
+        await OrgFollower.deleteMany({ user_id: userId });
+        // Delete ratings
+        await Rating.deleteMany({ user_id: userId });
+        // Delete notifications sent to user
+        await Notification.deleteMany({ recipient: userId, recipientModel: 'User' });
+        // Delete org invites for user
+        await OrgInvite.deleteMany({ user_id: userId });
+        // Delete org member applications
+        await OrgMemberApplication.deleteMany({ user_id: userId });
+        // Delete search history
+        await Search.deleteMany({ user_id: userId });
+        // Delete repeated visit records
+        await RepeatedVisit.deleteMany({ user_id: userId });
+ 
+        // Delete user document
+        await User.findByIdAndDelete(userId);
+ 
+        console.log(`DELETE: /delete-account - User ${userId} and associated data deleted`);
+        return res.status(200).json({ success: true, message: 'Account deleted successfully' });
+    } catch (error) {
+        console.error('DELETE: /delete-account failed:', error);
+        return res.status(500).json({ success: false, message: 'Failed to delete account', error: error.message });
+    }
+});
+ 
+module.exports = router;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/adminTenantEventsService.js.html b/backend/coverage/lcov-report/services/adminTenantEventsService.js.html new file mode 100644 index 00000000..ef3c0443 --- /dev/null +++ b/backend/coverage/lcov-report/services/adminTenantEventsService.js.html @@ -0,0 +1,529 @@ + + + + + + Code coverage report for services/adminTenantEventsService.js + + + + + + + + + +
+
+

All files / services adminTenantEventsService.js

+
+ +
+ 78.18% + Statements + 43/55 +
+ + +
+ 38.77% + Branches + 19/49 +
+ + +
+ 88.88% + Functions + 8/9 +
+ + +
+ 80.39% + Lines + 41/51 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149  +  +  +  +  +1x +1x +  +1x +  +  +2x +  +  +  +1x +1x +1x +  +1x +  +  +  +  +  +  +  +  +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +5x +5x +5x +  +5x +5x +5x +  +5x +5x +  +  +5x +4x +  +5x +2x +  +  +5x +  +5x +5x +5x +5x +  +5x +  +  +  +  +  +  +  +  +5x +5x +5x +1x +  +  +5x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +  +  +1x +  +  + 
/**
+ * Upcoming / live events for tenant admin views (tenant-scoped, elevated roles).
+ * Uses getModels(req, ...) per backend best practices.
+ */
+ 
+const getModels = require('./getModelService');
+const mongoose = require('mongoose');
+ 
+const MAX_LIMIT = 40;
+ 
+function escapeRegex(str) {
+    return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+ 
+function buildAnalyticsEventIdMatch(eventIds) {
+    const strIds = [...new Set((eventIds || []).map((id) => String(id)).filter(Boolean))];
+    const objIds = strIds
+        .filter((id) => mongoose.Types.ObjectId.isValid(id))
+        .map((id) => new mongoose.Types.ObjectId(id));
+    return {
+        strIds,
+        match: {
+            $or: [{ 'properties.event_id': { $in: strIds } }, { 'properties.event_id': { $in: objIds } }],
+        },
+    };
+}
+ 
+async function buildAnalyticsByEventId(AnalyticsEvent, eventIds) {
+    const { strIds, match } = buildAnalyticsEventIdMatch(eventIds);
+    Iif (!strIds.length) return new Map();
+ 
+    const rows = await AnalyticsEvent.aggregate([
+        {
+            $match: {
+                event: { $in: ['event_view', 'event_registration'] },
+                ...match,
+            },
+        },
+        {
+            $group: {
+                _id: { eventId: '$properties.event_id', event: '$event' },
+                count: { $sum: 1 },
+            },
+        },
+    ]);
+ 
+    const map = new Map();
+    for (const row of rows) {
+        const eventId = row?._id?.eventId != null ? String(row._id.eventId) : '';
+        if (!eventId) continue;
+        if (!map.has(eventId)) {
+            map.set(eventId, {
+                views: 0,
+                uniqueViews: 0,
+                registrations: 0,
+                uniqueRegistrations: 0,
+            });
+        }
+        const current = map.get(eventId);
+        if (row?._id?.event === 'event_view') {
+            current.views = row.count ?? 0;
+        } else if (row?._id?.event === 'event_registration') {
+            current.registrations = row.count ?? 0;
+        }
+    }
+    return map;
+}
+ 
+/**
+ * @param {import('express').Request} req
+ * @param {{ page?: number, limit?: number, q?: string, includePast?: boolean }} opts
+ */
+async function listAdminTenantUpcomingEvents(req, opts = {}) {
+    const { Event, AnalyticsEvent } = getModels(req, 'Event', 'AnalyticsEvent');
+    const rawPage = Math.max(1, parseInt(String(opts.page ?? 1), 10) || 1);
+    const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(String(opts.limit ?? 20), 10) || 20));
+ 
+    const rawQ = typeof opts.q === 'string' ? opts.q.trim() : '';
+    const q = rawQ.length >= 3 ? rawQ : '';
+    const includePast = Boolean(opts.includePast);
+ 
+    const now = new Date();
+    const query = {
+        isDeleted: { $ne: true },
+    };
+    if (!includePast) {
+        query.end_time = { $gte: now };
+    }
+    if (q) {
+        query.name = { $regex: escapeRegex(q), $options: 'i' };
+    }
+ 
+    const sort = includePast ? { start_time: -1 } : { start_time: 1 };
+ 
+    const total = await Event.countDocuments(query);
+    const totalPages = Math.max(1, Math.ceil(total / limit));
+    const page = Math.min(rawPage, totalPages);
+    const skip = (page - 1) * limit;
+ 
+    const events = await Event.find(query)
+        .select(
+            'name start_time end_time status visibility type hostingType hostingId location expectedAttendance registrationCount'
+        )
+        .sort(sort)
+        .skip(skip)
+        .limit(limit)
+        .lean();
+ 
+    const ids = events.map((e) => e._id).filter(Boolean);
+    let analyticsById = new Map();
+    if (ids.length) {
+        analyticsById = await buildAnalyticsByEventId(AnalyticsEvent, ids);
+    }
+ 
+    const enrichedEvents = events.map((e) => {
+        const a = analyticsById.get(String(e._id));
+        return {
+            ...e,
+            analyticsSummary: a
+                ? {
+                      views: a.views ?? 0,
+                      uniqueViews: a.uniqueViews ?? 0,
+                      registrations: a.registrations ?? 0,
+                      uniqueRegistrations: a.uniqueRegistrations ?? 0,
+                  }
+                : {
+                      views: 0,
+                      uniqueViews: 0,
+                      registrations: 0,
+                      uniqueRegistrations: 0,
+                  },
+        };
+    });
+ 
+    return {
+        events: enrichedEvents,
+        pagination: {
+            total,
+            page,
+            limit,
+            totalPages: Math.max(1, Math.ceil(total / limit)),
+        },
+    };
+}
+ 
+module.exports = {
+    listAdminTenantUpcomingEvents,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/adminTenantSummaryService.js.html b/backend/coverage/lcov-report/services/adminTenantSummaryService.js.html new file mode 100644 index 00000000..1a3cca22 --- /dev/null +++ b/backend/coverage/lcov-report/services/adminTenantSummaryService.js.html @@ -0,0 +1,205 @@ + + + + + + Code coverage report for services/adminTenantSummaryService.js + + + + + + + + + +
+
+

All files / services adminTenantSummaryService.js

+
+ +
+ 100% + Statements + 7/7 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 7/7 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +1x +  +1x +1x +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  + 
/**
+ * Read-only aggregates for community admin dashboard home (institution-scoped).
+ * Uses getModels(req, ...) per backend best practices.
+ */
+ 
+const getModels = require('./getModelService');
+ 
+/**
+ * @param {import('express').Request} req
+ * @returns {Promise<{
+ *   communityGroupCount: number,
+ *   upcomingEventsCount: number,
+ *   programsCount: number,
+ *   userCount: number
+ * }>}
+ */
+async function getAdminTenantSummary(req) {
+    const { Org, Event, Domain, User } = getModels(req, 'Org', 'Event', 'Domain', 'User');
+ 
+    const startOfToday = new Date();
+    startOfToday.setHours(0, 0, 0, 0);
+ 
+    const [communityGroupCount, upcomingEventsCount, programsCount, userCount] = await Promise.all([
+        Org.countDocuments({}),
+        Event.countDocuments({ start_time: { $gte: startOfToday } }),
+        Domain.countDocuments({}),
+        User.countDocuments({}),
+    ]);
+ 
+    return {
+        communityGroupCount,
+        upcomingEventsCount,
+        programsCount,
+        userCount,
+    };
+}
+ 
+module.exports = {
+    getAdminTenantSummary,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/atlasPolicyService.js.html b/backend/coverage/lcov-report/services/atlasPolicyService.js.html new file mode 100644 index 00000000..50c7c776 --- /dev/null +++ b/backend/coverage/lcov-report/services/atlasPolicyService.js.html @@ -0,0 +1,748 @@ + + + + + + Code coverage report for services/atlasPolicyService.js + + + + + + + + + +
+
+

All files / services atlasPolicyService.js

+
+ +
+ 70.65% + Statements + 65/92 +
+ + +
+ 56.91% + Branches + 70/123 +
+ + +
+ 80% + Functions + 12/15 +
+ + +
+ 71.76% + Lines + 61/85 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +2223x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +8x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +8x +8x +  +  +8x +  +  +8x +  +  +8x +  +  +  +2x +2x +2x +2x +  +  +  +  +  +  +2x +6x +  +  +  +2x +2x +  +  +  +  +2x +2x +  +  +2x +5x +2x +1x +1x +1x +  +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +2x +2x +2x +1x +  +  +  +  +1x +  +  +  +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +2x +1x +1x +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +1x +  +  +  +1x +  +  +1x +1x +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const DEFAULT_ATLAS_POLICY = {
+    lifecycle: {
+        statuses: [
+            { key: 'active', label: 'Active' },
+            { key: 'sunset', label: 'Sunset' },
+            { key: 'inactive', label: 'Inactive' }
+        ],
+        defaultStatus: 'active',
+        transitions: [
+            { from: 'active', to: 'sunset', allowedActors: ['admin', 'root', 'officer'] },
+            { from: 'sunset', to: 'inactive', allowedActors: ['admin', 'root'] },
+            { from: 'sunset', to: 'active', allowedActors: ['admin', 'root', 'officer'] },
+            { from: 'inactive', to: 'active', allowedActors: ['admin', 'root'] }
+        ]
+    },
+    orgTypes: [
+        { key: 'default', displayName: 'General', requiredGovernanceKeys: ['constitution'] },
+        { key: 'club', displayName: 'Club', requiredGovernanceKeys: ['constitution'] }
+    ],
+    defaultOrgTypeKey: 'default',
+    terminology: {
+        constitution: 'Constitution',
+        charter: 'Charter',
+        bylaws: 'Bylaws',
+        member_list: 'Member list',
+        financial_statement: 'Financial statement'
+    },
+    directory: {
+        hideNonActiveFromPublicList: false,
+        nonActiveStatuses: ['inactive']
+    },
+    budgets: {
+        /** template_only: clubs must use preset line-item definitions; template_plus_custom: clubs can append custom rows. */
+        lineItemMode: 'template_only',
+        /** Safety cap for custom rows when lineItemMode=template_plus_custom. */
+        maxCustomLineItems: 20
+    },
+    events: {
+        inactiveOrgBlocksEventCreation: true,
+        /** lifecycleStatus values that block creating new org events when policy enabled */
+        blockedLifecycleStatuses: ['inactive', 'sunset']
+    }
+};
+ 
+function deepMergeAtlasPolicy(stored) {
+    const base = JSON.parse(JSON.stringify(DEFAULT_ATLAS_POLICY));
+    if (!stored || typeof stored !== 'object') return base;
+    return {
+        lifecycle: { ...base.lifecycle, ...(stored.lifecycle || {}) },
+        orgTypes: Array.isArray(stored.orgTypes) && stored.orgTypes.length > 0
+            ? stored.orgTypes
+            : base.orgTypes,
+        defaultOrgTypeKey: stored.defaultOrgTypeKey || base.defaultOrgTypeKey,
+        terminology: { ...base.terminology, ...(stored.terminology || {}) },
+        directory: { ...base.directory, ...(stored.directory || {}) },
+        budgets: { ...base.budgets, ...(stored.budgets || {}) },
+        events: { ...base.events, ...(stored.events || {}) }
+    };
+}
+ 
+/**
+ * Merge transitions and statuses from stored policy without losing defaults entirely
+ */
+function getEffectivePolicyFromConfig(configDoc) {
+    const raw = configDoc?.atlasPolicy;
+    const merged = deepMergeAtlasPolicy(raw);
+    Iif (raw?.lifecycle?.transitions?.length) {
+        merged.lifecycle.transitions = raw.lifecycle.transitions;
+    }
+    Iif (raw?.lifecycle?.statuses?.length) {
+        merged.lifecycle.statuses = raw.lifecycle.statuses;
+    }
+    Iif (raw?.lifecycle?.defaultStatus) {
+        merged.lifecycle.defaultStatus = raw.lifecycle.defaultStatus;
+    }
+    return merged;
+}
+ 
+async function getEffectivePolicy(req) {
+    const getModels = require('./getModelService');
+    const { OrgManagementConfig } = getModels(req, 'OrgManagementConfig');
+    Eif (!OrgManagementConfig) {
+        return getEffectivePolicyFromConfig(null);
+    }
+    const config = await OrgManagementConfig.findOne();
+    return getEffectivePolicyFromConfig(config);
+}
+ 
+function statusKeys(policy) {
+    const statuses = policy?.lifecycle?.statuses || DEFAULT_ATLAS_POLICY.lifecycle.statuses;
+    return new Set(statuses.map((s) => s.key));
+}
+ 
+function assertLifecycleTransition(policy, org, toStatus, actor) {
+    const keys = statusKeys(policy);
+    Iif (!keys.has(toStatus)) {
+        const err = new Error(`Invalid lifecycle status: ${toStatus}`);
+        err.statusCode = 400;
+        throw err;
+    }
+    const fromStatus = org.lifecycleStatus || policy.lifecycle.defaultStatus || 'active';
+    Iif (fromStatus === toStatus) {
+        return;
+    }
+    const transitions = policy?.lifecycle?.transitions || [];
+    const match = transitions.find((t) => t.from === fromStatus && t.to === toStatus);
+    if (!match) {
+        const err = new Error(`Transition from "${fromStatus}" to "${toStatus}" is not allowed`);
+        err.statusCode = 400;
+        throw err;
+    }
+    const allowed = match.allowedActors || ['admin', 'root'];
+    Iif (actor.isPlatformAdmin) {
+        return;
+    }
+    Eif (actor.isOfficer && allowed.includes('officer')) {
+        return;
+    }
+    const err = new Error('You are not allowed to perform this lifecycle transition');
+    err.statusCode = 403;
+    throw err;
+}
+ 
+function governanceRequirementsForOrg(policy, org) {
+    const key = org.orgTypeKey || policy.defaultOrgTypeKey || 'default';
+    const orgType = (policy.orgTypes || []).find((t) => t.key === key);
+    Eif (orgType?.requiredGovernanceKeys?.length) {
+        return orgType.requiredGovernanceKeys;
+    }
+    const fallback = (policy.orgTypes || []).find((t) => t.key === policy.defaultOrgTypeKey);
+    return fallback?.requiredGovernanceKeys || ['constitution'];
+}
+ 
+function shouldHideOrgFromPublicList(policy, org) {
+    if (!policy?.directory?.hideNonActiveFromPublicList) return false;
+    const nonActive = policy.directory.nonActiveStatuses || ['inactive'];
+    const status = org.lifecycleStatus || policy.lifecycle?.defaultStatus || 'active';
+    return nonActive.includes(status);
+}
+ 
+function assertOrgAllowsEventCreation(policy, org) {
+    Iif (!policy?.events?.inactiveOrgBlocksEventCreation) {
+        return { ok: true };
+    }
+    const blocked = policy.events.blockedLifecycleStatuses || ['inactive', 'sunset'];
+    const status = org.lifecycleStatus || policy.lifecycle?.defaultStatus || 'active';
+    if (blocked.includes(status)) {
+        return {
+            ok: false,
+            message: `This organization cannot create events while lifecycle status is "${status}".`
+        };
+    }
+    return { ok: true };
+}
+ 
+function assertEventReservationReady(event, options = {}) {
+    const required = options.required !== false;
+    const resourceId = event?.reservation?.resourceId || event?.classroom_id || null;
+    Iif (!required || !resourceId) return { ok: true };
+    const state = event?.reservation?.state || 'draft';
+    const allowedStates = options.allowedStates || ['approved', 'requested', 'hold'];
+    Iif (!allowedStates.includes(state)) {
+        return {
+            ok: false,
+            message: `This event cannot proceed while reservation state is "${state}".`,
+            code: 'EVENT_RESERVATION_NOT_READY',
+            state
+        };
+    }
+    if (event?.reservation?.conflictSummary?.hasConflict) {
+        const detectedAt = event?.reservation?.detectedAt ? new Date(event.reservation.detectedAt) : null;
+        const conflictAgeHours = detectedAt && !Number.isNaN(detectedAt.getTime())
+            ? (Date.now() - detectedAt.getTime()) / (1000 * 60 * 60)
+            : 0;
+        const escalationThresholdHours = Number(options.escalationThresholdHours || process.env.RESERVATION_ESCALATION_THRESHOLD_HOURS || 24);
+        const escalated = conflictAgeHours >= escalationThresholdHours;
+        return {
+            ok: false,
+            message: event.reservation.conflictSummary.reason || 'This event has unresolved reservation conflicts.',
+            code: 'EVENT_RESERVATION_CONFLICT',
+            state,
+            escalated,
+            conflictAgeHours
+        };
+    }
+    return { ok: true, state };
+}
+ 
+function getReservationEscalation(event, options = {}) {
+    Iif (!event?.reservation?.conflictSummary?.hasConflict) {
+        return { escalated: false, severity: 'none', ageHours: 0 };
+    }
+    const detectedAt = event?.reservation?.detectedAt ? new Date(event.reservation.detectedAt) : null;
+    const ageHours = detectedAt && !Number.isNaN(detectedAt.getTime())
+        ? (Date.now() - detectedAt.getTime()) / (1000 * 60 * 60)
+        : 0;
+    const threshold = Number(options.escalationThresholdHours || process.env.RESERVATION_ESCALATION_THRESHOLD_HOURS || 24);
+    const escalated = ageHours >= threshold;
+    const severity = escalated ? 'high' : 'medium';
+    return { escalated, severity, ageHours, threshold };
+}
+ 
+function labelForGovernanceKey(policy, key) {
+    const t = policy?.terminology || {};
+    return t[key] || key;
+}
+ 
+module.exports = {
+    DEFAULT_ATLAS_POLICY,
+    deepMergeAtlasPolicy,
+    getEffectivePolicyFromConfig,
+    getEffectivePolicy,
+    assertLifecycleTransition,
+    governanceRequirementsForOrg,
+    shouldHideOrgFromPublicList,
+    assertOrgAllowsEventCreation,
+    assertEventReservationReady,
+    getReservationEscalation,
+    labelForGovernanceKey,
+    statusKeys
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/authGlobalService.js.html b/backend/coverage/lcov-report/services/authGlobalService.js.html new file mode 100644 index 00000000..9c091b6d --- /dev/null +++ b/backend/coverage/lcov-report/services/authGlobalService.js.html @@ -0,0 +1,673 @@ + + + + + + Code coverage report for services/authGlobalService.js + + + + + + + + + +
+
+

All files / services authGlobalService.js

+
+ +
+ 48.18% + Statements + 53/110 +
+ + +
+ 34.44% + Branches + 31/90 +
+ + +
+ 71.42% + Functions + 5/7 +
+ + +
+ 60.91% + Lines + 53/87 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197  +  +  +  +2x +2x +2x +2x +2x +  +  +  +  +  +2x +2x +2x +2x +  +  +  +  +  +  +1x +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +1x +1x +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +1x +  +  +  +  +  +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +1x +1x +  +1x +1x +  +1x +  +  +2x +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Helpers for global identity: get-or-create GlobalUser, TenantMembership, and issue tokens.
+ * Used by auth routes and SAML.
+ */
+const jwt = require('jsonwebtoken');
+const { randomUUID } = require('crypto');
+const getGlobalModels = require('./getGlobalModelService');
+const { createGlobalSession } = require('../utilities/sessionUtils');
+const { getCookieDomain } = require('../utilities/cookieUtils');
+ 
+function getModels(req, ...names) {
+    return require('./getModelService')(req, ...names);
+}
+ 
+const ACCESS_TOKEN_EXPIRY = process.env.ACCESS_TOKEN_EXPIRY || '15m';
+const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || '30d';
+const REFRESH_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
+const ACCESS_TOKEN_EXPIRY_MS = 15 * 60 * 1000;
+ 
+/**
+ * Build GlobalUser document from tenant user or OAuth profile.
+ * @param {object} source - { email, name?, picture?, googleId?, appleId?, samlId?, samlProvider? }
+ */
+function globalUserFromSource(source) {
+    const doc = {
+        email: (source.email || '').trim().toLowerCase(),
+        name: source.name || '',
+        picture: source.picture || '',
+    };
+    Iif (source.googleId) doc.googleId = source.googleId;
+    Iif (source.appleId) doc.appleId = source.appleId;
+    Iif (source.samlId) doc.samlId = source.samlId;
+    Iif (source.samlProvider) doc.samlProvider = source.samlProvider;
+    return doc;
+}
+ 
+/**
+ * Get or create GlobalUser by email and optional provider ids.
+ * @param {object} req - request with req.globalDb
+ * @param {object} source - tenant user (with _id) or profile with email, name, picture, googleId, appleId, samlId, samlProvider
+ * @returns {Promise<{GlobalUser}>}
+ */
+async function getOrCreateGlobalUser(req, source) {
+    const { GlobalUser } = getGlobalModels(req, 'GlobalUser');
+    const email = (source.email || '').trim().toLowerCase();
+    Iif (!email) throw new Error('Email is required for GlobalUser');
+ 
+    let globalUser = await GlobalUser.findOne({ email });
+    Iif (globalUser) {
+        const updates = {};
+        if (source.name != null) updates.name = source.name;
+        if (source.picture != null) updates.picture = source.picture;
+        if (source.googleId != null) updates.googleId = source.googleId;
+        if (source.appleId != null) updates.appleId = source.appleId;
+        if (source.samlId != null) updates.samlId = source.samlId;
+        if (source.samlProvider != null) updates.samlProvider = source.samlProvider;
+        if (Object.keys(updates).length) {
+            Object.assign(globalUser, updates);
+            await globalUser.save();
+        }
+        return globalUser;
+    }
+ 
+    const providerQuery = { $or: [{ email }] };
+    Iif (source.googleId) providerQuery.$or.push({ googleId: source.googleId });
+    Iif (source.appleId) providerQuery.$or.push({ appleId: source.appleId });
+    Iif (source.samlId && source.samlProvider) providerQuery.$or.push({ samlId: source.samlId, samlProvider: source.samlProvider });
+    globalUser = await GlobalUser.findOne(providerQuery);
+    Iif (globalUser) {
+        const updates = {};
+        if (source.name != null) updates.name = source.name;
+        if (source.picture != null) updates.picture = source.picture;
+        if (source.email && !globalUser.email) updates.email = email;
+        if (source.googleId != null) updates.googleId = source.googleId;
+        if (source.appleId != null) updates.appleId = source.appleId;
+        if (source.samlId != null) updates.samlId = source.samlId;
+        if (source.samlProvider != null) updates.samlProvider = source.samlProvider;
+        if (Object.keys(updates).length) {
+            Object.assign(globalUser, updates);
+            await globalUser.save();
+        }
+        return globalUser;
+    }
+ 
+    globalUser = new GlobalUser(globalUserFromSource(source));
+    await globalUser.save();
+    return globalUser;
+}
+ 
+/**
+ * Get or create TenantMembership for (globalUserId, req.school).
+ * @param {object} req - request with req.globalDb, req.school
+ * @param {object} globalUserId - ObjectId
+ * @param {object} tenantUser - tenant User document with _id
+ * @returns {Promise<{TenantMembership}>}
+ */
+async function getOrCreateTenantMembership(req, globalUserId, tenantUser) {
+    const { TenantMembership } = getGlobalModels(req, 'TenantMembership');
+    const tenantKey = req.school;
+    let membership = await TenantMembership.findOne({ globalUserId, tenantKey, status: 'active' });
+    Iif (membership) {
+        if (membership.tenantUserId.toString() !== tenantUser._id.toString()) {
+            membership.tenantUserId = tenantUser._id;
+            await membership.save();
+        }
+        return membership;
+    }
+    membership = new TenantMembership({
+        globalUserId,
+        tenantKey,
+        tenantUserId: tenantUser._id,
+        status: 'active',
+    });
+    await membership.save();
+    return membership;
+}
+ 
+/**
+ * Get platform roles for a global user.
+ */
+async function getPlatformRolesForGlobalUser(req, globalUserId) {
+    const { PlatformRole } = getGlobalModels(req, 'PlatformRole');
+    const pr = await PlatformRole.findOne({ globalUserId });
+    return (pr && pr.roles) ? [...pr.roles] : [];
+}
+ 
+/**
+ * Resolve tenant user for current request school from global user.
+ */
+async function resolveTenantUserForRequest(req, globalUserId) {
+    const { TenantMembership } = getGlobalModels(req, 'TenantMembership');
+    const membership = await TenantMembership.findOne({ globalUserId, tenantKey: req.school, status: 'active' });
+    if (!membership) return { tenantUserId: null, tenantUser: null };
+    const { User } = getModels(req, 'User');
+    const tenantUser = await User.findById(membership.tenantUserId).lean();
+    return { tenantUserId: membership.tenantUserId, tenantUser };
+}
+ 
+/**
+ * Issue access + refresh tokens and set cookies.
+ * Uses global Session and JWT payload: globalUserId, tenantUserId?, platformRoles?, roles?.
+ */
+async function issueTokens(req, res, globalUser, tenantUser, platformRoles = [], options = {}) {
+    const roles = tenantUser && tenantUser.roles ? tenantUser.roles : ['user'];
+    const tenantUserId = tenantUser ? tenantUser._id : null;
+    const mfaConfigured = Boolean(options.mfaConfigured);
+    const mfaVerified = Boolean(options.mfaVerified);
+ 
+    const refreshToken = jwt.sign(
+        { globalUserId: globalUser._id, type: 'refresh', mfaConfigured, mfaVerified, jti: randomUUID() },
+        process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
+        { expiresIn: REFRESH_TOKEN_EXPIRY }
+    );
+ 
+    await createGlobalSession(globalUser._id, refreshToken, req);
+ 
+    const accessToken = jwt.sign(
+        {
+            globalUserId: globalUser._id,
+            tenantUserId,
+            platformRoles: platformRoles.length ? platformRoles : undefined,
+            roles,
+            mfaConfigured,
+            mfaVerified,
+        },
+        process.env.JWT_SECRET,
+        { expiresIn: ACCESS_TOKEN_EXPIRY }
+    );
+ 
+    const cookieOptions = {
+        httpOnly: true,
+        secure: process.env.NODE_ENV === 'production',
+        sameSite: 'strict',
+        path: '/',
+    };
+    const domain = getCookieDomain(req);
+    Iif (domain) cookieOptions.domain = domain;
+ 
+    res.cookie('accessToken', accessToken, { ...cookieOptions, maxAge: ACCESS_TOKEN_EXPIRY_MS });
+    res.cookie('refreshToken', refreshToken, { ...cookieOptions, maxAge: 30 * 24 * 60 * 60 * 1000 });
+ 
+    return { accessToken, refreshToken, accessTokenExpiry: ACCESS_TOKEN_EXPIRY };
+}
+ 
+module.exports = {
+    getOrCreateGlobalUser,
+    getOrCreateTenantMembership,
+    getPlatformRolesForGlobalUser,
+    resolveTenantUserForRequest,
+    issueTokens,
+    globalUserFromSource,
+    ACCESS_TOKEN_EXPIRY,
+    REFRESH_TOKEN_EXPIRY,
+    ACCESS_TOKEN_EXPIRY_MS,
+    REFRESH_TOKEN_EXPIRY_MS,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/budgetService.js.html b/backend/coverage/lcov-report/services/budgetService.js.html new file mode 100644 index 00000000..b5d86f61 --- /dev/null +++ b/backend/coverage/lcov-report/services/budgetService.js.html @@ -0,0 +1,2389 @@ + + + + + + Code coverage report for services/budgetService.js + + + + + + + + + +
+
+

All files / services budgetService.js

+
+ +
+ 45.23% + Statements + 190/420 +
+ + +
+ 40.05% + Branches + 143/357 +
+ + +
+ 64.44% + Functions + 29/45 +
+ + +
+ 45.4% + Lines + 178/392 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +7692x +2x +2x +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +11x +11x +11x +3x +3x +8x +  +  +  +  +  +  +  +  +11x +  +  +  +2x +2x +  +  +  +  +7x +7x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +6x +6x +6x +6x +8x +  +6x +17x +17x +  +  +  +  +  +  +  +  +17x +11x +6x +  +  +6x +  +17x +  +  +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +3x +7x +3x +3x +3x +1x +  +2x +  +  +2x +  +  +  +2x +  +  +  +3x +3x +1x +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +2x +2x +2x +  +  +2x +  +  +  +2x +  +  +  +  +  +  +  +4x +4x +4x +  +  +  +  +4x +  +4x +4x +  +  +  +  +4x +4x +  +  +  +  +  +  +  +4x +1x +  +  +1x +1x +  +3x +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +3x +3x +  +  +  +2x +2x +2x +  +  +  +  +2x +  +  +  +  +2x +2x +2x +  +  +  +  +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +  +  +  +  +2x +  +  +  +  +2x +2x +2x +2x +  +  +  +  +2x +2x +  +  +  +  +2x +2x +  +  +  +  +  +  +2x +4x +  +  +  +  +  +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +2x +2x +  +  +  +3x +3x +3x +  +  +  +1x +1x +1x +  +  +  +  +1x +  +  +  +  +1x +1x +  +  +  +  +1x +  +  +  +  +1x +1x +1x +1x +1x +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +  +  +  +  +2x +  +  +  +  +2x +2x +  +  +  +  +2x +  +  +  +  +2x +  +  +  +  +2x +  +  +  +  +2x +2x +1x +1x +1x +  +1x +1x +1x +  +  +  +  +  +  +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +4x +  +  +  +  +  +  +  +  +2x +  +  +  +2x +  +13x +  +70x +70x +68x +  +  +  +  +  +  +  +1x +1x +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const getModels = require('./getModelService');
+const { ORG_PERMISSIONS } = require('../constants/permissions');
+const { getEffectivePolicy } = require('./atlasPolicyService');
+ 
+function defaultFinancePayload() {
+    return {
+        budgetTemplates: [
+            {
+                templateKey: 'annual_club',
+                displayName: 'Annual club budget',
+                orgTypeKeys: ['default', 'club'],
+                fiscalLabel: 'Fiscal year',
+                workflowPresetKey: 'two_stage',
+                lineItemDefinitions: [
+                    {
+                        key: 'operating',
+                        label: 'Operating',
+                        required: true,
+                        kind: 'currency',
+                        helpText: 'General operating funds requested'
+                    },
+                    {
+                        key: 'events',
+                        label: 'Events & programs',
+                        required: false,
+                        kind: 'currency',
+                        helpText: ''
+                    },
+                    {
+                        key: 'summary',
+                        label: 'Summary notes',
+                        required: false,
+                        kind: 'text',
+                        helpText: 'Optional context for reviewers'
+                    }
+                ]
+            }
+        ],
+        workflowPresets: [
+            {
+                presetKey: 'two_stage',
+                stages: [
+                    {
+                        key: 'officer',
+                        label: 'Officer review',
+                        actorType: 'org_permission',
+                        permission: ORG_PERMISSIONS.MANAGE_FINANCES
+                    },
+                    {
+                        key: 'finance_office',
+                        label: 'Finance office',
+                        actorType: 'platform_admin',
+                        permission: ''
+                    }
+                ]
+            }
+        ]
+    };
+}
+ 
+async function ensureFinanceConfig(req) {
+    const { FinanceConfig } = getModels(req, 'FinanceConfig');
+    let doc = await FinanceConfig.findOne();
+    if (!doc) {
+        doc = new FinanceConfig(defaultFinancePayload());
+        await doc.save();
+    } else Iif (
+        (!doc.budgetTemplates || doc.budgetTemplates.length === 0) &&
+        (!doc.workflowPresets || doc.workflowPresets.length === 0)
+    ) {
+        const defs = defaultFinancePayload();
+        doc.budgetTemplates = defs.budgetTemplates;
+        doc.workflowPresets = defs.workflowPresets;
+        await doc.save();
+    }
+    return doc;
+}
+ 
+function getPreset(config, presetKey) {
+    const key = presetKey || 'two_stage';
+    return (config.workflowPresets || []).find((p) => p.presetKey === key) || config.workflowPresets?.[0];
+}
+ 
+/** Append workflow audit entry (mongoose document). */
+function pushAudit(budget, { userId, actor, action, message = '', fromStatus = '', toStatus = '', stageKey = '' }) {
+    Iif (!budget.auditLog) budget.auditLog = [];
+    budget.auditLog.push({
+        at: new Date(),
+        userId,
+        actor,
+        action,
+        message: message || '',
+        fromStatus: fromStatus || '',
+        toStatus: toStatus || '',
+        stageKey: stageKey || ''
+    });
+}
+ 
+function pickTemplateForOrg(config, org) {
+    const typeKey = org.orgTypeKey || 'default';
+    const templates = config.budgetTemplates || [];
+    const match = templates.find(
+        (t) => !t.orgTypeKeys?.length || t.orgTypeKeys.includes(typeKey) || t.orgTypeKeys.includes('default')
+    );
+    return match || templates[0];
+}
+ 
+function normalizeCustomLineItemKey(raw, fallback) {
+    const base = String(raw || fallback || '')
+        .trim()
+        .toLowerCase()
+        .replace(/[^a-z0-9_]+/g, '_')
+        .replace(/^_+|_+$/g, '');
+    return base || '';
+}
+ 
+function materializeLineItems(template, incoming, options = {}) {
+    const allowCustomLineItems = options.allowCustomLineItems === true;
+    const maxCustomLineItems = Math.min(100, Math.max(0, Number(options.maxCustomLineItems) || 20));
+    const defs = template?.lineItemDefinitions || [];
+    const byKey = {};
+    (incoming || []).forEach((row) => {
+        Eif (row && row.key) byKey[row.key] = row;
+    });
+    const baseItems = defs.map((def) => {
+        const row = byKey[def.key] || {};
+        const base = {
+            key: def.key,
+            label: def.label,
+            kind: def.kind || 'currency',
+            amount: null,
+            numberValue: null,
+            textValue: '',
+            note: row.note != null ? String(row.note) : ''
+        };
+        if (def.kind === 'currency') {
+            base.amount = row.amount != null && row.amount !== '' ? Number(row.amount) : null;
+        } else Iif (def.kind === 'number') {
+            base.numberValue = row.numberValue != null && row.numberValue !== '' ? Number(row.numberValue) : null;
+        } else {
+            base.textValue = row.textValue != null ? String(row.textValue) : '';
+        }
+        return base;
+    });
+ 
+    Eif (!allowCustomLineItems) return baseItems;
+ 
+    const templateKeys = new Set(defs.map((d) => String(d.key)));
+    const usedKeys = new Set(baseItems.map((li) => String(li.key)));
+    const customItems = [];
+ 
+    for (const row of incoming || []) {
+        if (!row || row.key == null) continue;
+        const originalKey = String(row.key);
+        if (!originalKey || templateKeys.has(originalKey)) continue;
+ 
+        const normalizedKey = normalizeCustomLineItemKey(originalKey, row.label || 'custom');
+        if (!normalizedKey || usedKeys.has(normalizedKey)) continue;
+        usedKeys.add(normalizedKey);
+ 
+        const kind = ['currency', 'number', 'text'].includes(row.kind) ? row.kind : 'currency';
+        const item = {
+            key: normalizedKey,
+            label: row.label != null && String(row.label).trim() ? String(row.label).trim() : originalKey,
+            kind,
+            amount: null,
+            numberValue: null,
+            textValue: '',
+            note: row.note != null ? String(row.note) : ''
+        };
+ 
+        if (kind === 'currency') {
+            item.amount = row.amount != null && row.amount !== '' ? Number(row.amount) : null;
+        } else if (kind === 'number') {
+            item.numberValue = row.numberValue != null && row.numberValue !== '' ? Number(row.numberValue) : null;
+        } else {
+            item.textValue = row.textValue != null ? String(row.textValue) : '';
+        }
+ 
+        customItems.push(item);
+        if (customItems.length >= maxCustomLineItems) break;
+    }
+ 
+    return [...baseItems, ...customItems];
+}
+ 
+function validateRequiredLineItems(template, lineItems) {
+    const defs = template?.lineItemDefinitions || [];
+    for (const def of defs) {
+        if (!def.required) continue;
+        const li = lineItems.find((x) => x.key === def.key);
+        Iif (!li) return { ok: false, message: `Missing line item: ${def.label || def.key}` };
+        if (def.kind === 'currency' && (li.amount == null || Number.isNaN(li.amount))) {
+            return { ok: false, message: `Required amount: ${def.label || def.key}` };
+        }
+        Iif (def.kind === 'number' && (li.numberValue == null || Number.isNaN(li.numberValue))) {
+            return { ok: false, message: `Required number: ${def.label || def.key}` };
+        }
+        Iif (def.kind === 'text' && (!li.textValue || !String(li.textValue).trim())) {
+            return { ok: false, message: `Required text: ${def.label || def.key}` };
+        }
+    }
+    return { ok: true };
+}
+ 
+function nextRevisionNumber(budget) {
+    const revs = budget.revisions || [];
+    if (!revs.length) return 1;
+    return Math.max(...revs.map((r) => r.revision || 0)) + 1;
+}
+ 
+async function listBudgetsForOrg(req, orgId) {
+    const { OrgBudget } = getModels(req, 'OrgBudget');
+    return OrgBudget.find({ orgId }).sort({ updatedAt: -1 }).lean();
+}
+ 
+async function getBudgetById(req, orgId, budgetId) {
+    const { OrgBudget } = getModels(req, 'OrgBudget');
+    const b = await OrgBudget.findOne({ _id: budgetId, orgId }).lean();
+    return b;
+}
+ 
+async function getBudgetLineItemPolicy(req) {
+    const atlasPolicy = await getEffectivePolicy(req);
+    const budgetPolicy = atlasPolicy?.budgets || {};
+    const lineItemMode = budgetPolicy.lineItemMode === 'template_plus_custom'
+        ? 'template_plus_custom'
+        : 'template_only';
+    const maxCustomLineItems = Math.min(
+        100,
+        Math.max(0, Number(budgetPolicy.maxCustomLineItems) || 20)
+    );
+    return {
+        lineItemMode,
+        allowCustomLineItems: lineItemMode === 'template_plus_custom',
+        maxCustomLineItems
+    };
+}
+ 
+async function createBudget(req, orgId, userId, { templateKey, fiscalYear, title }) {
+    const { Org, OrgBudget } = getModels(req, 'Org', 'OrgBudget');
+    const org = await Org.findById(orgId);
+    Iif (!org) {
+        const err = new Error('Organization not found');
+        err.statusCode = 404;
+        throw err;
+    }
+    const config = await ensureFinanceConfig(req);
+    const template =
+        (config.budgetTemplates || []).find((t) => t.templateKey === templateKey) || pickTemplateForOrg(config, org);
+    Iif (!template) {
+        const err = new Error('No budget template configured');
+        err.statusCode = 400;
+        throw err;
+    }
+    const fy = fiscalYear != null ? String(fiscalYear) : String(new Date().getFullYear());
+    const dup = await OrgBudget.findOne({
+        orgId,
+        fiscalYear: fy,
+        templateKey: template.templateKey,
+        status: { $nin: ['rejected'] }
+    })
+        .select('_id')
+        .lean();
+    if (dup) {
+        const err = new Error(
+            'A budget already exists for this fiscal year and template. Continue the existing one or reject it before starting another.'
+        );
+        err.statusCode = 409;
+        throw err;
+    }
+    const lineItems = materializeLineItems(template, []);
+    const budget = new OrgBudget({
+        orgId,
+        templateKey: template.templateKey,
+        fiscalYear: fy,
+        title: title || `${template.displayName} ${fy}`,
+        status: 'draft',
+        lineItems,
+        workflow: {
+            presetKey: template.workflowPresetKey || 'two_stage',
+            currentStageIndex: 0,
+            stagesSnapshot: [],
+            completedStages: []
+        },
+        createdBy: userId,
+        updatedBy: userId
+    });
+    pushAudit(budget, {
+        userId,
+        actor: 'org',
+        action: 'draft_created',
+        toStatus: 'draft',
+        message: title ? `Title: ${title}` : ''
+    });
+    await budget.save();
+    return budget.toObject();
+}
+ 
+async function updateBudgetDraft(req, orgId, budgetId, userId, { lineItems: incoming, title }) {
+    const { OrgBudget } = getModels(req, 'OrgBudget');
+    const budget = await OrgBudget.findOne({ _id: budgetId, orgId });
+    Iif (!budget) {
+        const err = new Error('Budget not found');
+        err.statusCode = 404;
+        throw err;
+    }
+    Iif (!['draft', 'revision_requested'].includes(budget.status)) {
+        const err = new Error('Budget cannot be edited in its current status');
+        err.statusCode = 400;
+        throw err;
+    }
+    const config = await ensureFinanceConfig(req);
+    const template = (config.budgetTemplates || []).find((t) => t.templateKey === budget.templateKey);
+    Iif (!template) {
+        const err = new Error('Template missing');
+        err.statusCode = 400;
+        throw err;
+    }
+    Iif (title != null) budget.title = String(title);
+    const lineItemPolicy = await getBudgetLineItemPolicy(req);
+    budget.lineItems = materializeLineItems(template, incoming, lineItemPolicy);
+    budget.updatedBy = userId;
+    Iif (budget.status === 'revision_requested') {
+        pushAudit(budget, {
+            userId,
+            actor: 'org',
+            action: 'resumed_after_revision',
+            fromStatus: 'revision_requested',
+            toStatus: 'draft'
+        });
+        budget.status = 'draft';
+    }
+    await budget.save();
+    return budget.toObject();
+}
+ 
+async function addComment(req, orgId, budgetId, userId, { body }) {
+    const { OrgBudget } = getModels(req, 'OrgBudget');
+    const budget = await OrgBudget.findOne({ _id: budgetId, orgId });
+    if (!budget) {
+        const err = new Error('Budget not found');
+        err.statusCode = 404;
+        throw err;
+    }
+    const text = (body || '').trim();
+    if (!text) {
+        const err = new Error('Comment body required');
+        err.statusCode = 400;
+        throw err;
+    }
+    const revision =
+        budget.revisions && budget.revisions.length ? budget.revisions[budget.revisions.length - 1].revision : null;
+    budget.comments.push({ userId, body: text, revision });
+    budget.workflow.lastActionAt = new Date();
+    budget.workflow.lastActionBy = userId;
+    await budget.save();
+    return budget.toObject();
+}
+ 
+async function submitBudget(req, orgId, budgetId, userId) {
+    const { OrgBudget } = getModels(req, 'OrgBudget');
+    const budget = await OrgBudget.findOne({ _id: budgetId, orgId });
+    Iif (!budget) {
+        const err = new Error('Budget not found');
+        err.statusCode = 404;
+        throw err;
+    }
+    Iif (!['draft', 'revision_requested'].includes(budget.status)) {
+        const err = new Error('Only draft or revision-requested budgets can be submitted');
+        err.statusCode = 400;
+        throw err;
+    }
+    const config = await ensureFinanceConfig(req);
+    const template = (config.budgetTemplates || []).find((t) => t.templateKey === budget.templateKey);
+    const v = validateRequiredLineItems(template, budget.lineItems || []);
+    Iif (!v.ok) {
+        const err = new Error(v.message);
+        err.statusCode = 400;
+        throw err;
+    }
+    const preset = getPreset(config, budget.workflow?.presetKey || template?.workflowPresetKey);
+    Iif (!preset || !preset.stages?.length) {
+        const err = new Error('Workflow preset not configured');
+        err.statusCode = 500;
+        throw err;
+    }
+    const rev = nextRevisionNumber(budget);
+    budget.revisions.push({
+        revision: rev,
+        createdBy: userId,
+        lineItemsSnapshot: JSON.parse(JSON.stringify(budget.lineItems || [])),
+        workflowSnapshot: JSON.parse(JSON.stringify(budget.workflow || {})),
+        status: budget.status
+    });
+    budget.workflow.presetKey = preset.presetKey;
+    budget.workflow.stagesSnapshot = preset.stages.map((s) => ({
+        key: s.key,
+        label: s.label,
+        actorType: s.actorType,
+        permission: s.permission || ''
+    }));
+    budget.workflow.currentStageIndex = 0;
+    budget.workflow.completedStages = [];
+    budget.workflow.lastActionAt = new Date();
+    budget.workflow.lastActionBy = userId;
+    const prevStatus = budget.status;
+    budget.status = 'in_review';
+    budget.updatedBy = userId;
+    pushAudit(budget, {
+        userId,
+        actor: 'org',
+        action: 'submitted',
+        fromStatus: prevStatus,
+        toStatus: 'in_review'
+    });
+    await budget.save();
+    return budget.toObject();
+}
+ 
+function currentStage(budget) {
+    const stages = budget.workflow?.stagesSnapshot || [];
+    const idx = budget.workflow?.currentStageIndex ?? 0;
+    return stages[idx] || null;
+}
+ 
+async function approveStageOrg(req, orgId, budgetId, userId, stageKey) {
+    const { OrgBudget } = getModels(req, 'OrgBudget');
+    const budget = await OrgBudget.findOne({ _id: budgetId, orgId });
+    Iif (!budget) {
+        const err = new Error('Budget not found');
+        err.statusCode = 404;
+        throw err;
+    }
+    Iif (budget.status !== 'in_review') {
+        const err = new Error('Budget is not in review');
+        err.statusCode = 400;
+        throw err;
+    }
+    const stage = currentStage(budget);
+    Iif (!stage || stage.key !== stageKey) {
+        const err = new Error('Invalid workflow stage');
+        err.statusCode = 400;
+        throw err;
+    }
+    Iif (stage.actorType !== 'org_permission') {
+        const err = new Error('This stage is not an organization approval step');
+        err.statusCode = 403;
+        throw err;
+    }
+    const beforeStatus = budget.status;
+    budget.workflow.completedStages.push({ key: stage.key, approvedBy: userId, approvedAt: new Date() });
+    budget.workflow.currentStageIndex = (budget.workflow.currentStageIndex || 0) + 1;
+    budget.workflow.lastActionAt = new Date();
+    budget.workflow.lastActionBy = userId;
+    const stages = budget.workflow.stagesSnapshot || [];
+    Iif (budget.workflow.currentStageIndex >= stages.length) {
+        budget.status = 'approved';
+    }
+    budget.updatedBy = userId;
+    pushAudit(budget, {
+        userId,
+        actor: 'org',
+        action: budget.status === 'approved' ? 'approved' : 'officer_stage_approved',
+        fromStatus: beforeStatus,
+        toStatus: budget.status,
+        stageKey: stage.key
+    });
+    await budget.save();
+    return budget.toObject();
+}
+ 
+async function approveStagePlatform(req, orgId, budgetId, userId, stageKey) {
+    const { OrgBudget } = getModels(req, 'OrgBudget');
+    const budget = await OrgBudget.findOne({ _id: budgetId, orgId });
+    if (!budget) {
+        const err = new Error('Budget not found');
+        err.statusCode = 404;
+        throw err;
+    }
+    if (budget.status !== 'in_review') {
+        const err = new Error('Budget is not in review');
+        err.statusCode = 400;
+        throw err;
+    }
+    const stage = currentStage(budget);
+    if (!stage || stage.key !== stageKey) {
+        const err = new Error('Invalid workflow stage');
+        err.statusCode = 400;
+        throw err;
+    }
+    if (stage.actorType !== 'platform_admin') {
+        const err = new Error('This stage is not a platform admin step');
+        err.statusCode = 400;
+        throw err;
+    }
+    const beforeStatus = budget.status;
+    budget.workflow.completedStages.push({ key: stage.key, approvedBy: userId, approvedAt: new Date() });
+    budget.workflow.currentStageIndex = (budget.workflow.currentStageIndex || 0) + 1;
+    budget.workflow.lastActionAt = new Date();
+    budget.workflow.lastActionBy = userId;
+    const stages = budget.workflow.stagesSnapshot || [];
+    if (budget.workflow.currentStageIndex >= stages.length) {
+        budget.status = 'approved';
+    }
+    budget.updatedBy = userId;
+    pushAudit(budget, {
+        userId,
+        actor: 'platform',
+        action: budget.status === 'approved' ? 'approved' : 'platform_stage_approved',
+        fromStatus: beforeStatus,
+        toStatus: budget.status,
+        stageKey: stage.key
+    });
+    await budget.save();
+    return budget.toObject();
+}
+ 
+async function rejectBudget(req, orgId, budgetId, userId, { message, stageKey }, { platformOnly }) {
+    const { OrgBudget } = getModels(req, 'OrgBudget');
+    const budget = await OrgBudget.findOne({ _id: budgetId, orgId });
+    if (!budget) {
+        const err = new Error('Budget not found');
+        err.statusCode = 404;
+        throw err;
+    }
+    if (budget.status !== 'in_review') {
+        const err = new Error('Budget is not in review');
+        err.statusCode = 400;
+        throw err;
+    }
+    const stage = currentStage(budget);
+    if (!stage) {
+        const err = new Error('Invalid workflow');
+        err.statusCode = 400;
+        throw err;
+    }
+    if (stageKey && stage.key !== stageKey) {
+        const err = new Error('Invalid workflow stage');
+        err.statusCode = 400;
+        throw err;
+    }
+    if (platformOnly && stage.actorType !== 'platform_admin') {
+        const err = new Error('Use the organization route for this stage');
+        err.statusCode = 400;
+        throw err;
+    }
+    if (!platformOnly && stage.actorType !== 'org_permission') {
+        const err = new Error('Use the admin route for this stage');
+        err.statusCode = 400;
+        throw err;
+    }
+    const beforeStatus = budget.status;
+    budget.status = 'rejected';
+    budget.workflow.lastActionAt = new Date();
+    budget.workflow.lastActionBy = userId;
+    budget.updatedBy = userId;
+    const msg = (message && String(message).trim()) || '';
+    if (msg) {
+        budget.comments.push({ userId, body: `Rejected: ${msg}`, revision: null });
+    }
+    pushAudit(budget, {
+        userId,
+        actor: platformOnly ? 'platform' : 'org',
+        action: 'rejected',
+        fromStatus: beforeStatus,
+        toStatus: 'rejected',
+        stageKey: stage.key,
+        message: msg
+    });
+    await budget.save();
+    return budget.toObject();
+}
+ 
+async function requestRevision(req, orgId, budgetId, userId, { message, stageKey }, { platformOnly }) {
+    const { OrgBudget } = getModels(req, 'OrgBudget');
+    const budget = await OrgBudget.findOne({ _id: budgetId, orgId });
+    Iif (!budget) {
+        const err = new Error('Budget not found');
+        err.statusCode = 404;
+        throw err;
+    }
+    Iif (budget.status !== 'in_review') {
+        const err = new Error('Budget is not in review');
+        err.statusCode = 400;
+        throw err;
+    }
+    const stage = currentStage(budget);
+    Iif (!stage) {
+        const err = new Error('Invalid workflow');
+        err.statusCode = 400;
+        throw err;
+    }
+    Iif (stageKey && stage.key !== stageKey) {
+        const err = new Error('Invalid workflow stage');
+        err.statusCode = 400;
+        throw err;
+    }
+    Iif (platformOnly && stage.actorType !== 'platform_admin') {
+        const err = new Error('Use the organization route for this stage');
+        err.statusCode = 400;
+        throw err;
+    }
+    Iif (!platformOnly && stage.actorType !== 'org_permission') {
+        const err = new Error('Use the admin route for this stage');
+        err.statusCode = 400;
+        throw err;
+    }
+    const trimmed = String(message || '').trim();
+    if (!trimmed) {
+        const err = new Error('A note explaining the requested changes is required.');
+        err.statusCode = 400;
+        throw err;
+    }
+    const beforeStatus = budget.status;
+    const rev = nextRevisionNumber(budget);
+    budget.revisions.push({
+        revision: rev,
+        createdBy: userId,
+        lineItemsSnapshot: JSON.parse(JSON.stringify(budget.lineItems || [])),
+        workflowSnapshot: JSON.parse(JSON.stringify(budget.workflow || {})),
+        status: 'revision_requested'
+    });
+    budget.status = 'revision_requested';
+    budget.workflow.stagesSnapshot = [];
+    budget.workflow.currentStageIndex = 0;
+    budget.workflow.completedStages = [];
+    budget.workflow.lastActionAt = new Date();
+    budget.workflow.lastActionBy = userId;
+    budget.updatedBy = userId;
+    budget.comments.push({ userId, body: `Revision requested: ${trimmed}`, revision: rev });
+    pushAudit(budget, {
+        userId,
+        actor: platformOnly ? 'platform' : 'org',
+        action: 'revision_requested',
+        fromStatus: beforeStatus,
+        toStatus: 'revision_requested',
+        stageKey: stage.key,
+        message: trimmed
+    });
+    await budget.save();
+    return budget.toObject();
+}
+ 
+async function listBudgetsAdmin(req, { status, search, page = 1, limit = 30 }) {
+    const { OrgBudget, Org } = getModels(req, 'OrgBudget', 'Org');
+    const filter = {};
+    if (status) filter.status = status;
+    const skip = (Math.max(1, parseInt(page, 10)) - 1) * Math.min(100, Math.max(1, parseInt(limit, 10)));
+    const lim = Math.min(100, Math.max(1, parseInt(limit, 10)));
+    let orgIds = null;
+    if (search && String(search).trim()) {
+        const rx = new RegExp(String(search).trim(), 'i');
+        const orgs = await Org.find({ org_name: rx }).select('_id').lean();
+        orgIds = orgs.map((o) => o._id);
+        if (!orgIds.length) {
+            return { data: [], total: 0, page: parseInt(page, 10), limit: lim };
+        }
+        filter.orgId = { $in: orgIds };
+    }
+    const total = await OrgBudget.countDocuments(filter);
+    const rows = await OrgBudget.find(filter).sort({ updatedAt: -1 }).skip(skip).limit(lim).lean();
+    const ids = [...new Set(rows.map((r) => String(r.orgId)))];
+    const orgDocs = await Org.find({ _id: { $in: ids } })
+        .select('org_name org_profile_image orgTypeKey')
+        .lean();
+    const orgMap = Object.fromEntries(orgDocs.map((o) => [String(o._id), o]));
+    const enriched = rows.map((r) => ({
+        ...r,
+        org: orgMap[String(r.orgId)] || null
+    }));
+    return { data: enriched, total, page: parseInt(page, 10), limit: lim };
+}
+ 
+async function getFinanceConfigDoc(req) {
+    return ensureFinanceConfig(req);
+}
+ 
+async function updateFinanceConfig(req, patch) {
+    const { FinanceConfig } = getModels(req, 'FinanceConfig');
+    await ensureFinanceConfig(req);
+    const doc = await FinanceConfig.findOne();
+    if (patch.budgetTemplates) doc.budgetTemplates = patch.budgetTemplates;
+    if (patch.workflowPresets) doc.workflowPresets = patch.workflowPresets;
+    await doc.save();
+    return doc.toObject();
+}
+ 
+function budgetToExportRows(budget) {
+    const rows = [];
+    rows.push(['field', 'col2', 'col3', 'col4', 'col5', 'col6']);
+    rows.push(['budgetId', String(budget._id), '', '', '', '']);
+    rows.push(['title', budget.title || '', '', '', '', '']);
+    rows.push(['fiscalYear', budget.fiscalYear || '', '', '', '', '']);
+    rows.push(['templateKey', budget.templateKey || '', '', '', '', '']);
+    rows.push(['status', budget.status || '', '', '', '', '']);
+    rows.push(['---', 'lineItems', '', '', '', '']);
+    rows.push(['key', 'label', 'amount', 'numberValue', 'textValue', 'note']);
+    for (const li of budget.lineItems || []) {
+        rows.push([
+            li.key,
+            li.label || '',
+            li.amount != null ? String(li.amount) : '',
+            li.numberValue != null ? String(li.numberValue) : '',
+            li.textValue || '',
+            li.note || ''
+        ]);
+    }
+    return rows;
+}
+ 
+function toCsv(rows) {
+    return rows
+        .map((r) =>
+            r
+                .map((cell) => {
+                    const s = cell == null ? '' : String(cell);
+                    if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
+                    return s;
+                })
+                .join(',')
+        )
+        .join('\n');
+}
+ 
+async function exportBudget(req, orgId, budgetId, format) {
+    const budget = await getBudgetById(req, orgId, budgetId);
+    Iif (!budget) {
+        const err = new Error('Budget not found');
+        err.statusCode = 404;
+        throw err;
+    }
+    Eif (format === 'csv') {
+        const rows = budgetToExportRows(budget);
+        return { contentType: 'text/csv; charset=utf-8', body: toCsv(rows), filename: `budget-${budgetId}.csv` };
+    }
+    return {
+        contentType: 'application/json',
+        body: { success: true, data: budget },
+        filename: null
+    };
+}
+ 
+module.exports = {
+    ensureFinanceConfig,
+    defaultFinancePayload,
+    pickTemplateForOrg,
+    getPreset,
+    getBudgetLineItemPolicy,
+    listBudgetsForOrg,
+    getBudgetById,
+    createBudget,
+    updateBudgetDraft,
+    addComment,
+    submitBudget,
+    approveStageOrg,
+    approveStagePlatform,
+    rejectBudget,
+    requestRevision,
+    listBudgetsAdmin,
+    getFinanceConfigDoc,
+    updateFinanceConfig,
+    exportBudget,
+    materializeLineItems,
+    validateRequiredLineItems,
+    budgetToExportRows,
+    toCsv
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/discordWebookService.js.html b/backend/coverage/lcov-report/services/discordWebookService.js.html new file mode 100644 index 00000000..ec0b0903 --- /dev/null +++ b/backend/coverage/lcov-report/services/discordWebookService.js.html @@ -0,0 +1,196 @@ + + + + + + Code coverage report for services/discordWebookService.js + + + + + + + + + +
+
+

All files / services discordWebookService.js

+
+ +
+ 36.36% + Statements + 4/11 +
+ + +
+ 0% + Branches + 0/2 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 36.36% + Lines + 4/11 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +381x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x
require('dotenv').config();
+const express = require('express');
+const axios = require('axios');
+ 
+function sendDiscordMessage (title, message, type){
+    if(process.env.NODE_ENV === "development"){
+        return;
+    }
+ 
+    const typeColors={
+        error: 16711680,
+        normal: 16414061,
+        newUser: 10536191,
+    }
+    const embed = {
+        title: title,
+        description: message,
+        color: typeColors[type],
+        timestamp: new Date(),
+        footer: {
+          text: 'Study Compass',
+        },
+        author: {
+            name: 'Study Compass Tracker', // Author's name
+            icon_url: 'https://meridian.study/Logo.png', // URL to author's avatar image
+          },
+    };
+    try{
+        axios.post(process.env.DISCORD_WEBHOOK, {
+            embeds: [embed]
+        })
+    } catch (error){
+        console.log(error);
+    }
+ 
+} 
+ 
+module.exports = { sendDiscordMessage };
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/feedbackService.js.html b/backend/coverage/lcov-report/services/feedbackService.js.html new file mode 100644 index 00000000..00164a4d --- /dev/null +++ b/backend/coverage/lcov-report/services/feedbackService.js.html @@ -0,0 +1,1132 @@ + + + + + + Code coverage report for services/feedbackService.js + + + + + + + + + +
+
+

All files / services feedbackService.js

+
+ +
+ 2.56% + Statements + 2/78 +
+ + +
+ 0% + Branches + 0/27 +
+ + +
+ 0% + Functions + 0/14 +
+ + +
+ 2.56% + Lines + 2/78 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +3501x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
const getModels = require('./getModelService');
+ 
+class FeedbackService {
+    constructor(req) {
+        this.req = req;
+        this.models = getModels(req, 'UniversalFeedback', 'FeedbackConfig', 'SystemVersion', 'User');
+    }
+ 
+    // Get current system version
+    async getCurrentSystemVersion() {
+        const { SystemVersion } = this.models;
+        
+        let currentVersion = await SystemVersion.getCurrentVersion();
+        
+        // Initialize default version if none exists
+        if (!currentVersion) {
+            currentVersion = await SystemVersion.initializeDefaultVersion();
+        }
+        
+        return currentVersion.version;
+    }
+ 
+    // Get feedback configuration for a feature
+    async getFeedbackConfig(feature, userType = 'all') {
+        const { FeedbackConfig } = this.models;
+        
+        const configs = await FeedbackConfig.getActiveConfig(feature, userType);
+        const selectedConfig = FeedbackConfig.selectConfigForUser(configs);
+        
+        return selectedConfig;
+    }
+ 
+    // Submit feedback
+    async submitFeedback(userId, feature, processId, responses, metadata = {}) {
+        const { UniversalFeedback, FeedbackConfig } = this.models;
+        
+        // Get current system version
+        const systemVersion = await this.getCurrentSystemVersion();
+        
+        // Get feedback configuration to validate responses
+        const config = await this.getFeedbackConfig(feature);
+        
+        if (!config) {
+            throw new Error(`No feedback configuration found for feature: ${feature}`);
+        }
+        
+        // Validate responses against configuration
+        const validationErrors = config.validateResponse(responses);
+        if (validationErrors.length > 0) {
+            throw new Error(`Validation errors: ${validationErrors.join(', ')}`);
+        }
+        
+        // Check for existing feedback (prevent duplicates)
+        const existingFeedback = await UniversalFeedback.findOne({
+            user: userId,
+            feature,
+            processId
+        });
+        
+        if (existingFeedback) {
+            // Update existing feedback
+            existingFeedback.responses = responses;
+            existingFeedback.systemVersion = systemVersion;
+            existingFeedback.feedbackVersion = config.version;
+            existingFeedback.metadata = { ...existingFeedback.metadata, ...metadata };
+            existingFeedback.submittedAt = new Date();
+            
+            return existingFeedback.save();
+        } else {
+            // Create new feedback
+            const feedback = new UniversalFeedback({
+                user: userId,
+                feature,
+                processId,
+                systemVersion,
+                feedbackVersion: config.version,
+                responses,
+                metadata,
+                submittedAt: new Date()
+            });
+            
+            return feedback.save();
+        }
+    }
+ 
+    // Get feedback for a specific process (admin only)
+    async getProcessFeedback(feature, processId) {
+        const { UniversalFeedback } = this.models;
+        
+        const feedback = await UniversalFeedback.find({
+            feature,
+            processId
+        })
+        .populate('user', 'name email')
+        .sort({ submittedAt: -1 });
+        
+        return feedback;
+    }
+ 
+    // Get aggregated feedback statistics
+    async getFeedbackStats(feature, processId = null) {
+        const { UniversalFeedback } = this.models;
+        
+        const stats = await UniversalFeedback.getFeatureStats(feature, processId);
+        return stats;
+    }
+ 
+    // Get version comparison stats (for A/B testing)
+    async getVersionStats(feature, systemVersion) {
+        const { UniversalFeedback } = this.models;
+        
+        const stats = await UniversalFeedback.getVersionStats(feature, systemVersion);
+        return stats;
+    }
+ 
+    // Create default feedback configurations
+    async initializeDefaultConfigs(userId) {
+        const { FeedbackConfig, SystemVersion } = this.models;
+        const systemVersion = await this.getCurrentSystemVersion();
+        
+        // Study Session feedback configuration
+        const studySessionConfig = new FeedbackConfig({
+            feature: 'studySession',
+            version: 'v1.0',
+            systemVersion,
+            name: 'Study Session Feedback',
+            description: 'Collect feedback after study sessions',
+            fields: [
+                {
+                    fieldId: 'wasPositive',
+                    fieldType: 'boolean',
+                    label: 'Was this a positive experience?',
+                    description: 'Tell us if you found this study session helpful',
+                    required: true,
+                    order: 1
+                },
+                {
+                    fieldId: 'suggestions',
+                    fieldType: 'text',
+                    label: 'Suggestions for improvement',
+                    description: 'How can we make study sessions better?',
+                    required: false,
+                    validation: {
+                        maxLength: 1000
+                    },
+                    order: 2
+                },
+                {
+                    fieldId: 'rating',
+                    fieldType: 'rating',
+                    label: 'Overall rating',
+                    description: 'Rate your overall experience',
+                    required: false,
+                    validation: {
+                        min: 1,
+                        max: 5
+                    },
+                    order: 3
+                }
+            ],
+            isActive: true,
+            weight: 100,
+            targetUsers: 'all',
+            createdBy: userId
+        });
+        
+        // Event feedback configuration
+        const eventConfig = new FeedbackConfig({
+            feature: 'event',
+            version: 'v1.0',
+            systemVersion,
+            name: 'Event Feedback',
+            description: 'Collect feedback after events',
+            fields: [
+                {
+                    fieldId: 'wasHelpful',
+                    fieldType: 'boolean',
+                    label: 'Was this event helpful?',
+                    required: true,
+                    order: 1
+                },
+                {
+                    fieldId: 'rating',
+                    fieldType: 'rating',
+                    label: 'Event rating',
+                    description: 'Rate this event from 1 to 5',
+                    required: true,
+                    validation: {
+                        min: 1,
+                        max: 5
+                    },
+                    order: 2
+                },
+                {
+                    fieldId: 'comments',
+                    fieldType: 'text',
+                    label: 'Additional comments',
+                    required: false,
+                    validation: {
+                        maxLength: 500
+                    },
+                    order: 3
+                }
+            ],
+            isActive: true,
+            weight: 100,
+            targetUsers: 'all',
+            createdBy: userId
+        });
+        
+        // Save configurations if they don't exist
+        const existingStudySession = await FeedbackConfig.findOne({ 
+            feature: 'studySession', 
+            version: 'v1.0' 
+        });
+        
+        const existingEvent = await FeedbackConfig.findOne({ 
+            feature: 'event', 
+            version: 'v1.0' 
+        });
+        
+        const results = [];
+        
+        if (!existingStudySession) {
+            results.push(await studySessionConfig.save());
+        }
+        
+        if (!existingEvent) {
+            results.push(await eventConfig.save());
+        }
+ 
+        const existingPivotEvent = await FeedbackConfig.findOne({
+            feature: 'pivot_event',
+            version: 'v1.0',
+        });
+ 
+        if (!existingPivotEvent) {
+            results.push(await this.createPivotEventFeedbackConfig(userId, systemVersion));
+        }
+        
+        return results;
+    }
+ 
+    /** Idempotent — ensures pivot_event v1.0 exists for POST /pivot/feedback. */
+    async ensurePivotEventFeedbackConfig(userId) {
+        const { FeedbackConfig } = this.models;
+        const existing = await FeedbackConfig.findOne({
+            feature: 'pivot_event',
+            version: 'v1.0',
+        });
+        if (existing) {
+            return existing;
+        }
+ 
+        const systemVersion = await this.getCurrentSystemVersion();
+        return this.createPivotEventFeedbackConfig(userId, systemVersion);
+    }
+ 
+    createPivotEventFeedbackConfig(userId, systemVersion) {
+        const { FeedbackConfig } = this.models;
+        const pivotEventConfig = new FeedbackConfig({
+            feature: 'pivot_event',
+            version: 'v1.0',
+            systemVersion,
+            name: 'Pivot Event Feedback',
+            description: 'Quick post-event rating after Just Go events',
+            fields: [
+                {
+                    fieldId: 'rating',
+                    fieldType: 'rating',
+                    label: 'Overall rating',
+                    description: 'Rate this event from 1 to 5',
+                    required: true,
+                    validation: {
+                        min: 1,
+                        max: 5,
+                    },
+                    order: 1,
+                },
+                {
+                    fieldId: 'comment',
+                    fieldType: 'text',
+                    label: 'Optional comment',
+                    required: false,
+                    validation: {
+                        maxLength: 500,
+                    },
+                    order: 2,
+                },
+            ],
+            isActive: true,
+            weight: 100,
+            targetUsers: 'all',
+            createdBy: userId,
+        });
+        return pivotEventConfig.save();
+    }
+ 
+    // Get feedback form configuration for frontend
+    async getFeedbackForm(feature, userType = 'all') {
+        const config = await this.getFeedbackConfig(feature, userType);
+        
+        if (!config) {
+            return null;
+        }
+        
+        // Return only the necessary information for rendering the form
+        return {
+            feature,
+            version: config.version,
+            name: config.name,
+            description: config.description,
+            fields: config.fields.sort((a, b) => a.order - b.order)
+        };
+    }
+ 
+    // Check if user has already submitted feedback
+    async hasUserSubmittedFeedback(userId, feature, processId) {
+        const { UniversalFeedback } = this.models;
+        
+        const existing = await UniversalFeedback.findOne({
+            user: userId,
+            feature,
+            processId
+        });
+        
+        return !!existing;
+    }
+ 
+    // Get user's feedback history
+    async getUserFeedbackHistory(userId, feature = null, limit = 20, skip = 0) {
+        const { UniversalFeedback } = this.models;
+        
+        const query = { user: userId };
+        if (feature) {
+            query.feature = feature;
+        }
+        
+        const feedback = await UniversalFeedback.find(query)
+            .sort({ submittedAt: -1 })
+            .skip(skip)
+            .limit(limit)
+            .select('feature processId responses systemVersion feedbackVersion submittedAt');
+        
+        return feedback;
+    }
+}
+ 
+module.exports = FeedbackService;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/getGlobalModelService.js.html b/backend/coverage/lcov-report/services/getGlobalModelService.js.html new file mode 100644 index 00000000..a91fa0fc --- /dev/null +++ b/backend/coverage/lcov-report/services/getGlobalModelService.js.html @@ -0,0 +1,253 @@ + + + + + + Code coverage report for services/getGlobalModelService.js + + + + + + + + + +
+
+

All files / services getGlobalModelService.js

+
+ +
+ 95% + Statements + 19/20 +
+ + +
+ 50% + Branches + 2/4 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 95% + Lines + 19/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +579x +9x +9x +9x +9x +9x +9x +9x +9x +9x +  +  +  +  +  +  +  +  +  +  +9x +3x +  +  +3x +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +3x +3x +  +3x +  +  +  +9x + 
const globalUserSchema = require('../schemas/globalUser');
+const platformRoleSchema = require('../schemas/platformRole');
+const tenantMembershipSchema = require('../schemas/tenantMembership');
+const globalSessionSchema = require('../schemas/globalSession');
+const tenantConfigSchema = require('../schemas/tenantConfig');
+const pivotReferralCodeSchema = require('../schemas/pivotReferralCode');
+const pivotReferralRedemptionSchema = require('../schemas/pivotReferralRedemption');
+const pivotWeeklySnapshotSchema = require('../schemas/pivotWeeklySnapshot');
+const pivotLabNotesSchema = require('../schemas/pivotLabNotes');
+const pivotTagCatalogSchema = require('../schemas/pivotTagCatalog');
+ 
+/**
+ * Get models from the global/platform DB (cross-tenant data).
+ * Use only in auth flows and admin-resolution logic.
+ * Requires req.globalDb to be set (see app.js middleware).
+ *
+ * @param {object} req - request with req.globalDb
+ * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'PivotReferralCode', 'PivotReferralRedemption', 'PivotWeeklySnapshot', 'PivotLabNotes', 'PivotTagCatalog'
+ * @returns {object} map of requested models
+ */
+const getGlobalModels = (req, ...names) => {
+    Iif (!req.globalDb) {
+        throw new Error('req.globalDb is not set; ensure global DB middleware runs first');
+    }
+    const db = req.globalDb;
+ 
+    const models = {
+        GlobalUser: db.model('GlobalUser', globalUserSchema, 'global_users'),
+        PlatformRole: db.model('PlatformRole', platformRoleSchema, 'platform_roles'),
+        TenantMembership: db.model('TenantMembership', tenantMembershipSchema, 'tenant_memberships'),
+        Session: db.model('Session', globalSessionSchema, 'sessions'),
+        TenantConfig: db.model('TenantConfig', tenantConfigSchema, 'tenant_config'),
+        PivotReferralCode: db.model('PivotReferralCode', pivotReferralCodeSchema, 'pivot_referral_codes'),
+        PivotReferralRedemption: db.model(
+            'PivotReferralRedemption',
+            pivotReferralRedemptionSchema,
+            'pivot_referral_redemptions'
+        ),
+        PivotWeeklySnapshot: db.model(
+            'PivotWeeklySnapshot',
+            pivotWeeklySnapshotSchema,
+            'pivot_weekly_snapshots'
+        ),
+        PivotLabNotes: db.model('PivotLabNotes', pivotLabNotesSchema, 'pivot_lab_notes'),
+        PivotTagCatalog: db.model('PivotTagCatalog', pivotTagCatalogSchema, 'pivot_tag_catalog'),
+    };
+ 
+    return names.reduce((acc, name) => {
+        Eif (models[name]) {
+            acc[name] = models[name];
+        }
+        return acc;
+    }, {});
+};
+ 
+module.exports = getGlobalModels;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/imageUploadService.js.html b/backend/coverage/lcov-report/services/imageUploadService.js.html new file mode 100644 index 00000000..b8e08d90 --- /dev/null +++ b/backend/coverage/lcov-report/services/imageUploadService.js.html @@ -0,0 +1,601 @@ + + + + + + Code coverage report for services/imageUploadService.js + + + + + + + + + +
+
+

All files / services imageUploadService.js

+
+ +
+ 23.94% + Statements + 17/71 +
+ + +
+ 0% + Branches + 0/36 +
+ + +
+ 0% + Functions + 0/6 +
+ + +
+ 23.94% + Lines + 17/71 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +1731x +1x +1x +1x +  +  +1x +  +  +  +  +  +  +  +1x +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  + 
const path = require('path');
+const s3 = require('../aws-config');
+const multer = require('multer');
+const crypto = require('crypto');
+ 
+// Define allowed file types
+const ALLOWED_MIME_TYPES = [
+    'image/jpeg',
+    'image/png',
+    'image/gif',
+    'image/webp'
+];
+ 
+// Maximum file size (5MB)
+const MAX_FILE_SIZE = 5 * 1024 * 1024;
+ 
+// File type validation middleware
+const fileFilter = (req, file, cb) => {
+    if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
+        cb(new Error('Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.'), false);
+        return;
+    }
+    cb(null, true);
+};
+ 
+const upload = multer({
+    storage: multer.memoryStorage(),
+    limits: {
+        fileSize: MAX_FILE_SIZE,
+    },
+    fileFilter: fileFilter
+});
+ 
+// Generate a secure random filename
+const generateSecureFileName = (originalName, prefix = '') => {
+    const timestamp = Date.now();
+    const randomString = crypto.randomBytes(8).toString('hex');
+    const extension = path.extname(originalName).toLowerCase();
+    return `${prefix}${timestamp}-${randomString}${extension}`;
+};
+ 
+const uploadImageToS3 = async (file, folderName, customFileName = null) => {
+    if (!file) {
+        throw new Error('No file provided.');
+    }
+ 
+    // Validate file type again (double-check)
+    if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
+        throw new Error('Invalid file type.');
+    }
+ 
+    // Validate file size again (double-check)
+    if (file.size > MAX_FILE_SIZE) {
+        throw new Error('File size exceeds limit.');
+    }
+ 
+    const fileName = customFileName || generateSecureFileName(file.originalname);
+    const s3Params = {
+        Bucket: process.env.AWS_S3_BUCKET_NAME,
+        Key: `${folderName}/${fileName}`,
+        Body: file.buffer,
+        ContentType: file.mimetype,
+        // Add security headers
+        ContentDisposition: 'inline',
+        CacheControl: 'public, max-age=31536000',
+    };
+ 
+    try {
+        const s3Response = await s3.upload(s3Params).promise();
+        return s3Response.Location;
+    } catch (error) {
+        console.error('Error uploading image to S3:', error);
+        throw new Error('Failed to upload image to S3.');
+    }
+};
+ 
+const deleteAndUploadImageToS3 = async (file, folderName, existingImageUrl, customFileName = null) => {
+    if (!file) {
+        throw new Error('No file provided.');
+    }
+ 
+    // Validate file type again (double-check)
+    if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
+        throw new Error('Invalid file type.');
+    }
+ 
+    // Validate file size again (double-check)
+    if (file.size > MAX_FILE_SIZE) {
+        throw new Error('File size exceeds limit.');
+    }
+ 
+    const fileName = customFileName || generateSecureFileName(file.originalname);
+    const s3ParamsUpload = {
+        Bucket: process.env.AWS_S3_BUCKET_NAME,
+        Key: `${folderName}/${fileName}`,
+        Body: file.buffer,
+        ContentType: file.mimetype,
+        // Add security headers
+        ContentDisposition: 'inline',
+        CacheControl: 'public, max-age=31536000',
+    };
+ 
+    try {
+        //if an existing image URL is provided, delete it
+        if (existingImageUrl) {
+            const existingKey = existingImageUrl.split(`${process.env.AWS_S3_BUCKET_NAME}/`)[1];
+            if (existingKey) {
+                const s3ParamsDelete = {
+                    Bucket: process.env.AWS_S3_BUCKET_NAME,
+                    Key: existingKey,
+                };
+ 
+                await s3.deleteObject(s3ParamsDelete).promise();
+                console.log(`Deleted existing image: ${existingKey}`);
+            }
+        }
+ 
+        const s3Response = await s3.upload(s3ParamsUpload).promise();
+        return s3Response.Location;
+    } catch (error) {
+        console.error('Error deleting or uploading image to S3:', error);
+        throw new Error('Failed to delete or upload image to S3.');
+    }
+};
+ 
+const GOVERNANCE_DOC_MIME_TYPES = ['application/pdf'];
+const MAX_GOVERNANCE_DOC_SIZE = 15 * 1024 * 1024;
+ 
+const governanceDocFileFilter = (req, file, cb) => {
+    if (!GOVERNANCE_DOC_MIME_TYPES.includes(file.mimetype)) {
+        cb(new Error('Only PDF files are allowed for governance documents.'), false);
+        return;
+    }
+    cb(null, true);
+};
+ 
+const governanceUpload = multer({
+    storage: multer.memoryStorage(),
+    limits: { fileSize: MAX_GOVERNANCE_DOC_SIZE },
+    fileFilter: governanceDocFileFilter
+});
+ 
+const uploadDocumentToS3 = async (file, folderName, customFileName = null) => {
+    if (!file) {
+        throw new Error('No file provided.');
+    }
+    if (!GOVERNANCE_DOC_MIME_TYPES.includes(file.mimetype)) {
+        throw new Error('Invalid file type for document upload.');
+    }
+    if (file.size > MAX_GOVERNANCE_DOC_SIZE) {
+        throw new Error('File size exceeds limit.');
+    }
+    const fileName = customFileName || generateSecureFileName(file.originalname, 'gov-');
+    const s3Params = {
+        Bucket: process.env.AWS_S3_BUCKET_NAME,
+        Key: `${folderName}/${fileName}`,
+        Body: file.buffer,
+        ContentType: file.mimetype,
+        ContentDisposition: 'inline',
+        CacheControl: 'public, max-age=31536000'
+    };
+    const s3Response = await s3.upload(s3Params).promise();
+    return s3Response.Location;
+};
+ 
+module.exports = {
+    uploadImageToS3,
+    deleteAndUploadImageToS3,
+    upload,
+    uploadDocumentToS3,
+    governanceUpload
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/index.html b/backend/coverage/lcov-report/services/index.html new file mode 100644 index 00000000..9111b730 --- /dev/null +++ b/backend/coverage/lcov-report/services/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for services + + + + + + + + + +
+
+

All files services

+
+ +
+ 44.85% + Statements + 240/535 +
+ + +
+ 35.57% + Branches + 148/416 +
+ + +
+ 43.05% + Functions + 31/72 +
+ + +
+ 45.14% + Lines + 237/525 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
feedbackService.js +
+
2.56%2/780%0/270%0/142.56%2/78
pivotFeedService.js +
+
91.28%220/24174.74%148/198100%31/3191.17%217/238
pivotFeedbackService.js +
+
8.98%8/890%0/610%0/149.75%8/82
pivotIntentService.js +
+
7.87%10/1270%0/1300%0/137.87%10/127
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/notificationService.js.html b/backend/coverage/lcov-report/services/notificationService.js.html new file mode 100644 index 00000000..9c5d3561 --- /dev/null +++ b/backend/coverage/lcov-report/services/notificationService.js.html @@ -0,0 +1,4423 @@ + + + + + + Code coverage report for services/notificationService.js + + + + + + + + + +
+
+

All files / services notificationService.js

+
+ +
+ 4.44% + Statements + 16/360 +
+ + +
+ 2.99% + Branches + 9/301 +
+ + +
+ 7.69% + Functions + 4/52 +
+ + +
+ 4.5% + Lines + 16/355 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +1157 +1158 +1159 +1160 +1161 +1162 +1163 +1164 +1165 +1166 +1167 +1168 +1169 +1170 +1171 +1172 +1173 +1174 +1175 +1176 +1177 +1178 +1179 +1180 +1181 +1182 +1183 +1184 +1185 +1186 +1187 +1188 +1189 +1190 +1191 +1192 +1193 +1194 +1195 +1196 +1197 +1198 +1199 +1200 +1201 +1202 +1203 +1204 +1205 +1206 +1207 +1208 +1209 +1210 +1211 +1212 +1213 +1214 +1215 +1216 +1217 +1218 +1219 +1220 +1221 +1222 +1223 +1224 +1225 +1226 +1227 +1228 +1229 +1230 +1231 +1232 +1233 +1234 +1235 +1236 +1237 +1238 +1239 +1240 +1241 +1242 +1243 +1244 +1245 +1246 +1247 +1248 +1249 +1250 +1251 +1252 +1253 +1254 +1255 +1256 +1257 +1258 +1259 +1260 +1261 +1262 +1263 +1264 +1265 +1266 +1267 +1268 +1269 +1270 +1271 +1272 +1273 +1274 +1275 +1276 +1277 +1278 +1279 +1280 +1281 +1282 +1283 +1284 +1285 +1286 +1287 +1288 +1289 +1290 +1291 +1292 +1293 +1294 +1295 +1296 +1297 +1298 +1299 +1300 +1301 +1302 +1303 +1304 +1305 +1306 +1307 +1308 +1309 +1310 +1311 +1312 +1313 +1314 +1315 +1316 +1317 +1318 +1319 +1320 +1321 +1322 +1323 +1324 +1325 +1326 +1327 +1328 +1329 +1330 +1331 +1332 +1333 +1334 +1335 +1336 +1337 +1338 +1339 +1340 +1341 +1342 +1343 +1344 +1345 +1346 +1347 +1348 +1349 +1350 +1351 +1352 +1353 +1354 +1355 +1356 +1357 +1358 +1359 +1360 +1361 +1362 +1363 +1364 +1365 +1366 +1367 +1368 +1369 +1370 +1371 +1372 +1373 +1374 +1375 +1376 +1377 +1378 +1379 +1380 +1381 +1382 +1383 +1384 +1385 +1386 +1387 +1388 +1389 +1390 +1391 +1392 +1393 +1394 +1395 +1396 +1397 +1398 +1399 +1400 +1401 +1402 +1403 +1404 +1405 +1406 +1407 +1408 +1409 +1410 +1411 +1412 +1413 +1414 +1415 +1416 +1417 +1418 +1419 +1420 +1421 +1422 +1423 +1424 +1425 +1426 +1427 +1428 +1429 +1430 +1431 +1432 +1433 +1434 +1435 +1436 +1437 +1438 +1439 +1440 +1441 +1442 +1443 +1444 +1445 +1446 +14471x +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +2x +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +3x +3x +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +2x +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x
const getModels = require('./getModelService');
+const mongoose = require('mongoose');
+const axios = require('axios');
+ 
+class NotificationService {
+    constructor(models = null) {
+        this.models = models;
+    }
+ 
+    /**
+     * Create service instance with models
+     */
+    static withModels(models) {
+        return new NotificationService(models);
+    }
+ 
+    /**
+     * Get models - throws error if not provided
+     */
+    getModels() {
+        if (!this.models) {
+            throw new Error('Models not provided to NotificationService');
+        }
+        return this.models;
+    }
+    /**
+     * Create a single notification
+     */
+    async createNotification(notificationData) {
+        try {
+            const { Notification } = this.getModels();
+            const notification = new Notification(notificationData);
+            await notification.save();
+            
+            // Trigger delivery if not scheduled
+            if (!notification.scheduledFor || notification.scheduledFor <= new Date()) {
+                await this.deliverNotification(notification);
+            }
+            
+            return notification;
+        } catch (error) {
+            throw new Error(`Failed to create notification: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Create multiple notifications in batch
+     */
+    async createBatchNotifications(notificationsData) {
+        try {
+            const { Notification } = this.getModels();
+            const notifications = await Notification.createBatch(notificationsData);
+            
+            // Deliver notifications that aren't scheduled
+            const immediateNotifications = notifications.filter(
+                n => !n.scheduledFor || n.scheduledFor <= new Date()
+            );
+            
+            for (const notification of immediateNotifications) {
+                await this.deliverNotification(notification);
+            }
+            
+            return notifications;
+        } catch (error) {
+            throw new Error(`Failed to create batch notifications: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Send notification to multiple recipients
+     */
+    async sendToMultipleRecipients(recipients, notificationData) {
+        try {
+            const notifications = recipients.map(recipient => ({
+                ...notificationData,
+                recipient: recipient.id,
+                recipientModel: recipient.model
+            }));
+            
+            return await this.createBatchNotifications(notifications);
+        } catch (error) {
+            throw new Error(`Failed to send to multiple recipients: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Send notification to all users in an organization
+     */
+    async sendToOrgMembers(orgId, notificationData, options = {}) {
+        try {
+            const { Org } = this.getModels();
+            const org = await Org.findById(orgId).populate('members');
+            if (!org) {
+                throw new Error('Organization not found');
+            }
+ 
+            let members = org.members || [];
+            
+            // Filter by role if specified
+            if (options.roles) {
+                members = members.filter(member => 
+                    options.roles.includes(member.role)
+                );
+            }
+ 
+            const notifications = members.map(member => ({
+                ...notificationData,
+                recipient: member.userId,
+                recipientModel: 'User',
+                metadata: {
+                    ...notificationData.metadata,
+                    orgId: orgId,
+                    memberRole: member.role
+                }
+            }));
+ 
+            return await this.createBatchNotifications(notifications);
+        } catch (error) {
+            throw new Error(`Failed to send to org members: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Deliver notification through specified channels
+     */
+    async deliverNotification(notification) {
+        try {
+            notification.deliveryAttempts += 1;
+            notification.lastDeliveryAttempt = new Date();
+            
+            const deliveryPromises = notification.channels.map(channel => 
+                this.deliverToChannel(notification, channel)
+            );
+            
+            await Promise.allSettled(deliveryPromises);
+            
+            // Update delivery status based on results
+            notification.deliveryStatus = 'sent';
+            await notification.save();
+            
+        } catch (error) {
+            notification.deliveryStatus = 'failed';
+            await notification.save();
+            throw new Error(`Failed to deliver notification: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Deliver notification to a specific channel
+     */
+    async deliverToChannel(notification, channel) {
+        switch (channel) {
+            case 'in_app':
+                // In-app notifications are already "delivered" when created
+                return Promise.resolve();
+                
+            case 'email':
+                return this.sendEmailNotification(notification);
+                
+            case 'push':
+                return this.sendPushNotification(notification);
+                
+            case 'sms':
+                return this.sendSMSNotification(notification);
+                
+            case 'webhook':
+                return this.sendWebhookNotification(notification);
+                
+            default:
+                throw new Error(`Unsupported channel: ${channel}`);
+        }
+    }
+ 
+    /**
+     * Send email notification
+     */
+    async sendEmailNotification(notification) {
+        // TODO: Implement email service integration
+        console.log(`Sending email notification: ${notification.title}`);
+        return Promise.resolve();
+    }
+ 
+    /**
+     * Send push notification via Expo Push Notification API
+     */
+    async sendPushNotification(notification) {
+        try {
+            const { User } = this.getModels();
+            
+            // Get recipient user to fetch push token
+            let recipient;
+            const recipientId = notification.recipient?._id || notification.recipient;
+            const recipientModel = notification.recipientModel;
+            
+            if (recipientModel === 'User') {
+                recipient = await User.findById(recipientId);
+            } else {
+                // For other recipient types, we might need to handle differently
+                console.warn(`Push notifications not supported for recipient model: ${recipientModel}`);
+                return Promise.resolve(); // Don't fail, just skip
+            }
+ 
+            if (!recipient) {
+                console.warn(`Recipient not found for notification ${notification._id}`);
+                return Promise.resolve(); // Don't fail, just skip
+            }
+ 
+            if (!recipient.pushToken) {
+                console.warn(`User ${recipient._id} does not have a push token registered`);
+                return Promise.resolve(); // Don't fail, just skip
+            }
+ 
+            // Strip HTML tags from message for push notification body
+            const stripHtml = (html) => {
+                if (!html) return '';
+                return html.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim();
+            };
+ 
+            // Build navigation instructions from notification
+            const notificationKind = this.resolveNotificationKind(notification);
+            const navigationInstructions = this.buildNavigationInstructions(notification, {
+                pushAppEdition: recipient.pushAppEdition,
+            });
+            const friendshipId = notification.template?.variables?.friendshipId;
+            const pushNotificationType = ['friend_request', 'friend_accepted', 'friend_activity'].includes(
+                notificationKind,
+            )
+                ? notificationKind
+                : (notification.type || 'system');
+            console.log(`📱 [PushNotification] Building navigation for notification ${notification._id}:`, {
+                notificationType: notification.type,
+                notificationKind,
+                pushNotificationType,
+                hasMetadataNavigation: !!(notification.metadata && notification.metadata.navigation),
+                builtNavigation: navigationInstructions,
+                metadata: notification.metadata
+            });
+ 
+            // Prepare notification payload for Expo
+            const expoPushMessage = {
+                to: recipient.pushToken,
+                sound: 'default',
+                title: notification.title || 'Notification',
+                body: stripHtml(notification.message) || '',
+                data: {
+                    notificationId: notification._id?.toString() || notification._id,
+                    type: pushNotificationType,
+                    edition: recipient.pushAppEdition || 'campus',
+                    appEdition: recipient.pushAppEdition || 'campus',
+                    navigation: navigationInstructions, // Backend-controlled navigation
+                    ...(friendshipId ? { friendshipId: String(friendshipId) } : {}),
+                    ...(notification.metadata || {}),
+                    ...(notification.actions ? { actions: notification.actions } : {})
+                },
+                badge: notification.priority === 'high' || notification.priority === 'urgent' ? 1 : undefined,
+                priority: notification.priority === 'urgent' ? 'high' : 'default',
+                channelId: 'default'
+            };
+ 
+            // Send to Expo Push Notification API
+            const expoPushUrl = 'https://exp.host/--/api/v2/push/send';
+            const response = await axios.post(expoPushUrl, expoPushMessage, {
+                headers: {
+                    'Content-Type': 'application/json',
+                    'Accept': 'application/json',
+                    'Accept-Encoding': 'gzip, deflate'
+                }
+            });
+ 
+            // Check response for errors
+            if (response.data && response.data.data) {
+                const result = response.data.data;
+                if (result.status === 'error') {
+                    console.error('Expo push notification error:', result.message);
+                    // Handle specific error cases
+                    if (result.message && result.message.includes('InvalidCredentials')) {
+                        console.error('Invalid Expo push token - user may need to re-register');
+                    }
+                    return Promise.resolve(); // Don't throw - allow other channels to still work
+                }
+            }
+ 
+            console.log(`Push notification sent successfully to ${recipient._id}`);
+            return Promise.resolve();
+        } catch (error) {
+            console.error('Error sending push notification:', error);
+            // Don't throw - allow other channels to still work
+            return Promise.resolve();
+        }
+    }
+ 
+    /**
+     * Resolve the logical notification kind (template name wins over stored type).
+     */
+    resolveNotificationKind(notification) {
+        return notification?.template?.name || notification?.type || 'system';
+    }
+ 
+    /**
+     * Pivot edition routes for friend notifications (Task 7.6).
+     */
+    applyPivotEditionNavigation(notificationKind, navigation) {
+        switch (notificationKind) {
+            case 'friend_request':
+                return {
+                    type: 'navigate',
+                    route: 'PivotFriendRequests',
+                    params: {},
+                    deepLink: 'meridian://pivot/friends/requests',
+                };
+            case 'friend_accepted':
+                return {
+                    type: 'navigate',
+                    route: 'PivotFriends',
+                    params: {},
+                    deepLink: 'meridian://pivot/friends',
+                };
+            default:
+                return navigation;
+        }
+    }
+ 
+    /**
+     * Build navigation instructions for mobile app from notification
+     * This allows the backend to control what happens when a notification is tapped
+     */
+    buildNavigationInstructions(notification, { pushAppEdition } = {}) {
+        console.log(`🧭 [Backend] buildNavigationInstructions called for notification:`, {
+            notificationId: notification._id,
+            notificationType: notification.type,
+            notificationKind: this.resolveNotificationKind(notification),
+            pushAppEdition,
+            hasMetadata: !!notification.metadata,
+            metadataNavigation: notification.metadata?.navigation,
+        });
+        
+        let navigation;
+ 
+        // If navigation is explicitly set in metadata, use it
+        if (notification.metadata && notification.metadata.navigation) {
+            console.log(`🧭 [Backend] Using explicit navigation from metadata:`, notification.metadata.navigation);
+            navigation = notification.metadata.navigation;
+        } else E{
+            console.log(`🧭 [Backend] Building navigation from notification type:`, notification.type);
+ 
+            // Otherwise, build navigation based on notification type and metadata
+            navigation = {
+                type: 'navigate', // 'navigate', 'deep_link', 'api_call', or 'none'
+                route: null,
+                params: {},
+                deepLink: null
+            };
+ 
+            const notificationKind = this.resolveNotificationKind(notification);
+ 
+            // Build navigation based on notification kind
+            switch (notificationKind) {
+            case 'event':
+            case 'event_reminder':
+            case 'event_update':
+                if (notification.metadata && notification.metadata.eventId) {
+                    navigation.route = 'EventDetails';
+                    navigation.params = { eventId: notification.metadata.eventId };
+                    navigation.deepLink = `meridian://event/${notification.metadata.eventId}`;
+                }
+                break;
+ 
+            case 'org':
+            case 'membership':
+                if (notification.metadata && notification.metadata.orgId) {
+                    navigation.route = 'OrganizationProfile';
+                    navigation.params = { orgId: notification.metadata.orgId };
+                    navigation.deepLink = `meridian://organization/${notification.metadata.orgId}`;
+                }
+                break;
+ 
+            case 'friend_request':
+                navigation.route = 'MainTabs';
+                navigation.params = { 
+                    screen: 'Friends',
+                    params: { initialTab: 'requests' }
+                };
+                navigation.deepLink = 'meridian://friends/requests';
+                console.log(`🧭 [Backend] Built friend_request navigation:`, navigation);
+                break;
+ 
+            case 'friend_accepted':
+            case 'friend_activity':
+                navigation.route = 'MainTabs';
+                navigation.params = { 
+                    screen: 'Friends'
+                };
+                navigation.deepLink = 'meridian://friends';
+                break;
+ 
+            case 'room':
+            case 'room_availability':
+                if (notification.metadata && notification.metadata.roomId) {
+                    navigation.route = 'RoomDetails';
+                    navigation.params = { roomId: notification.metadata.roomId };
+                    navigation.deepLink = `meridian://room/${notification.metadata.roomId}`;
+                }
+                break;
+ 
+            case 'system':
+            case 'system_update':
+                if (notification.metadata && notification.metadata.settings) {
+                    navigation.route = 'Profile';
+                    navigation.params = { screen: 'Settings' };
+                    navigation.deepLink = 'meridian://settings';
+                }
+                break;
+ 
+            default:
+                // Default to home/events screen
+                navigation.route = 'Events';
+                navigation.deepLink = 'meridian://';
+        }
+        }
+ 
+        if (pushAppEdition === 'pivot') {
+            navigation = this.applyPivotEditionNavigation(
+                this.resolveNotificationKind(notification),
+                navigation,
+            );
+        }
+ 
+        return navigation;
+    }
+ 
+    /**
+     * Send SMS notification
+     */
+    async sendSMSNotification(notification) {
+        // TODO: Implement SMS service
+        console.log(`Sending SMS notification: ${notification.title}`);
+        return Promise.resolve();
+    }
+ 
+    /**
+     * Send webhook notification
+     */
+    async sendWebhookNotification(notification) {
+        // TODO: Implement webhook delivery
+        console.log(`Sending webhook notification: ${notification.title}`);
+        return Promise.resolve();
+    }
+ 
+    /**
+     * Get notifications for a recipient
+     */
+    async getNotifications(recipientId, recipientModel, options = {}) {
+        try {
+            const { Notification } = this.getModels();
+            const queryOptions = {
+                limit: options.limit || 20,
+                skip: options.skip || 0
+            };
+            
+            if (options.status) {
+                queryOptions.status = options.status;
+            }
+            
+            if (options.type) {
+                queryOptions.type = options.type;
+            }
+            
+            return await Notification.findByRecipient(recipientId, recipientModel, queryOptions).populate('sender');
+        } catch (error) {
+            throw new Error(`Failed to get notifications: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Get unread notification count
+     */
+    async getUnreadCount(recipientId, recipientModel) {
+        try {
+            const { Notification } = this.getModels();
+            return await Notification.countDocuments({
+                recipient: recipientId,
+                recipientModel: recipientModel,
+                status: 'unread'
+            });
+        } catch (error) {
+            throw new Error(`Failed to get unread count: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Mark notification as read
+     */
+    async markAsRead(notificationId, recipientId) {
+        try {
+            const { Notification } = this.getModels();
+            const notification = await Notification.findOne({
+                _id: notificationId,
+                recipient: recipientId
+            });
+            
+            if (!notification) {
+                throw new Error('Notification not found');
+            }
+            
+            return await notification.markAsRead();
+        } catch (error) {
+            throw new Error(`Failed to mark as read: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Mark multiple notifications as read
+     */
+    async markMultipleAsRead(notificationIds, recipientId) {
+        try {
+            const { Notification } = this.getModels();
+            return await Notification.updateMany(
+                {
+                    _id: { $in: notificationIds },
+                    recipient: recipientId
+                },
+                {
+                    status: 'read',
+                    readAt: new Date()
+                }
+            );
+        } catch (error) {
+            throw new Error(`Failed to mark multiple as read: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Mark all notifications as read for a recipient
+     */
+    async markAllAsRead(recipientId, recipientModel, options = {}) {
+        try {
+            const { Notification } = this.getModels();
+            const query = {
+                recipient: recipientId,
+                recipientModel: recipientModel,
+                status: 'unread'
+            };
+            
+            if (options.type) {
+                query.type = options.type;
+            }
+            
+            return await Notification.updateMany(
+                query,
+                {
+                    status: 'read',
+                    readAt: new Date()
+                }
+            );
+        } catch (error) {
+            throw new Error(`Failed to mark all as read: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Acknowledge notification
+     */
+    async acknowledgeNotification(notificationId, recipientId) {
+        try {
+            const { Notification } = this.getModels();
+            const notification = await Notification.findOne({
+                _id: notificationId,
+                recipient: recipientId
+            });
+            
+            if (!notification) {
+                throw new Error('Notification not found');
+            }
+            
+            return await notification.acknowledge();
+        } catch (error) {
+            throw new Error(`Failed to acknowledge notification: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Archive notification
+     */
+    async archiveNotification(notificationId, recipientId) {
+        try {
+            const { Notification } = this.getModels();
+            const notification = await Notification.findOne({
+                _id: notificationId,
+                recipient: recipientId
+            });
+            
+            if (!notification) {
+                throw new Error('Notification not found');
+            }
+            
+            return await notification.archive();
+        } catch (error) {
+            throw new Error(`Failed to archive notification: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Delete notification (soft delete)
+     */
+    async deleteNotification(notificationId, recipientId) {
+        try {
+            const { Notification } = this.getModels();
+            const notification = await Notification.findOne({
+                _id: notificationId,
+                recipient: recipientId
+            });
+            
+            if (!notification) {
+                throw new Error('Notification not found');
+            }
+            
+            return await notification.softDelete();
+        } catch (error) {
+            throw new Error(`Failed to delete notification: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Execute notification action
+     */
+    async executeAction(notificationId, actionId, recipientId, additionalData = {}, req = null) {
+        try {
+            const { Notification } = this.getModels();
+            const notification = await Notification.findOne({
+                _id: notificationId,
+                recipient: recipientId
+            });
+            
+            if (!notification) {
+                throw new Error('Notification not found');
+            }
+            
+            const action = notification.actions.find(a => a.id === actionId);
+            if (!action) {
+                throw new Error('Action not found');
+            }
+            
+            // Execute action based on type
+            switch (action.type) {
+                case 'api_call':
+                    const result =  await this.executeApiCall(action, additionalData, req); // pass req
+                    notification.actionResult = result;
+                    await notification.save();
+                    return result;
+                case 'link':
+                    return { type: 'redirect', url: action.url };
+                case 'form':
+                    return { type: 'form', action: action };
+                default:
+                    return { type: 'button', action: action };
+            }
+        } catch (error) {
+            console.error('Error executing action:', error);
+            throw new Error(`Failed to execute action: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Execute API call action
+     */
+    async executeApiCall(action, additionalData, req = null) {
+        // If req is provided, forward cookies for internal calls
+        const headers = {};
+        let url = action.url;
+        if (req && req.headers && req.headers.cookie && url && url.startsWith('/')) {
+            headers.Cookie = req.headers.cookie;
+            const baseUrl = process.env.NODE_ENV === 'production' ? `https://${req.school}.meridian.study` : 'http://localhost:5001';
+            url = baseUrl + url;
+        }
+        // Optionally, add more headers (user-agent, etc.)
+        const response = await require('axios')({
+            method: action.method,
+            url,
+            data: additionalData,
+            headers,
+        });
+        return { type: 'api_call', success: true, data: response.data };
+    }
+ 
+    /**
+     * Interpolate all string fields in an object/array
+     */
+    interpolateObject(obj, variables) {
+        if (typeof obj === 'string') {
+            return this.interpolateTemplate(obj, variables);
+        } else if (Array.isArray(obj)) {
+            return obj.map(item => this.interpolateObject(item, variables));
+        } else if (typeof obj === 'object' && obj !== null) {
+            const result = {};
+            for (const key in obj) {
+                result[key] = this.interpolateObject(obj[key], variables);
+            }
+            return result;
+        }
+        return obj;
+    }
+ 
+    /**
+     * Create system notification
+     */
+    async createSystemNotification(recipientId, recipientModel, templateName, variables = {}) {
+        try {
+            // TODO: Load template from database or file system
+            const template = await this.loadTemplate(templateName);
+            
+            const notificationData = {
+                recipient: recipientId,
+                recipientModel: recipientModel,
+                type: 'system',
+                title: this.interpolateTemplate(template.title, variables),
+                message: this.interpolateTemplate(template.message, variables),
+                template: {
+                    name: templateName,
+                    version: template.version,
+                    variables: variables
+                },
+                actions: template.actions ? this.interpolateObject(template.actions, variables) : [],
+                priority: template.priority || 'normal',
+                channels: template.channels || ['in_app'],
+                metadata: {
+                    ...(template.navigation ? { navigation: this.interpolateObject(template.navigation, variables) } : {}),
+                    ...(variables.friendshipId ? { friendshipId: String(variables.friendshipId) } : {}),
+                    ...(variables.sender ? { sender: String(variables.sender) } : {}),
+                    ...(variables.metadata || {})
+                }
+            };
+            
+            // Add sender fields if they exist in the template
+            if (template.sender) {
+                notificationData.sender = this.interpolateTemplate(template.sender, variables);
+            }
+            if (template.senderModel) {
+                notificationData.senderModel = template.senderModel;
+            }
+            
+            return await this.createNotification(notificationData);
+        } catch (error) {
+            throw new Error(`Failed to create system notification: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Load notification template
+     */
+    async loadTemplate(templateName) {
+        // TODO: Implement template loading from database
+        const templates = {
+            'welcome': {
+                title: 'Welcome to Study Compass!',
+                message: 'Hi {{name|capitalize}}, welcome to Study Compass! We\'re excited to have you on board.',
+                version: '1.0',
+                priority: 'normal',
+                channels: ['in_app', 'email'],
+                actions: [
+                    {
+                        id: 'complete_profile',
+                        label: 'Complete Profile',
+                        type: 'link',
+                        url: '/profile/complete',
+                        style: 'primary'
+                    }
+                ]
+            },
+            'org_invitation': {
+                title: 'Organization Invitation',
+                message: 'You have been invited to join <strong>{{orgName}}</strong> as <em>{{role|capitalize}}</em>.',
+                version: '1.0',
+                priority: 'high',
+                channels: ['in_app', 'email'],
+                actions: [
+                    {
+                        id: 'accept_invitation',
+                        label: 'Accept',
+                        type: 'api_call',
+                        url: '/org-invites/{{invitationId}}/accept',
+                        method: 'POST',
+                        payload: { invitationId: '{{invitationId}}' },
+                        style: 'success'
+                    },
+                    {
+                        id: 'decline_invitation',
+                        label: 'Decline',
+                        type: 'api_call',
+                        url: '/org-invites/{{invitationId}}/decline',
+                        method: 'POST',
+                        payload: { invitationId: '{{invitationId}}' },
+                        style: 'secondary'
+                    }
+                ]
+            },
+            'event_collaboration_invite': {
+                title: 'Event Collaboration Invite',
+                message: '<strong>{{hostOrgName}}</strong> invited your organization to collaborate on <strong>{{eventName}}</strong>.',
+                version: '1.0',
+                priority: 'high',
+                channels: ['in_app', 'email'],
+                actions: [
+                    {
+                        id: 'accept_event_collaboration_invite',
+                        label: 'Accept',
+                        type: 'api_call',
+                        url: '/event-collaboration-invites/{{inviteId}}/accept',
+                        method: 'POST',
+                        payload: { inviteId: '{{inviteId}}' },
+                        style: 'success'
+                    },
+                    {
+                        id: 'decline_event_collaboration_invite',
+                        label: 'Decline',
+                        type: 'api_call',
+                        url: '/event-collaboration-invites/{{inviteId}}/decline',
+                        method: 'POST',
+                        payload: { inviteId: '{{inviteId}}' },
+                        style: 'secondary'
+                    }
+                ]
+            },
+            'event_collaboration_accepted_host': {
+                title: 'Collaboration accepted',
+                message: '<strong>{{collaboratorOrgName}}</strong> accepted your invite to collaborate on <strong>{{eventName}}</strong>. Open the event workspace Details tab to manage collaborating organizations.',
+                version: '1.0',
+                priority: 'normal',
+                channels: ['in_app']
+            },
+            'friend_request': {
+                title: 'New Friend Request',
+                message: '<strong>{{senderName|capitalize}}</strong> has sent you a friend request.',
+                version: '1.0',
+                priority: 'normal',
+                channels: ['in_app', 'push'],
+                sender: '{{sender}}',
+                senderModel: 'User',
+                // Backend-controlled navigation: navigate to MainTabs -> Friends screen with requests tab
+                navigation: {
+                    type: 'navigate',
+                    route: 'MainTabs',
+                    params: {
+                        screen: 'Friends',
+                        params: { initialTab: 'requests' }
+                    },
+                    deepLink: 'meridian://friends/requests'
+                },
+                actions: [
+                    {
+                        id: 'accept_friend_request',
+                        label: 'Accept',
+                        type: 'api_call',
+                        url: '/friend-request/accept/{{friendshipId}}',
+                        method: 'POST',
+                        style: 'success',
+                        icon: 'material-symbols:person-check-rounded'
+                    },
+                    {
+                        id: 'reject_friend_request',
+                        label: 'Reject',
+                        type: 'api_call',
+                        url: '/friend-request/reject/{{friendshipId}}',
+                        method: 'POST',
+                        style: 'danger',
+                        icon: 'material-symbols:person-cancel-rounded'
+                    }
+                ]
+            },
+            'org_message_new':{
+                title: '{{orgName}} has posted a new announcement',
+                message: '{{messagePreview}}',
+                version: '1.0',
+                priority: 'normal',
+                channels: ['in_app', 'push'],
+                // Backend-controlled navigation: navigate to organization profile
+                // Note: orgId should be provided in variables when creating notification
+                navigation: {
+                    type: 'navigate',
+                    route: 'OrganizationProfile',
+                    params: { orgId: '{{orgId}}' },
+                    deepLink: 'meridian://organization/{{orgId}}'
+                },
+                actions: [
+                    {
+                        id: 'view_announcement',
+                        label: 'View Announcement',
+                        type: 'link',
+                        url: 'meridian://organization/{{orgId}}',
+                        style: 'primary'
+                    }
+                ]
+            },
+            'org_event_announcement': {
+                title: '{{orgName}}: announcement for {{eventName}}',
+                message: '{{messagePreview}}',
+                version: '1.0',
+                priority: 'normal',
+                channels: ['in_app', 'push'],
+                navigation: {
+                    type: 'navigate',
+                    route: 'EventDetails',
+                    params: { eventId: '{{eventId}}' },
+                    deepLink: 'meridian://event/{{eventId}}'
+                },
+                actions: [
+                    {
+                        id: 'view_event_announcement',
+                        label: 'View Announcement',
+                        type: 'link',
+                        url: 'meridian://event/{{eventId}}',
+                        style: 'primary'
+                    }
+                ]
+            },
+            'org_member_applied': {
+                title: 'New Member Applied',
+                message: '<strong>{{senderName|capitalize}}</strong> has applied to join <strong>{{orgName}}</strong>.',
+                version: '1.0',
+                priority: 'normal',
+                channels: ['in_app', 'push'],
+                sender: '{{sender}}',
+                senderModel: 'User',
+                // Backend-controlled navigation: navigate to organization profile
+                // Note: orgId should be provided in variables when creating notification
+                navigation: {
+                    type: 'navigate',
+                    route: 'OrganizationProfile',
+                    params: { orgId: '{{orgId}}' },
+                    deepLink: 'meridian://organization/{{orgId}}'
+                },
+                actions: [
+                    {
+                        id: 'go_to_application',
+                        label: 'Go to Application',
+                        type: 'link',
+                        url: '/club-dashboard/{{orgName}}?page=2',
+                        style: 'primary'
+                    }   
+                ]
+            },
+            'org_approval_needed': {
+                title: 'Organization Needs Approval',
+                message: 'A new organization <strong>{{orgName}}</strong> is pending approval.',
+                version: '1.0',
+                priority: 'high',
+                channels: ['in_app', 'push'],
+                navigation: {
+                    type: 'navigate',
+                    route: 'OrganizationProfile',
+                    params: { orgId: '{{orgId}}' },
+                    deepLink: 'meridian://organization/{{orgId}}'
+                },
+                actions: [
+                    {
+                        id: 'review_approval',
+                        label: 'Review and Approve',
+                        type: 'link',
+                        url: '/org-management',
+                        style: 'primary'
+                    }
+                ]
+            },
+            'event_reminder': {
+                title: 'Event Reminder',
+                message: 'Your event <strong>{{eventName}}</strong> starts in <em>{{timeUntil}}</em> at {{startTime|time}}.',
+                version: '1.0',
+                priority: 'high',
+                channels: ['in_app', 'push'],
+                // Backend-controlled navigation: navigate to event details
+                navigation: {
+                    type: 'navigate',
+                    route: 'Events',
+                    params: {
+                        screen: 'EventDetails',
+                        params: { eventId: '{{eventId}}' }
+                    },
+                    deepLink: 'meridian://events/{{eventId}}'
+                },
+                actions: [
+                    {
+                        id: 'view_event',
+                        label: 'View Event',
+                        type: 'link',
+                        url: '/events/{{eventId}}',
+                        style: 'primary'
+                    },
+                    {
+                        id: 'join_now',
+                        label: 'Join Now',
+                        type: 'link',
+                        url: '/events/{{eventId}}/join',
+                        style: 'success'
+                    }
+                ]
+            },
+            'achievement_unlocked': {
+                title: 'Achievement Unlocked! 🏆',
+                message: 'Congratulations! You\'ve earned the <strong>{{badgeName}}</strong> badge for {{achievementDescription|short}}.',
+                version: '1.0',
+                priority: 'normal',
+                channels: ['in_app', 'email'],
+                actions: [
+                    {
+                        id: 'view_badge',
+                        label: 'View Badge',
+                        type: 'link',
+                        url: '/badges/{{badgeId}}',
+                        style: 'success'
+                    },
+                    {
+                        id: 'share_achievement',
+                        label: 'Share',
+                        type: 'api_call',
+                        url: '/api/achievements/share',
+                        method: 'POST',
+                        payload: { badgeId: '{{badgeId}}' },
+                        style: 'info'
+                    }
+                ]
+            },
+            'payment_received': {
+                title: 'Payment Received',
+                message: 'You received <strong>{{amount|currency}}</strong> from <em>{{senderName|capitalize}}</em> on {{paymentDate|date}}.',
+                version: '1.0',
+                priority: 'normal',
+                channels: ['in_app', 'email'],
+                actions: [
+                    {
+                        id: 'view_transaction',
+                        label: 'View Details',
+                        type: 'link',
+                        url: '/transactions/{{transactionId}}',
+                        style: 'primary'
+                    }
+                ]
+            }
+        };
+        
+        return templates[templateName] || templates['welcome'];
+    }
+ 
+    /**
+     * Interpolate template variables with validation and formatting
+     */
+    interpolateTemplate(template, variables) {
+        if (!template || typeof template !== 'string') {
+            return template;
+        }
+ 
+        // Remove console.log statements
+        return template.replace(/\{\{(\w+)(?:\|(\w+))?\}\}/g, (match, key, format) => {
+            const value = variables[key];
+            
+            // Handle missing variables
+            if (value === undefined || value === null) {
+                console.warn(`Missing template variable: ${key}`);
+                return `[${key}]`; // Show placeholder instead of raw {{key}}
+            }
+            
+            // Apply formatting if specified
+            if (format) {
+                return this.formatValue(value, format);
+            }
+            
+            return value;
+        });
+    }
+ 
+    /**
+     * Format values based on type
+     */
+    formatValue(value, format) {
+        switch (format) {
+            case 'date':
+                return new Date(value).toLocaleDateString();
+            case 'time':
+                return new Date(value).toLocaleTimeString();
+            case 'datetime':
+                return new Date(value).toLocaleString();
+            case 'capitalize':
+                return typeof value === 'string' ? value.charAt(0).toUpperCase() + value.slice(1) : value;
+            case 'uppercase':
+                return typeof value === 'string' ? value.toUpperCase() : value;
+            case 'lowercase':
+                return typeof value === 'string' ? value.toLowerCase() : value;
+            case 'number':
+                return typeof value === 'number' ? value.toLocaleString() : value;
+            case 'currency':
+                return typeof value === 'number' ? `$${value.toFixed(2)}` : value;
+            case 'short':
+                return typeof value === 'string' && value.length > 50 ? value.substring(0, 50) + '...' : value;
+            default:
+                return value;
+        }
+    }
+ 
+    /**
+     * Cleanup expired notifications
+     */
+    async cleanupExpired() {
+        try {
+            const { Notification } = this.getModels();
+            return await Notification.cleanupExpired();
+        } catch (error) {
+            throw new Error(`Failed to cleanup expired notifications: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Get notification statistics
+     */
+    async getStatistics(recipientId, recipientModel) {
+        try {
+            const { Notification } = this.getModels();
+            const stats = await Notification.aggregate([
+                {
+                    $match: {
+                        recipient: new mongoose.Types.ObjectId(recipientId),
+                        recipientModel: recipientModel
+                    }
+                },
+                {
+                    $group: {
+                        _id: '$status',
+                        count: { $sum: 1 }
+                    }
+                }
+            ]);
+            
+            const result = {
+                total: 0,
+                unread: 0,
+                read: 0,
+                acknowledged: 0,
+                archived: 0
+            };
+            
+            stats.forEach(stat => {
+                result[stat._id] = stat.count;
+                result.total += stat.count;
+            });
+            
+            return result;
+        } catch (error) {
+            throw new Error(`Failed to get statistics: ${error.message}`);
+        }
+    }
+    //batch template notification
+    async createBatchTemplateNotification(recipients, templateName, variables = {}) {
+        const template = await this.loadTemplate(templateName);
+        const notifications = recipients.map(recipient => {
+            const notificationData = {
+                recipient: recipient.id,
+                recipientModel: recipient.model,
+                type: 'system',
+                title: this.interpolateTemplate(template.title, variables),
+                message: this.interpolateTemplate(template.message, variables),
+                template: {
+                    name: templateName,
+                    version: template.version,
+                    variables: variables
+                },
+                actions: template.actions ? this.interpolateObject(template.actions, variables) : [],
+                priority: template.priority || 'normal',
+                channels: template.channels || ['in_app'],
+                metadata: {
+                    ...(template.navigation ? { navigation: this.interpolateObject(template.navigation, variables) } : {}),
+                    ...(variables.friendshipId ? { friendshipId: String(variables.friendshipId) } : {}),
+                    ...(variables.sender ? { sender: String(variables.sender) } : {}),
+                    ...(variables.metadata || {})
+                }
+            };
+            
+            // Add sender fields if they exist in the template
+            if (template.sender) {
+                console.log("sender", template.sender);
+                notificationData.sender = this.interpolateTemplate(template.sender, variables);
+            }
+            if (template.senderModel) {
+                notificationData.senderModel = template.senderModel;
+            }
+            
+            return notificationData;
+        });
+        return await this.createBatchNotifications(notifications);
+    }
+    
+    /**
+     * Create system notification with advanced template support
+     */
+    async createAdvancedSystemNotification(recipientId, recipientModel, templateName, variables = {}) {
+        try {
+            const template = await this.loadAdvancedTemplate(templateName);
+            
+            // Process template with conditional logic
+            const processedTemplate = this.processAdvancedTemplate(template, variables);
+            
+            const notificationData = {
+                recipient: recipientId,
+                recipientModel: recipientModel,
+                type: 'system',
+                title: processedTemplate.title,
+                message: processedTemplate.message,
+                template: {
+                    name: templateName,
+                    version: template.version,
+                    variables: variables,
+                    processed: true
+                },
+                actions: processedTemplate.actions || [],
+                priority: processedTemplate.priority || 'normal',
+                channels: processedTemplate.channels || ['in_app'],
+                metadata: {
+                    ...processedTemplate.metadata,
+                    templateType: 'advanced'
+                }
+            };
+            
+            // Add sender fields if they exist in the template
+            if (processedTemplate.sender) {
+                notificationData.sender = processedTemplate.sender;
+            }
+            if (processedTemplate.senderModel) {
+                notificationData.senderModel = processedTemplate.senderModel;
+            }
+            
+            return await this.createNotification(notificationData);
+        } catch (error) {
+            throw new Error(`Failed to create advanced system notification: ${error.message}`);
+        }
+    }
+ 
+    /**
+     * Load advanced notification template with conditional logic
+     */
+    async loadAdvancedTemplate(templateName) {
+        const advancedTemplates = {
+            'dynamic_welcome': {
+                title: 'Welcome to Study Compass!',
+                message: {
+                    type: 'conditional',
+                    conditions: [
+                        {
+                            if: '{{hasProfile}}',
+                            then: 'Welcome back, {{name|capitalize}}! Your profile is complete.',
+                            else: 'Hi {{name|capitalize}}, welcome to Study Compass! Please complete your profile.'
+                        }
+                    ],
+                    default: 'Welcome to Study Compass!'
+                },
+                version: '2.0',
+                priority: 'normal',
+                channels: ['in_app', 'email'],
+                actions: {
+                    type: 'conditional',
+                    conditions: [
+                        {
+                            if: '!{{hasProfile}}',
+                            then: [
+                                {
+                                    id: 'complete_profile',
+                                    label: 'Complete Profile',
+                                    type: 'link',
+                                    url: '/profile/complete',
+                                    style: 'primary'
+                                }
+                            ]
+                        }
+                    ],
+                    default: [
+                        {
+                            id: 'explore',
+                            label: 'Explore',
+                            type: 'link',
+                            url: '/dashboard',
+                            style: 'secondary'
+                        }
+                    ]
+                }
+            },
+            'smart_event_reminder': {
+                title: 'Event Reminder',
+                message: {
+                    type: 'conditional',
+                    conditions: [
+                        {
+                            if: '{{isUrgent}}',
+                            then: '🚨 URGENT: Your event <strong>{{eventName}}</strong> starts in <em>{{timeUntil}}</em>!',
+                            else: 'Your event <strong>{{eventName}}</strong> starts in <em>{{timeUntil}}</em> at {{startTime|time}}.'
+                        }
+                    ]
+                },
+                version: '2.0',
+                priority: {
+                    type: 'conditional',
+                    conditions: [
+                        { if: '{{isUrgent}}', then: 'urgent' },
+                        { if: '{{isToday}}', then: 'high' }
+                    ],
+                    default: 'normal'
+                },
+                channels: {
+                    type: 'conditional',
+                    conditions: [
+                        { if: '{{isUrgent}}', then: ['in_app', 'push', 'email'] },
+                        { if: '{{isToday}}', then: ['in_app', 'push'] }
+                    ],
+                    default: ['in_app']
+                },
+                actions: [
+                    {
+                        id: 'view_event',
+                        label: 'View Event',
+                        type: 'link',
+                        url: '/events/{{eventId}}',
+                        style: 'primary'
+                    },
+                    {
+                        id: 'join_now',
+                        label: 'Join Now',
+                        type: 'link',
+                        url: '/events/{{eventId}}/join',
+                        style: 'success',
+                        condition: '{{canJoin}}'
+                    }
+                ]
+            }
+        };
+        
+        return advancedTemplates[templateName] || advancedTemplates['dynamic_welcome'];
+    }
+ 
+    /**
+     * Process advanced template with conditional logic
+     */
+    processAdvancedTemplate(template, variables) {
+        const result = { ...template };
+        
+        // Process title
+        if (typeof result.title === 'object' && result.title.type === 'conditional') {
+            result.title = this.processConditionalField(result.title, variables);
+        } else if (typeof result.title === 'string') {
+            result.title = this.interpolateTemplate(result.title, variables);
+        }
+        
+        // Process message
+        if (typeof result.message === 'object' && result.message.type === 'conditional') {
+            result.message = this.processConditionalField(result.message, variables);
+        } else if (typeof result.message === 'string') {
+            result.message = this.interpolateTemplate(result.message, variables);
+        }
+        
+        // Process priority
+        if (typeof result.priority === 'object' && result.priority.type === 'conditional') {
+            result.priority = this.processConditionalField(result.priority, variables);
+        }
+        
+        // Process channels
+        if (typeof result.channels === 'object' && result.channels.type === 'conditional') {
+            result.channels = this.processConditionalField(result.channels, variables);
+        }
+        
+        // Process actions
+        if (typeof result.actions === 'object' && result.actions.type === 'conditional') {
+            result.actions = this.processConditionalField(result.actions, variables);
+        } else if (Array.isArray(result.actions)) {
+            result.actions = result.actions.filter(action => {
+                if (action.condition) {
+                    return this.evaluateCondition(action.condition, variables);
+                }
+                return true;
+            }).map(action => {
+                const processedAction = { ...action };
+                if (action.url) {
+                    processedAction.url = this.interpolateTemplate(action.url, variables);
+                }
+                if (action.payload) {
+                    processedAction.payload = this.interpolateObject(action.payload, variables);
+                }
+                return processedAction;
+            });
+        }
+        
+        // Process sender fields
+        if (result.sender) {
+            result.sender = this.interpolateTemplate(result.sender, variables);
+        }
+        if (result.senderModel) {
+            // senderModel is typically static, but could be conditional in the future
+            result.senderModel = result.senderModel;
+        }
+        
+        return result;
+    }
+ 
+    /**
+     * Process conditional field
+     */
+    processConditionalField(field, variables) {
+        if (!field.conditions || !Array.isArray(field.conditions)) {
+            return field.default || '';
+        }
+        
+        for (const condition of field.conditions) {
+            if (this.evaluateCondition(condition.if, variables)) {
+                if (typeof condition.then === 'string') {
+                    return this.interpolateTemplate(condition.then, variables);
+                }
+                return condition.then;
+            }
+        }
+        
+        if (typeof field.default === 'string') {
+            return this.interpolateTemplate(field.default, variables);
+        }
+        return field.default || '';
+    }
+ 
+    /**
+     * Evaluate condition string
+     */
+    evaluateCondition(condition, variables) {
+        // Simple condition evaluation
+        // Supports: {{variable}}, !{{variable}}, {{variable}} == value, etc.
+        
+        // Handle negation
+        if (condition.startsWith('!')) {
+            const varName = condition.substring(2, condition.length - 2);
+            return !variables[varName];
+        }
+        
+        // Handle equality
+        if (condition.includes(' == ')) {
+            const [varPart, valuePart] = condition.split(' == ');
+            const varName = varPart.replace(/\{\{|\}\}/g, '');
+            const value = valuePart.replace(/['"]/g, '');
+            return variables[varName] == value;
+        }
+        
+        // Handle simple variable existence
+        const varName = condition.replace(/\{\{|\}\}/g, '');
+        return Boolean(variables[varName]);
+    }
+    
+}
+ 
+module.exports = NotificationService; 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotAdminOverviewService.js.html b/backend/coverage/lcov-report/services/pivotAdminOverviewService.js.html new file mode 100644 index 00000000..55a9b04d --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotAdminOverviewService.js.html @@ -0,0 +1,667 @@ + + + + + + Code coverage report for services/pivotAdminOverviewService.js + + + + + + + + + +
+
+

All files / services pivotAdminOverviewService.js

+
+ +
+ 89.47% + Statements + 51/57 +
+ + +
+ 54.54% + Branches + 12/22 +
+ + +
+ 90.9% + Functions + 10/11 +
+ + +
+ 89.09% + Lines + 49/55 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +1951x +1x +1x +1x +1x +1x +  +  +  +  +  +1x +1x +  +  +2x +2x +2x +  +  +  +3x +  +  +  +3x +  +  +  +  +  +  +  +3x +2x +  +  +1x +  +1x +  +  +  +1x +  +  +  +  +  +  +1x +2x +  +  +1x +1x +1x +  +1x +  +  +  +1x +1x +  +  +  +  +  +  +2x +2x +2x +2x +  +  +  +  +  +  +2x +2x +  +  +  +2x +  +2x +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +1x +  +1x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +1x +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  + 
const getGlobalModels = require('./getGlobalModelService');
+const getModels = require('./getModelService');
+const { getMergedTenants } = require('./tenantConfigService');
+const { isPivotTenant, serializePivotReferralCode } = require('./pivotReferralCodeService');
+const { connectToDatabase } = require('../connectionsManager');
+const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService');
+const {
+  normalizeBatchWeek,
+  PUBLISHED_EVENT_QUERY,
+  getWeeklySnapshot,
+  aggregateEngagementMetrics,
+} = require('./pivotWeeklySnapshotService');
+const { buildDropSchedulePayload } = require('./pivotConfigService');
+ 
+async function loadReferralCodesForTenant(req, tenantKey) {
+  const { PivotReferralCode } = getGlobalModels(req, 'PivotReferralCode');
+  const docs = await PivotReferralCode.find({ tenantKey }).sort({ active: -1, code: 1 }).lean();
+  return docs.map(serializePivotReferralCode);
+}
+ 
+async function aggregateRegisteredFeedback(PivotEventIntent, UniversalFeedback, batchWeek, eventIds) {
+  Iif (!eventIds.length) {
+    return { feedbackCount: 0, feedbackAvg: null };
+  }
+ 
+  const registeredIntents = await PivotEventIntent.find({
+    batchWeek,
+    status: 'registered',
+    eventId: { $in: eventIds },
+  })
+    .select('userId eventId')
+    .lean();
+ 
+  if (!registeredIntents.length) {
+    return { feedbackCount: 0, feedbackAvg: null };
+  }
+ 
+  const registeredKeys = new Set(
+    registeredIntents.map(
+      (intent) => `${String(intent.userId)}:${String(intent.eventId)}`,
+    ),
+  );
+ 
+  const feedbackRows = await UniversalFeedback.find({
+    feature: PIVOT_EVENT_FEATURE,
+    processId: { $in: eventIds },
+  })
+    .select('user processId responses.rating')
+    .lean();
+ 
+  const registeredFeedback = feedbackRows.filter((row) =>
+    registeredKeys.has(`${String(row.user)}:${String(row.processId)}`),
+  );
+ 
+  const ratings = registeredFeedback
+    .map((row) => row.responses?.rating)
+    .filter((rating) => typeof rating === 'number' && rating >= 1 && rating <= 5);
+ 
+  Iif (!ratings.length) {
+    return { feedbackCount: registeredFeedback.length, feedbackAvg: null };
+  }
+ 
+  const sum = ratings.reduce((acc, rating) => acc + rating, 0);
+  return {
+    feedbackCount: registeredFeedback.length,
+    feedbackAvg: Math.round((sum / ratings.length) * 100) / 100,
+  };
+}
+ 
+async function aggregateTenantOverview(req, tenant, batchWeek) {
+  const tenantKey = tenant.tenantKey;
+  const db = await connectToDatabase(tenantKey);
+  const tenantReq = { db };
+  const { Event, PivotEventIntent, UniversalFeedback } = getModels(
+    tenantReq,
+    'Event',
+    'PivotEventIntent',
+    'UniversalFeedback',
+  );
+ 
+  const eventQuery = PUBLISHED_EVENT_QUERY(batchWeek);
+  const [eventCount, events] = await Promise.all([
+    Event.countDocuments(eventQuery),
+    Event.find(eventQuery).select('_id').lean(),
+  ]);
+  const eventIds = events.map((event) => event._id);
+ 
+  const intentFilter = { batchWeek };
+  const [
+    interestedCount,
+    registeredCount,
+    passedCount,
+    activeUserIds,
+    externalOpenAgg,
+    externalOpenUserIds,
+    engagement,
+    feedback,
+    referralCodes,
+  ] = await Promise.all([
+    PivotEventIntent.countDocuments({ ...intentFilter, status: 'interested' }),
+    PivotEventIntent.countDocuments({ ...intentFilter, status: 'registered' }),
+    PivotEventIntent.countDocuments({ ...intentFilter, status: 'passed' }),
+    PivotEventIntent.distinct('userId', intentFilter),
+    PivotEventIntent.aggregate([
+      { $match: intentFilter },
+      { $group: { _id: null, total: { $sum: { $ifNull: ['$externalOpenCount', 0] } } } },
+    ]),
+    PivotEventIntent.distinct('userId', { ...intentFilter, externalOpenAt: { $ne: null } }),
+    aggregateEngagementMetrics(tenantReq, batchWeek),
+    aggregateRegisteredFeedback(PivotEventIntent, UniversalFeedback, batchWeek, eventIds),
+    loadReferralCodesForTenant(req, tenantKey),
+  ]);
+ 
+  const swipeCount = passedCount + interestedCount + registeredCount;
+ 
+  return {
+    tenantKey,
+    cityDisplayName: tenant.location || tenant.name || tenantKey,
+    eventCount,
+    interestedCount,
+    registeredCount,
+    externalOpenCount: externalOpenAgg[0]?.total ?? 0,
+    externalOpenUsers: externalOpenUserIds.length,
+    calendarAdds: engagement.calendarAdds,
+    inviteShares: engagement.inviteShares,
+    interestsSaved: engagement.interestsSaved,
+    swipeCount,
+    feedbackCount: feedback.feedbackCount,
+    feedbackAvg: feedback.feedbackAvg,
+    activeUsers: activeUserIds.length,
+    referralCodes,
+    dropSchedule: buildDropSchedulePayload(tenant, batchWeek),
+  };
+}
+ 
+async function getPivotOverview(req, options = {}) {
+  const normalized = normalizeBatchWeek(options.batchWeek, options.now);
+  Iif (normalized.error) {
+    return normalized;
+  }
+ 
+  const { batchWeek } = normalized;
+  const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant);
+  const tenants = [];
+ 
+  for (const tenant of pivotTenants) {
+    try {
+      tenants.push(await aggregateTenantOverview(req, tenant, batchWeek));
+    } catch (error) {
+      console.error(
+        `[pivotAdminOverview] aggregate failed tenant=${tenant.tenantKey} batchWeek=${batchWeek}:`,
+        error,
+      );
+      tenants.push({
+        tenantKey: tenant.tenantKey,
+        cityDisplayName: tenant.location || tenant.name || tenant.tenantKey,
+        eventCount: 0,
+        interestedCount: 0,
+        registeredCount: 0,
+        externalOpenCount: 0,
+        externalOpenUsers: 0,
+        calendarAdds: 0,
+        inviteShares: 0,
+        interestsSaved: 0,
+        swipeCount: 0,
+        feedbackCount: 0,
+        feedbackAvg: null,
+        activeUsers: 0,
+        referralCodes: await loadReferralCodesForTenant(req, tenant.tenantKey).catch(() => []),
+        dropSchedule: buildDropSchedulePayload(tenant, batchWeek),
+        error: 'AGGREGATION_FAILED',
+      });
+    }
+  }
+ 
+  const snapshotResult = await getWeeklySnapshot(req, { batchWeek });
+  const snapshotGeneratedAt =
+    snapshotResult.data?.generatedAt ?? null;
+ 
+  return {
+    data: {
+      batchWeek,
+      snapshotGeneratedAt,
+      tenants,
+    },
+  };
+}
+ 
+module.exports = {
+  aggregateTenantOverview,
+  aggregateRegisteredFeedback,
+  getPivotOverview,
+  loadReferralCodesForTenant,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotCatalogPurgeService.js.html b/backend/coverage/lcov-report/services/pivotCatalogPurgeService.js.html new file mode 100644 index 00000000..d6980165 --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotCatalogPurgeService.js.html @@ -0,0 +1,568 @@ + + + + + + Code coverage report for services/pivotCatalogPurgeService.js + + + + + + + + + +
+
+

All files / services pivotCatalogPurgeService.js

+
+ +
+ 98.27% + Statements + 57/58 +
+ + +
+ 62.5% + Branches + 25/40 +
+ + +
+ 100% + Functions + 8/8 +
+ + +
+ 98.21% + Lines + 55/56 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +1621x +1x +1x +1x +1x +1x +  +1x +1x +  +  +5x +  +  +  +3x +3x +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +3x +3x +3x +  +3x +  +  +  +  +  +  +  +  +  +3x +  +  +3x +  +3x +  +  +  +3x +  +3x +3x +  +  +  +  +  +  +  +  +3x +3x +3x +3x +  +3x +3x +  +  +3x +  +  +  +2x +2x +2x +  +  +  +4x +1x +  +  +  +  +  +  +3x +3x +1x +  +  +  +  +  +  +2x +2x +  +2x +2x +1x +1x +  +  +  +  +  +  +1x +  +  +2x +2x +3x +3x +  +  +  +  +  +  +  +2x +  +2x +  +3x +21x +  +3x +  +  +  +  +2x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  + 
const getModels = require('./getModelService');
+const getGlobalModels = require('./getGlobalModelService');
+const { getMergedTenants } = require('./tenantConfigService');
+const { isPivotTenant } = require('./pivotReferralCodeService');
+const { connectToDatabase } = require('../connectionsManager');
+const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService');
+ 
+const PURGE_CONFIRM_TOKEN = 'PURGE';
+const PIVOT_CATALOG_EVENT_QUERY = { 'customFields.pivot': { $exists: true } };
+ 
+function isDevEnvironment() {
+  return process.env.NODE_ENV !== 'production';
+}
+ 
+async function purgeTenantPivotCatalog(tenantKey) {
+  const db = await connectToDatabase(tenantKey);
+  const tenantReq = { db };
+  const {
+    Event,
+    PivotEventIntent,
+    UniversalFeedback,
+    FormResponse,
+    EventAnalytics,
+    EventQR,
+    AnalyticsEvent,
+  } = getModels(
+    tenantReq,
+    'Event',
+    'PivotEventIntent',
+    'UniversalFeedback',
+    'FormResponse',
+    'EventAnalytics',
+    'EventQR',
+    'AnalyticsEvent',
+  );
+ 
+  const events = await Event.find(PIVOT_CATALOG_EVENT_QUERY).select('_id').lean();
+  const eventIds = events.map((event) => event._id);
+  const eventIdStrings = eventIds.map(String);
+ 
+  const deleted = {
+    events: 0,
+    intents: 0,
+    feedback: 0,
+    formResponses: 0,
+    eventAnalytics: 0,
+    eventQr: 0,
+    analyticsEvents: 0,
+  };
+ 
+  const intentResult = await PivotEventIntent.deleteMany(
+    eventIds.length ? { eventId: { $in: eventIds } } : {},
+  );
+  deleted.intents = intentResult.deletedCount || 0;
+ 
+  const feedbackResult = await UniversalFeedback.deleteMany({
+    feature: PIVOT_EVENT_FEATURE,
+    ...(eventIds.length ? { processId: { $in: eventIds } } : {}),
+  });
+  deleted.feedback = feedbackResult.deletedCount || 0;
+ 
+  Eif (eventIds.length) {
+    const [formResult, analyticsResult, qrResult, analyticsEventsResult] = await Promise.all([
+      FormResponse.deleteMany({ event: { $in: eventIds } }),
+      EventAnalytics.deleteMany({ eventId: { $in: eventIds } }),
+      EventQR.deleteMany({ eventId: { $in: eventIds } }),
+      AnalyticsEvent.deleteMany({
+        'properties.event_id': { $in: eventIdStrings },
+      }),
+    ]);
+ 
+    deleted.formResponses = formResult.deletedCount || 0;
+    deleted.eventAnalytics = analyticsResult.deletedCount || 0;
+    deleted.eventQr = qrResult.deletedCount || 0;
+    deleted.analyticsEvents = analyticsEventsResult.deletedCount || 0;
+ 
+    const eventResult = await Event.deleteMany({ _id: { $in: eventIds } });
+    deleted.events = eventResult.deletedCount || 0;
+  }
+ 
+  return deleted;
+}
+ 
+async function purgeGlobalPivotSnapshots(req) {
+  const { PivotWeeklySnapshot } = getGlobalModels(req, 'PivotWeeklySnapshot');
+  const result = await PivotWeeklySnapshot.deleteMany({});
+  return { weeklySnapshots: result.deletedCount || 0 };
+}
+ 
+async function purgePivotCatalog(req, options = {}) {
+  if (!isDevEnvironment()) {
+    return {
+      error: 'Not available in production.',
+      status: 404,
+      code: 'NOT_FOUND',
+    };
+  }
+ 
+  const confirm = options.confirm?.trim();
+  if (confirm !== PURGE_CONFIRM_TOKEN) {
+    return {
+      error: `Type ${PURGE_CONFIRM_TOKEN} to confirm.`,
+      status: 400,
+      code: 'CONFIRMATION_REQUIRED',
+    };
+  }
+ 
+  const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant);
+  const tenantKeyFilter = options.tenantKey?.trim()?.toLowerCase();
+ 
+  let tenantsToPurge = pivotTenants;
+  if (tenantKeyFilter) {
+    const tenant = pivotTenants.find((row) => row.tenantKey === tenantKeyFilter);
+    Iif (!tenant) {
+      return {
+        error: 'Pivot tenant not found.',
+        status: 404,
+        code: 'TENANT_NOT_FOUND',
+      };
+    }
+    tenantsToPurge = [tenant];
+  }
+ 
+  const tenantResults = [];
+  for (const tenant of tenantsToPurge) {
+    const counts = await purgeTenantPivotCatalog(tenant.tenantKey);
+    tenantResults.push({
+      tenantKey: tenant.tenantKey,
+      cityDisplayName: tenant.location || tenant.name || tenant.tenantKey,
+      deleted: counts,
+    });
+  }
+ 
+  const globalDeleted =
+    options.clearSnapshots === false ? {} : await purgeGlobalPivotSnapshots(req);
+ 
+  const totals = tenantResults.reduce(
+    (acc, row) => {
+      Object.entries(row.deleted).forEach(([key, value]) => {
+        acc[key] = (acc[key] || 0) + value;
+      });
+      return acc;
+    },
+    { weeklySnapshots: globalDeleted.weeklySnapshots || 0 },
+  );
+ 
+  return {
+    data: {
+      tenants: tenantResults,
+      totals,
+    },
+  };
+}
+ 
+module.exports = {
+  purgePivotCatalog,
+  purgeTenantPivotCatalog,
+  PURGE_CONFIRM_TOKEN,
+  PIVOT_CATALOG_EVENT_QUERY,
+  isDevEnvironment,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotConfigService.js.html b/backend/coverage/lcov-report/services/pivotConfigService.js.html new file mode 100644 index 00000000..bbc823bf --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotConfigService.js.html @@ -0,0 +1,262 @@ + + + + + + Code coverage report for services/pivotConfigService.js + + + + + + + + + +
+
+

All files / services pivotConfigService.js

+
+ +
+ 35% + Statements + 7/20 +
+ + +
+ 4.76% + Branches + 1/21 +
+ + +
+ 50% + Functions + 1/2 +
+ + +
+ 35% + Lines + 7/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +603x +3x +  +  +  +  +3x +  +  +9x +9x +  +9x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  + 
const { getTenantByKey } = require('./tenantConfigService');
+const { isValidIsoWeek, toIsoWeek } = require('../utilities/pivotIsoWeek');
+const {
+  describePivotDropSchedule,
+  isPivotTenant,
+  resolvePivotDropInstant,
+} = require('../utilities/pivotDropSchedule');
+ 
+function buildDropSchedulePayload(tenant, batchWeek, now = new Date()) {
+  const resolved = resolvePivotDropInstant(tenant, batchWeek, now);
+  const description = describePivotDropSchedule(resolved);
+ 
+  return {
+    batchWeek,
+    timezone: resolved.timezone,
+    dayOfWeek: resolved.dayOfWeek,
+    hour: resolved.hour,
+    minute: resolved.minute,
+    nextDropAt: resolved.dropAt.toISOString(),
+    nextDropFormatted: description.formatted,
+    localSchedule: description.localTime,
+    source: resolved.source,
+    usingPilotDefaults: resolved.usingPilotDefaults,
+  };
+}
+ 
+async function getPivotConfig(req, options = {}) {
+  const tenantKey = req.school || options.tenantKey;
+  if (!tenantKey) {
+    return { error: 'Tenant context required.', status: 400 };
+  }
+ 
+  const tenant = await getTenantByKey(req, tenantKey);
+  if (!tenant) {
+    return { error: 'Tenant not found.', status: 404 };
+  }
+  if (!isPivotTenant(tenant)) {
+    return { error: 'Pivot config is only available for pivot city tenants.', status: 400 };
+  }
+ 
+  const now = options.now || new Date();
+  const batchWeek = options.batchWeek?.trim() || toIsoWeek(now);
+  if (options.batchWeek && !isValidIsoWeek(batchWeek)) {
+    return { error: 'batchWeek must be ISO format YYYY-Www.', status: 400, code: 'INVALID_BATCH_WEEK' };
+  }
+ 
+  return {
+    data: {
+      tenantKey: tenant.tenantKey,
+      cityDisplayName: tenant.location || tenant.name || tenant.tenantKey,
+      dropSchedule: buildDropSchedulePayload(tenant, batchWeek, now),
+    },
+  };
+}
+ 
+module.exports = {
+  buildDropSchedulePayload,
+  getPivotConfig,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotFeedService.js.html b/backend/coverage/lcov-report/services/pivotFeedService.js.html new file mode 100644 index 00000000..944ec0a6 --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotFeedService.js.html @@ -0,0 +1,2134 @@ + + + + + + Code coverage report for services/pivotFeedService.js + + + + + + + + + +
+
+

All files / services pivotFeedService.js

+
+ +
+ 91.28% + Statements + 220/241 +
+ + +
+ 74.74% + Branches + 148/198 +
+ + +
+ 100% + Functions + 31/31 +
+ + +
+ 91.17% + Lines + 217/238 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +6841x +1x +1x +1x +1x +  +  +  +  +1x +  +  +  +1x +1x +  +1x +1x +1x +  +1x +  +  +14x +  +  +14x +14x +  +  +  +  +13x +  +13x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +20x +  +  +  +20x +20x +1x +  +  +  +19x +  +  +19x +5x +  +  +  +14x +  +  +14x +14x +  +  +  +  +  +  +13x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +34x +34x +34x +2x +  +  +32x +  +  +  +  +  +  +  +16x +16x +16x +16x +16x +  +  +  +16x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +11x +  +  +  +  +  +  +11x +11x +2x +  +  +  +  +3x +  +  +  +  +  +  +  +15x +20x +  +  +  +  +  +  +  +  +  +  +  +  +13x +  +13x +2x +  +  +  +  +  +  +11x +  +  +  +  +  +  +11x +  +  +  +11x +10x +  +  +11x +  +  +  +11x +1x +  +  +  +  +  +  +  +  +11x +11x +9x +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +3x +  +2x +  +  +2x +  +2x +2x +  +2x +3x +3x +3x +3x +  +  +  +3x +3x +2x +2x +2x +2x +  +2x +2x +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +2x +  +  +  +16x +13x +  +  +3x +  +  +  +3x +3x +7x +7x +5x +  +  +  +3x +  +  +  +17x +  +  +  +17x +17x +10x +  +  +10x +10x +10x +  +  +17x +  +  +  +14x +2x +  +  +12x +12x +3x +  +  +9x +9x +10x +  +  +10x +10x +8x +  +  +  +9x +  +  +  +8x +2x +  +  +6x +6x +2x +  +  +4x +4x +4x +  +  +4x +4x +2x +  +  +  +4x +  +  +  +  +  +  +  +14x +7x +7x +7x +7x +7x +1x +  +  +6x +6x +6x +1x +  +  +5x +5x +5x +2x +  +  +3x +3x +3x +2x +  +  +1x +1x +1x +  +  +  +  +12x +12x +12x +  +  +  +1x +1x +1x +1x +  +  +1x +1x +  +  +1x +1x +1x +  +  +  +1x +  +  +  +12x +12x +  +12x +  +  +  +  +  +  +  +12x +11x +  +  +1x +1x +  +  +  +  +  +  +1x +  +  +  +13x +13x +  +  +  +  +  +  +  +13x +13x +13x +1x +  +  +  +  +  +  +12x +12x +  +12x +  +  +  +  +  +  +  +12x +1x +  +  +12x +  +  +  +  +12x +  +17x +  +  +15x +12x +  +  +  +  +  +  +  +12x +12x +12x +  +  +  +12x +12x +  +12x +15x +  +  +12x +  +  +  +  +  +  +  +  +  +  +  +12x +  +  +  +  +15x +15x +  +  +  +  +  +15x +15x +  +  +15x +15x +2x +2x +  +  +  +  +15x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +  +  +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +1x +  +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const mongoose = require('mongoose');
+const getModels = require('./getModelService');
+const { getTenantByKey } = require('./tenantConfigService');
+const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek');
+const { PIVOT_TAG_SLUG_PATTERN } = require('../schemas/pivotTagCatalog');
+const {
+  normalizePivotTimeSlots,
+  serializePivotTimeSlots,
+  isUpcomingWithTimeSlots,
+} = require('../utilities/pivotTimeSlots');
+const {
+  serializePivotMovie,
+  resolvePivotCoverImageUrl,
+} = require('../utilities/pivotMovieMetadata');
+const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger');
+ 
+const FRIEND_CAP = 5;
+const PIVOT_EVENT_STATUSES = ['approved', 'not-applicable'];
+const LOW_FEEDBACK_RATING_THRESHOLD = 3;
+const PUBLIC_EVENT_FIELDS =
+  'name description location start_time end_time externalLink type registrationCount image customFields.pivot';
+ 
+function getPilotWindow(now = new Date()) {
+  const windowStart = new Date(
+    Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
+  );
+  const windowEnd = new Date(windowStart.getTime() + 7 * 24 * 60 * 60 * 1000);
+  return { windowStart, windowEnd };
+}
+ 
+/** Mongo filter: upcoming events with start (or any showtime) inside the 7-day pilot window. */
+function getFeedPilotWindowFilter(now = new Date()) {
+  const { windowStart, windowEnd } = getPilotWindow(now);
+ 
+  return {
+    $and: [
+      getUpcomingEventTimeFilter(now),
+      {
+        $or: [
+          {
+            'customFields.pivot.timeSlots.0': { $exists: false },
+            start_time: { $gte: windowStart, $lt: windowEnd },
+          },
+          {
+            'customFields.pivot.timeSlots.0': { $exists: true },
+            'customFields.pivot.timeSlots': {
+              $elemMatch: {
+                start_time: { $gte: windowStart, $lt: windowEnd },
+              },
+            },
+          },
+        ],
+      },
+    ],
+  };
+}
+ 
+/** True when the event has not ended yet — deck should not surface past plans. */
+function isUpcomingPivotEvent(event, now = new Date()) {
+  Iif (!event) {
+    return false;
+  }
+ 
+  const slotUpcoming = isUpcomingWithTimeSlots(event.customFields?.pivot, now);
+  if (slotUpcoming != null) {
+    return slotUpcoming;
+  }
+ 
+  const end =
+    event.end_time != null && event.end_time !== ''
+      ? new Date(event.end_time)
+      : null;
+  if (end && !Number.isNaN(end.getTime())) {
+    return end > now;
+  }
+ 
+  const start =
+    event.start_time != null && event.start_time !== ''
+      ? new Date(event.start_time)
+      : null;
+  Eif (start && !Number.isNaN(start.getTime())) {
+    return start > now;
+  }
+ 
+  return false;
+}
+ 
+function getUpcomingEventTimeFilter(now = new Date()) {
+  return {
+    $or: [
+      { end_time: { $gt: now } },
+      {
+        end_time: { $in: [null] },
+        start_time: { $gt: now },
+      },
+      {
+        end_time: { $exists: false },
+        start_time: { $gt: now },
+      },
+    ],
+  };
+}
+ 
+function resolveDisplayHost(pivotMeta) {
+  const host = pivotMeta?.host;
+  const name = host?.name?.trim();
+  if (!name) {
+    return null;
+  }
+ 
+  return {
+    name,
+    ...(host.imageUrl ? { imageUrl: host.imageUrl } : {}),
+    ...(host.profileUrl ? { profileUrl: host.profileUrl } : {}),
+  };
+}
+ 
+function serializePivotFeedEvent(event, extras) {
+  const pivot = event.customFields?.pivot || {};
+  const coverImageUrl = resolvePivotCoverImageUrl(event);
+  const movie = serializePivotMovie(pivot.movie);
+  const normalizedSlots = normalizePivotTimeSlots(pivot.timeSlots);
+  const timeSlots = normalizedSlots.length
+    ? serializePivotTimeSlots(normalizedSlots, extras.socialByTimeSlot)
+    : undefined;
+ 
+  return {
+    _id: String(event._id),
+    name: event.name,
+    description: movie?.synopsis || event.description,
+    location: event.location,
+    start_time: event.start_time,
+    end_time: event.end_time,
+    externalLink: event.externalLink,
+    type: event.type,
+    registrationCount: event.registrationCount ?? 0,
+    tags: Array.isArray(pivot.tags) ? pivot.tags : [],
+    ...(coverImageUrl ? { coverImageUrl } : {}),
+    ...(timeSlots ? { timeSlots } : {}),
+    ...(movie ? { movie } : {}),
+    displayHost: extras.displayHost,
+    userIntent: extras.userIntent,
+    ...(extras.userTimeSlotId ? { userTimeSlotId: extras.userTimeSlotId } : {}),
+    friendsInterested: extras.friendsInterested,
+    friendsGoing: extras.friendsGoing,
+    // Total counts (uncapped) so the client can render "N friends interested"
+    // even when the preview arrays above are capped at FRIEND_CAP.
+    friendsInterestedCount: extras.friendsInterestedCount,
+    friendsGoingCount: extras.friendsGoingCount,
+  };
+}
+ 
+async function getAcceptedFriendIds(Friendship, userId) {
+  const rows = await Friendship.find({
+    status: 'accepted',
+    $or: [{ requester: userId }, { recipient: userId }],
+  })
+    .select('requester recipient')
+    .lean();
+ 
+  const uid = String(userId);
+  return rows.map((row) =>
+    String(row.requester) === uid ? row.recipient : row.requester,
+  );
+}
+ 
+function mapFriendPreview(user) {
+  return {
+    id: String(user._id),
+    name: user.name || user.username || 'friend',
+    picture: user.picture || null,
+  };
+}
+ 
+function makeEmptySocialMap(eventIds) {
+  return new Map(
+    eventIds.map((id) => [
+      String(id),
+      {
+        friendsInterested: [],
+        friendsGoing: [],
+        friendInterestedCount: 0,
+        friendRegisteredCount: 0,
+      },
+    ]),
+  );
+}
+ 
+async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, batchWeek = null) {
+  const emptySocial = makeEmptySocialMap(eventIds);
+ 
+  if (!eventIds.length) {
+    return {
+      userIntents: new Map(),
+      socialByEvent: emptySocial,
+      socialByEventAndSlot: new Map(),
+    };
+  }
+ 
+  const { Friendship, PivotEventIntent, User } = getModels(
+    req,
+    'Friendship',
+    'PivotEventIntent',
+    'User',
+  );
+ 
+  const userIntentQuery = {
+    userId,
+    eventId: { $in: eventIds },
+  };
+  if (batchWeek) {
+    userIntentQuery.batchWeek = batchWeek;
+  }
+ 
+  const userIntentRows = await PivotEventIntent.find(userIntentQuery)
+    .select('eventId status timeSlotId')
+    .lean();
+ 
+  const userIntents = new Map(
+    userIntentRows.map((row) => [
+      String(row.eventId),
+      {
+        status: row.status,
+        timeSlotId: row.timeSlotId || null,
+      },
+    ]),
+  );
+ 
+  const friendIds = await getAcceptedFriendIds(Friendship, userId);
+  if (!friendIds.length) {
+    return {
+      userIntents,
+      socialByEvent: emptySocial,
+      socialByEventAndSlot: new Map(),
+    };
+  }
+ 
+  const friendIntentRows = await PivotEventIntent.find({
+    eventId: { $in: eventIds },
+    userId: { $in: friendIds },
+    status: { $in: ['interested', 'registered'] },
+  })
+    .select('eventId userId status timeSlotId')
+    .lean();
+ 
+  Iif (!friendIntentRows.length) {
+    return {
+      userIntents,
+      socialByEvent: emptySocial,
+      socialByEventAndSlot: new Map(),
+    };
+  }
+ 
+  const friendUserIds = [
+    ...new Set(friendIntentRows.map((row) => String(row.userId))),
+  ];
+  const users = await User.find({ _id: { $in: friendUserIds } })
+    .select('name username picture')
+    .lean();
+  const userById = new Map(users.map((user) => [String(user._id), user]));
+ 
+  const socialByEvent = makeEmptySocialMap(eventIds);
+  const socialByEventAndSlot = new Map();
+ 
+  for (const row of friendIntentRows) {
+    const eventKey = String(row.eventId);
+    const bucket = socialByEvent.get(eventKey);
+    const friend = userById.get(String(row.userId));
+    Iif (!bucket || !friend) {
+      continue;
+    }
+ 
+    const preview = mapFriendPreview(friend);
+    if (row.status === 'registered') {
+      bucket.friendRegisteredCount += 1;
+      bucket.friendInterestedCount += 1;
+      Eif (bucket.friendsGoing.length < previewCap) {
+        bucket.friendsGoing.push(preview);
+      }
+      Eif (bucket.friendsInterested.length < previewCap) {
+        bucket.friendsInterested.push(preview);
+      }
+ 
+      const slotId = row.timeSlotId ? String(row.timeSlotId).trim() : '';
+      Iif (slotId) {
+        const slotKey = `${eventKey}:${slotId}`;
+        if (!socialByEventAndSlot.has(slotKey)) {
+          socialByEventAndSlot.set(slotKey, {
+            friendsGoing: [],
+            friendsGoingCount: 0,
+          });
+        }
+        const slotBucket = socialByEventAndSlot.get(slotKey);
+        slotBucket.friendsGoingCount += 1;
+        if (slotBucket.friendsGoing.length < previewCap) {
+          slotBucket.friendsGoing.push(preview);
+        }
+      }
+    } else Eif (row.status === 'interested') {
+      bucket.friendInterestedCount += 1;
+      Eif (bucket.friendsInterested.length < previewCap) {
+        bucket.friendsInterested.push(preview);
+      }
+    }
+  }
+ 
+  return { userIntents, socialByEvent, socialByEventAndSlot };
+}
+ 
+function normalizeExcludeEventIds(rawExcludeEventIds) {
+  if (!rawExcludeEventIds) {
+    return [];
+  }
+ 
+  const raw = Array.isArray(rawExcludeEventIds)
+    ? rawExcludeEventIds
+    : String(rawExcludeEventIds).split(',');
+ 
+  const seen = new Set();
+  for (const value of raw) {
+    const id = String(value).trim();
+    if (id && mongoose.Types.ObjectId.isValid(id)) {
+      seen.add(id);
+    }
+  }
+ 
+  return [...seen];
+}
+ 
+function normalizeInterestTagSet(rawTags) {
+  Iif (!Array.isArray(rawTags)) {
+    return new Set();
+  }
+ 
+  const tags = new Set();
+  for (const raw of rawTags) {
+    Iif (typeof raw !== 'string') {
+      continue;
+    }
+    const slug = raw.trim().toLowerCase();
+    Eif (slug) {
+      tags.add(slug);
+    }
+  }
+  return tags;
+}
+ 
+function countInterestOverlap(event, userInterestTags) {
+  if (!userInterestTags.size) {
+    return 0;
+  }
+ 
+  const eventTags = event.customFields?.pivot?.tags;
+  if (!Array.isArray(eventTags) || !eventTags.length) {
+    return 0;
+  }
+ 
+  let overlap = 0;
+  for (const raw of eventTags) {
+    Iif (typeof raw !== 'string') {
+      continue;
+    }
+    const slug = raw.trim().toLowerCase();
+    if (slug && userInterestTags.has(slug)) {
+      overlap += 1;
+    }
+  }
+ 
+  return overlap;
+}
+ 
+function countNegativeTagOverlap(event, negativeFeedbackTags) {
+  if (!negativeFeedbackTags.size) {
+    return 0;
+  }
+ 
+  const eventTags = event.customFields?.pivot?.tags;
+  if (!Array.isArray(eventTags) || !eventTags.length) {
+    return 0;
+  }
+ 
+  let overlap = 0;
+  for (const raw of eventTags) {
+    Iif (typeof raw !== 'string') {
+      continue;
+    }
+    const slug = raw.trim().toLowerCase();
+    if (slug && negativeFeedbackTags.has(slug)) {
+      overlap += 1;
+    }
+  }
+ 
+  return overlap;
+}
+ 
+function compareByFeedRank(
+  socialByEvent,
+  userInterestTags,
+  negativeFeedbackTags = new Set(),
+) {
+  return (a, b) => {
+    const sa = socialByEvent.get(String(a._id));
+    const sb = socialByEvent.get(String(b._id));
+    const aRegistered = sa?.friendRegisteredCount || 0;
+    const bRegistered = sb?.friendRegisteredCount || 0;
+    if (aRegistered !== bRegistered) {
+      return bRegistered - aRegistered;
+    }
+ 
+    const aInterested = sa?.friendInterestedCount || 0;
+    const bInterested = sb?.friendInterestedCount || 0;
+    if (aInterested !== bInterested) {
+      return bInterested - aInterested;
+    }
+ 
+    const aOverlap = countInterestOverlap(a, userInterestTags);
+    const bOverlap = countInterestOverlap(b, userInterestTags);
+    if (aOverlap !== bOverlap) {
+      return bOverlap - aOverlap;
+    }
+ 
+    const aPenalty = countNegativeTagOverlap(a, negativeFeedbackTags);
+    const bPenalty = countNegativeTagOverlap(b, negativeFeedbackTags);
+    if (aPenalty !== bPenalty) {
+      return aPenalty - bPenalty;
+    }
+ 
+    const aStart = new Date(a.start_time).getTime() || 0;
+    const bStart = new Date(b.start_time).getTime() || 0;
+    return aStart - bStart;
+  };
+}
+ 
+async function loadUserInterestTags(req, userId) {
+  const { User } = getModels(req, 'User');
+  const user = await User.findById(userId).select('pivotInterestTags').lean();
+  return normalizeInterestTagSet(user?.pivotInterestTags);
+}
+ 
+function collectCatalogTagsFromEvents(events) {
+  const tags = new Set();
+  for (const event of events) {
+    const eventTags = event.customFields?.pivot?.tags;
+    Iif (!Array.isArray(eventTags)) {
+      continue;
+    }
+    for (const raw of eventTags) {
+      Iif (typeof raw !== 'string') {
+        continue;
+      }
+      const slug = raw.trim().toLowerCase();
+      Eif (slug && PIVOT_TAG_SLUG_PATTERN.test(slug)) {
+        tags.add(slug);
+      }
+    }
+  }
+  return tags;
+}
+ 
+async function loadNegativeFeedbackTags(req, userId) {
+  const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService');
+  const { UniversalFeedback, Event } = getModels(req, 'UniversalFeedback', 'Event');
+ 
+  const lowRatings = await UniversalFeedback.find({
+    user: userId,
+    feature: PIVOT_EVENT_FEATURE,
+    'responses.rating': { $lt: LOW_FEEDBACK_RATING_THRESHOLD },
+  })
+    .select('processId')
+    .lean();
+ 
+  if (!lowRatings.length) {
+    return new Set();
+  }
+ 
+  const eventIds = lowRatings.map((row) => row.processId);
+  const events = await Event.find({
+    _id: { $in: eventIds },
+    isDeleted: { $ne: true },
+  })
+    .select('customFields.pivot.tags')
+    .lean();
+ 
+  return collectCatalogTagsFromEvents(events);
+}
+ 
+async function getPivotFeed(req, options = {}) {
+  const userId = req.user?.userId;
+  Iif (!userId) {
+    return {
+      error: 'Authentication required.',
+      status: 401,
+      code: 'UNAUTHORIZED',
+    };
+  }
+ 
+  const now = options.now || new Date();
+  const batchWeek = options.batchWeek?.trim() || toIsoWeek(now);
+  if (options.batchWeek && !isValidIsoWeek(batchWeek)) {
+    return {
+      error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).',
+      status: 400,
+      code: 'INVALID_BATCH_WEEK',
+    };
+  }
+ 
+  const { Event } = getModels(req, 'Event');
+  const excludeEventIds = normalizeExcludeEventIds(options.excludeEventIds);
+ 
+  const query = {
+    'customFields.pivot.batchWeek': batchWeek,
+    'customFields.pivot.ingestStatus': 'published',
+    status: { $in: PIVOT_EVENT_STATUSES },
+    isDeleted: { $ne: true },
+    'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
+    ...getFeedPilotWindowFilter(now),
+  };
+  if (excludeEventIds.length) {
+    query._id = { $nin: excludeEventIds };
+  }
+ 
+  const events = await Event.find(query)
+    .select(PUBLIC_EVENT_FIELDS)
+    .sort({ registrationCount: -1, start_time: 1 })
+    .lean();
+ 
+  const validEvents = events.filter(
+    (event) =>
+      resolveDisplayHost(event.customFields?.pivot) &&
+      isUpcomingPivotEvent(event, now),
+  );
+  const eventIds = validEvents.map((event) => event._id);
+  const { userIntents, socialByEvent, socialByEventAndSlot } = await loadFriendSocial(
+    req,
+    userId,
+    eventIds,
+    FRIEND_CAP,
+    batchWeek,
+  );
+ 
+  const userInterestTags = await loadUserInterestTags(req, userId);
+  const negativeFeedbackTags = await loadNegativeFeedbackTags(req, userId);
+  validEvents.sort(
+    compareByFeedRank(socialByEvent, userInterestTags, negativeFeedbackTags),
+  );
+ 
+  const tenant = await getTenantByKey(req, req.school);
+  const cityDisplayName = tenant?.location || tenant?.name || req.school;
+ 
+  const multiSlotEventCount = validEvents.filter(
+    (event) => normalizePivotTimeSlots(event.customFields?.pivot?.timeSlots).length > 0,
+  ).length;
+ 
+  logPivot('info', 'feed built', {
+    ...pivotRequestContext(req),
+    batchWeek,
+    cityDisplayName,
+    candidateCount: events.length,
+    eventCount: validEvents.length,
+    multiSlotEventCount,
+    excludedCount: excludeEventIds.length,
+    interestTagCount: userInterestTags.size,
+    negativeTagPenaltyCount: negativeFeedbackTags.size,
+  });
+ 
+  return {
+    data: {
+      batchWeek,
+      cityDisplayName,
+      events: validEvents.map((event) => {
+        const id = String(event._id);
+        const social = socialByEvent.get(id) || {
+          friendsInterested: [],
+          friendsGoing: [],
+          friendInterestedCount: 0,
+          friendRegisteredCount: 0,
+        };
+        const userIntentRow = userIntents.get(id);
+        const normalizedSlots = normalizePivotTimeSlots(
+          event.customFields?.pivot?.timeSlots,
+        );
+        const socialByTimeSlot = new Map();
+        for (const slot of normalizedSlots) {
+          const slotSocial = socialByEventAndSlot.get(`${id}:${slot.id}`);
+          Iif (slotSocial) {
+            socialByTimeSlot.set(slot.id, slotSocial);
+          }
+        }
+ 
+        return serializePivotFeedEvent(event, {
+          displayHost: resolveDisplayHost(event.customFields.pivot),
+          userIntent: userIntentRow?.status || null,
+          userTimeSlotId: userIntentRow?.timeSlotId || null,
+          socialByTimeSlot,
+          friendsInterested: social.friendsInterested,
+          friendsGoing: social.friendsGoing,
+          friendsInterestedCount: social.friendInterestedCount || 0,
+          friendsGoingCount: social.friendRegisteredCount || 0,
+        });
+      }),
+    },
+  };
+}
+ 
+async function getPivotEventFriends(req, eventId) {
+  const userId = req.user?.userId;
+  Iif (!userId) {
+    return {
+      error: 'Authentication required.',
+      status: 401,
+      code: 'UNAUTHORIZED',
+    };
+  }
+ 
+  const eventKey = String(eventId || '').trim();
+  Iif (!mongoose.Types.ObjectId.isValid(eventKey)) {
+    return {
+      error: 'A valid eventId is required.',
+      status: 400,
+      code: 'INVALID_EVENT_ID',
+    };
+  }
+ 
+  const { Event } = getModels(req, 'Event');
+  const event = await Event.findOne({
+    _id: eventKey,
+    'customFields.pivot.ingestStatus': 'published',
+    status: { $in: PIVOT_EVENT_STATUSES },
+    isDeleted: { $ne: true },
+    'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
+  })
+    .select('_id')
+    .lean();
+ 
+  if (!event) {
+    return {
+      error: 'Event not found.',
+      status: 404,
+      code: 'EVENT_NOT_FOUND',
+    };
+  }
+ 
+  const { socialByEvent } = await loadFriendSocial(
+    req,
+    userId,
+    [eventKey],
+    Number.POSITIVE_INFINITY,
+  );
+  const social = socialByEvent.get(eventKey) || {
+    friendsInterested: [],
+    friendsGoing: [],
+  };
+ 
+  return {
+    data: {
+      interested: social.friendsInterested,
+      going: social.friendsGoing,
+    },
+  };
+}
+ 
+module.exports = {
+  getPivotFeed,
+  getPivotEventFriends,
+  getPilotWindow,
+  getFeedPilotWindowFilter,
+  isUpcomingPivotEvent,
+  getUpcomingEventTimeFilter,
+  resolveDisplayHost,
+  serializePivotFeedEvent,
+  normalizeExcludeEventIds,
+  normalizeInterestTagSet,
+  countInterestOverlap,
+  countNegativeTagOverlap,
+  compareByFeedRank,
+  loadFriendSocial,
+  loadUserInterestTags,
+  loadNegativeFeedbackTags,
+  collectCatalogTagsFromEvents,
+  mapFriendPreview,
+  LOW_FEEDBACK_RATING_THRESHOLD,
+  PIVOT_EVENT_STATUSES,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotFeedbackService.js.html b/backend/coverage/lcov-report/services/pivotFeedbackService.js.html new file mode 100644 index 00000000..a5b2e97f --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotFeedbackService.js.html @@ -0,0 +1,826 @@ + + + + + + Code coverage report for services/pivotFeedbackService.js + + + + + + + + + +
+
+

All files / services pivotFeedbackService.js

+
+ +
+ 8.98% + Statements + 8/89 +
+ + +
+ 0% + Branches + 0/61 +
+ + +
+ 0% + Functions + 0/14 +
+ + +
+ 9.75% + Lines + 8/82 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +2481x +1x +1x +  +  +  +1x +1x +  +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  + 
const mongoose = require('mongoose');
+const getModels = require('./getModelService');
+const FeedbackService = require('./feedbackService');
+const {
+  findPublishedPivotEvent,
+  serializeRecapEvent,
+} = require('./pivotIntentService');
+const { resolveDisplayHost, PIVOT_EVENT_STATUSES } = require('./pivotFeedService');
+ 
+const PIVOT_EVENT_FEATURE = 'pivot_event';
+const RECAP_EVENT_FIELDS =
+  'name description location start_time end_time externalLink type customFields.pivot';
+ 
+function unauthorized() {
+  return { error: 'Authentication required.', status: 401, code: 'UNAUTHORIZED' };
+}
+ 
+function serializePendingEvent(event, userIntent) {
+  const base = serializeRecapEvent(event, userIntent);
+  return {
+    _id: base._id,
+    name: base.name,
+    end_time: base.end_time,
+    displayHost: base.displayHost,
+    batchWeek: event.customFields?.pivot?.batchWeek || null,
+  };
+}
+ 
+async function getPendingEventFeedback(req, options = {}) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const now = options.now || new Date();
+  const { PivotEventIntent, Event, UniversalFeedback } = getModels(
+    req,
+    'PivotEventIntent',
+    'Event',
+    'UniversalFeedback',
+  );
+ 
+  const intents = await PivotEventIntent.find({
+    userId,
+    status: 'registered',
+  })
+    .select('eventId')
+    .lean();
+ 
+  if (!intents.length) {
+    return { data: { events: [] } };
+  }
+ 
+  const eventIds = intents.map((intent) => intent.eventId);
+ 
+  const events = await Event.find({
+    _id: { $in: eventIds },
+    end_time: { $lt: now },
+    'customFields.pivot.ingestStatus': 'published',
+    status: { $in: PIVOT_EVENT_STATUSES },
+    isDeleted: { $ne: true },
+    'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
+  })
+    .select(RECAP_EVENT_FIELDS)
+    .sort({ end_time: 1 })
+    .lean();
+ 
+  const eligible = events.filter((event) => resolveDisplayHost(event.customFields?.pivot));
+  if (!eligible.length) {
+    return { data: { events: [] } };
+  }
+ 
+  const eligibleIds = eligible.map((event) => event._id);
+  const submitted = await UniversalFeedback.find({
+    user: userId,
+    feature: PIVOT_EVENT_FEATURE,
+    processId: { $in: eligibleIds },
+  })
+    .select('processId')
+    .lean();
+ 
+  const submittedIds = new Set(submitted.map((row) => String(row.processId)));
+  const pending = eligible.filter((event) => !submittedIds.has(String(event._id)));
+ 
+  return {
+    data: {
+      events: pending.map((event) => serializePendingEvent(event, 'registered')),
+    },
+  };
+}
+ 
+async function submitEventFeedback(req, body = {}) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const eventId = String(body.eventId || '').trim();
+  const rating = body.rating;
+  const comment =
+    typeof body.comment === 'string' ? body.comment.trim() : undefined;
+ 
+  if (!mongoose.Types.ObjectId.isValid(eventId)) {
+    return {
+      error: 'A valid eventId is required.',
+      status: 400,
+      code: 'INVALID_EVENT_ID',
+    };
+  }
+ 
+  const ratingNumber = Number(rating);
+  if (!Number.isInteger(ratingNumber) || ratingNumber < 1 || ratingNumber > 5) {
+    return {
+      error: 'rating must be an integer from 1 to 5.',
+      status: 400,
+      code: 'INVALID_RATING',
+    };
+  }
+ 
+  const now = body.now || new Date();
+  const event = await findPublishedPivotEvent(req, eventId);
+  if (!event) {
+    return {
+      error: 'Event is not an active Pivot catalog event.',
+      status: 404,
+      code: 'EVENT_NOT_FOUND',
+    };
+  }
+ 
+  if (!event.end_time || new Date(event.end_time) >= now) {
+    return {
+      error: 'Feedback is only available after the event ends.',
+      status: 403,
+      code: 'EVENT_NOT_ENDED',
+    };
+  }
+ 
+  const { PivotEventIntent } = getModels(req, 'PivotEventIntent');
+  const intent = await PivotEventIntent.findOne({
+    userId,
+    eventId,
+    status: 'registered',
+  }).lean();
+ 
+  if (!intent) {
+    return {
+      error: 'Only users who confirmed a ticket can leave feedback.',
+      status: 403,
+      code: 'NOT_REGISTERED',
+    };
+  }
+ 
+  const responses = { rating: ratingNumber };
+  if (comment) {
+    responses.comment = comment;
+  }
+ 
+  const batchWeek = event.customFields?.pivot?.batchWeek || intent.batchWeek || null;
+  const metadata = { batchWeek, source: 'pivot_mobile' };
+ 
+  try {
+    const feedbackService = new FeedbackService(req);
+    await feedbackService.ensurePivotEventFeedbackConfig(userId);
+    const feedback = await feedbackService.submitFeedback(
+      userId,
+      PIVOT_EVENT_FEATURE,
+      eventId,
+      responses,
+      metadata,
+    );
+ 
+    return {
+      data: {
+        eventId: String(feedback.processId),
+        rating: ratingNumber,
+        submittedAt: feedback.submittedAt,
+      },
+    };
+  } catch (err) {
+    if (err.message?.includes('No feedback configuration')) {
+      return {
+        error: 'Pivot event feedback is not configured for this tenant.',
+        status: 503,
+        code: 'FEEDBACK_NOT_CONFIGURED',
+      };
+    }
+    if (err.message?.includes('Validation errors')) {
+      return {
+        error: err.message,
+        status: 400,
+        code: 'VALIDATION_ERROR',
+      };
+    }
+    throw err;
+  }
+}
+ 
+async function listUserPivotEventFeedback(req, options = {}) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 50);
+  const { UniversalFeedback, Event } = getModels(req, 'UniversalFeedback', 'Event');
+ 
+  const rows = await UniversalFeedback.find({
+    user: userId,
+    feature: PIVOT_EVENT_FEATURE,
+  })
+    .sort({ submittedAt: -1 })
+    .limit(limit)
+    .lean();
+ 
+  if (!rows.length) {
+    return { data: { feedback: [] } };
+  }
+ 
+  const eventIds = rows.map((row) => row.processId);
+  const events = await Event.find({ _id: { $in: eventIds } })
+    .select('name customFields.pivot.host')
+    .lean();
+  const eventById = new Map(events.map((event) => [String(event._id), event]));
+ 
+  const feedback = rows.map((row) => {
+    const event = eventById.get(String(row.processId));
+    const hostName = event?.customFields?.pivot?.host?.name || null;
+    return {
+      eventId: String(row.processId),
+      eventName: event?.name || null,
+      hostName,
+      rating: row.responses?.rating ?? null,
+      comment: row.responses?.comment ?? null,
+      batchWeek: row.metadata?.batchWeek ?? null,
+      submittedAt: row.submittedAt,
+    };
+  });
+ 
+  return { data: { feedback } };
+}
+ 
+module.exports = {
+  getPendingEventFeedback,
+  submitEventFeedback,
+  listUserPivotEventFeedback,
+  PIVOT_EVENT_FEATURE,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotFriendService.js.html b/backend/coverage/lcov-report/services/pivotFriendService.js.html new file mode 100644 index 00000000..61172a28 --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotFriendService.js.html @@ -0,0 +1,1045 @@ + + + + + + Code coverage report for services/pivotFriendService.js + + + + + + + + + +
+
+

All files / services pivotFriendService.js

+
+ +
+ 68.69% + Statements + 79/115 +
+ + +
+ 54.66% + Branches + 41/75 +
+ + +
+ 93.33% + Functions + 14/15 +
+ + +
+ 68.46% + Lines + 76/111 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +3211x +1x +1x +1x +  +1x +1x +  +  +4x +  +  +  +6x +  +  +  +4x +  +  +  +4x +4x +  +  +  +  +  +4x +3x +2x +  +2x +  +  +  +  +  +4x +  +  +  +  +  +  +4x +2x +  +  +4x +  +  +  +  +  +  +  +7x +7x +1x +  +  +6x +6x +2x +  +  +4x +  +4x +  +  +  +  +  +  +  +4x +2x +  +  +4x +2x +  +  +  +  +  +  +  +  +2x +2x +  +3x +  +  +3x +  +  +2x +4x +  +  +2x +  +  +  +  +  +  +  +4x +4x +1x +  +  +3x +3x +1x +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +  +2x +2x +  +  +  +2x +  +  +  +  +  +  +2x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +  +  +  +  +  +1x +  +1x +  +  +  +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  + 
const mongoose = require('mongoose');
+const getModels = require('./getModelService');
+const NotificationService = require('./notificationService');
+const { getFriendRequests } = require('../utilities/friendUtils');
+ 
+const SEARCH_RESULT_LIMIT = 20;
+const MIN_QUERY_LENGTH = 2;
+ 
+function unauthorized() {
+  return { error: 'Authentication required.', status: 401, code: 'UNAUTHORIZED' };
+}
+ 
+function normalizeQuery(q) {
+  return String(q || '').trim();
+}
+ 
+function escapeRegex(value) {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+ 
+function buildNameUsernameQuery(term) {
+  const regex = new RegExp(escapeRegex(term), 'i');
+  return {
+    $or: [{ name: { $regex: regex } }, { username: { $regex: regex } }],
+  };
+}
+ 
+function resolveFriendshipStatus(friendship, currentUserId) {
+  if (!friendship) return 'none';
+  if (friendship.status === 'accepted') return 'accepted';
+  Iif (friendship.status !== 'pending') return 'none';
+ 
+  return friendship.requester.toString() === currentUserId.toString()
+    ? 'pending_outgoing'
+    : 'pending_incoming';
+}
+ 
+function serializeSearchUser(user, friendshipStatus) {
+  const row = {
+    id: user._id.toString(),
+    name: user.name || '',
+    picture: user.picture || null,
+    friendshipStatus,
+  };
+ 
+  if (user.username) {
+    row.username = user.username;
+  }
+ 
+  return row;
+}
+ 
+/**
+ * Search for users in the current pilot city tenant by display name or username.
+ * Scoped to req.db (derived from req.school subdomain) — not cross-tenant.
+ */
+async function searchPivotFriends(req, options = {}) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const query = normalizeQuery(options.q);
+  if (query.length < MIN_QUERY_LENGTH) {
+    return { data: { users: [] } };
+  }
+ 
+  const { User, Friendship } = getModels(req, 'User', 'Friendship');
+ 
+  const users = await User.find({
+    ...buildNameUsernameQuery(query),
+    _id: { $ne: userId },
+  })
+    .select('name picture username')
+    .limit(SEARCH_RESULT_LIMIT)
+    .lean();
+ 
+  if (!users.length) {
+    return { data: { users: [] } };
+  }
+ 
+  const hitIds = users.map((user) => user._id);
+  const friendships = await Friendship.find({
+    $or: [
+      { requester: userId, recipient: { $in: hitIds } },
+      { requester: { $in: hitIds }, recipient: userId },
+    ],
+  })
+    .select('requester recipient status')
+    .lean();
+ 
+  const friendshipByOtherId = new Map();
+  for (const friendship of friendships) {
+    const otherId =
+      friendship.requester.toString() === userId.toString()
+        ? friendship.recipient.toString()
+        : friendship.requester.toString();
+    friendshipByOtherId.set(otherId, friendship);
+  }
+ 
+  const results = users.map((user) =>
+    serializeSearchUser(user, resolveFriendshipStatus(friendshipByOtherId.get(user._id.toString()), userId)),
+  );
+ 
+  return { data: { users: results } };
+}
+ 
+/**
+ * Send a friend request to another user in the current pilot city tenant by userId.
+ * Pivot users may lack a campus username; this avoids POST /friend-request/:username.
+ */
+async function sendPivotFriendRequest(req, body = {}) {
+  const requesterId = req.user?.userId;
+  if (!requesterId) {
+    return unauthorized();
+  }
+ 
+  const recipientId = String(body.userId || '').trim();
+  if (!mongoose.Types.ObjectId.isValid(recipientId)) {
+    return {
+      error: 'A valid userId is required.',
+      status: 400,
+      code: 'INVALID_USER_ID',
+    };
+  }
+ 
+  Iif (recipientId === requesterId.toString()) {
+    return {
+      error: 'Cannot send friend request to self.',
+      status: 400,
+      code: 'SELF_REQUEST',
+    };
+  }
+ 
+  const { User, Friendship, Notification } = getModels(req, 'User', 'Friendship', 'Notification');
+ 
+  const recipient = await User.findById(recipientId).select('name username').lean();
+  Iif (!recipient) {
+    return { error: 'User not found.', status: 404, code: 'USER_NOT_FOUND' };
+  }
+ 
+  const existingFriendship = await Friendship.findOne({
+    $or: [
+      { requester: requesterId, recipient: recipientId },
+      { requester: recipientId, recipient: requesterId },
+    ],
+  });
+ 
+  if (existingFriendship) {
+    Eif (existingFriendship.status === 'pending') {
+      return {
+        error: 'Friend request already sent.',
+        status: 400,
+        code: 'REQUEST_PENDING',
+      };
+    }
+    if (existingFriendship.status === 'accepted') {
+      return {
+        error: 'You are already friends with this user.',
+        status: 400,
+        code: 'ALREADY_FRIENDS',
+      };
+    }
+  }
+ 
+  const requesterUser = await User.findById(requesterId).select('name username').lean();
+  Iif (!requesterUser) {
+    return { error: 'User not found.', status: 404, code: 'REQUESTER_NOT_FOUND' };
+  }
+ 
+  const newFriendship = await new Friendship({
+    requester: requesterId,
+    recipient: recipientId,
+    status: 'pending',
+  }).save();
+ 
+  const notificationService = NotificationService.withModels({ Notification, User });
+  const senderName =
+    requesterUser.name?.trim() ||
+    requesterUser.username?.trim() ||
+    'Someone';
+ 
+  await notificationService.createSystemNotification(recipientId, 'User', 'friend_request', {
+    senderName,
+    friendshipId: newFriendship._id,
+    sender: requesterId,
+  });
+ 
+  return {
+    data: {
+      friendshipId: newFriendship._id.toString(),
+      friendshipStatus: 'pending_outgoing',
+    },
+  };
+}
+ 
+/**
+ * List accepted friends for the current pilot city tenant.
+ * Uses the same tenant-scoped Friendship/User models as campus /getFriends.
+ */
+async function listPivotFriends(req) {
+  const userId = req.user?.userId;
+  Eif (!userId) {
+    return unauthorized();
+  }
+ 
+  const { User, Friendship } = getModels(req, 'User', 'Friendship');
+ 
+  const friendships = await Friendship.find({
+    $or: [
+      { requester: userId, status: 'accepted' },
+      { recipient: userId, status: 'accepted' },
+    ],
+  }).populate('requester recipient', 'username name picture email');
+ 
+  const friendIds = friendships.map((friendship) =>
+    friendship.requester._id.toString() === userId.toString()
+      ? friendship.recipient._id
+      : friendship.requester._id,
+  );
+ 
+  const friends = friendIds.length
+    ? await User.find({ _id: { $in: friendIds } }).select('name username picture email partners')
+    : [];
+ 
+  return { data: { friends } };
+}
+ 
+/**
+ * List pending friend requests (received + sent) for the current pilot city tenant.
+ */
+async function listPivotFriendRequests(req) {
+  const userId = req.user?.userId;
+  Eif (!userId) {
+    return unauthorized();
+  }
+ 
+  const { Friendship } = getModels(req, 'Friendship');
+  const friendRequests = await getFriendRequests(Friendship, userId, {
+    receivedFields: 'username name picture email _id',
+    sentFields: 'username name picture email _id',
+    lean: true,
+  });
+ 
+  return { data: friendRequests };
+}
+ 
+async function acceptPivotFriendRequest(req, friendshipId) {
+  const recipientId = req.user?.userId;
+  Iif (!recipientId) {
+    return unauthorized();
+  }
+ 
+  const id = String(friendshipId || '').trim();
+  Eif (!mongoose.Types.ObjectId.isValid(id)) {
+    return {
+      error: 'A valid friendshipId is required.',
+      status: 400,
+      code: 'INVALID_FRIENDSHIP_ID',
+    };
+  }
+ 
+  const { User, Friendship } = getModels(req, 'User', 'Friendship');
+ 
+  const friendship = await Friendship.findById(id);
+  if (!friendship) {
+    return { error: 'Friendship not found.', status: 404, code: 'FRIENDSHIP_NOT_FOUND' };
+  }
+  if (friendship.recipient.toString() !== recipientId.toString()) {
+    return { error: 'Not authorized to accept request.', status: 403, code: 'FORBIDDEN' };
+  }
+ 
+  friendship.status = 'accepted';
+  await friendship.save();
+  await User.updateOne({ _id: friendship.requester }, { $inc: { partners: 1 } });
+  await User.updateOne({ _id: friendship.recipient }, { $inc: { partners: 1 } });
+ 
+  return { data: { friendshipId: friendship._id.toString(), status: 'accepted' } };
+}
+ 
+async function declinePivotFriendRequest(req, friendshipId) {
+  const recipientId = req.user?.userId;
+  Iif (!recipientId) {
+    return unauthorized();
+  }
+ 
+  const id = String(friendshipId || '').trim();
+  Eif (!mongoose.Types.ObjectId.isValid(id)) {
+    return {
+      error: 'A valid friendshipId is required.',
+      status: 400,
+      code: 'INVALID_FRIENDSHIP_ID',
+    };
+  }
+ 
+  const { Friendship } = getModels(req, 'Friendship');
+ 
+  const friendship = await Friendship.findById(id);
+  if (!friendship) {
+    return { error: 'Friendship not found.', status: 404, code: 'FRIENDSHIP_NOT_FOUND' };
+  }
+  if (friendship.recipient.toString() !== recipientId.toString()) {
+    return { error: 'Not authorized to reject request.', status: 403, code: 'FORBIDDEN' };
+  }
+ 
+  await Friendship.deleteOne({ _id: friendship._id });
+ 
+  return { data: { friendshipId: id, status: 'declined' } };
+}
+ 
+module.exports = {
+  searchPivotFriends,
+  sendPivotFriendRequest,
+  listPivotFriends,
+  listPivotFriendRequests,
+  acceptPivotFriendRequest,
+  declinePivotFriendRequest,
+  SEARCH_RESULT_LIMIT,
+  MIN_QUERY_LENGTH,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotIngestDuplicateService.js.html b/backend/coverage/lcov-report/services/pivotIngestDuplicateService.js.html new file mode 100644 index 00000000..7bcede18 --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotIngestDuplicateService.js.html @@ -0,0 +1,739 @@ + + + + + + Code coverage report for services/pivotIngestDuplicateService.js + + + + + + + + + +
+
+

All files / services pivotIngestDuplicateService.js

+
+ +
+ 76.13% + Statements + 67/88 +
+ + +
+ 58.88% + Branches + 53/90 +
+ + +
+ 81.25% + Functions + 13/16 +
+ + +
+ 77.77% + Lines + 63/81 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +2191x +1x +  +  +27x +  +  +  +9x +9x +9x +  +  +  +  +9x +9x +  +9x +9x +9x +9x +9x +9x +  +  +  +  +  +  +18x +  +  +  +  +  +  +  +  +9x +9x +9x +9x +  +9x +  +  +  +9x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +4x +4x +  +4x +4x +4x +1x +  +  +  +3x +3x +3x +1x +  +  +  +2x +  +  +  +1x +1x +1x +  +1x +2x +  +  +  +  +  +  +2x +2x +2x +  +2x +  +  +  +  +  +  +  +  +  +2x +1x +  +  +  +  +  +  +  +  +  +  +2x +2x +  +2x +1x +  +  +2x +1x +  +  +2x +  +  +  +  +  +1x +  +  +  +2x +2x +  +  +2x +1x +  +1x +  +  +1x +1x +  +  +  +  +  +3x +3x +2x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  + 
const getModels = require('./getModelService');
+const { connectToDatabase } = require('../connectionsManager');
+ 
+function trimString(value) {
+  return typeof value === 'string' ? value.trim() : '';
+}
+ 
+function parseDateTime(value) {
+  Iif (!value) return null;
+  const parsed = value instanceof Date ? value : new Date(value);
+  return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+ 
+/** Canonical Partiful/Luma ingest URL for duplicate checks. */
+function normalizeIngestSourceUrl(raw) {
+  const trimmed = trimString(raw);
+  Iif (!trimmed) return null;
+ 
+  try {
+    const parsed = new URL(trimmed);
+    let host = parsed.hostname.toLowerCase().replace(/^www\./, '');
+    if (host === 'lu.ma') host = 'luma.com';
+    const path = parsed.pathname.replace(/\/+$/, '') || '/';
+    return `${host}${path}`.toLowerCase();
+  } catch {
+    return trimmed.toLowerCase();
+  }
+}
+ 
+function normalizeEventText(value) {
+  return trimString(value)
+    .toLowerCase()
+    .replace(/[^\p{L}\p{N}\s]/gu, ' ')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+ 
+/** Name + start minute + location fingerprint for near-duplicate detection. */
+function buildEventFingerprint({ name, start_time, location }) {
+  const title = normalizeEventText(name);
+  const place = normalizeEventText(location);
+  const start = parseDateTime(start_time);
+  const startKey = start ? start.toISOString().slice(0, 16) : '';
+ 
+  Iif (!title && !startKey && !place) {
+    return null;
+  }
+ 
+  return `${title}|${startKey}|${place}`;
+}
+ 
+function summarizeCatalogEvent(event) {
+  const pivot = event.customFields?.pivot || {};
+  const host = pivot.host || {};
+ 
+  return {
+    _id: String(event._id),
+    name: event.name || '',
+    batchWeek: pivot.batchWeek || null,
+    organizerName: host.name || '',
+    sourceKey: normalizeIngestSourceUrl(pivot.sourceUrl || event.externalLink),
+    fingerprint: buildEventFingerprint({
+      name: event.name,
+      start_time: event.start_time,
+      location: event.location,
+    }),
+  };
+}
+ 
+async function loadCatalogDuplicateIndex(tenantKey) {
+  const db = await connectToDatabase(tenantKey);
+  const { Event } = getModels({ db }, 'Event');
+ 
+  const events = await Event.find({
+    'customFields.pivot': { $exists: true },
+    isDeleted: { $ne: true },
+  })
+    .select('name start_time location externalLink customFields.pivot')
+    .lean();
+ 
+  return events.map(summarizeCatalogEvent);
+}
+ 
+function duplicateSummary(existing, matchType, { willUpdate = false } = {}) {
+  return {
+    matchType,
+    willUpdate,
+    existingEventId: existing._id,
+    existingName: existing.name,
+    existingBatchWeek: existing.batchWeek,
+    existingOrganizerName: existing.organizerName,
+  };
+}
+ 
+function findCatalogDuplicate(index, candidate) {
+  const sourceKey = normalizeIngestSourceUrl(candidate.sourceUrl);
+  const fingerprint = buildEventFingerprint(candidate);
+ 
+  Eif (sourceKey) {
+    const bySource = index.find((row) => row.sourceKey && row.sourceKey === sourceKey);
+    if (bySource) {
+      return duplicateSummary(bySource, 'sourceUrl', { willUpdate: true });
+    }
+  }
+ 
+  Eif (fingerprint) {
+    const byFingerprint = index.find((row) => row.fingerprint && row.fingerprint === fingerprint);
+    if (byFingerprint) {
+      return duplicateSummary(byFingerprint, 'fingerprint');
+    }
+  }
+ 
+  return null;
+}
+ 
+function annotateImportDrafts(drafts, catalogIndex = []) {
+  const seenSourceKeys = new Map();
+  const seenFingerprints = new Map();
+  const duplicateWarnings = [];
+ 
+  const annotated = drafts.map((entry, index) => {
+    const candidate = {
+      name: entry.draft?.name,
+      start_time: entry.draft?.start_time,
+      location: entry.draft?.location,
+      sourceUrl: entry.sourceUrl || entry.draft?.sourceUrl,
+    };
+ 
+    const sourceKey = normalizeIngestSourceUrl(candidate.sourceUrl);
+    const fingerprint = buildEventFingerprint(candidate);
+    let duplicate = findCatalogDuplicate(catalogIndex, candidate);
+ 
+    Iif (!duplicate && sourceKey && seenSourceKeys.has(sourceKey)) {
+      duplicate = {
+        matchType: 'batchSourceUrl',
+        willUpdate: false,
+        existingEventId: null,
+        existingName: seenSourceKeys.get(sourceKey).name,
+        existingBatchWeek: null,
+        existingOrganizerName: null,
+        batchIndex: seenSourceKeys.get(sourceKey).index,
+      };
+    } else if (!duplicate && fingerprint && seenFingerprints.has(fingerprint)) {
+      duplicate = {
+        matchType: 'batchFingerprint',
+        willUpdate: false,
+        existingEventId: null,
+        existingName: seenFingerprints.get(fingerprint).name,
+        existingBatchWeek: null,
+        existingOrganizerName: null,
+        batchIndex: seenFingerprints.get(fingerprint).index,
+      };
+    }
+ 
+    Eif (!seenSourceKeys.has(sourceKey) && sourceKey) {
+      seenSourceKeys.set(sourceKey, { index, name: candidate.name || entry.sourceUrl });
+    }
+    if (!seenFingerprints.has(fingerprint) && fingerprint) {
+      seenFingerprints.set(fingerprint, { index, name: candidate.name || 'event' });
+    }
+ 
+    if (duplicate) {
+      duplicateWarnings.push(formatDuplicateWarning(duplicate, candidate.name));
+    }
+ 
+    return {
+      ...entry,
+      duplicate,
+    };
+  });
+ 
+  return { drafts: annotated, duplicateWarnings };
+}
+ 
+function formatDuplicateWarning(duplicate, candidateName) {
+  const label = candidateName || 'Event';
+  Iif (duplicate.matchType === 'sourceUrl') {
+    return `${label} already exists in catalog and will update the existing row.`;
+  }
+  if (duplicate.matchType === 'fingerprint') {
+    return `${label} looks like a duplicate of "${duplicate.existingName}" (same title, time, and location).`;
+  }
+  Iif (duplicate.matchType === 'batchSourceUrl') {
+    return `${label} duplicates another row in this import batch (same source URL).`;
+  }
+  Eif (duplicate.matchType === 'batchFingerprint') {
+    return `${label} duplicates another row in this import batch (same title, time, and location).`;
+  }
+  return `${label} looks like a duplicate.`;
+}
+ 
+function isBlockingDuplicate(duplicate) {
+  Iif (!duplicate) return false;
+  if (duplicate.matchType === 'sourceUrl') return false;
+  return true;
+}
+ 
+async function resolveImportDuplicate(req, { tenantKey, candidate }) {
+  if (!tenantKey) {
+    return { duplicate: null, catalogIndex: [] };
+  }
+ 
+  const catalogIndex = await loadCatalogDuplicateIndex(tenantKey);
+  const duplicate = findCatalogDuplicate(catalogIndex, candidate);
+  return { duplicate, catalogIndex };
+}
+ 
+module.exports = {
+  normalizeIngestSourceUrl,
+  buildEventFingerprint,
+  summarizeCatalogEvent,
+  loadCatalogDuplicateIndex,
+  findCatalogDuplicate,
+  annotateImportDrafts,
+  formatDuplicateWarning,
+  isBlockingDuplicate,
+  resolveImportDuplicate,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotIngestPreviewService.js.html b/backend/coverage/lcov-report/services/pivotIngestPreviewService.js.html new file mode 100644 index 00000000..5857fe26 --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotIngestPreviewService.js.html @@ -0,0 +1,3553 @@ + + + + + + Code coverage report for services/pivotIngestPreviewService.js + + + + + + + + + +
+
+

All files / services pivotIngestPreviewService.js

+
+ +
+ 67.84% + Statements + 327/482 +
+ + +
+ 53.08% + Branches + 267/503 +
+ + +
+ 79.31% + Functions + 69/87 +
+ + +
+ 70.65% + Lines + 313/443 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +11571x +  +  +  +  +  +  +1x +  +1x +1x +1x +1x +  +1x +  +1x +  +  +  +  +  +13x +13x +  +  +  +  +  +  +  +  +  +27x +  +  +  +  +  +  +  +  +  +  +27x +41x +41x +13x +  +  +  +14x +  +  +  +11x +  +11x +  +11x +11x +8x +8x +8x +8x +  +  +  +  +8x +  +  +11x +  +  +  +7x +  +  +7x +7x +  +  +  +7x +  +7x +7x +  +  +  +  +7x +7x +  +  +  +20x +20x +  +  +20x +  +  +  +8x +8x +8x +  +  +  +  +  +31x +31x +31x +  +  +  +  +  +  +  +  +  +  +11x +21x +9x +  +  +2x +  +  +  +7x +7x +  +7x +  +  +  +  +  +  +  +18x +18x +11x +  +7x +  +  +  +12x +12x +14x +14x +14x +14x +  +  +  +12x +12x +  +  +  +7x +  +7x +7x +9x +  +  +7x +3x +  +  +4x +  +  +  +9x +5x +  +  +4x +6x +  +  +  +  +6x +  +4x +  +  +  +6x +4x +4x +4x +  +  +4x +  +4x +  +  +  +  +  +  +1x +  +  +  +1x +  +2x +  +  +  +  +2x +  +  +  +  +  +  +2x +  +  +  +  +2x +  +1x +  +  +  +1x +  +2x +  +  +  +  +  +  +  +14x +14x +  +6x +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +7x +7x +  +  +7x +  +  +7x +  +  +7x +  +  +  +8x +8x +3x +  +  +5x +5x +4x +  +4x +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +6x +  +1x +1x +1x +  +  +1x +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +7x +7x +2x +  +  +5x +5x +  +  +  +5x +5x +  +  +  +  +  +  +  +  +  +  +5x +  +  +  +1x +1x +  +  +  +  +  +  +1x +  +  +  +1x +1x +  +  +  +1x +  +  +  +6x +6x +2x +  +  +  +  +7x +7x +12x +  +  +  +  +9x +9x +  +  +  +  +9x +9x +  +2x +  +  +7x +  +  +  +7x +1x +  +  +  +  +  +  +6x +  +  +  +3x +  +3x +2x +1x +  +1x +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +14x +14x +1x +  +14x +1x +  +14x +3x +  +14x +4x +  +14x +  +  +  +3x +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +3x +  +3x +3x +1x +2x +2x +  +  +  +  +  +3x +  +  +3x +  +  +  +  +  +  +  +3x +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +7x +7x +7x +5x +  +  +2x +2x +  +  +  +  +  +  +  +  +2x +  +  +  +2x +  +  +  +2x +2x +  +  +2x +2x +  +  +  +2x +  +2x +2x +  +  +36x +18x +2x +2x +  +18x +4x +4x +  +14x +  +  +2x +2x +  +  +  +1x +1x +1x +  +1x +  +  +  +  +  +  +  +2x +  +  +  +2x +  +  +2x +  +  +2x +  +  +  +2x +2x +  +  +2x +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +  +2x +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +3x +3x +2x +  +  +  +1x +1x +  +  +  +  +1x +  +1x +  +  +  +1x +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +3x +3x +1x +  +1x +  +  +1x +  +  +  +  +  +  +2x +2x +2x +  +  +  +2x +2x +2x +  +2x +2x +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +56x +113x +37x +  +  +19x +  +  +  +8x +8x +  +8x +  +  +  +  +  +8x +8x +8x +7x +7x +1x +1x +  +  +8x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +  +  +  +3x +3x +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +4x +1x +  +  +3x +3x +1x +  +  +2x +2x +  +2x +  +2x +1x +  +  +2x +1x +  +  +  +1x +1x +  +  +1x +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const axios = require('axios');
+const {
+  annotateImportDrafts,
+  formatDuplicateWarning,
+  isBlockingDuplicate,
+  loadCatalogDuplicateIndex,
+  resolveImportDuplicate,
+} = require('./pivotIngestDuplicateService');
+ 
+const FETCH_TIMEOUT_MS = 10_000;
+const MAX_BATCH_EVENTS = 50;
+const MAX_BATCH_ENRICH = MAX_BATCH_EVENTS;
+const HOST_ENRICH_CONCURRENCY = 4;
+ 
+const ALLOWED_HOST_SUFFIXES = ['partiful.com', 'lu.ma', 'luma.com'];
+ 
+const PROVIDER_LABELS = {
+  partiful: 'Partiful',
+  luma: 'Luma',
+};
+ 
+function decodeHtmlEntities(value) {
+  Iif (!value) return value;
+  return value
+    .replace(/&amp;/g, '&')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"')
+    .replace(/&#39;/g, "'")
+    .replace(/&#x27;/g, "'");
+}
+ 
+function extractMetaContent(html, key) {
+  const patterns = [
+    new RegExp(
+      `<meta\\s+[^>]*(?:property|name)=["']${key}["'][^>]*content=["']([^"']*)["']`,
+      'i',
+    ),
+    new RegExp(
+      `<meta\\s+[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${key}["']`,
+      'i',
+    ),
+  ];
+ 
+  for (const pattern of patterns) {
+    const match = html.match(pattern);
+    if (match?.[1]) {
+      return decodeHtmlEntities(match[1].trim());
+    }
+  }
+ 
+  return null;
+}
+ 
+function extractJsonLdBlocks(html) {
+  const blocks = [];
+  const pattern =
+    /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
+ 
+  let match = pattern.exec(html);
+  while (match) {
+    const raw = match[1]?.trim();
+    Eif (raw) {
+      try {
+        blocks.push(JSON.parse(raw));
+      } catch {
+        // Skip malformed JSON-LD blocks.
+      }
+    }
+    match = pattern.exec(html);
+  }
+ 
+  return blocks;
+}
+ 
+function flattenJsonLdNodes(block) {
+  const nodes = [];
+ 
+  function walk(value) {
+    Iif (!value) return;
+    Iif (Array.isArray(value)) {
+      value.forEach(walk);
+      return;
+    }
+    Iif (typeof value !== 'object') return;
+ 
+    nodes.push(value);
+    Iif (Array.isArray(value['@graph'])) {
+      value['@graph'].forEach(walk);
+    }
+  }
+ 
+  walk(block);
+  return nodes;
+}
+ 
+function hasType(node, typeName) {
+  const type = node['@type'];
+  Iif (Array.isArray(type)) {
+    return type.some((entry) => String(entry).toLowerCase() === typeName.toLowerCase());
+  }
+  return String(type || '').toLowerCase() === typeName.toLowerCase();
+}
+ 
+function organizerNameFromNode(node) {
+  Iif (!node || typeof node !== 'object') return null;
+  Eif (typeof node.name === 'string' && node.name.trim()) {
+    return node.name.trim();
+  }
+  return null;
+}
+ 
+function isInvalidHostName(name) {
+  Iif (typeof name !== 'string') return true;
+  const normalized = name.trim().toLowerCase();
+  return (
+    !normalized ||
+    normalized === 'partiful.com' ||
+    normalized === 'luma.com' ||
+    normalized === 'lu.ma' ||
+    normalized === 'partiful' ||
+    normalized === 'luma'
+  );
+}
+ 
+function firstPlausibleHostName(...values) {
+  for (const value of values) {
+    if (typeof value === 'string' && value.trim() && !isInvalidHostName(value)) {
+      return value.trim();
+    }
+  }
+  return null;
+}
+ 
+function isProfileOrAvatarImageUrl(raw) {
+  const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
+  Iif (!normalized) return false;
+ 
+  return (
+    /(?:^|[/])(?:avatars|profileimages)(?:[/]|$)/.test(normalized) ||
+    normalized.includes('cdn.lu.ma/avatars') ||
+    (normalized.includes('lumacdn.com/avatars') && normalized.includes('/uc/'))
+  );
+}
+ 
+function sanitizeEventPosterImage(raw) {
+  const trimmed = typeof raw === 'string' ? raw.trim() : '';
+  if (!trimmed || isProfileOrAvatarImageUrl(trimmed)) {
+    return null;
+  }
+  return trimmed;
+}
+ 
+function joinHostNames(names, limit = 3) {
+  const unique = [];
+  for (const name of names) {
+    const trimmed = typeof name === 'string' ? name.trim() : '';
+    Iif (!trimmed || isInvalidHostName(trimmed)) continue;
+    Eif (!unique.some((existing) => existing.toLowerCase() === trimmed.toLowerCase())) {
+      unique.push(trimmed);
+    }
+  }
+ 
+  Iif (!unique.length) return null;
+  return unique.slice(0, limit).join(' & ');
+}
+ 
+function organizerNamesFromNodes(organizer) {
+  Iif (!organizer) return null;
+ 
+  const nodes = Array.isArray(organizer) ? organizer : [organizer];
+  const organizationNames = nodes
+    .filter((node) => hasType(node, 'Organization'))
+    .map(organizerNameFromNode)
+    .filter(Boolean);
+  if (organizationNames.length) {
+    return joinHostNames(organizationNames, 2);
+  }
+ 
+  return joinHostNames(nodes.map(organizerNameFromNode).filter(Boolean));
+}
+ 
+function hostNamesFromPartifulHosts(hosts) {
+  if (!Array.isArray(hosts) || !hosts.length) {
+    return { hostName: null, hostImageUrl: null };
+  }
+ 
+  const normalized = hosts
+    .map((host) => ({
+      name: typeof host?.name === 'string' ? host.name.trim() : '',
+      isManaged: host?.isManaged === true,
+      host,
+    }))
+    .filter((entry) => entry.name && !isInvalidHostName(entry.name));
+ 
+  Iif (!normalized.length) {
+    return { hostName: null, hostImageUrl: null };
+  }
+ 
+  const managed = normalized.filter((entry) => entry.isManaged);
+  const chosen = managed.length ? managed : normalized;
+  const hostName = joinHostNames(
+    chosen.map((entry) => entry.name),
+    managed.length ? 2 : 3,
+  );
+  const primary = chosen[0];
+ 
+  return {
+    hostName,
+    hostImageUrl: null,
+  };
+}
+ 
+function hostNamesFromLumaHosts(hosts) {
+  Iif (!Array.isArray(hosts) || !hosts.length) {
+    return { hostName: null, hostImageUrl: null };
+  }
+ 
+  const normalized = hosts
+    .map((host) => {
+      Iif (typeof host === 'string') {
+        return { name: host.trim(), avatarUrl: null };
+      }
+ 
+      const name =
+        host?.name?.trim() ||
+        [host?.first_name, host?.last_name]
+          .map((value) => (typeof value === 'string' ? value.trim() : ''))
+          .filter(Boolean)
+          .join(' ')
+          .trim();
+ 
+      return {
+        name,
+        avatarUrl: host?.avatar_url?.trim() || null,
+      };
+    })
+    .filter((entry) => entry.name && !isInvalidHostName(entry.name));
+ 
+  Iif (!normalized.length) {
+    return { hostName: null, hostImageUrl: null };
+  }
+ 
+  return {
+    hostName: joinHostNames(
+      normalized.map((entry) => entry.name),
+      3,
+    ),
+    hostImageUrl: null,
+  };
+}
+ 
+function parsePartifulPageProps(html) {
+  const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/i);
+  if (!match?.[1]) return null;
+ 
+  try {
+    return JSON.parse(match[1])?.props?.pageProps || null;
+  } catch {
+    return null;
+  }
+}
+function organizerNodeFromEvent(eventNode) {
+  const organizer = eventNode.organizer;
+  if (!organizer) return null;
+ 
+  if (Array.isArray(organizer)) {
+    const organization = organizer.find(
+      (node) => hasType(node, 'Organization') && organizerNameFromNode(node),
+    );
+    if (organization) return organization;
+ 
+    return organizer.find((node) => organizerNameFromNode(node)) || null;
+  }
+ 
+  if (typeof organizer === 'object') {
+    return organizer;
+  }
+ 
+  return null;
+}
+ 
+function organizerImageFromNode(node) {
+  Iif (!node || typeof node !== 'object') return null;
+  Iif (typeof node.image === 'string' && node.image.trim()) {
+    return node.image.trim();
+  }
+  Iif (Array.isArray(node.image) && typeof node.image[0] === 'string') {
+    return node.image[0].trim();
+  }
+  Iif (node.image && typeof node.image.url === 'string') {
+    return node.image.url.trim();
+  }
+  return null;
+}
+ 
+function parseJsonLdEvent(nodes) {
+  const eventNode = nodes.find((node) => hasType(node, 'Event'));
+  if (!eventNode) {
+    return {};
+  }
+ 
+  let location = null;
+  if (eventNode.location) {
+    Iif (typeof eventNode.location === 'string') {
+      location = eventNode.location.trim();
+    } else Eif (typeof eventNode.location === 'object') {
+      location =
+        eventNode.location.name?.trim() ||
+        eventNode.location.address?.streetAddress?.trim() ||
+        [
+          eventNode.location.address?.addressLocality,
+          eventNode.location.address?.addressRegion,
+        ]
+          .filter(Boolean)
+          .join(', ')
+          .trim() ||
+        null;
+    }
+  }
+ 
+  return {
+    name: typeof eventNode.name === 'string' ? eventNode.name.trim() : null,
+    description:
+      typeof eventNode.description === 'string' ? eventNode.description.trim() : null,
+    image: sanitizeEventPosterImage(
+      typeof eventNode.image === 'string'
+        ? eventNode.image.trim()
+        : organizerImageFromNode({ image: eventNode.image }),
+    ),
+    start_time: eventNode.startDate || null,
+    end_time: eventNode.endDate || null,
+    location,
+    hostName: organizerNamesFromNodes(eventNode.organizer),
+    hostImageUrl: null,
+  };
+}
+ 
+function parseNextDataHost(html) {
+  const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/i);
+  if (!match?.[1]) return {};
+ 
+  try {
+    const payload = JSON.parse(match[1]);
+    const serialized = JSON.stringify(payload);
+ 
+    // Match hostName exactly — a case-insensitive pattern also matches `"hostname":"partiful.com"`.
+    const hostNameMatch = serialized.match(/"hostName"\s*:\s*"([^"\\]+)"/);
+    const hostImageMatch = serialized.match(/"host(?:Image|Avatar|Photo)(?:Url)?"\s*:\s*"([^"\\]+)"/i);
+    const organizerMatch = serialized.match(/"organizerName"\s*:\s*"([^"\\]+)"/);
+ 
+    return {
+      hostName: firstPlausibleHostName(hostNameMatch?.[1], organizerMatch?.[1]),
+      hostImageUrl: hostImageMatch?.[1] || null,
+    };
+  } catch {
+    return {};
+  }
+}
+ 
+function parsePartifulHost(html) {
+  const fromHosts = hostNamesFromPartifulHosts(parsePartifulPageProps(html)?.hosts);
+  if (fromHosts.hostName) {
+    return fromHosts;
+  }
+ 
+  const fromNext = parseNextDataHost(html);
+  Iif (fromNext.hostName) {
+    return fromNext;
+  }
+ 
+  const profileMatch = html.match(/partiful\.com\/u\/([^/"'?]+)/i);
+  Iif (profileMatch?.[1]) {
+    const slug = decodeURIComponent(profileMatch[1]).replace(/[-_]/g, ' ');
+    const hostName = slug.replace(/\b\w/g, (char) => char.toUpperCase());
+    if (!isInvalidHostName(hostName)) {
+      return {
+        hostName,
+        hostImageUrl: fromNext.hostImageUrl || null,
+      };
+    }
+  }
+ 
+  return fromNext;
+}
+ 
+function parseLumaHost(html, nodes) {
+  const fromJson = nodes
+    .filter((node) => hasType(node, 'Person') || hasType(node, 'Organization'))
+    .map((node) => ({
+      hostName: organizerNameFromNode(node),
+      hostImageUrl: organizerImageFromNode(node),
+    }))
+    .find((row) => row.hostName);
+ 
+  Iif (fromJson?.hostName) {
+    return fromJson;
+  }
+ 
+  const hostLabel = extractMetaContent(html, 'luma:event:host_name');
+  Iif (hostLabel) {
+    return { hostName: hostLabel, hostImageUrl: null };
+  }
+ 
+  return parseNextDataHost(html);
+}
+ 
+function detectProvider(hostname) {
+  const host = hostname.toLowerCase();
+  if (host.includes('partiful')) return 'partiful';
+  Eif (host.includes('lu.ma') || host.includes('luma')) return 'luma';
+  return null;
+}
+ 
+function isAllowedHost(hostname) {
+  const host = hostname.toLowerCase();
+  return ALLOWED_HOST_SUFFIXES.some(
+    (suffix) => host === suffix || host.endsWith(`.${suffix}`),
+  );
+}
+ 
+function normalizeUrl(rawUrl) {
+  const trimmed = rawUrl?.trim();
+  Iif (!trimmed) {
+    return { error: 'URL is required.', status: 400, code: 'URL_REQUIRED' };
+  }
+ 
+  let parsed;
+  try {
+    parsed = new URL(trimmed);
+  } catch {
+    return { error: 'Invalid URL.', status: 400, code: 'INVALID_URL' };
+  }
+ 
+  Iif (!['http:', 'https:'].includes(parsed.protocol)) {
+    return { error: 'Only HTTP(S) URLs are supported.', status: 400, code: 'INVALID_URL' };
+  }
+ 
+  if (!isAllowedHost(parsed.hostname)) {
+    return {
+      error: 'URL must be a Partiful or Luma event or explore link.',
+      status: 400,
+      code: 'UNSUPPORTED_HOST',
+    };
+  }
+ 
+  return { url: parsed.toString(), provider: detectProvider(parsed.hostname), parsed };
+}
+ 
+function classifyIngestUrl(parsed, provider) {
+  const path = parsed.pathname.replace(/\/+$/, '') || '/';
+ 
+  if (provider === 'partiful') {
+    if (/^\/explore\/[^/]+$/i.test(path)) {
+      return { kind: 'batch', batchType: 'partiful-explore' };
+    }
+    return { kind: 'single' };
+  }
+ 
+  Eif (provider === 'luma') {
+    Eif (/^\/[^/]+$/i.test(path) && !/^\/(user|discover|signin|signup|home|login)/i.test(path)) {
+      return { kind: 'batch-candidate', batchType: 'luma-discover' };
+    }
+    return { kind: 'single' };
+  }
+ 
+  return { kind: 'single' };
+}
+ 
+function draftWarnings(draft) {
+  const warnings = [];
+  if (!draft.name) {
+    warnings.push('Could not parse event title — enter manually before publishing.');
+  }
+  if (!draft.hostName) {
+    warnings.push('Could not parse organizer name — enter manually before publishing.');
+  }
+  if (!draft.start_time) {
+    warnings.push('Could not parse start time — set manually before publishing.');
+  }
+  if (!draft.location) {
+    warnings.push('Could not parse location — set manually before publishing.');
+  }
+  return warnings;
+}
+ 
+function partifulLocationFromInfo(locationInfo) {
+  if (!locationInfo || typeof locationInfo !== 'object') return null;
+ 
+  Eif (locationInfo.type === 'freeform' && typeof locationInfo.value === 'string') {
+    return locationInfo.value.trim() || null;
+  }
+ 
+  if (locationInfo.mapsInfo?.name) {
+    const lines = locationInfo.mapsInfo.addressLines || [];
+    return [locationInfo.mapsInfo.name, ...lines].filter(Boolean).join(', ').trim() || null;
+  }
+ 
+  if (Array.isArray(locationInfo.displayAddressLines) && locationInfo.displayAddressLines.length) {
+    return locationInfo.displayAddressLines.join(', ').trim() || null;
+  }
+ 
+  return locationInfo.approximateLocation?.trim() || null;
+}
+ 
+function partifulImageFromEvent(event) {
+  if (!event?.image || typeof event.image !== 'object') return null;
+ 
+  const uploadPath =
+    typeof event.image.upload?.path === 'string' ? event.image.upload.path.trim() : null;
+  Eif (uploadPath) {
+    return sanitizeEventPosterImage(
+      `https://partiful.imgix.net/${uploadPath}?w=598&h=642&fit=clip`,
+    );
+  }
+ 
+  const directUrl = event.image.url?.trim() || event.image.upload?.url?.trim();
+  return sanitizeEventPosterImage(directUrl);
+}
+ 
+function isInaccessiblePartifulImage(url) {
+  return typeof url !== 'string' || !url.trim() || url.includes('firebasestorage.googleapis.com');
+}
+ 
+function buildPartifulExploreDraft(event) {
+  const sourceUrl = event.id ? `https://partiful.com/e/${event.id}` : null;
+  const draft = {
+    name: typeof event.title === 'string' ? event.title.trim() : null,
+    description: typeof event.description === 'string' ? event.description.trim() : null,
+    image: partifulImageFromEvent(event),
+    start_time: event.startDate || null,
+    end_time: event.endDate || null,
+    location: partifulLocationFromInfo(event.locationInfo),
+    hostName: typeof event.hostName === 'string' ? event.hostName.trim() : null,
+    hostImageUrl: null,
+    sourceUrl,
+    source: 'partiful',
+    sourceTags: extractPartifulSourceTags(event),
+  };
+ 
+  return { draft, warnings: draftWarnings(draft), sourceUrl };
+}
+ 
+function extractPartifulSourceTags(event) {
+  Iif (!event || typeof event !== 'object') return [];
+ 
+  const tags = [];
+  if (Array.isArray(event.tags)) {
+    for (const entry of event.tags) {
+      if (typeof entry === 'string' && entry.trim()) {
+        tags.push(entry.trim());
+      } else Eif (entry && typeof entry.name === 'string' && entry.name.trim()) {
+        tags.push(entry.name.trim());
+      }
+    }
+  }
+  Iif (typeof event.category === 'string' && event.category.trim()) {
+    tags.push(event.category.trim());
+  }
+  Iif (Array.isArray(event.categories)) {
+    for (const entry of event.categories) {
+      if (typeof entry === 'string' && entry.trim()) {
+        tags.push(entry.trim());
+      }
+    }
+  }
+ 
+  return [...new Set(tags)];
+}
+ 
+function extractPartifulEventSlugFromUrl(sourceUrl) {
+  Iif (!sourceUrl) return null;
+  try {
+    const match = new URL(sourceUrl).pathname.match(/\/e\/([^/]+)/i);
+    return match?.[1] ? decodeURIComponent(match[1]) : null;
+  } catch {
+    return null;
+  }
+}
+ 
+function parsePartifulSingleEventDraft(html, sourceUrl) {
+  const pageProps = parsePartifulPageProps(html);
+  const event = pageProps?.event;
+  if (!event || typeof event !== 'object') {
+    return null;
+  }
+ 
+  const hostFields = hostNamesFromPartifulHosts(pageProps?.hosts);
+  const built = buildPartifulExploreDraft({
+    ...event,
+    id: event.id || extractPartifulEventSlugFromUrl(sourceUrl),
+    hostName: firstPlausibleHostName(
+      typeof event.hostName === 'string' ? event.hostName.trim() : null,
+      hostFields.hostName,
+    ),
+  });
+ 
+  Iif (!built.draft.sourceUrl && sourceUrl) {
+    built.draft.sourceUrl = sourceUrl;
+  }
+ 
+  return built.draft;
+}
+ 
+function extractPartifulExploreEvents(html) {
+  const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/i);
+  Iif (!match?.[1]) return [];
+ 
+  let pageProps;
+  try {
+    pageProps = JSON.parse(match[1])?.props?.pageProps;
+  } catch {
+    return [];
+  }
+  Iif (!pageProps) return [];
+ 
+  const events = [];
+  const seen = new Set();
+ 
+  function collect(node) {
+    if (!node || typeof node !== 'object') return;
+    if (node.event?.id && !seen.has(node.event.id)) {
+      seen.add(node.event.id);
+      events.push(node.event);
+    }
+    if (Array.isArray(node)) {
+      node.forEach(collect);
+      return;
+    }
+    Object.values(node).forEach(collect);
+  }
+ 
+  collect(pageProps);
+  return events;
+}
+ 
+function parsePartifulExploreBatch(html, sourceUrl) {
+  const events = extractPartifulExploreEvents(html).slice(0, MAX_BATCH_EVENTS);
+  const drafts = events.map((event) => buildPartifulExploreDraft(event));
+  const listLabel = extractMetaContent(html, 'og:title') || 'Partiful explore';
+ 
+  return {
+    listLabel,
+    drafts,
+    truncated: extractPartifulExploreEvents(html).length > MAX_BATCH_EVENTS,
+  };
+}
+ 
+function organizerFromLumaEvent(eventNode) {
+  return organizerNamesFromNodes(eventNode.organizer);
+}
+ 
+function imageFromLumaEvent(eventNode) {
+  Iif (typeof eventNode.image === 'string') {
+    return sanitizeEventPosterImage(eventNode.image);
+  }
+  Iif (Array.isArray(eventNode.image) && typeof eventNode.image[0] === 'string') {
+    return sanitizeEventPosterImage(eventNode.image[0]);
+  }
+  return sanitizeEventPosterImage(organizerImageFromNode({ image: eventNode.image }));
+}
+ 
+function locationFromLumaEvent(eventNode) {
+  Iif (!eventNode.location) return null;
+  Iif (typeof eventNode.location === 'string') {
+    return eventNode.location.trim();
+  }
+  Iif (typeof eventNode.location !== 'object') return null;
+ 
+  return (
+    eventNode.location.name?.trim() ||
+    eventNode.location.address?.streetAddress?.trim() ||
+    [
+      eventNode.location.address?.addressLocality,
+      eventNode.location.address?.addressRegion,
+    ]
+      .filter(Boolean)
+      .join(', ')
+      .trim() ||
+    null
+  );
+}
+ 
+function buildLumaDiscoverDraft(eventNode) {
+  const rawUrl = eventNode.url || eventNode['@id'] || null;
+  let sourceUrl = rawUrl;
+  Iif (sourceUrl && sourceUrl.startsWith('/')) {
+    sourceUrl = `https://luma.com${sourceUrl}`;
+  } else Iif (sourceUrl && !/^https?:\/\//i.test(sourceUrl)) {
+    sourceUrl = `https://luma.com/${sourceUrl.replace(/^\/+/, '')}`;
+  }
+ 
+  const draft = {
+    name: typeof eventNode.name === 'string' ? eventNode.name.trim() : null,
+    description: typeof eventNode.description === 'string' ? eventNode.description.trim() : null,
+    image: imageFromLumaEvent(eventNode),
+    start_time: eventNode.startDate || eventNode.start_at || null,
+    end_time: eventNode.endDate || eventNode.end_at || null,
+    location: locationFromLumaEvent(eventNode),
+    hostName: organizerFromLumaEvent(eventNode),
+    hostImageUrl: null,
+    sourceUrl,
+    source: 'luma',
+  };
+ 
+  return { draft, warnings: draftWarnings(draft), sourceUrl };
+}
+ 
+function extractLumaDiscoverEventsFromNextData(html) {
+  const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/i);
+  if (!match?.[1]) {
+    return { events: [], listLabel: null };
+  }
+ 
+  let initialData;
+  try {
+    initialData = JSON.parse(match[1])?.props?.pageProps?.initialData;
+  } catch {
+    return { events: [], listLabel: null };
+  }
+ 
+  const events = Array.isArray(initialData?.data?.events) ? initialData.data.events : [];
+  const listLabel =
+    initialData?.data?.place?.publication_name?.trim() ||
+    initialData?.data?.place?.name?.trim() ||
+    null;
+ 
+  return { events, listLabel };
+}
+ 
+function hostFieldsFromLumaDiscoverEntry(entry) {
+  const fromHosts = hostNamesFromLumaHosts(entry?.hosts);
+  Eif (fromHosts.hostName) {
+    return fromHosts;
+  }
+ 
+  const calendar = entry?.calendar;
+  const user = calendar?.personal_user;
+  if (user) {
+    const fullName = [user.first_name, user.last_name]
+      .map((value) => (typeof value === 'string' ? value.trim() : ''))
+      .filter(Boolean)
+      .join(' ')
+      .trim();
+    if (fullName) {
+      return {
+        hostName: fullName,
+        hostImageUrl: null,
+      };
+    }
+    if (typeof user.name === 'string' && user.name.trim()) {
+      return {
+        hostName: user.name.trim(),
+        hostImageUrl: null,
+      };
+    }
+  }
+ 
+  if (typeof calendar?.name === 'string') {
+    const calendarName = calendar.name.trim();
+    if (calendarName && calendarName.toLowerCase() !== 'personal') {
+      return {
+        hostName: calendarName,
+        hostImageUrl: null,
+      };
+    }
+  }
+ 
+  return { hostName: null, hostImageUrl: null };
+}
+ 
+function locationFromLumaDiscoverEvent(event) {
+  const geo = event?.geo_address_info;
+  Iif (!geo || typeof geo !== 'object') return null;
+ 
+  return (
+    geo.full_address?.trim() ||
+    geo.short_address?.trim() ||
+    geo.address?.trim() ||
+    geo.city_state?.trim() ||
+    null
+  );
+}
+ 
+function imageFromLumaDiscoverEvent(event) {
+  return (
+    sanitizeEventPosterImage(event?.cover_url) ||
+    sanitizeEventPosterImage(event?.social_image_url) ||
+    null
+  );
+}
+ 
+function buildLumaDiscoverDraftFromNextData(entry) {
+  const event = entry?.event;
+  Iif (!event) {
+    return null;
+  }
+ 
+  const slug = typeof event.url === 'string' ? event.url.trim() : '';
+  const sourceUrl = slug ? `https://luma.com/${slug.replace(/^\/+/, '')}` : null;
+  const hostFields = hostFieldsFromLumaDiscoverEntry(entry);
+  const draft = {
+    name: typeof event.name === 'string' ? event.name.trim() : null,
+    description: null,
+    image: imageFromLumaDiscoverEvent(event),
+    start_time: event.start_at || null,
+    end_time: event.end_at || null,
+    location: locationFromLumaDiscoverEvent(event),
+    hostName: hostFields.hostName,
+    hostImageUrl: hostFields.hostImageUrl,
+    sourceUrl,
+    source: 'luma',
+  };
+ 
+  return { draft, warnings: draftWarnings(draft), sourceUrl };
+}
+ 
+function parseLumaDiscoverBatch(html, sourceUrl) {
+  const nextData = extractLumaDiscoverEventsFromNextData(html);
+  if (nextData.events.length) {
+    const drafts = nextData.events
+      .slice(0, MAX_BATCH_EVENTS)
+      .map((entry) => buildLumaDiscoverDraftFromNextData(entry))
+      .filter(Boolean);
+ 
+    return {
+      listLabel: nextData.listLabel || extractMetaContent(html, 'og:title') || 'Luma discover',
+      drafts,
+      truncated: nextData.events.length > MAX_BATCH_EVENTS,
+    };
+  }
+ 
+  const jsonLdNodes = extractJsonLdBlocks(html).flatMap(flattenJsonLdNodes);
+  const itemList = jsonLdNodes.find((node) => hasType(node, 'ItemList'));
+  Iif (!itemList?.itemListElement?.length) {
+    return { listLabel: null, drafts: [], truncated: false };
+  }
+ 
+  const eventNodes = itemList.itemListElement
+    .map((entry) => entry?.item || entry)
+    .filter((node) => node && hasType(node, 'Event'));
+ 
+  const drafts = eventNodes.slice(0, MAX_BATCH_EVENTS).map((eventNode) => buildLumaDiscoverDraft(eventNode));
+  const listLabel = itemList.name || extractMetaContent(html, 'og:title') || 'Luma discover';
+ 
+  return {
+    listLabel,
+    drafts,
+    truncated: eventNodes.length > MAX_BATCH_EVENTS,
+  };
+}
+ 
+async function mapWithConcurrency(items, limit, iteratee) {
+  if (!items.length) return [];
+ 
+  const results = new Array(items.length);
+  let nextIndex = 0;
+ 
+  async function worker() {
+    while (nextIndex < items.length) {
+      const currentIndex = nextIndex;
+      nextIndex += 1;
+      results[currentIndex] = await iteratee(items[currentIndex], currentIndex);
+    }
+  }
+ 
+  const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
+  await Promise.all(workers);
+  return results;
+}
+ 
+async function enrichPartifulBatchDrafts(entries) {
+  const toEnrich = entries
+    .filter((entry) => {
+      if (!entry.sourceUrl) return false;
+      const needsHost = !entry.draft.hostName;
+      const needsImage = isInaccessiblePartifulImage(entry.draft.image);
+      return needsHost || needsImage;
+    })
+    .slice(0, MAX_BATCH_ENRICH);
+ 
+  await mapWithConcurrency(toEnrich, HOST_ENRICH_CONCURRENCY, async (entry) => {
+    const fetched = await fetchEventPage(entry.sourceUrl);
+    if (fetched.error || !fetched.html) {
+      return;
+    }
+ 
+    const { draft } = buildDraft({
+      html: fetched.html,
+      provider: 'partiful',
+      sourceUrl: entry.sourceUrl,
+    });
+ 
+    if (draft.hostName && !entry.draft.hostName && !isInvalidHostName(draft.hostName)) {
+      entry.draft.hostName = draft.hostName;
+    }
+    if (draft.image && isInaccessiblePartifulImage(entry.draft.image)) {
+      entry.draft.image = draft.image;
+    }
+    entry.warnings = draftWarnings(entry.draft);
+  });
+}
+ 
+function firstNonEmpty(...values) {
+  for (const value of values) {
+    if (typeof value === 'string' && value.trim()) {
+      return value.trim();
+    }
+  }
+  return null;
+}
+ 
+function buildDraft({ html, provider, sourceUrl }) {
+  const jsonLdNodes = extractJsonLdBlocks(html).flatMap(flattenJsonLdNodes);
+  const jsonLdEvent = parseJsonLdEvent(jsonLdNodes);
+ 
+  const openGraph = {
+    name: extractMetaContent(html, 'og:title'),
+    description: extractMetaContent(html, 'og:description'),
+    image: extractMetaContent(html, 'og:image'),
+  };
+ 
+  let hostFields = {};
+  let partifulPageDraft = null;
+  if (provider === 'partiful') {
+    partifulPageDraft = parsePartifulSingleEventDraft(html, sourceUrl);
+    hostFields = parsePartifulHost(html);
+  } else Eif (provider === 'luma') {
+    hostFields = parseLumaHost(html, jsonLdNodes);
+  }
+ 
+  const draft = {
+    name: firstNonEmpty(partifulPageDraft?.name, openGraph.name, jsonLdEvent.name),
+    description: firstNonEmpty(partifulPageDraft?.description, openGraph.description, jsonLdEvent.description),
+    image: sanitizeEventPosterImage(
+      firstNonEmpty(partifulPageDraft?.image, openGraph.image, jsonLdEvent.image),
+    ),
+    start_time: firstNonEmpty(partifulPageDraft?.start_time, jsonLdEvent.start_time),
+    end_time: firstNonEmpty(partifulPageDraft?.end_time, jsonLdEvent.end_time),
+    location: firstNonEmpty(partifulPageDraft?.location, jsonLdEvent.location),
+    hostName: firstPlausibleHostName(
+      partifulPageDraft?.hostName,
+      jsonLdEvent.hostName,
+      hostFields.hostName,
+    ),
+    hostImageUrl: null,
+    sourceUrl: firstNonEmpty(partifulPageDraft?.sourceUrl, sourceUrl),
+    source: provider,
+    sourceTags: partifulPageDraft?.sourceTags || [],
+  };
+ 
+  return { draft, warnings: draftWarnings(draft), providerLabel: PROVIDER_LABELS[provider] || provider };
+}
+ 
+async function fetchEventPage(url) {
+  try {
+    const response = await axios.get(url, {
+      timeout: FETCH_TIMEOUT_MS,
+      maxRedirects: 5,
+      headers: {
+        'User-Agent':
+          'MeridianPivotLab/1.0 (+https://meridian.study; event ingest preview)',
+        Accept: 'text/html,application/xhtml+xml',
+      },
+      validateStatus: (status) => status >= 200 && status < 400,
+    });
+ 
+    Iif (typeof response.data !== 'string') {
+      return {
+        error: 'Unexpected response from event page.',
+        status: 422,
+        code: 'UNPARSEABLE_RESPONSE',
+      };
+    }
+ 
+    return { html: response.data };
+  } catch (err) {
+    Eif (err.code === 'ECONNABORTED') {
+      return {
+        error: 'Event page fetch timed out after 10 seconds.',
+        status: 504,
+        code: 'FETCH_TIMEOUT',
+      };
+    }
+ 
+    if (err.response?.status === 404) {
+      return {
+        error: 'Event page not found (404).',
+        status: 404,
+        code: 'PAGE_NOT_FOUND',
+      };
+    }
+ 
+    if (err.response?.status >= 400 && err.response?.status < 500) {
+      return {
+        error: `Unable to fetch event page (${err.response.status}).`,
+        status: 422,
+        code: 'FETCH_FAILED',
+      };
+    }
+ 
+    return {
+      error: 'Unable to fetch event page.',
+      status: 502,
+      code: 'FETCH_FAILED',
+    };
+  }
+}
+ 
+async function attachPreviewDuplicates(data, options = {}) {
+  const tenantKey = options.tenantKey?.trim()?.toLowerCase();
+  Eif (!tenantKey || !data?.mode) {
+    return data;
+  }
+ 
+  if (data.mode === 'batch') {
+    const catalogIndex = await loadCatalogDuplicateIndex(tenantKey);
+    const annotated = annotateImportDrafts(data.drafts || [], catalogIndex);
+    const blockingCount = annotated.drafts.filter(
+      (entry) => entry.duplicate && isBlockingDuplicate(entry.duplicate),
+    ).length;
+ 
+    return {
+      ...data,
+      drafts: annotated.drafts,
+      warnings: [...(data.warnings || []), ...annotated.duplicateWarnings],
+      duplicateCount: blockingCount,
+    };
+  }
+ 
+  if (data.mode === 'single' && data.draft) {
+    const { duplicate } = await resolveImportDuplicate(null, {
+      tenantKey,
+      candidate: {
+        name: data.draft.name,
+        start_time: data.draft.start_time,
+        location: data.draft.location,
+        sourceUrl: data.draft.sourceUrl,
+      },
+    });
+ 
+    const warnings = [...(data.warnings || [])];
+    if (duplicate) {
+      warnings.push(formatDuplicateWarning(duplicate, data.draft.name));
+    }
+ 
+    return {
+      ...data,
+      duplicate: duplicate || null,
+      warnings,
+    };
+  }
+ 
+  return data;
+}
+ 
+async function previewIngestUrl(_req, options = {}) {
+  const normalized = normalizeUrl(options.url);
+  if (normalized.error) {
+    return normalized;
+  }
+ 
+  const fetched = await fetchEventPage(normalized.url);
+  if (fetched.error) {
+    return fetched;
+  }
+ 
+  const classification = classifyIngestUrl(normalized.parsed, normalized.provider);
+  let batchResult = null;
+ 
+  Iif (normalized.provider === 'partiful' && classification.kind === 'batch') {
+    batchResult = parsePartifulExploreBatch(fetched.html, normalized.url);
+  } else if (normalized.provider === 'luma') {
+    batchResult = parseLumaDiscoverBatch(fetched.html, normalized.url);
+  }
+ 
+  if (batchResult?.drafts?.length) {
+    Iif (normalized.provider === 'partiful') {
+      await enrichPartifulBatchDrafts(batchResult.drafts);
+    }
+ 
+    const batchWarnings = [];
+    Iif (batchResult.truncated) {
+      batchWarnings.push(`Only the first ${MAX_BATCH_EVENTS} events were imported from this page.`);
+    }
+    const missingOrganizerCount = batchResult.drafts.filter((entry) => !entry.draft.hostName).length;
+    Iif (missingOrganizerCount) {
+      batchWarnings.push(
+        `${missingOrganizerCount} event(s) still need an organizer name before publishing.`,
+      );
+    }
+ 
+    return {
+      data: await attachPreviewDuplicates(
+        {
+          mode: 'batch',
+          listLabel: batchResult.listLabel,
+          drafts: batchResult.drafts,
+          warnings: batchWarnings,
+          provider: normalized.provider,
+          providerLabel: PROVIDER_LABELS[normalized.provider] || normalized.provider,
+          truncated: batchResult.truncated,
+        },
+        options,
+      ),
+    };
+  }
+ 
+  Iif (classification.kind === 'batch') {
+    return {
+      error: 'No events found on this explore page.',
+      status: 422,
+      code: 'NO_EVENTS_FOUND',
+    };
+  }
+ 
+  const { draft, warnings, providerLabel } = buildDraft({
+    html: fetched.html,
+    provider: normalized.provider,
+    sourceUrl: normalized.url,
+  });
+ 
+  return {
+    data: await attachPreviewDuplicates(
+      {
+        mode: 'single',
+        draft,
+        warnings,
+        provider: normalized.provider,
+        providerLabel,
+      },
+      options,
+    ),
+  };
+}
+ 
+module.exports = {
+  previewIngestUrl,
+  normalizeUrl,
+  buildDraft,
+  classifyIngestUrl,
+  parsePartifulExploreBatch,
+  parseLumaDiscoverBatch,
+  extractPartifulExploreEvents,
+  buildPartifulExploreDraft,
+  buildLumaDiscoverDraft,
+  isInvalidHostName,
+  firstPlausibleHostName,
+  extractMetaContent,
+  extractJsonLdBlocks,
+  sanitizeEventPosterImage,
+  parsePartifulSingleEventDraft,
+  extractPartifulSourceTags,
+  FETCH_TIMEOUT_MS,
+  MAX_BATCH_EVENTS,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotIngestPublishService.js.html b/backend/coverage/lcov-report/services/pivotIngestPublishService.js.html new file mode 100644 index 00000000..036d8824 --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotIngestPublishService.js.html @@ -0,0 +1,2098 @@ + + + + + + Code coverage report for services/pivotIngestPublishService.js + + + + + + + + + +
+
+

All files / services pivotIngestPublishService.js

+
+ +
+ 63.77% + Statements + 169/265 +
+ + +
+ 51.87% + Branches + 138/266 +
+ + +
+ 90.47% + Functions + 19/21 +
+ + +
+ 64.54% + Lines + 162/251 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +6721x +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +1x +1x +1x +1x +  +  +  +1x +1x +  +1x +  +  +231x +  +  +  +122x +198x +198x +  +50x +  +  +  +18x +9x +9x +  +  +  +5x +  +  +  +  +  +  +  +  +10x +10x +  +  +  +  +  +  +  +10x +10x +10x +  +  +  +  +  +  +  +10x +  +  +  +5x +4x +  +  +1x +1x +  +  +  +7x +7x +2x +2x +  +  +  +8x +8x +1x +  +  +  +7x +7x +  +  +  +  +7x +  +  +  +7x +  +  +  +  +  +  +10x +8x +  +  +4x +  +  +  +  +  +  +  +  +10x +  +  +  +  +  +  +  +10x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +10x +10x +10x +10x +10x +10x +  +  +  +10x +1x +  +  +  +  +  +  +9x +9x +9x +  +9x +2x +1x +  +2x +1x +2x +2x +  +  +  +  +9x +  +  +  +  +  +  +  +9x +7x +  +  +9x +  +  +  +  +  +  +  +  +  +  +5x +  +  +  +  +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +5x +  +5x +4x +  +  +  +  +  +  +1x +1x +  +  +  +8x +8x +  +  +  +8x +8x +  +  +  +8x +8x +8x +  +  +  +8x +8x +5x +5x +  +  +  +5x +  +  +  +  +  +  +  +5x +3x +2x +  +  +  +  +1x +  +  +  +  +  +8x +8x +8x +  +  +8x +8x +  +  +  +8x +8x +2x +  +  +6x +6x +  +  +  +  +  +  +  +  +  +6x +1x +  +  +  +  +  +  +  +5x +5x +5x +  +  +  +  +  +  +  +5x +5x +5x +  +5x +  +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +  +  +2x +2x +  +  +  +  +  +  +  +2x +2x +2x +  +  +  +  +  +  +  +2x +2x +2x +  +2x +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +2x +2x +  +2x +2x +  +  +2x +2x +  +2x +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +2x +1x +  +2x +  +  +  +  +2x +  +  +  +  +  +2x +2x +1x +1x +  +  +  +  +  +  +1x +  +  +2x +  +  +  +  +  +  +  +2x +1x +1x +  +  +1x +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +  +2x +  +  +  +  +  +2x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  + 
const getModels = require('./getModelService');
+const {
+  getMergedTenants,
+  provisionPivotCatalogOrg,
+} = require('./tenantConfigService');
+const { isPivotTenant } = require('./pivotReferralCodeService');
+const { connectToDatabase } = require('../connectionsManager');
+const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService');
+const { previewIngestUrl, sanitizeEventPosterImage } = require('./pivotIngestPreviewService');
+const {
+  formatDuplicateWarning,
+  isBlockingDuplicate,
+  resolveImportDuplicate,
+} = require('./pivotIngestDuplicateService');
+const { serializeLabEvent } = require('./pivotLabEventsService');
+const { validatePivotEventTags } = require('./pivotTagCatalogService');
+const { normalizePivotTimeSlots } = require('../utilities/pivotTimeSlots');
+const {
+  normalizePivotMovie,
+  applyMovieListingDefaults,
+} = require('../utilities/pivotMovieMetadata');
+const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger');
+ 
+const DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000;
+ 
+function trimString(value) {
+  return typeof value === 'string' ? value.trim() : '';
+}
+ 
+function firstNonEmpty(...values) {
+  for (const value of values) {
+    const trimmed = trimString(value);
+    if (trimmed) return trimmed;
+  }
+  return null;
+}
+ 
+function parseDateTime(value) {
+  if (!value) return null;
+  const parsed = new Date(value);
+  return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+ 
+function resolveImportedBy(req) {
+  return (
+    trimString(req.user?.email) ||
+    trimString(req.user?.globalUserId) ||
+    trimString(req.user?.userId) ||
+    'pivot-lab'
+  );
+}
+ 
+async function resolvePivotTenant(req, tenantKey) {
+  const normalizedKey = tenantKey?.trim()?.toLowerCase();
+  Iif (!normalizedKey) {
+    return {
+      error: 'tenantKey is required.',
+      status: 400,
+      code: 'TENANT_KEY_REQUIRED',
+    };
+  }
+ 
+  const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant);
+  const tenant = pivotTenants.find((row) => row.tenantKey === normalizedKey);
+  Iif (!tenant) {
+    return {
+      error: 'Pivot tenant not found.',
+      status: 404,
+      code: 'TENANT_NOT_FOUND',
+    };
+  }
+ 
+  return { tenant };
+}
+ 
+async function resolveCatalogOrgId(req, tenant) {
+  if (tenant.pivotCatalogOrgId) {
+    return { orgId: tenant.pivotCatalogOrgId };
+  }
+ 
+  const provisioned = await provisionPivotCatalogOrg(req, tenant.tenantKey, tenant);
+  return { orgId: provisioned.orgId };
+}
+ 
+function detectIngestProvider(hostname) {
+  const host = hostname.toLowerCase();
+  if (host.includes('partiful')) return 'partiful';
+  Iif (host.includes('lu.ma') || host.includes('luma')) return 'luma';
+  return null;
+}
+ 
+function normalizePublishUrl(rawUrl) {
+  const trimmed = trimString(rawUrl);
+  if (!trimmed) {
+    return { url: null, provider: null };
+  }
+ 
+  let parsed;
+  try {
+    parsed = new URL(trimmed);
+  } catch {
+    return { error: 'Invalid URL.', status: 400, code: 'INVALID_URL' };
+  }
+ 
+  Iif (!['http:', 'https:'].includes(parsed.protocol)) {
+    return { error: 'Only HTTP(S) URLs are supported.', status: 400, code: 'INVALID_URL' };
+  }
+ 
+  return {
+    url: parsed.toString(),
+    provider: detectIngestProvider(parsed.hostname),
+  };
+}
+ 
+function normalizeIngestTimeSlots(rawSlots) {
+  if (!Array.isArray(rawSlots) || !rawSlots.length) {
+    return [];
+  }
+ 
+  return normalizePivotTimeSlots(rawSlots).map((slot) => ({
+    id: slot.id,
+    start_time: slot.start_time,
+    ...(slot.end_time ? { end_time: slot.end_time } : {}),
+    ...(slot.label ? { label: slot.label } : {}),
+  }));
+}
+ 
+function mergeDraftWithOverrides(draft = {}, overrides = {}) {
+  const timeSlots = normalizeIngestTimeSlots(
+    Array.isArray(overrides.timeSlots)
+      ? overrides.timeSlots
+      : Array.isArray(draft.timeSlots)
+        ? draft.timeSlots
+        : [],
+  );
+ 
+  return {
+    name: firstNonEmpty(overrides.name, draft.name),
+    description: firstNonEmpty(overrides.description, draft.description) || '',
+    image: sanitizeEventPosterImage(firstNonEmpty(overrides.image, draft.image)),
+    location: firstNonEmpty(overrides.location, draft.location),
+    start_time: firstNonEmpty(overrides.start_time, draft.start_time),
+    end_time: firstNonEmpty(overrides.end_time, draft.end_time),
+    hostName: firstNonEmpty(overrides.hostName, draft.hostName),
+    hostImageUrl: null,
+    hostProfileUrl: firstNonEmpty(overrides.hostProfileUrl, draft.hostProfileUrl),
+    source: firstNonEmpty(overrides.source, draft.source),
+    sourceUrl: firstNonEmpty(overrides.sourceUrl, draft.sourceUrl),
+    tags: Array.isArray(overrides.tags)
+      ? overrides.tags
+      : Array.isArray(draft.tags)
+        ? draft.tags
+        : [],
+    timeSlots,
+    movie: normalizePivotMovie(
+      overrides.movie !== undefined ? overrides.movie : draft.movie,
+    ),
+  };
+}
+ 
+function validateMergedDraft(merged) {
+  const withMovieDefaults = applyMovieListingDefaults(merged);
+  const missing = [];
+  if (!withMovieDefaults.hostName) missing.push('hostName');
+  Iif (!withMovieDefaults.name) missing.push('name');
+  Iif (!withMovieDefaults.location) missing.push('location');
+  Iif (!withMovieDefaults.start_time && !withMovieDefaults.timeSlots?.length) {
+    missing.push('start_time');
+  }
+ 
+  if (missing.length) {
+    return {
+      error: `Missing required fields after merge: ${missing.join(', ')}.`,
+      status: 400,
+      code: 'MISSING_REQUIRED_FIELDS',
+    };
+  }
+ 
+  const slots = normalizePivotTimeSlots(withMovieDefaults.timeSlots);
+  let startTime = parseDateTime(withMovieDefaults.start_time);
+  let endTime = parseDateTime(withMovieDefaults.end_time);
+ 
+  if (slots.length) {
+    if (!startTime) {
+      startTime = slots[0].start_time;
+    }
+    if (!endTime) {
+      endTime = slots.reduce((latest, slot) => {
+        const candidate = slot.end_time || slot.start_time;
+        return !latest || candidate > latest ? candidate : latest;
+      }, null);
+    }
+  }
+ 
+  Iif (!startTime) {
+    return {
+      error: 'start_time must be a valid datetime.',
+      status: 400,
+      code: 'INVALID_START_TIME',
+    };
+  }
+ 
+  if (!endTime || endTime <= startTime) {
+    endTime = new Date(startTime.getTime() + DEFAULT_DURATION_MS);
+  }
+ 
+  return {
+    merged: {
+      ...withMovieDefaults,
+      timeSlots: slots,
+      startTime,
+      endTime,
+    },
+  };
+}
+ 
+function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }) {
+  const host = {
+    name: merged.hostName,
+    ...(merged.hostProfileUrl ? { profileUrl: merged.hostProfileUrl } : {}),
+  };
+ 
+  return {
+    batchWeek,
+    source: merged.source || 'manual',
+    sourceUrl,
+    host,
+    tags: tags || [],
+    ...(merged.timeSlots?.length ? { timeSlots: merged.timeSlots } : {}),
+    ...(merged.movie ? { movie: merged.movie } : {}),
+    ingestStatus: 'published',
+    importedAt: new Date().toISOString(),
+    importedBy,
+  };
+}
+ 
+function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importedBy, tags }) {
+  const listingUrl = trimString(sourceUrl) || null;
+  return {
+    name: merged.name,
+    description: merged.description || '',
+    type: 'social',
+    location: merged.location,
+    start_time: merged.startTime,
+    end_time: merged.endTime,
+    status: 'not-applicable',
+    visibility: 'public',
+    registrationEnabled: true,
+    expectedAttendance: 0,
+    ...(listingUrl ? { externalLink: listingUrl } : {}),
+    hostingType: 'Org',
+    hostingId: catalogOrgId,
+    isDeleted: false,
+    ...(merged.image ? { image: merged.image } : {}),
+    customFields: {
+      pivot: buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }),
+    },
+  };
+}
+ 
+async function savePublishedCatalogEvent(tenantReq, eventPayload, sourceUrl) {
+  const { Event } = getModels(tenantReq, 'Event');
+  const listingUrl = trimString(sourceUrl);
+ 
+  if (listingUrl) {
+    return Event.findOneAndUpdate(
+      { 'customFields.pivot.sourceUrl': listingUrl },
+      { $set: eventPayload },
+      { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true },
+    ).lean();
+  }
+ 
+  const created = await Event.create(eventPayload);
+  return typeof created.toObject === 'function' ? created.toObject() : created;
+}
+ 
+async function publishIngestEvent(req, options = {}) {
+  const batchNormalized = normalizeBatchWeek(options.batchWeek, options.now);
+  Iif (batchNormalized.error) {
+    return batchNormalized;
+  }
+ 
+  const tenantResult = await resolvePivotTenant(req, options.tenantKey);
+  Iif (tenantResult.error) {
+    return tenantResult;
+  }
+ 
+  const overrides = options.overrides || {};
+  const urlNormalized = normalizePublishUrl(options.url);
+  Iif (urlNormalized.error) {
+    return urlNormalized;
+  }
+ 
+  let previewDraft = {};
+  if (urlNormalized.url && urlNormalized.provider) {
+    const previewResult = await previewIngestUrl(req, { url: urlNormalized.url });
+    Iif (previewResult.error) {
+      return previewResult;
+    }
+ 
+    Iif (previewResult.data?.mode === 'batch') {
+      return {
+        error: 'Explore links must be published from batch import.',
+        status: 400,
+        code: 'BATCH_URL_REQUIRES_BATCH_PUBLISH',
+      };
+    }
+ 
+    previewDraft = previewResult.data?.draft || {};
+  } else if (urlNormalized.url) {
+    previewDraft = {
+      sourceUrl: urlNormalized.url,
+      source: firstNonEmpty(overrides.source) || 'manual',
+    };
+  } else {
+    previewDraft = {
+      source: firstNonEmpty(overrides.source) || 'manual',
+      sourceUrl: firstNonEmpty(overrides.sourceUrl),
+    };
+  }
+ 
+  const mergedInput = mergeDraftWithOverrides(previewDraft, overrides);
+  mergedInput.sourceUrl = firstNonEmpty(urlNormalized.url, mergedInput.sourceUrl);
+  mergedInput.source =
+    firstNonEmpty(mergedInput.source, urlNormalized.provider) || 'manual';
+ 
+  const validated = validateMergedDraft(mergedInput);
+  Iif (validated.error) {
+    return validated;
+  }
+ 
+  const tagResult = await validatePivotEventTags(req, mergedInput.tags, { required: true });
+  if (tagResult.error) {
+    return tagResult;
+  }
+ 
+  const listingUrl = trimString(mergedInput.sourceUrl) || null;
+  const { duplicate } = await resolveImportDuplicate(req, {
+    tenantKey: tenantResult.tenant.tenantKey,
+    candidate: {
+      name: validated.merged.name,
+      start_time: validated.merged.start_time,
+      location: validated.merged.location,
+      sourceUrl: listingUrl,
+    },
+  });
+ 
+  if (isBlockingDuplicate(duplicate)) {
+    return {
+      error: formatDuplicateWarning(duplicate, validated.merged.name),
+      status: 409,
+      code: 'DUPLICATE_EVENT',
+      data: { duplicate },
+    };
+  }
+ 
+  const catalogResult = await resolveCatalogOrgId(req, tenantResult.tenant);
+  const importedBy = resolveImportedBy(req);
+  const eventPayload = buildEventPayload(validated.merged, {
+    catalogOrgId: catalogResult.orgId,
+    sourceUrl: listingUrl,
+    batchWeek: batchNormalized.batchWeek,
+    importedBy,
+    tags: tagResult.tags,
+  });
+ 
+  const db = await connectToDatabase(tenantResult.tenant.tenantKey);
+  const tenantReq = { db };
+  const event = await savePublishedCatalogEvent(tenantReq, eventPayload, listingUrl);
+ 
+  logPivot('info', 'catalog event published', {
+    tenantKey: tenantResult.tenant.tenantKey,
+    batchWeek: batchNormalized.batchWeek,
+    eventId: String(event._id),
+    name: event.name,
+    source: mergedInput.source,
+    timeSlotCount: validated.merged.timeSlots?.length ?? 0,
+    importedBy,
+  });
+ 
+  return {
+    data: {
+      event: serializeLabEvent(event),
+      created: true,
+    },
+  };
+}
+ 
+async function publishBatchIngestEvents(req, options = {}) {
+  const batchNormalized = normalizeBatchWeek(options.batchWeek, options.now);
+  if (batchNormalized.error) {
+    return batchNormalized;
+  }
+ 
+  const tenantResult = await resolvePivotTenant(req, options.tenantKey);
+  if (tenantResult.error) {
+    return tenantResult;
+  }
+ 
+  const events = Array.isArray(options.events) ? options.events : [];
+  if (!events.length) {
+    return {
+      error: 'At least one event is required.',
+      status: 400,
+      code: 'EVENTS_REQUIRED',
+    };
+  }
+ 
+  const published = [];
+  const failures = [];
+ 
+  for (const entry of events) {
+    const url = trimString(entry?.url) || undefined;
+ 
+    const result = await publishIngestEvent(req, {
+      tenantKey: options.tenantKey,
+      batchWeek: batchNormalized.batchWeek,
+      url,
+      overrides: entry.overrides || {},
+      now: options.now,
+    });
+ 
+    if (result.error) {
+      failures.push({ url: url || null, message: result.error, code: result.code });
+      continue;
+    }
+ 
+    published.push(result.data.event);
+  }
+ 
+  if (!published.length) {
+    return {
+      error: failures[0]?.message || 'Unable to publish any events.',
+      status: 400,
+      code: failures[0]?.code || 'BATCH_PUBLISH_FAILED',
+      data: { published, failures },
+    };
+  }
+ 
+  logPivot('info', 'batch catalog publish complete', {
+    tenantKey: tenantResult.tenant.tenantKey,
+    batchWeek: batchNormalized.batchWeek,
+    publishedCount: published.length,
+    failedCount: failures.length,
+  });
+ 
+  return {
+    data: {
+      published,
+      failures,
+      publishedCount: published.length,
+      failedCount: failures.length,
+    },
+  };
+}
+ 
+async function updateIngestEvent(req, options = {}) {
+  const tenantResult = await resolvePivotTenant(req, options.tenantKey);
+  Iif (tenantResult.error) {
+    return tenantResult;
+  }
+ 
+  const eventId = trimString(options.eventId);
+  Iif (!eventId) {
+    return {
+      error: 'eventId is required.',
+      status: 400,
+      code: 'EVENT_ID_REQUIRED',
+    };
+  }
+ 
+  const overrides = options.overrides || {};
+  const hostName = firstNonEmpty(overrides.hostName);
+  Iif (overrides.hostName !== undefined && !hostName) {
+    return {
+      error: 'hostName cannot be empty.',
+      status: 400,
+      code: 'HOST_NAME_REQUIRED',
+    };
+  }
+ 
+  const db = await connectToDatabase(tenantResult.tenant.tenantKey);
+  const tenantReq = { db };
+  const { Event } = getModels(tenantReq, 'Event');
+ 
+  const existing = await Event.findOne({
+    _id: eventId,
+    isDeleted: { $ne: true },
+    'customFields.pivot': { $exists: true },
+  }).lean();
+ 
+  Iif (!existing) {
+    return {
+      error: 'Pivot catalog event not found.',
+      status: 404,
+      code: 'EVENT_NOT_FOUND',
+    };
+  }
+ 
+  const pivot = existing.customFields?.pivot || {};
+  const host = { ...(pivot.host || {}) };
+  const setPayload = {};
+ 
+  if (overrides.name !== undefined) setPayload.name = trimString(overrides.name);
+  Iif (overrides.description !== undefined) {
+    setPayload.description = trimString(overrides.description);
+  }
+  Iif (overrides.location !== undefined) setPayload.location = trimString(overrides.location);
+  Iif (overrides.image !== undefined) setPayload.image = trimString(overrides.image) || null;
+ 
+  Iif (overrides.start_time !== undefined) {
+    const startTime = parseDateTime(overrides.start_time);
+    if (!startTime) {
+      return {
+        error: 'start_time must be a valid datetime.',
+        status: 400,
+        code: 'INVALID_START_TIME',
+      };
+    }
+    setPayload.start_time = startTime;
+  }
+ 
+  Iif (overrides.end_time !== undefined) {
+    const endTime = parseDateTime(overrides.end_time);
+    if (!endTime) {
+      return {
+        error: 'end_time must be a valid datetime.',
+        status: 400,
+        code: 'INVALID_END_TIME',
+      };
+    }
+    setPayload.end_time = endTime;
+  }
+ 
+  if (hostName) {
+    host.name = hostName;
+  }
+  Iif (overrides.hostImageUrl !== undefined) {
+    const imageUrl = trimString(overrides.hostImageUrl);
+    if (imageUrl) host.imageUrl = imageUrl;
+    else delete host.imageUrl;
+  }
+  Iif (overrides.hostProfileUrl !== undefined) {
+    const profileUrl = trimString(overrides.hostProfileUrl);
+    if (profileUrl) host.profileUrl = profileUrl;
+    else delete host.profileUrl;
+  }
+ 
+  const pivotPatch = { ...pivot, host };
+  if (overrides.ingestStatus !== undefined) {
+    const ingestStatus = trimString(overrides.ingestStatus);
+    Iif (!['draft', 'published'].includes(ingestStatus)) {
+      return {
+        error: 'ingestStatus must be draft or published.',
+        status: 400,
+        code: 'INVALID_INGEST_STATUS',
+      };
+    }
+    pivotPatch.ingestStatus = ingestStatus;
+  }
+ 
+  Iif (overrides.batchWeek !== undefined) {
+    const batchNormalized = normalizeBatchWeek(overrides.batchWeek, options.now);
+    if (batchNormalized.error) {
+      return batchNormalized;
+    }
+    pivotPatch.batchWeek = batchNormalized.batchWeek;
+  }
+ 
+  if (overrides.tags !== undefined) {
+    const tagResult = await validatePivotEventTags(req, overrides.tags, { required: false });
+    Iif (tagResult.error) {
+      return tagResult;
+    }
+    pivotPatch.tags = tagResult.tags;
+  }
+ 
+  Iif (overrides.timeSlots !== undefined) {
+    const slots = normalizeIngestTimeSlots(overrides.timeSlots);
+    if (slots.length) {
+      pivotPatch.timeSlots = slots;
+      if (overrides.start_time === undefined) {
+        setPayload.start_time = slots[0].start_time;
+      }
+      if (overrides.end_time === undefined) {
+        const latestEnd = slots.reduce((latest, slot) => {
+          const candidate = slot.end_time || slot.start_time;
+          return !latest || new Date(candidate).getTime() > new Date(latest).getTime()
+            ? candidate
+            : latest;
+        }, null);
+        if (latestEnd) {
+          setPayload.end_time = latestEnd;
+        }
+      }
+    } else {
+      delete pivotPatch.timeSlots;
+    }
+  }
+ 
+  Iif (overrides.sourceUrl !== undefined) {
+    const sourceUrl = trimString(overrides.sourceUrl);
+    if (sourceUrl) {
+      pivotPatch.sourceUrl = sourceUrl;
+      setPayload.externalLink = sourceUrl;
+    } else {
+      delete pivotPatch.sourceUrl;
+      setPayload.externalLink = null;
+    }
+  }
+ 
+  Iif (overrides.movie !== undefined) {
+    const movie = normalizePivotMovie(overrides.movie);
+    if (movie) {
+      pivotPatch.movie = movie;
+      if (overrides.name === undefined && movie.title) {
+        setPayload.name = movie.title;
+      }
+      if (overrides.description === undefined && movie.synopsis) {
+        setPayload.description = movie.synopsis;
+      }
+      if (overrides.image === undefined && movie.posterUrl) {
+        setPayload.image = movie.posterUrl;
+      }
+    } else {
+      delete pivotPatch.movie;
+    }
+  }
+ 
+  const nextIngestStatus = pivotPatch.ingestStatus ?? pivot.ingestStatus;
+  const nextTags = pivotPatch.tags ?? pivot.tags ?? [];
+  Iif (nextIngestStatus === 'published' && nextTags.length === 0) {
+    return {
+      error: 'At least one catalog tag is required for published events.',
+      status: 400,
+      code: 'TAGS_REQUIRED',
+    };
+  }
+ 
+  Iif (!host.name) {
+    return {
+      error: 'hostName cannot be empty.',
+      status: 400,
+      code: 'HOST_NAME_REQUIRED',
+    };
+  }
+ 
+  setPayload['customFields.pivot'] = pivotPatch;
+ 
+  const updated = await Event.findByIdAndUpdate(
+    eventId,
+    { $set: setPayload },
+    { new: true, runValidators: true },
+  ).lean();
+ 
+  return {
+    data: {
+      event: serializeLabEvent(updated),
+    },
+  };
+}
+ 
+module.exports = {
+  publishIngestEvent,
+  publishBatchIngestEvents,
+  updateIngestEvent,
+  mergeDraftWithOverrides,
+  validateMergedDraft,
+  buildEventPayload,
+  normalizePublishUrl,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotIntentService.js.html b/backend/coverage/lcov-report/services/pivotIntentService.js.html new file mode 100644 index 00000000..338ed408 --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotIntentService.js.html @@ -0,0 +1,1426 @@ + + + + + + Code coverage report for services/pivotIntentService.js + + + + + + + + + +
+
+

All files / services pivotIntentService.js

+
+ +
+ 7.87% + Statements + 10/127 +
+ + +
+ 0% + Branches + 0/130 +
+ + +
+ 0% + Functions + 0/13 +
+ + +
+ 7.87% + Lines + 10/127 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +4481x +1x +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +1x +  +1x +  +  +  +  +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  + 
const mongoose = require('mongoose');
+const getModels = require('./getModelService');
+const {
+  getPilotWindow,
+  resolveDisplayHost,
+  serializePivotFeedEvent,
+  loadFriendSocial,
+  PIVOT_EVENT_STATUSES,
+} = require('./pivotFeedService');
+const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek');
+const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger');
+const {
+  normalizePivotTimeSlots,
+  findTimeSlotById,
+  eventHasTimeSlots,
+} = require('../utilities/pivotTimeSlots');
+ 
+const FEED_ACTION_TO_STATUS = {
+  interested: 'interested',
+  pass: 'passed',
+};
+ 
+const RECAP_STATUSES = ['interested', 'registered'];
+const RECAP_EVENT_FIELDS =
+  'name description location start_time end_time externalLink type image customFields.pivot';
+ 
+function unauthorized() {
+  return { error: 'Authentication required.', status: 401, code: 'UNAUTHORIZED' };
+}
+ 
+/**
+ * Loads a published Pivot catalog event by id, enforcing the same visibility
+ * filters as the feed (never campus RSVP rows). Optionally enforces the active
+ * pilot window for swipe actions.
+ */
+async function findPublishedPivotEvent(req, eventId, { now, requireWindow } = {}) {
+  const { Event } = getModels(req, 'Event');
+ 
+  const query = {
+    _id: eventId,
+    'customFields.pivot.ingestStatus': 'published',
+    status: { $in: PIVOT_EVENT_STATUSES },
+    isDeleted: { $ne: true },
+    'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
+  };
+ 
+  if (requireWindow) {
+    const { windowStart, windowEnd } = getPilotWindow(now);
+    query.start_time = { $gte: windowStart, $lt: windowEnd };
+  }
+ 
+  return Event.findOne(query)
+    .select('start_time end_time externalLink customFields.pivot')
+    .lean();
+}
+ 
+async function upsertIntent(req, { userId, eventId, status, batchWeek, timeSlotId }) {
+  const { PivotEventIntent } = getModels(req, 'PivotEventIntent');
+ 
+  const update = { status, batchWeek };
+  if (timeSlotId !== undefined) {
+    update.timeSlotId = timeSlotId || null;
+  }
+ 
+  const doc = await PivotEventIntent.findOneAndUpdate(
+    { userId, eventId },
+    { $set: update },
+    { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true },
+  ).lean();
+ 
+  return doc;
+}
+ 
+function resolveRegisteredTimeSlotId(event, requestedTimeSlotId) {
+  const pivot = event.customFields?.pivot;
+  if (!eventHasTimeSlots(pivot)) {
+    return { timeSlotId: null };
+  }
+ 
+  const slots = normalizePivotTimeSlots(pivot?.timeSlots);
+  const trimmed = typeof requestedTimeSlotId === 'string' ? requestedTimeSlotId.trim() : '';
+ 
+  if (slots.length === 1) {
+    return { timeSlotId: trimmed || slots[0].id };
+  }
+ 
+  if (!trimmed) {
+    return {
+      error: 'A showtime is required for this event.',
+      status: 400,
+      code: 'TIME_SLOT_REQUIRED',
+    };
+  }
+ 
+  if (!findTimeSlotById(pivot, trimmed)) {
+    return {
+      error: 'Invalid showtime for this event.',
+      status: 400,
+      code: 'INVALID_TIME_SLOT',
+    };
+  }
+ 
+  return { timeSlotId: trimmed };
+}
+ 
+async function recordFeedAction(req, body = {}) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const eventId = String(body.eventId || '').trim();
+  const action = String(body.action || '').trim();
+ 
+  if (!mongoose.Types.ObjectId.isValid(eventId)) {
+    return {
+      error: 'A valid eventId is required.',
+      status: 400,
+      code: 'INVALID_EVENT_ID',
+    };
+  }
+ 
+  const status = FEED_ACTION_TO_STATUS[action];
+  if (!status) {
+    return {
+      error: "action must be 'interested' or 'pass'.",
+      status: 400,
+      code: 'INVALID_ACTION',
+    };
+  }
+ 
+  const event = await findPublishedPivotEvent(req, eventId, {
+    now: body.now,
+    requireWindow: true,
+  });
+  if (!event) {
+    return {
+      error: 'Event is not an active Pivot catalog event.',
+      status: 404,
+      code: 'EVENT_NOT_FOUND',
+    };
+  }
+ 
+  const batchWeek = event.customFields?.pivot?.batchWeek || toIsoWeek();
+  const doc = await upsertIntent(req, {
+    userId,
+    eventId,
+    status,
+    batchWeek,
+    timeSlotId: null,
+  });
+ 
+  logPivot('info', 'feed action recorded', {
+    ...pivotRequestContext(req),
+    eventId,
+    status: doc.status,
+    batchWeek: doc.batchWeek,
+    action,
+  });
+ 
+  return {
+    data: {
+      eventId: String(doc.eventId),
+      status: doc.status,
+      batchWeek: doc.batchWeek,
+      timeSlotId: doc.timeSlotId || null,
+    },
+  };
+}
+ 
+async function recordExternalOpen(req, rawEventId, body = {}) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const eventId = String(rawEventId || '').trim();
+  if (!mongoose.Types.ObjectId.isValid(eventId)) {
+    return {
+      error: 'A valid eventId is required.',
+      status: 400,
+      code: 'INVALID_EVENT_ID',
+    };
+  }
+ 
+  const event = await findPublishedPivotEvent(req, eventId);
+  if (!event) {
+    return {
+      error: 'Event is not an active Pivot catalog event.',
+      status: 404,
+      code: 'EVENT_NOT_FOUND',
+    };
+  }
+ 
+  const batchWeek = event.customFields?.pivot?.batchWeek || toIsoWeek();
+  const openedAt = body.openedExternalAt
+    ? new Date(body.openedExternalAt)
+    : new Date();
+  const externalOpenAt = Number.isNaN(openedAt.getTime()) ? new Date() : openedAt;
+ 
+  const { PivotEventIntent } = getModels(req, 'PivotEventIntent');
+ 
+  // Opening tickets is a stronger signal than passing, so a brand-new row lands
+  // as `interested`; an existing `interested`/`registered` row keeps its status.
+  const doc = await PivotEventIntent.findOneAndUpdate(
+    { userId, eventId },
+    {
+      $set: { externalOpenAt },
+      $inc: { externalOpenCount: 1 },
+      $setOnInsert: { status: 'interested', batchWeek },
+    },
+    { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true },
+  ).lean();
+ 
+  logPivot('info', 'external ticket open recorded', {
+    ...pivotRequestContext(req),
+    eventId,
+    status: doc.status,
+    externalOpenCount: doc.externalOpenCount,
+    batchWeek: doc.batchWeek,
+  });
+ 
+  return {
+    data: {
+      eventId: String(doc.eventId),
+      status: doc.status,
+      batchWeek: doc.batchWeek,
+      externalOpenCount: doc.externalOpenCount,
+      externalOpenAt: doc.externalOpenAt,
+    },
+  };
+}
+ 
+async function confirmRegistered(req, rawEventId, body = {}) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const eventId = String(rawEventId || '').trim();
+  if (!mongoose.Types.ObjectId.isValid(eventId)) {
+    return {
+      error: 'A valid eventId is required.',
+      status: 400,
+      code: 'INVALID_EVENT_ID',
+    };
+  }
+ 
+  const event = await findPublishedPivotEvent(req, eventId);
+  if (!event) {
+    return {
+      error: 'Event is not an active Pivot catalog event.',
+      status: 404,
+      code: 'EVENT_NOT_FOUND',
+    };
+  }
+ 
+  const slotResolution = resolveRegisteredTimeSlotId(event, body.timeSlotId);
+  if (slotResolution.error) {
+    return slotResolution;
+  }
+ 
+  const batchWeek = event.customFields?.pivot?.batchWeek || toIsoWeek();
+  const doc = await upsertIntent(req, {
+    userId,
+    eventId,
+    status: 'registered',
+    batchWeek,
+    timeSlotId: slotResolution.timeSlotId,
+  });
+ 
+  logPivot('info', 'registration confirmed', {
+    ...pivotRequestContext(req),
+    eventId,
+    batchWeek: doc.batchWeek,
+    timeSlotId: doc.timeSlotId || null,
+  });
+ 
+  return {
+    data: {
+      eventId: String(doc.eventId),
+      status: doc.status,
+      batchWeek: doc.batchWeek,
+      timeSlotId: doc.timeSlotId || null,
+    },
+  };
+}
+ 
+function serializeRecapEvent(event, intentRow, extras = {}) {
+  const pivot = event.customFields?.pivot || {};
+  const status =
+    intentRow && typeof intentRow === 'object' ? intentRow.status : intentRow;
+  const userTimeSlotId =
+    (intentRow && typeof intentRow === 'object' ? intentRow.timeSlotId : null) ||
+    extras.userTimeSlotId ||
+    null;
+ 
+  return serializePivotFeedEvent(event, {
+    displayHost: resolveDisplayHost(pivot),
+    userIntent: status || null,
+    userTimeSlotId,
+    socialByTimeSlot: extras.socialByTimeSlot || new Map(),
+    friendsInterested: extras.friendsInterested || [],
+    friendsGoing: extras.friendsGoing || [],
+    friendsInterestedCount: extras.friendsInterestedCount || 0,
+    friendsGoingCount: extras.friendsGoingCount || 0,
+  });
+}
+ 
+async function getWeekRecap(req, options = {}) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const now = options.now || new Date();
+  const batchWeek = options.batchWeek?.trim() || toIsoWeek(now);
+  if (options.batchWeek && !isValidIsoWeek(batchWeek)) {
+    return {
+      error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).',
+      status: 400,
+      code: 'INVALID_BATCH_WEEK',
+    };
+  }
+ 
+  const { PivotEventIntent, Event } = getModels(req, 'PivotEventIntent', 'Event');
+ 
+  const intents = await PivotEventIntent.find({
+    userId,
+    batchWeek,
+    status: { $in: RECAP_STATUSES },
+  })
+    .select('eventId status timeSlotId')
+    .lean();
+ 
+  if (!intents.length) {
+    return { data: { batchWeek, events: [] } };
+  }
+ 
+  const intentByEvent = new Map(
+    intents.map((intent) => [
+      String(intent.eventId),
+      { status: intent.status, timeSlotId: intent.timeSlotId || null },
+    ]),
+  );
+  const eventIds = [...intentByEvent.keys()];
+ 
+  const events = await Event.find({
+    _id: { $in: eventIds },
+    'customFields.pivot.ingestStatus': 'published',
+    status: { $in: PIVOT_EVENT_STATUSES },
+    isDeleted: { $ne: true },
+    'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
+  })
+    .select(RECAP_EVENT_FIELDS)
+    .sort({ start_time: 1 })
+    .lean();
+ 
+  const { socialByEvent, socialByEventAndSlot } = await loadFriendSocial(
+    req,
+    userId,
+    eventIds,
+  );
+ 
+  const recapEvents = events
+    .filter((event) => resolveDisplayHost(event.customFields?.pivot))
+    .map((event) => {
+      const id = String(event._id);
+      const social = socialByEvent.get(id) || {
+        friendsInterested: [],
+        friendsGoing: [],
+        friendInterestedCount: 0,
+        friendRegisteredCount: 0,
+      };
+      const normalizedSlots = normalizePivotTimeSlots(
+        event.customFields?.pivot?.timeSlots,
+      );
+      const socialByTimeSlot = new Map();
+      for (const slot of normalizedSlots) {
+        const slotSocial = socialByEventAndSlot.get(`${id}:${slot.id}`);
+        if (slotSocial) {
+          socialByTimeSlot.set(slot.id, slotSocial);
+        }
+      }
+ 
+      return serializeRecapEvent(event, intentByEvent.get(id), {
+        socialByTimeSlot,
+        friendsInterested: social.friendsInterested,
+        friendsGoing: social.friendsGoing,
+        friendsInterestedCount: social.friendInterestedCount || 0,
+        friendsGoingCount: social.friendRegisteredCount || 0,
+      });
+    });
+ 
+  logPivot('info', 'week recap built', {
+    ...pivotRequestContext(req),
+    batchWeek,
+    intentCount: intents.length,
+    eventCount: recapEvents.length,
+  });
+ 
+  return { data: { batchWeek, events: recapEvents } };
+}
+ 
+async function resetWeekActions(req, options = {}) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const now = options.now || new Date();
+  const batchWeek = options.batchWeek?.trim() || toIsoWeek(now);
+  if (options.batchWeek && !isValidIsoWeek(batchWeek)) {
+    return {
+      error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).',
+      status: 400,
+      code: 'INVALID_BATCH_WEEK',
+    };
+  }
+ 
+  const { PivotEventIntent } = getModels(req, 'PivotEventIntent');
+ 
+  // Match getWeekRecap: intents are keyed by batchWeek on the intent row, not
+  // the feed event pool (registered events can sit outside the pilot window).
+  const result = await PivotEventIntent.deleteMany({
+    userId,
+    batchWeek,
+  });
+ 
+  return {
+    data: {
+      batchWeek,
+      deletedCount: result.deletedCount ?? 0,
+    },
+  };
+}
+ 
+module.exports = {
+  recordFeedAction,
+  recordExternalOpen,
+  confirmRegistered,
+  getWeekRecap,
+  resetWeekActions,
+  findPublishedPivotEvent,
+  serializeRecapEvent,
+  resolveRegisteredTimeSlotId,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotLabEventsService.js.html b/backend/coverage/lcov-report/services/pivotLabEventsService.js.html new file mode 100644 index 00000000..54558d1b --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotLabEventsService.js.html @@ -0,0 +1,547 @@ + + + + + + Code coverage report for services/pivotLabEventsService.js + + + + + + + + + +
+
+

All files / services pivotLabEventsService.js

+
+ +
+ 92.68% + Statements + 38/41 +
+ + +
+ 80% + Branches + 48/60 +
+ + +
+ 100% + Functions + 9/9 +
+ + +
+ 92.5% + Lines + 37/40 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +1552x +2x +2x +2x +  +  +2x +2x +  +  +1x +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +10x +10x +10x +10x +2x +  +  +  +  +  +  +  +10x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +1x +1x +1x +1x +  +1x +  +1x +  +  +  +  +1x +  +2x +  +  +1x +  +  +  +  +2x +  +  +  +  +2x +  +  +  +  +  + 
const getModels = require('./getModelService');
+const { getMergedTenants } = require('./tenantConfigService');
+const { isPivotTenant } = require('./pivotReferralCodeService');
+const { connectToDatabase } = require('../connectionsManager');
+const {
+  normalizeBatchWeek,
+} = require('./pivotWeeklySnapshotService');
+const { serializePivotMovie } = require('../utilities/pivotMovieMetadata');
+ 
+function labEventsQuery(batchWeek) {
+  return {
+    'customFields.pivot.batchWeek': batchWeek,
+    'customFields.pivot': { $exists: true },
+    isDeleted: { $ne: true },
+  };
+}
+ 
+const EMPTY_INTENT_STATS = Object.freeze({
+  interested: 0,
+  registered: 0,
+  passed: 0,
+  externalOpens: 0,
+  externalOpenUsers: 0,
+});
+ 
+function serializeLabEvent(event, intentStatsByEventId) {
+  const pivot = event.customFields?.pivot || {};
+  const host = pivot.host || {};
+  const movie = serializePivotMovie(pivot.movie);
+  const timeSlots = Array.isArray(pivot.timeSlots)
+    ? pivot.timeSlots.map((slot) => ({
+        id: slot.id,
+        start_time: slot.start_time,
+        end_time: slot.end_time || null,
+        label: slot.label || null,
+      }))
+    : [];
+ 
+  return {
+    _id: String(event._id),
+    name: event.name,
+    description: event.description || '',
+    image: event.image || null,
+    start_time: event.start_time,
+    end_time: event.end_time || null,
+    location: event.location || '',
+    externalLink: event.externalLink || null,
+    sourceUrl: pivot.sourceUrl || null,
+    ingestStatus: pivot.ingestStatus || null,
+    source: pivot.source || null,
+    batchWeek: pivot.batchWeek || null,
+    tags: Array.isArray(pivot.tags) ? pivot.tags : [],
+    timeSlots,
+    ...(movie ? { movie } : {}),
+    organizerName: host.name || '',
+    organizerImageUrl: host.imageUrl || null,
+    intentStats: intentStatsByEventId?.get(String(event._id)) || EMPTY_INTENT_STATS,
+  };
+}
+ 
+/** Per-event intent counts so Lab can see which catalog events earned the swipes. */
+async function loadIntentStatsByEventId(PivotEventIntent, eventIds) {
+  if (!eventIds.length) {
+    return new Map();
+  }
+ 
+  const rows = await PivotEventIntent.aggregate([
+    { $match: { eventId: { $in: eventIds } } },
+    {
+      $group: {
+        _id: '$eventId',
+        interested: { $sum: { $cond: [{ $eq: ['$status', 'interested'] }, 1, 0] } },
+        registered: { $sum: { $cond: [{ $eq: ['$status', 'registered'] }, 1, 0] } },
+        passed: { $sum: { $cond: [{ $eq: ['$status', 'passed'] }, 1, 0] } },
+        externalOpens: { $sum: { $ifNull: ['$externalOpenCount', 0] } },
+        externalOpenUsers: {
+          $sum: { $cond: [{ $gt: [{ $ifNull: ['$externalOpenCount', 0] }, 0] }, 1, 0] },
+        },
+      },
+    },
+  ]);
+ 
+  return new Map(
+    rows.map((row) => [
+      String(row._id),
+      {
+        interested: row.interested ?? 0,
+        registered: row.registered ?? 0,
+        passed: row.passed ?? 0,
+        externalOpens: row.externalOpens ?? 0,
+        externalOpenUsers: row.externalOpenUsers ?? 0,
+      },
+    ]),
+  );
+}
+ 
+async function listPivotLabEvents(req, options = {}) {
+  const normalized = normalizeBatchWeek(options.batchWeek, options.now);
+  Iif (normalized.error) {
+    return normalized;
+  }
+ 
+  const tenantKey = options.tenantKey?.trim()?.toLowerCase();
+  Iif (!tenantKey) {
+    return {
+      error: 'tenantKey is required.',
+      status: 400,
+      code: 'TENANT_KEY_REQUIRED',
+    };
+  }
+ 
+  const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant);
+  const tenant = pivotTenants.find((row) => row.tenantKey === tenantKey);
+  Iif (!tenant) {
+    return {
+      error: 'Pivot tenant not found.',
+      status: 404,
+      code: 'TENANT_NOT_FOUND',
+    };
+  }
+ 
+  const { batchWeek } = normalized;
+  const db = await connectToDatabase(tenantKey);
+  const tenantReq = { db };
+  const { Event, PivotEventIntent } = getModels(tenantReq, 'Event', 'PivotEventIntent');
+ 
+  const query = labEventsQuery(batchWeek);
+ 
+  const events = await Event.find(query)
+    .select('name description image start_time end_time location externalLink customFields.pivot')
+    .sort({ start_time: 1 })
+    .lean();
+ 
+  const intentStatsByEventId = await loadIntentStatsByEventId(
+    PivotEventIntent,
+    events.map((event) => event._id),
+  );
+ 
+  return {
+    data: {
+      tenantKey,
+      cityDisplayName: tenant.location || tenant.name || tenantKey,
+      batchWeek,
+      events: events.map((event) => serializeLabEvent(event, intentStatsByEventId)),
+    },
+  };
+}
+ 
+module.exports = {
+  listPivotLabEvents,
+  serializeLabEvent,
+  loadIntentStatsByEventId,
+  labEventsQuery,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotLabNotesService.js.html b/backend/coverage/lcov-report/services/pivotLabNotesService.js.html new file mode 100644 index 00000000..8944f7cd --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotLabNotesService.js.html @@ -0,0 +1,280 @@ + + + + + + Code coverage report for services/pivotLabNotesService.js + + + + + + + + + +
+
+

All files / services pivotLabNotesService.js

+
+ +
+ 86.36% + Statements + 19/22 +
+ + +
+ 54.54% + Branches + 12/22 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 86.36% + Lines + 19/22 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +661x +1x +  +  +2x +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +1x +  +1x +  +  +  +  +  +1x +1x +  +  +  +1x +  +1x +  +  +  +  +1x +1x +1x +  +  +  +  +  +1x +  +  +1x +  +  +  +  + 
const getGlobalModels = require('./getGlobalModelService');
+const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService');
+ 
+function serializeLabNotes(doc) {
+  Iif (!doc) {
+    return {
+      batchWeek: null,
+      notes: '',
+      updatedBy: null,
+      updatedAt: null,
+    };
+  }
+ 
+  return {
+    batchWeek: doc.batchWeek,
+    notes: doc.notes || '',
+    updatedBy: doc.updatedBy || null,
+    updatedAt: doc.updatedAt || null,
+  };
+}
+ 
+async function getInterviewNotes(req, options = {}) {
+  const normalized = normalizeBatchWeek(options.batchWeek, options.now);
+  Iif (normalized.error) {
+    return normalized;
+  }
+ 
+  const { batchWeek } = normalized;
+  const { PivotLabNotes } = getGlobalModels(req, 'PivotLabNotes');
+  const doc = await PivotLabNotes.findOne({ batchWeek }).lean();
+ 
+  return {
+    data: serializeLabNotes(doc ? { ...doc, batchWeek } : { batchWeek, notes: '' }),
+  };
+}
+ 
+async function saveInterviewNotes(req, options = {}) {
+  const normalized = normalizeBatchWeek(options.batchWeek, options.now);
+  Iif (normalized.error) {
+    return normalized;
+  }
+ 
+  const notes = typeof options.notes === 'string' ? options.notes : '';
+  const updatedBy =
+    req.user?.email ||
+    req.user?.globalUserId ||
+    req.user?.userId ||
+    null;
+ 
+  const { batchWeek } = normalized;
+  const { PivotLabNotes } = getGlobalModels(req, 'PivotLabNotes');
+  const doc = await PivotLabNotes.findOneAndUpdate(
+    { batchWeek },
+    { notes, updatedBy },
+    { upsert: true, new: true, setDefaultsOnInsert: true },
+  ).lean();
+ 
+  return { data: serializeLabNotes(doc) };
+}
+ 
+module.exports = {
+  getInterviewNotes,
+  saveInterviewNotes,
+  serializeLabNotes,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotProfileService.js.html b/backend/coverage/lcov-report/services/pivotProfileService.js.html new file mode 100644 index 00000000..e8d0ff2a --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotProfileService.js.html @@ -0,0 +1,292 @@ + + + + + + Code coverage report for services/pivotProfileService.js + + + + + + + + + +
+
+

All files / services pivotProfileService.js

+
+ +
+ 89.28% + Statements + 25/28 +
+ + +
+ 73.33% + Branches + 11/15 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 89.28% + Lines + 25/28 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +701x +1x +  +  +1x +  +  +  +2x +  +  +  +3x +3x +1x +  +  +2x +2x +2x +  +  +  +2x +  +  +  +  +  +  +  +4x +4x +  +  +  +4x +1x +  +  +  +  +  +  +3x +3x +1x +  +  +2x +2x +2x +  +  +  +2x +2x +  +2x +  +  +  +  +  +  +1x +  +  +  + 
const getModels = require('./getModelService');
+const { validatePivotInterestTags } = require('./pivotTagCatalogService');
+ 
+function unauthorized() {
+  return { error: 'Authentication required.', status: 401, code: 'UNAUTHORIZED' };
+}
+ 
+function normalizeStoredInterestTags(raw) {
+  return Array.isArray(raw) ? raw : [];
+}
+ 
+async function getPivotProfileInterests(req) {
+  const userId = req.user?.userId;
+  if (!userId) {
+    return unauthorized();
+  }
+ 
+  const { User } = getModels(req, 'User');
+  const user = await User.findById(userId).select('pivotInterestTags').lean();
+  Iif (!user) {
+    return { error: 'User not found.', status: 404, code: 'USER_NOT_FOUND' };
+  }
+ 
+  return {
+    data: {
+      interestTags: normalizeStoredInterestTags(user.pivotInterestTags),
+    },
+  };
+}
+ 
+async function updatePivotProfileInterests(req, body = {}) {
+  const userId = req.user?.userId;
+  Iif (!userId) {
+    return unauthorized();
+  }
+ 
+  if (!Object.prototype.hasOwnProperty.call(body, 'interestTags')) {
+    return {
+      error: 'interestTags is required.',
+      status: 400,
+      code: 'VALIDATION_ERROR',
+    };
+  }
+ 
+  const validation = await validatePivotInterestTags(req, body.interestTags);
+  if (validation.error) {
+    return validation;
+  }
+ 
+  const { User } = getModels(req, 'User');
+  const user = await User.findById(userId);
+  Iif (!user) {
+    return { error: 'User not found.', status: 404, code: 'USER_NOT_FOUND' };
+  }
+ 
+  user.pivotInterestTags = validation.tags;
+  await user.save();
+ 
+  return {
+    data: {
+      interestTags: validation.tags,
+    },
+  };
+}
+ 
+module.exports = {
+  getPivotProfileInterests,
+  updatePivotProfileInterests,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotReferralCodeService.js.html b/backend/coverage/lcov-report/services/pivotReferralCodeService.js.html new file mode 100644 index 00000000..ed92a6ac --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotReferralCodeService.js.html @@ -0,0 +1,1552 @@ + + + + + + Code coverage report for services/pivotReferralCodeService.js + + + + + + + + + +
+
+

All files / services pivotReferralCodeService.js

+
+ +
+ 42.69% + Statements + 76/178 +
+ + +
+ 36.7% + Branches + 69/188 +
+ + +
+ 50% + Functions + 6/12 +
+ + +
+ 44.7% + Lines + 76/170 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +4903x +3x +3x +3x +  +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +  +2x +2x +  +  +  +2x +  +  +2x +1x +  +  +1x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +1x +  +  +  +  +  +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +11x +  +  +  +6x +6x +1x +  +  +  +  +  +  +5x +5x +  +5x +1x +  +  +  +  +  +  +4x +1x +  +  +  +  +  +  +3x +1x +  +  +  +  +  +  +2x +1x +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +5x +1x +  +  +  +  +  +  +4x +4x +  +  +  +  +  +  +  +4x +  +4x +  +  +  +  +  +4x +  +  +4x +  +  +  +  +  +  +  +4x +  +  +  +4x +1x +1x +  +  +  +  +  +  +  +  +3x +3x +  +  +  +  +  +  +  +3x +3x +1x +  +  +  +  +  +  +2x +  +  +  +  +  +  +2x +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  + 
const mongoose = require('mongoose');
+const getGlobalModels = require('./getGlobalModelService');
+const { getTenantByKey } = require('./tenantConfigService');
+const { isValidIsoWeek, toIsoWeek } = require('../utilities/pivotIsoWeek');
+ 
+function isPivotTenant(tenant) {
+  return tenant?.pivotPilot === true || tenant?.tenantType === 'pivot';
+}
+ 
+function serializePivotReferralCode(doc) {
+  const row = doc?.toObject ? doc.toObject() : doc;
+  const now = new Date();
+  const redeemable =
+    row.active === true &&
+    (!row.expiresAt || new Date(row.expiresAt) >= now) &&
+    row.redemptionCount < row.maxRedemptions;
+ 
+  return {
+    _id: String(row._id),
+    code: row.code,
+    tenantKey: row.tenantKey,
+    cohortId: row.cohortId,
+    maxRedemptions: row.maxRedemptions,
+    redemptionCount: row.redemptionCount,
+    expiresAt: row.expiresAt || null,
+    active: row.active,
+    batchWeek: row.batchWeek || null,
+    redeemable,
+    createdAt: row.createdAt,
+    updatedAt: row.updatedAt,
+  };
+}
+ 
+function validateCreatePayload(body = {}) {
+  const code = String(body.code || '').trim();
+  const cohortId = String(body.cohortId || '').trim();
+  Iif (!code) return { error: 'Referral code is required.' };
+  Iif (!cohortId) return { error: 'Cohort ID is required.' };
+ 
+  const maxRedemptions = body.maxRedemptions !== undefined ? Number(body.maxRedemptions) : 50;
+  Iif (!Number.isFinite(maxRedemptions) || maxRedemptions < 0) {
+    return { error: 'maxRedemptions must be a non-negative number.' };
+  }
+ 
+  const batchWeek = body.batchWeek != null && String(body.batchWeek).trim() !== ''
+    ? String(body.batchWeek).trim()
+    : null;
+  if (batchWeek && !isValidIsoWeek(batchWeek)) {
+    return { error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).' };
+  }
+ 
+  let expiresAt = null;
+  Iif (body.expiresAt != null && body.expiresAt !== '') {
+    expiresAt = new Date(body.expiresAt);
+    if (Number.isNaN(expiresAt.getTime())) {
+      return { error: 'expiresAt must be a valid date.' };
+    }
+  }
+ 
+  return {
+    row: {
+      code,
+      cohortId,
+      maxRedemptions,
+      redemptionCount: 0,
+      active: body.active !== false,
+      batchWeek,
+      expiresAt,
+    },
+  };
+}
+ 
+function validateUpdatePayload(body = {}) {
+  const patch = {};
+ 
+  Iif (body.code !== undefined) {
+    const code = String(body.code || '').trim();
+    if (!code) return { error: 'Referral code cannot be empty.' };
+    patch.code = code;
+  }
+ 
+  Iif (body.cohortId !== undefined) {
+    const cohortId = String(body.cohortId || '').trim();
+    if (!cohortId) return { error: 'Cohort ID cannot be empty.' };
+    patch.cohortId = cohortId;
+  }
+ 
+  Iif (body.maxRedemptions !== undefined) {
+    const maxRedemptions = Number(body.maxRedemptions);
+    if (!Number.isFinite(maxRedemptions) || maxRedemptions < 0) {
+      return { error: 'maxRedemptions must be a non-negative number.' };
+    }
+    patch.maxRedemptions = maxRedemptions;
+  }
+ 
+  Iif (body.redemptionCount !== undefined) {
+    const redemptionCount = Number(body.redemptionCount);
+    if (!Number.isFinite(redemptionCount) || redemptionCount < 0) {
+      return { error: 'redemptionCount must be a non-negative number.' };
+    }
+    patch.redemptionCount = redemptionCount;
+  }
+ 
+  Iif (body.active !== undefined) {
+    patch.active = body.active === true;
+  }
+ 
+  Iif (body.batchWeek !== undefined) {
+    const batchWeek =
+      body.batchWeek == null || String(body.batchWeek).trim() === ''
+        ? null
+        : String(body.batchWeek).trim();
+    if (batchWeek && !isValidIsoWeek(batchWeek)) {
+      return { error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).' };
+    }
+    patch.batchWeek = batchWeek;
+  }
+ 
+  Iif (body.expiresAt !== undefined) {
+    if (body.expiresAt == null || body.expiresAt === '') {
+      patch.expiresAt = null;
+    } else {
+      const expiresAt = new Date(body.expiresAt);
+      if (Number.isNaN(expiresAt.getTime())) {
+        return { error: 'expiresAt must be a valid date.' };
+      }
+      patch.expiresAt = expiresAt;
+    }
+  }
+ 
+  Eif (Object.keys(patch).length === 0) {
+    return { error: 'No valid fields to update.' };
+  }
+ 
+  return { patch };
+}
+ 
+async function requirePivotTenant(req, tenantKey) {
+  const tenant = await getTenantByKey(req, tenantKey);
+  if (!tenant) {
+    return { error: 'Tenant not found.', status: 404 };
+  }
+  if (!isPivotTenant(tenant)) {
+    return {
+      error: 'Referral codes are only available for Pivot city tenants.',
+      status: 403,
+    };
+  }
+  return { tenant };
+}
+ 
+async function listReferralCodesForTenant(req, tenantKey) {
+  const gate = await requirePivotTenant(req, tenantKey);
+  if (gate.error) return gate;
+ 
+  const { PivotReferralCode } = getGlobalModels(req, 'PivotReferralCode');
+  const docs = await PivotReferralCode.find({ tenantKey })
+    .sort({ active: -1, code: 1 })
+    .lean();
+ 
+  return {
+    tenantKey,
+    currentBatchWeek: toIsoWeek(),
+    codes: docs.map(serializePivotReferralCode),
+  };
+}
+ 
+async function createReferralCode(req, tenantKey, body) {
+  const gate = await requirePivotTenant(req, tenantKey);
+  if (gate.error) return gate;
+ 
+  const validation = validateCreatePayload(body);
+  if (validation.error) {
+    return { error: validation.error, status: 400 };
+  }
+ 
+  const { PivotReferralCode } = getGlobalModels(req, 'PivotReferralCode');
+  try {
+    const doc = await PivotReferralCode.create({
+      ...validation.row,
+      tenantKey,
+    });
+    return { code: serializePivotReferralCode(doc) };
+  } catch (err) {
+    if (err?.code === 11000) {
+      return { error: `Referral code "${validation.row.code}" already exists.`, status: 409 };
+    }
+    throw err;
+  }
+}
+ 
+async function updateReferralCode(req, tenantKey, codeId, body) {
+  const gate = await requirePivotTenant(req, tenantKey);
+  if (gate.error) return gate;
+ 
+  const validation = validateUpdatePayload(body);
+  if (validation.error) {
+    return { error: validation.error, status: 400 };
+  }
+ 
+  const { PivotReferralCode } = getGlobalModels(req, 'PivotReferralCode');
+  const existing = await PivotReferralCode.findOne({ _id: codeId, tenantKey });
+  if (!existing) {
+    return { error: 'Referral code not found for this tenant.', status: 404 };
+  }
+ 
+  if (
+    validation.patch.maxRedemptions !== undefined &&
+    (validation.patch.redemptionCount ?? existing.redemptionCount) > validation.patch.maxRedemptions
+  ) {
+    return {
+      error: 'maxRedemptions cannot be less than current redemptionCount.',
+      status: 400,
+    };
+  }
+ 
+  if (
+    validation.patch.redemptionCount !== undefined &&
+    validation.patch.redemptionCount > (validation.patch.maxRedemptions ?? existing.maxRedemptions)
+  ) {
+    return {
+      error: 'redemptionCount cannot exceed maxRedemptions.',
+      status: 400,
+    };
+  }
+ 
+  try {
+    Object.assign(existing, validation.patch);
+    await existing.save();
+    return { code: serializePivotReferralCode(existing) };
+  } catch (err) {
+    if (err?.code === 11000) {
+      return { error: 'Referral code already exists.', status: 409 };
+    }
+    throw err;
+  }
+}
+ 
+async function deleteReferralCode(req, tenantKey, codeId) {
+  const gate = await requirePivotTenant(req, tenantKey);
+  if (gate.error) return gate;
+ 
+  const { PivotReferralCode } = getGlobalModels(req, 'PivotReferralCode');
+  const deleted = await PivotReferralCode.findOneAndDelete({ _id: codeId, tenantKey });
+  if (!deleted) {
+    return { error: 'Referral code not found for this tenant.', status: 404 };
+  }
+  return { deleted: true, code: deleted.code };
+}
+ 
+function normalizeReferralCodeInput(code) {
+  return String(code || '').trim().toUpperCase();
+}
+ 
+async function validateReferralCode(req, rawCode) {
+  const code = normalizeReferralCodeInput(rawCode);
+  if (!code) {
+    return {
+      error: 'Referral code is required.',
+      status: 400,
+      code: 'REFERRAL_CODE_REQUIRED',
+    };
+  }
+ 
+  const { PivotReferralCode } = getGlobalModels(req, 'PivotReferralCode');
+  const referral = await PivotReferralCode.findOne({ code }).lean();
+ 
+  if (!referral) {
+    return {
+      error: 'Invalid referral code.',
+      status: 404,
+      code: 'REFERRAL_CODE_NOT_FOUND',
+    };
+  }
+ 
+  if (!referral.active) {
+    return {
+      error: 'This referral code is no longer active.',
+      status: 403,
+      code: 'REFERRAL_CODE_INACTIVE',
+    };
+  }
+ 
+  if (referral.expiresAt && new Date(referral.expiresAt) < new Date()) {
+    return {
+      error: 'This referral code has expired.',
+      status: 403,
+      code: 'REFERRAL_CODE_EXPIRED',
+    };
+  }
+ 
+  if (referral.redemptionCount >= referral.maxRedemptions) {
+    return {
+      error: 'This referral code has reached its redemption limit.',
+      status: 403,
+      code: 'REFERRAL_CODE_MAXED',
+    };
+  }
+ 
+  const tenant = await getTenantByKey(req, referral.tenantKey);
+  Iif (!tenant) {
+    return {
+      error: 'City tenant for this code is not configured.',
+      status: 503,
+      code: 'TENANT_NOT_FOUND',
+    };
+  }
+ 
+  Iif (!isPivotTenant(tenant)) {
+    return {
+      error: 'Referral code is not valid for Pivot.',
+      status: 403,
+      code: 'NOT_PIVOT_TENANT',
+    };
+  }
+ 
+  return {
+    data: {
+      tenantKey: tenant.tenantKey,
+      subdomain: tenant.subdomain || tenant.tenantKey,
+      cohortId: referral.cohortId,
+      cityDisplayName: tenant.location || tenant.name,
+      batchWeek: referral.batchWeek || null,
+    },
+  };
+}
+ 
+/**
+ * Persist a redemption for authenticated global users only; increments PivotReferralCode.redemptionCount
+ * once per (globalUserId, code). Idempotent when the user retries.
+ *
+ * Requires req.school to match the code's tenant (city).
+ */
+async function redeemReferralCode(req, rawCode) {
+  const gid = req.user?.globalUserId;
+  if (!gid) {
+    return {
+      error: 'Pivot referral redemption requires signing in with a Meridian account.',
+      status: 403,
+      code: 'GLOBAL_USER_REQUIRED',
+    };
+  }
+ 
+  const code = normalizeReferralCodeInput(rawCode);
+  Iif (!code) {
+    return {
+      error: 'Referral code is required.',
+      status: 400,
+      code: 'REFERRAL_CODE_REQUIRED',
+    };
+  }
+ 
+  const tenantKeyReq = typeof req.school === 'string' ? req.school.trim().toLowerCase() : '';
+ 
+  const { PivotReferralCode, PivotReferralRedemption } = getGlobalModels(
+    req,
+    'PivotReferralCode',
+    'PivotReferralRedemption',
+  );
+ 
+  const globalUserObjectId = mongoose.Types.ObjectId.isValid(gid)
+    ? new mongoose.Types.ObjectId(String(gid))
+    : null;
+  Iif (!globalUserObjectId) {
+    return {
+      error: 'Invalid identity for redemption.',
+      status: 403,
+      code: 'INVALID_GLOBAL_USER_ID',
+    };
+  }
+ 
+  const existingRedemption = await PivotReferralRedemption.findOne({
+    globalUserId: globalUserObjectId,
+    code,
+  }).lean();
+  if (existingRedemption) {
+    const refRow = await PivotReferralCode.findOne({ code }).select('redemptionCount maxRedemptions').lean();
+    return {
+      data: {
+        alreadyRedeemed: true,
+        redemptionCount: refRow?.redemptionCount ?? null,
+        maxRedemptions: refRow?.maxRedemptions ?? null,
+      },
+    };
+  }
+ 
+  const referralLean = await PivotReferralCode.findOne({ code }).lean();
+  Iif (!referralLean) {
+    return {
+      error: 'Invalid referral code.',
+      status: 404,
+      code: 'REFERRAL_CODE_NOT_FOUND',
+    };
+  }
+ 
+  const referralTenantKey = String(referralLean.tenantKey || '').toLowerCase();
+  if (!tenantKeyReq || referralTenantKey !== tenantKeyReq) {
+    return {
+      error: `Sign in against the pilot city (${referralTenantKey}) before redeeming this code.`,
+      status: 403,
+      code: 'TENANT_MISMATCH',
+    };
+  }
+ 
+  Iif (!referralLean.active) {
+    return {
+      error: 'This referral code is no longer active.',
+      status: 403,
+      code: 'REFERRAL_CODE_INACTIVE',
+    };
+  }
+  Iif (referralLean.expiresAt && new Date(referralLean.expiresAt) < new Date()) {
+    return {
+      error: 'This referral code has expired.',
+      status: 403,
+      code: 'REFERRAL_CODE_EXPIRED',
+    };
+  }
+  Iif (referralLean.redemptionCount >= referralLean.maxRedemptions) {
+    return {
+      error: 'This referral code has reached its redemption limit.',
+      status: 403,
+      code: 'REFERRAL_CODE_MAXED',
+    };
+  }
+ 
+  const updated = await PivotReferralCode.findOneAndUpdate(
+    {
+      _id: referralLean._id,
+      active: true,
+      $or: [{ expiresAt: null }, { expiresAt: { $gte: new Date() } }],
+      $expr: { $lt: ['$redemptionCount', '$maxRedemptions'] },
+    },
+    { $inc: { redemptionCount: 1 } },
+    { new: true }
+  ).lean();
+ 
+  Iif (!updated) {
+    return {
+      error: 'This referral code has reached its redemption limit.',
+      status: 403,
+      code: 'REFERRAL_CODE_MAXED',
+    };
+  }
+ 
+  try {
+    await PivotReferralRedemption.create({
+      globalUserId: globalUserObjectId,
+      code,
+      pivotReferralCodeId: updated._id,
+    });
+  } catch (err) {
+    Eif (err?.code === 11000) {
+      await PivotReferralCode.updateOne({ _id: updated._id }, { $inc: { redemptionCount: -1 } });
+      const refRow = await PivotReferralCode.findOne({ code }).select('redemptionCount maxRedemptions').lean();
+      return {
+        data: {
+          alreadyRedeemed: true,
+          redemptionCount: refRow?.redemptionCount ?? null,
+          maxRedemptions: refRow?.maxRedemptions ?? null,
+        },
+      };
+    }
+    throw err;
+  }
+ 
+  return {
+    data: {
+      redeemed: true,
+      alreadyRedeemed: false,
+      redemptionCount: updated.redemptionCount,
+      maxRedemptions: updated.maxRedemptions,
+    },
+  };
+}
+ 
+module.exports = {
+  isPivotTenant,
+  normalizeReferralCodeInput,
+  serializePivotReferralCode,
+  validateCreatePayload,
+  validateUpdatePayload,
+  validateReferralCode,
+  redeemReferralCode,
+  listReferralCodesForTenant,
+  createReferralCode,
+  updateReferralCode,
+  deleteReferralCode,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotRetentionService.js.html b/backend/coverage/lcov-report/services/pivotRetentionService.js.html new file mode 100644 index 00000000..c73f06c7 --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotRetentionService.js.html @@ -0,0 +1,415 @@ + + + + + + Code coverage report for services/pivotRetentionService.js + + + + + + + + + +
+
+

All files / services pivotRetentionService.js

+
+ +
+ 100% + Statements + 43/43 +
+ + +
+ 95% + Branches + 19/20 +
+ + +
+ 100% + Functions + 8/8 +
+ + +
+ 100% + Lines + 42/42 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +1111x +1x +1x +1x +1x +1x +  +1x +1x +  +  +8x +8x +6x +  +  +  +  +  +  +  +  +3x +3x +3x +  +3x +  +9x +6x +  +  +  +2x +6x +6x +6x +3x +  +  +6x +  +  +  +6x +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +4x +4x +1x +  +  +3x +3x +3x +9x +  +  +3x +3x +  +3x +3x +3x +  +1x +  +  +  +1x +  +  +3x +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +1x +  +  +  +  + 
const getModels = require('./getModelService');
+const { getMergedTenants } = require('./tenantConfigService');
+const { isPivotTenant } = require('./pivotReferralCodeService');
+const { connectToDatabase } = require('../connectionsManager');
+const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService');
+const { shiftIsoWeek } = require('../utilities/pivotIsoWeek');
+ 
+const DEFAULT_WEEKS = 6;
+const MAX_WEEKS = 12;
+ 
+function normalizeWeeksParam(raw) {
+  const parsed = Number(raw);
+  if (!Number.isFinite(parsed)) return DEFAULT_WEEKS;
+  return Math.min(MAX_WEEKS, Math.max(2, Math.trunc(parsed)));
+}
+ 
+/**
+ * Week-over-week retention from PivotEventIntent activity: a user is "active" in a
+ * batchWeek when they have any intent row (interested / registered / passed) for it,
+ * and "returning" when they were also active the week before.
+ */
+async function aggregateTenantRetention(tenant, weekList) {
+  const db = await connectToDatabase(tenant.tenantKey);
+  const tenantReq = { db };
+  const { PivotEventIntent } = getModels(tenantReq, 'PivotEventIntent');
+ 
+  const userSets = await Promise.all(
+    weekList.map(async (batchWeek) => {
+      const userIds = await PivotEventIntent.distinct('userId', { batchWeek });
+      return new Set(userIds.map(String));
+    }),
+  );
+ 
+  const weeks = weekList.map((batchWeek, index) => {
+    const active = userSets[index];
+    const previous = index > 0 ? userSets[index - 1] : null;
+    const returningUsers = previous
+      ? [...active].filter((userId) => previous.has(userId)).length
+      : null;
+    const retentionRate =
+      previous && previous.size > 0 && returningUsers !== null
+        ? Math.round((returningUsers / previous.size) * 1000) / 10
+        : null;
+ 
+    return {
+      batchWeek,
+      activeUsers: active.size,
+      returningUsers,
+      retentionRate,
+    };
+  });
+ 
+  return {
+    tenantKey: tenant.tenantKey,
+    cityDisplayName: tenant.location || tenant.name || tenant.tenantKey,
+    weeks,
+  };
+}
+ 
+async function getPivotRetention(req, options = {}) {
+  const normalized = normalizeBatchWeek(options.batchWeek, options.now);
+  if (normalized.error) {
+    return normalized;
+  }
+ 
+  const { batchWeek } = normalized;
+  const weeksCount = normalizeWeeksParam(options.weeks);
+  const weekList = Array.from({ length: weeksCount }, (_, index) =>
+    shiftIsoWeek(batchWeek, index - (weeksCount - 1)),
+  );
+ 
+  const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant);
+  const tenants = [];
+ 
+  for (const tenant of pivotTenants) {
+    try {
+      tenants.push(await aggregateTenantRetention(tenant, weekList));
+    } catch (error) {
+      console.error(
+        `[pivotRetention] aggregate failed tenant=${tenant.tenantKey} batchWeek=${batchWeek}:`,
+        error,
+      );
+      tenants.push({
+        tenantKey: tenant.tenantKey,
+        cityDisplayName: tenant.location || tenant.name || tenant.tenantKey,
+        weeks: weekList.map((week) => ({
+          batchWeek: week,
+          activeUsers: 0,
+          returningUsers: null,
+          retentionRate: null,
+        })),
+        error: 'AGGREGATION_FAILED',
+      });
+    }
+  }
+ 
+  return {
+    data: {
+      batchWeek,
+      weeks: weekList,
+      tenants,
+    },
+  };
+}
+ 
+module.exports = {
+  getPivotRetention,
+  aggregateTenantRetention,
+  normalizeWeeksParam,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotTagCatalogService.js.html b/backend/coverage/lcov-report/services/pivotTagCatalogService.js.html new file mode 100644 index 00000000..eae7fe4a --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotTagCatalogService.js.html @@ -0,0 +1,649 @@ + + + + + + Code coverage report for services/pivotTagCatalogService.js + + + + + + + + + +
+
+

All files / services pivotTagCatalogService.js

+
+ +
+ 73.68% + Statements + 56/76 +
+ + +
+ 48.83% + Branches + 21/43 +
+ + +
+ 83.33% + Functions + 10/12 +
+ + +
+ 74.28% + Lines + 52/70 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +1891x +1x +1x +  +1x +  +  +4x +  +4x +4x +  +4x +14x +14x +13x +13x +  +  +4x +  +  +  +2x +2x +2x +2x +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +  +  +  +4x +  +  +  +  +  +  +  +4x +  +4x +1x +  +  +  +  +  +  +3x +1x +  +  +2x +4x +  +  +  +  +  +  +  +  +2x +4x +2x +1x +  +  +  +  +  +  +1x +  +  +  +2x +1x +  +  +1x +1x +  +1x +1x +  +  +  +  +1x +  +2x +  +  +  +  +  +  +  +  +1x +  +  +  +1x +18x +1x +  +1x +1x +18x +  +  +  +  +18x +  +  +1x +  +  +  +  +  +1x +  +  +  +  +  +18x +  +  +  +  +1x +  +  +  +  +  +  +  +  + 
const getGlobalModels = require('./getGlobalModelService');
+const { PIVOT_TAG_SLUG_PATTERN } = require('../schemas/pivotTagCatalog');
+const { getPivotTagCatalogSeedRows } = require('../constants/pivotTagCatalogSeed');
+ 
+const MAX_PIVOT_INTEREST_TAGS = 8;
+ 
+function normalizePivotTagSlugs(rawTags) {
+  Iif (!Array.isArray(rawTags)) return [];
+ 
+  const seen = new Set();
+  const normalized = [];
+ 
+  for (const raw of rawTags) {
+    const slug = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
+    if (!slug || seen.has(slug)) continue;
+    seen.add(slug);
+    normalized.push(slug);
+  }
+ 
+  return normalized;
+}
+ 
+async function loadCatalogSlugSet(req, options = {}) {
+  const includeInactive = options.includeInactive !== false;
+  const { PivotTagCatalog } = getGlobalModels(req, 'PivotTagCatalog');
+  const query = includeInactive ? {} : { active: true };
+  const rows = await PivotTagCatalog.find(query).select('slug').lean();
+  return new Set(rows.map((row) => row.slug));
+}
+ 
+async function validatePivotEventTags(req, rawTags, options = {}) {
+  const { required = true, activeOnly = true } = options;
+  const tags = normalizePivotTagSlugs(rawTags);
+ 
+  if (required && tags.length === 0) {
+    return {
+      error: 'At least one catalog tag is required.',
+      status: 400,
+      code: 'TAGS_REQUIRED',
+    };
+  }
+ 
+  if (tags.length === 0) {
+    return { tags: [] };
+  }
+ 
+  for (const slug of tags) {
+    if (!PIVOT_TAG_SLUG_PATTERN.test(slug)) {
+      return {
+        error: `Invalid tag slug: ${slug}`,
+        status: 400,
+        code: 'INVALID_TAG',
+      };
+    }
+  }
+ 
+  const slugSet = await loadCatalogSlugSet(req, { includeInactive: !activeOnly });
+  const unknown = tags.filter((slug) => !slugSet.has(slug));
+  if (unknown.length) {
+    return {
+      error: `Unknown catalog tag(s): ${unknown.join(', ')}`,
+      status: 400,
+      code: 'INVALID_TAG',
+    };
+  }
+ 
+  return { tags };
+}
+ 
+async function validatePivotInterestTags(req, rawTags) {
+  Iif (!req.globalDb) {
+    return { error: 'Global database context required.', status: 500 };
+  }
+ 
+  Iif (!Array.isArray(rawTags)) {
+    return {
+      error: 'interestTags must be an array.',
+      status: 400,
+      code: 'VALIDATION_ERROR',
+    };
+  }
+ 
+  const tags = normalizePivotTagSlugs(rawTags);
+ 
+  if (tags.length > MAX_PIVOT_INTEREST_TAGS) {
+    return {
+      error: `At most ${MAX_PIVOT_INTEREST_TAGS} interest tags allowed.`,
+      status: 400,
+      code: 'INTEREST_TAGS_LIMIT',
+    };
+  }
+ 
+  if (tags.length === 0) {
+    return { tags: [] };
+  }
+ 
+  for (const slug of tags) {
+    Iif (!PIVOT_TAG_SLUG_PATTERN.test(slug)) {
+      return {
+        error: `Invalid tag slug: ${slug}`,
+        status: 400,
+        code: 'INVALID_TAG',
+      };
+    }
+  }
+ 
+  const slugSet = await loadCatalogSlugSet(req, { includeInactive: false });
+  const unknown = tags.filter((slug) => !slugSet.has(slug));
+  if (unknown.length) {
+    return {
+      error: `Unknown catalog tag(s): ${unknown.join(', ')}`,
+      status: 400,
+      code: 'INVALID_TAG',
+    };
+  }
+ 
+  return { tags };
+}
+ 
+async function listPivotTags(req, options = {}) {
+  if (!req.globalDb) {
+    return { error: 'Global database context required.', status: 500 };
+  }
+ 
+  const { PivotTagCatalog } = getGlobalModels(req, 'PivotTagCatalog');
+  const includeInactive = options.includeInactive === true;
+ 
+  const query = includeInactive ? {} : { active: true };
+  const rows = await PivotTagCatalog.find(query)
+    .sort({ sortOrder: 1, slug: 1 })
+    .select('slug label active sortOrder')
+    .lean();
+ 
+  return {
+    data: {
+      tags: rows.map((row) => ({
+        slug: row.slug,
+        label: row.label,
+      })),
+    },
+  };
+}
+ 
+async function seedPivotTagCatalog(req) {
+  Iif (!req.globalDb) {
+    return { error: 'Global database context required.', status: 500 };
+  }
+ 
+  const rows = getPivotTagCatalogSeedRows();
+  const seedSlugs = new Set(rows.map((row) => row.slug));
+  const { PivotTagCatalog } = getGlobalModels(req, 'PivotTagCatalog');
+ 
+  let upserted = 0;
+  for (const row of rows) {
+    await PivotTagCatalog.findOneAndUpdate(
+      { slug: row.slug },
+      { $set: row },
+      { upsert: true, new: true, runValidators: true },
+    );
+    upserted += 1;
+  }
+ 
+  const [activeCount, totalCount, legacyNotInSeed] = await Promise.all([
+    PivotTagCatalog.countDocuments({ active: true }),
+    PivotTagCatalog.countDocuments({}),
+    PivotTagCatalog.countDocuments({ slug: { $nin: [...seedSlugs] } }),
+  ]);
+ 
+  return {
+    data: {
+      upserted,
+      activeCount,
+      totalCount,
+      legacyNotInSeed,
+      tags: rows.map((row) => ({ slug: row.slug, label: row.label })),
+    },
+  };
+}
+ 
+module.exports = {
+  listPivotTags,
+  seedPivotTagCatalog,
+  normalizePivotTagSlugs,
+  validatePivotEventTags,
+  validatePivotInterestTags,
+  loadCatalogSlugSet,
+  MAX_PIVOT_INTEREST_TAGS,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotTagSuggestService.js.html b/backend/coverage/lcov-report/services/pivotTagSuggestService.js.html new file mode 100644 index 00000000..9b4c828f --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotTagSuggestService.js.html @@ -0,0 +1,1135 @@ + + + + + + Code coverage report for services/pivotTagSuggestService.js + + + + + + + + + +
+
+

All files / services pivotTagSuggestService.js

+
+ +
+ 70% + Statements + 84/120 +
+ + +
+ 55.55% + Branches + 60/108 +
+ + +
+ 93.75% + Functions + 15/16 +
+ + +
+ 69.82% + Lines + 81/116 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +3511x +  +  +  +1x +  +1x +1x +1x +1x +1x +  +  +26x +26x +26x +  +  +  +26x +2x +2x +  +24x +  +  +  +3x +3x +  +  +  +6x +  +  +  +9x +  +  +  +21x +  +  +  +5x +  +  +  +  +  +  +  +  +  +  +  +4x +7x +  +  +4x +  +  +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +  +  +  +4x +4x +  +4x +4x +4x +  +  +4x +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +3x +3x +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +3x +3x +  +  +  +  +  +3x +  +  +  +5x +5x +  +  +  +  +  +  +  +  +5x +5x +2x +2x +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +3x +3x +  +  +  +  +  +  +  +  +3x +3x +3x +  +  +  +  +  +  +  +  +3x +3x +3x +3x +3x +  +3x +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +  +  +  +  +  +  +  +2x +2x +  +2x +3x +3x +3x +1x +  +  +  +  +  +1x +1x +  +  +2x +  +  +3x +2x +  +  +  +1x +  +  +2x +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  + 
const axios = require('axios');
+const {
+  listPivotTags,
+  validatePivotEventTags,
+} = require('./pivotTagCatalogService');
+ 
+const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
+const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6';
+const DEFAULT_MAX_TOKENS = 256;
+const REQUEST_TIMEOUT_MS = 20_000;
+const LOG_PREFIX = '[pivotTagSuggest]';
+ 
+function logSuggest(level, message, meta) {
+  const suffix = meta ? ` ${JSON.stringify(meta)}` : '';
+  const line = `${LOG_PREFIX} ${message}${suffix}`;
+  Iif (level === 'error') {
+    console.error(line);
+    return;
+  }
+  if (level === 'warn') {
+    console.warn(line);
+    return;
+  }
+  console.log(line);
+}
+ 
+function truncateForLog(value, max = 500) {
+  Iif (typeof value !== 'string') return value;
+  return value.length <= max ? value : `${value.slice(0, max)}…`;
+}
+ 
+function resolveAnthropicApiKey() {
+  return process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY || null;
+}
+ 
+function resolveClaudeModel() {
+  return process.env.CLAUDE_MODEL || process.env.ANTHROPIC_MODEL || DEFAULT_CLAUDE_MODEL;
+}
+ 
+function trimString(value) {
+  return typeof value === 'string' ? value.trim() : '';
+}
+ 
+function normalizeEventInput(raw = {}) {
+  return {
+    name: trimString(raw.name),
+    description: trimString(raw.description),
+    location: trimString(raw.location),
+    hostName: trimString(raw.hostName || raw.organizerName),
+    sourceTags: Array.isArray(raw.sourceTags)
+      ? raw.sourceTags.map((entry) => trimString(entry)).filter(Boolean)
+      : [],
+  };
+}
+ 
+function buildTagSuggestionPrompt(event, catalogTags) {
+  const catalogLines = catalogTags
+    .map((tag) => `- ${tag.slug} (${tag.label})`)
+    .join('\n');
+ 
+  const sourceTagLine = event.sourceTags.length
+    ? `\nSource listing tags (hints only, may not match catalog): ${event.sourceTags.join(', ')}`
+    : '';
+ 
+  return [
+    'You assign taxonomy tags to local events for a weekly events pilot app.',
+    'Pick 1 to 3 tags from ONLY the catalog below. Use exact slug strings.',
+    'Prefer specific tags over generic "social" when a better match exists.',
+    '',
+    'Catalog:',
+    catalogLines,
+    '',
+    'Event:',
+    `Title: ${event.name || '(unknown)'}`,
+    `Description: ${event.description || '(none)'}`,
+    `Location: ${event.location || '(unknown)'}`,
+    `Organizer: ${event.hostName || '(unknown)'}`,
+    sourceTagLine,
+    '',
+    'Respond with JSON only, no markdown:',
+    '{"tags":["slug-one","slug-two"]}',
+  ].join('\n');
+}
+ 
+function parseTagsFromClaudeText(text) {
+  Iif (typeof text !== 'string' || !text.trim()) {
+    return [];
+  }
+ 
+  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
+  const candidate = fenced?.[1]?.trim() || text.trim();
+ 
+  try {
+    const parsed = JSON.parse(candidate);
+    Iif (Array.isArray(parsed)) {
+      return parsed;
+    }
+    Eif (Array.isArray(parsed?.tags)) {
+      return parsed.tags;
+    }
+  } catch {
+    const objectMatch = candidate.match(/\{[\s\S]*\}/);
+    if (objectMatch) {
+      try {
+        const parsed = JSON.parse(objectMatch[0]);
+        if (Array.isArray(parsed?.tags)) {
+          return parsed.tags;
+        }
+      } catch {
+        // Fall through to slug extraction.
+      }
+    }
+  }
+ 
+  const slugMatches = candidate.match(/[a-z0-9]+(?:-[a-z0-9]+)+/g);
+  return slugMatches || [];
+}
+ 
+async function callClaudeForTags(prompt, apiKey) {
+  const model = resolveClaudeModel();
+  const startedAt = Date.now();
+  logSuggest('info', 'claude request start', { model, promptChars: prompt.length });
+ 
+  const response = await axios.post(
+    ANTHROPIC_API_URL,
+    {
+      model,
+      max_tokens: DEFAULT_MAX_TOKENS,
+      temperature: 0,
+      messages: [{ role: 'user', content: prompt }],
+    },
+    {
+      timeout: REQUEST_TIMEOUT_MS,
+      headers: {
+        'x-api-key': apiKey,
+        'anthropic-version': '2023-06-01',
+        'content-type': 'application/json',
+      },
+    },
+  );
+ 
+  const textBlock = (response.data?.content || []).find((block) => block.type === 'text');
+  const text = textBlock?.text || '';
+  logSuggest('info', 'claude request complete', {
+    model,
+    elapsedMs: Date.now() - startedAt,
+    responseChars: text.length,
+    preview: truncateForLog(text, 240),
+  });
+  return text;
+}
+ 
+async function suggestPivotEventTags(req, rawEvent = {}, options = {}) {
+  const event = normalizeEventInput(rawEvent);
+  logSuggest('info', 'single suggest start', {
+    eventName: event.name || null,
+    hasDescription: Boolean(event.description),
+    hasLocation: Boolean(event.location),
+    hostName: event.hostName || null,
+    sourceTagCount: event.sourceTags.length,
+    batchIndex: options.batchIndex,
+  });
+ 
+  const apiKey = resolveAnthropicApiKey();
+  if (!apiKey) {
+    logSuggest('warn', 'missing anthropic api key');
+    return {
+      error: 'Tag suggestion requires ANTHROPIC_API_KEY (or CLAUDE_API_KEY) in the environment.',
+      status: 503,
+      code: 'LLM_NOT_CONFIGURED',
+    };
+  }
+ 
+  Iif (!event.name && !event.description && !event.location) {
+    logSuggest('warn', 'event context missing', { rawKeys: Object.keys(rawEvent || {}) });
+    return {
+      error: 'Event title, description, or location is required for tag suggestion.',
+      status: 400,
+      code: 'EVENT_CONTEXT_REQUIRED',
+    };
+  }
+ 
+  const catalogResult = await listPivotTags(req);
+  Iif (catalogResult.error) {
+    logSuggest('warn', 'catalog lookup failed', {
+      message: catalogResult.error,
+      code: catalogResult.code,
+      hasGlobalDb: Boolean(req.globalDb),
+    });
+    return catalogResult;
+  }
+ 
+  const catalogTags = catalogResult.data?.tags || [];
+  logSuggest('info', 'catalog loaded', { activeTagCount: catalogTags.length });
+  Iif (!catalogTags.length) {
+    logSuggest('warn', 'catalog empty');
+    return {
+      error: 'Pivot tag catalog is empty. Run seed:pivot-tag-catalog first.',
+      status: 503,
+      code: 'TAG_CATALOG_EMPTY',
+    };
+  }
+ 
+  try {
+    const prompt = buildTagSuggestionPrompt(event, catalogTags);
+    const responseText = await callClaudeForTags(prompt, apiKey);
+    const rawTags = parseTagsFromClaudeText(responseText);
+    logSuggest('info', 'parsed claude tags', { rawTags });
+ 
+    const validated = await validatePivotEventTags(req, rawTags, { required: true });
+    Iif (validated.error) {
+      logSuggest('warn', 'tag validation failed', {
+        code: validated.code,
+        message: validated.error,
+        rawTags,
+      });
+      return {
+        error: validated.error,
+        status: validated.status,
+        code: validated.code,
+        data: { rawTags, model: resolveClaudeModel(), responsePreview: truncateForLog(responseText, 240) },
+      };
+    }
+ 
+    logSuggest('info', 'single suggest success', {
+      eventName: event.name || null,
+      tags: validated.tags,
+      model: resolveClaudeModel(),
+    });
+ 
+    return {
+      data: {
+        tags: validated.tags,
+        model: resolveClaudeModel(),
+      },
+    };
+  } catch (err) {
+    if (err.code === 'ECONNABORTED') {
+      logSuggest('warn', 'claude request timed out');
+      return {
+        error: 'Claude tag suggestion timed out.',
+        status: 504,
+        code: 'LLM_TIMEOUT',
+      };
+    }
+ 
+    const status = err.response?.status;
+    if (status === 404) {
+      const modelMessage = err.response?.data?.error?.message || '';
+      logSuggest('warn', 'claude model not found', { model: resolveClaudeModel(), modelMessage });
+      return {
+        error: modelMessage.includes('model:')
+          ? `Claude model not found (${resolveClaudeModel()}). Set CLAUDE_MODEL to a current ID such as claude-sonnet-4-6 or claude-haiku-4-5-20251001.`
+          : 'Claude model not found. Check CLAUDE_MODEL or ANTHROPIC_MODEL.',
+        status: 502,
+        code: 'LLM_MODEL_NOT_FOUND',
+      };
+    }
+    if (status === 401 || status === 403) {
+      logSuggest('warn', 'claude auth failed', { status });
+      return {
+        error: 'Claude API rejected the API key.',
+        status: 502,
+        code: 'LLM_AUTH_FAILED',
+      };
+    }
+ 
+    logSuggest('error', 'claude request failed', {
+      status,
+      message: err.message,
+      response: err.response?.data,
+    });
+    return {
+      error: 'Unable to suggest tags from Claude.',
+      status: 502,
+      code: 'LLM_REQUEST_FAILED',
+    };
+  }
+}
+ 
+async function suggestPivotEventTagsBatch(req, rawEvents = []) {
+  const events = Array.isArray(rawEvents) ? rawEvents : [];
+  logSuggest('info', 'batch suggest start', { eventCount: events.length });
+  Iif (!events.length) {
+    return {
+      error: 'At least one event is required.',
+      status: 400,
+      code: 'EVENTS_REQUIRED',
+    };
+  }
+ 
+  const suggestions = [];
+  const failures = [];
+ 
+  for (let index = 0; index < events.length; index += 1) {
+    const rawEvent = events[index];
+    const result = await suggestPivotEventTags(req, rawEvent, { batchIndex: index });
+    if (result.error) {
+      failures.push({
+        index,
+        name: trimString(rawEvent?.name) || null,
+        message: result.error,
+        code: result.code,
+      });
+      suggestions.push({ tags: [] });
+      continue;
+    }
+ 
+    suggestions.push({ tags: result.data.tags, model: result.data.model });
+  }
+ 
+  const suggestedCount = suggestions.filter((entry) => entry.tags?.length).length;
+  logSuggest('info', 'batch suggest complete', {
+    eventCount: events.length,
+    suggestedCount,
+    failedCount: failures.length,
+    failures: failures.map((entry) => ({ index: entry.index, code: entry.code, name: entry.name })),
+  });
+ 
+  if (suggestedCount === 0 && failures.length > 0) {
+    return {
+      error: failures[0].message,
+      status: failures[0].code === 'LLM_NOT_CONFIGURED' ? 503 : 400,
+      code: failures[0].code || 'BATCH_SUGGEST_FAILED',
+      data: {
+        suggestions,
+        failures,
+        suggestedCount: 0,
+        failedCount: failures.length,
+      },
+    };
+  }
+ 
+  return {
+    data: {
+      suggestions,
+      failures,
+      suggestedCount,
+      failedCount: failures.length,
+    },
+  };
+}
+ 
+module.exports = {
+  suggestPivotEventTags,
+  suggestPivotEventTagsBatch,
+  buildTagSuggestionPrompt,
+  parseTagsFromClaudeText,
+  resolveAnthropicApiKey,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotTmdbService.js.html b/backend/coverage/lcov-report/services/pivotTmdbService.js.html new file mode 100644 index 00000000..63e87fcb --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotTmdbService.js.html @@ -0,0 +1,835 @@ + + + + + + Code coverage report for services/pivotTmdbService.js + + + + + + + + + +
+
+

All files / services pivotTmdbService.js

+
+ +
+ 35.06% + Statements + 27/77 +
+ + +
+ 20.51% + Branches + 16/78 +
+ + +
+ 62.5% + Functions + 10/16 +
+ + +
+ 33.33% + Lines + 25/75 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +2511x +  +  +  +1x +  +1x +1x +  +  +10x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +  +2x +2x +  +  +  +1x +1x +  +  +  +1x +1x +1x +  +  +  +1x +  +  +  +1x +1x +1x +  +1x +  +  +2x +  +  +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  + 
const axios = require('axios');
+const {
+  buildTmdbImageUrl,
+  normalizePivotMovie,
+} = require('../utilities/pivotMovieMetadata');
+ 
+const TMDB_API_BASE = 'https://api.themoviedb.org/3';
+const REQUEST_TIMEOUT_MS = 12_000;
+ 
+function trimString(value) {
+  return typeof value === 'string' ? value.trim() : '';
+}
+ 
+function resolveTmdbCredentials() {
+  const readAccessToken =
+    trimString(process.env.TMDB_API_READ_ACCESS_TOKEN) ||
+    trimString(process.env.TMDB_READ_ACCESS_TOKEN) ||
+    trimString(process.env.TMDB_ACCESS_TOKEN);
+ 
+  if (readAccessToken) {
+    return { mode: 'bearer', token: readAccessToken };
+  }
+ 
+  const apiKey = trimString(process.env.TMDB_API_KEY);
+  if (apiKey) {
+    return { mode: 'api_key', key: apiKey };
+  }
+ 
+  return null;
+}
+ 
+function buildTmdbRequestConfig(params = {}) {
+  const credentials = resolveTmdbCredentials();
+  if (!credentials) {
+    return null;
+  }
+ 
+  const config = {
+    timeout: REQUEST_TIMEOUT_MS,
+    params,
+  };
+ 
+  if (credentials.mode === 'bearer') {
+    config.headers = {
+      Authorization: `Bearer ${credentials.token}`,
+    };
+    return config;
+  }
+ 
+  config.params = {
+    api_key: credentials.key,
+    ...params,
+  };
+  return config;
+}
+ 
+function tmdbUnavailableResult() {
+  return {
+    error:
+      'TMDB is not configured. Set TMDB_API_READ_ACCESS_TOKEN (recommended) or TMDB_API_KEY in the backend environment.',
+    status: 503,
+    code: 'TMDB_NOT_CONFIGURED',
+  };
+}
+ 
+function extractYear(releaseDate) {
+  const value = trimString(releaseDate);
+  Iif (!value) {
+    return null;
+  }
+  const year = Number(value.slice(0, 4));
+  return Number.isFinite(year) ? year : null;
+}
+ 
+function extractUsContentRating(releaseDates) {
+  const results = releaseDates?.results;
+  Iif (!Array.isArray(results)) {
+    return null;
+  }
+ 
+  const us = results.find((entry) => entry.iso_3166_1 === 'US');
+  const release = us?.release_dates?.find((entry) => entry.certification);
+  return trimString(release?.certification) || null;
+}
+ 
+function mapTmdbMovieDetails(details) {
+  Iif (!details || typeof details !== 'object') {
+    return null;
+  }
+ 
+  const credits = details.credits || {};
+  const director = Array.isArray(credits.crew)
+    ? credits.crew.find((person) => person.job === 'Director')?.name
+    : null;
+  const cast = Array.isArray(credits.cast)
+    ? credits.cast
+        .slice(0, 5)
+        .map((person) => trimString(person.name))
+        .filter(Boolean)
+    : [];
+ 
+  const raw = {
+    tmdbId: details.id,
+    title: details.title || details.original_title,
+    year: extractYear(details.release_date),
+    synopsis: details.overview,
+    posterPath: details.poster_path,
+    backdropPath: details.backdrop_path,
+    runtimeMinutes: details.runtime,
+    genres: Array.isArray(details.genres)
+      ? details.genres.map((genre) => trimString(genre.name)).filter(Boolean)
+      : [],
+    contentRating: extractUsContentRating(details.release_dates),
+    director: director ? trimString(director) : '',
+    cast,
+    imdbId: trimString(details.external_ids?.imdb_id),
+    ratings: {
+      tmdb: {
+        score: details.vote_average,
+        voteCount: details.vote_count,
+      },
+    },
+  };
+ 
+  return normalizePivotMovie(raw);
+}
+ 
+function mapTmdbSearchResult(result) {
+  return {
+    tmdbId: result.id,
+    title: trimString(result.title || result.original_title),
+    year: extractYear(result.release_date),
+    overview: trimString(result.overview),
+    posterUrl: buildTmdbImageUrl(result.poster_path, 'w185'),
+    voteAverage: result.vote_average ?? null,
+  };
+}
+ 
+async function searchTmdbMovies(options = {}) {
+  const requestConfig = buildTmdbRequestConfig();
+  if (!requestConfig) {
+    return tmdbUnavailableResult();
+  }
+ 
+  const query = trimString(options.query);
+  if (!query) {
+    return {
+      error: 'query is required.',
+      status: 400,
+      code: 'TMDB_QUERY_REQUIRED',
+    };
+  }
+ 
+  const year = trimString(options.year);
+ 
+  try {
+    const response = await axios.get(`${TMDB_API_BASE}/search/movie`, {
+      ...requestConfig,
+      params: {
+        ...requestConfig.params,
+        query,
+        ...(year ? { year } : {}),
+        include_adult: false,
+        language: 'en-US',
+      },
+    });
+ 
+    const results = Array.isArray(response.data?.results)
+      ? response.data.results.map(mapTmdbSearchResult).filter((row) => row.tmdbId && row.title)
+      : [];
+ 
+    return {
+      data: {
+        query,
+        year: year || null,
+        results,
+      },
+    };
+  } catch (err) {
+    const status = err.response?.status;
+    return {
+      error: status === 401 ? 'TMDB credentials are invalid.' : 'Unable to search TMDB.',
+      status: status === 401 ? 503 : 502,
+      code: status === 401 ? 'TMDB_AUTH_FAILED' : 'TMDB_SEARCH_FAILED',
+    };
+  }
+}
+ 
+async function fetchTmdbMovieDetails(options = {}) {
+  const requestConfig = buildTmdbRequestConfig();
+  if (!requestConfig) {
+    return tmdbUnavailableResult();
+  }
+ 
+  const tmdbId = Number(options.tmdbId);
+  if (!Number.isFinite(tmdbId) || tmdbId <= 0) {
+    return {
+      error: 'tmdbId is required.',
+      status: 400,
+      code: 'TMDB_ID_REQUIRED',
+    };
+  }
+ 
+  try {
+    const response = await axios.get(`${TMDB_API_BASE}/movie/${tmdbId}`, {
+      ...requestConfig,
+      params: {
+        ...requestConfig.params,
+        language: 'en-US',
+        append_to_response: 'credits,external_ids,release_dates',
+      },
+    });
+ 
+    const movie = mapTmdbMovieDetails(response.data);
+    if (!movie) {
+      return {
+        error: 'TMDB movie not found.',
+        status: 404,
+        code: 'TMDB_MOVIE_NOT_FOUND',
+      };
+    }
+ 
+    return {
+      data: {
+        movie,
+      },
+    };
+  } catch (err) {
+    const status = err.response?.status;
+    if (status === 404) {
+      return {
+        error: 'TMDB movie not found.',
+        status: 404,
+        code: 'TMDB_MOVIE_NOT_FOUND',
+      };
+    }
+    return {
+      error: status === 401 ? 'TMDB credentials are invalid.' : 'Unable to load TMDB movie.',
+      status: status === 401 ? 503 : 502,
+      code: status === 401 ? 'TMDB_AUTH_FAILED' : 'TMDB_FETCH_FAILED',
+    };
+  }
+}
+ 
+module.exports = {
+  searchTmdbMovies,
+  fetchTmdbMovieDetails,
+  mapTmdbMovieDetails,
+  mapTmdbSearchResult,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotWeeklyDropService.js.html b/backend/coverage/lcov-report/services/pivotWeeklyDropService.js.html new file mode 100644 index 00000000..9380dce7 --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotWeeklyDropService.js.html @@ -0,0 +1,1018 @@ + + + + + + Code coverage report for services/pivotWeeklyDropService.js + + + + + + + + + +
+
+

All files / services pivotWeeklyDropService.js

+
+ +
+ 81.51% + Statements + 97/119 +
+ + +
+ 45.76% + Branches + 27/59 +
+ + +
+ 100% + Functions + 11/11 +
+ + +
+ 82.05% + Lines + 96/117 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +3121x +1x +1x +1x +1x +1x +1x +1x +  +  +  +1x +  +1x +1x +1x +  +1x +1x +  +  +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +4x +4x +4x +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +3x +3x +3x +3x +  +  +  +  +  +  +  +  +1x +1x +  +1x +  +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +5x +5x +  +5x +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +1x +  +  +  +1x +1x +  +  +  +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +1x +1x +  +  +1x +  +  +  +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +2x +  +  +  +2x +2x +2x +  +2x +4x +4x +  +  +  +  +  +  +2x +  +  +  +3x +3x +  +  +3x +  +  +  +3x +3x +  +  +  +3x +3x +3x +3x +3x +3x +  +3x +3x +  +  +  +  +3x +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +6x +  +  +3x +1x +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +2x +2x +2x +  +2x +2x +2x +2x +2x +2x +  +  +  +  +2x +2x +2x +1x +  +1x +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  + 
const axios = require('axios');
+const { connectToDatabase } = require('../connectionsManager');
+const getModels = require('./getModelService');
+const { getTenantByKey, upsertStoredTenantRow, serializeTenantForAdmin } = require('./tenantConfigService');
+const { normalizePivotDropFields, normalizePivotDropOverrides } = require('../constants/defaultTenants');
+const { isValidIsoWeek, toIsoWeek } = require('../utilities/pivotIsoWeek');
+const { buildDropSchedulePayload } = require('./pivotConfigService');
+const { rebuildWeeklySnapshot } = require('./pivotWeeklySnapshotService');
+const {
+  DAY_NAMES,
+  isPivotTenant,
+} = require('../utilities/pivotDropSchedule');
+ 
+const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
+const EXPO_BATCH_SIZE = 100;
+const DROP_WINDOW_MS = 30 * 60 * 1000;
+ 
+const PUSH_TITLE = 'just go*';
+const PUSH_BODY = 'What are you doing this week? Just go.';
+ 
+function buildWeeklyDropPushMessage(pushToken, batchWeek) {
+  return {
+    to: pushToken,
+    sound: 'default',
+    title: PUSH_TITLE,
+    body: PUSH_BODY,
+    data: {
+      type: 'pivot_week',
+      edition: 'pivot',
+      appEdition: 'pivot',
+      batchWeek,
+      navigation: {
+        type: 'navigate',
+        route: 'PivotWeek',
+        deepLink: 'meridian://pivot/week',
+      },
+    },
+    priority: 'default',
+    channelId: 'default',
+  };
+}
+ 
+async function countPublishedEvents(tenantKey, batchWeek) {
+  const db = await connectToDatabase(tenantKey);
+  const req = { db, school: tenantKey };
+  const { Event } = getModels(req, 'Event');
+  return Event.countDocuments({
+    'customFields.pivot.batchWeek': batchWeek,
+    'customFields.pivot.ingestStatus': 'published',
+  });
+}
+ 
+async function countPivotPushRecipients(tenantKey) {
+  const db = await connectToDatabase(tenantKey);
+  const req = { db, school: tenantKey };
+  const { User } = getModels(req, 'User');
+  return User.countDocuments({
+    pushToken: { $exists: true, $nin: [null, ''] },
+    pushAppEdition: 'pivot',
+  });
+}
+ 
+async function loadPivotPushRecipients(tenantKey) {
+  const db = await connectToDatabase(tenantKey);
+  const req = { db, school: tenantKey };
+  const { User } = getModels(req, 'User');
+  return User.find({
+    pushToken: { $exists: true, $nin: [null, ''] },
+    pushAppEdition: 'pivot',
+  })
+    .select('_id pushToken')
+    .lean();
+}
+ 
+function validateDropConfigPayload(body = {}) {
+  const patch = {};
+  normalizePivotDropFields(body, patch);
+ 
+  Iif (body.pivotDropOverrides !== undefined) {
+    const overrides = normalizePivotDropOverrides(body.pivotDropOverrides);
+    patch.pivotDropOverrides = overrides || [];
+  }
+ 
+  Iif (Object.keys(patch).length === 0) {
+    return { error: 'No drop schedule fields provided.' };
+  }
+ 
+  Iif (patch.pivotDropTimezone !== undefined && !patch.pivotDropTimezone) {
+    return { error: 'pivotDropTimezone cannot be empty.' };
+  }
+ 
+  return { patch };
+}
+ 
+function serializeDropSchedule(tenant, batchWeek, now = new Date()) {
+  const dropSchedule = buildDropSchedulePayload(tenant, batchWeek, now);
+  const deltaMs = Math.abs(now.getTime() - new Date(dropSchedule.nextDropAt).getTime());
+ 
+  return {
+    ...dropSchedule,
+    minutesFromDropAt: Math.round(deltaMs / 60000),
+    withinDropWindow: deltaMs <= DROP_WINDOW_MS,
+    pushCopy: {
+      title: PUSH_TITLE,
+      body: PUSH_BODY,
+    },
+  };
+}
+ 
+async function getWeeklyDropStatus(req, tenantKey, batchWeekInput) {
+  const tenant = await getTenantByKey(req, tenantKey);
+  Iif (!tenant) {
+    return { status: 404, error: 'Tenant not found.' };
+  }
+  Iif (!isPivotTenant(tenant)) {
+    return { status: 400, error: 'Weekly drop is only available for pivot city tenants.' };
+  }
+ 
+  const batchWeek = batchWeekInput || toIsoWeek();
+  Iif (!isValidIsoWeek(batchWeek)) {
+    return { status: 400, error: 'batchWeek must be YYYY-Www.' };
+  }
+ 
+  const [publishedEventCount, pivotPushRecipientCount] = await Promise.all([
+    countPublishedEvents(tenantKey, batchWeek),
+    countPivotPushRecipients(tenantKey),
+  ]);
+ 
+  return {
+    tenant: serializeTenantForAdmin(tenant),
+    dropSchedule: serializeDropSchedule(tenant, batchWeek),
+    publishedEventCount,
+    pivotPushRecipientCount,
+    dayNames: DAY_NAMES,
+  };
+}
+ 
+async function updateWeeklyDropConfig(req, tenantKey, body, updatedBy) {
+  const tenant = await getTenantByKey(req, tenantKey);
+  Iif (!tenant) {
+    return { status: 404, error: 'Tenant not found.' };
+  }
+  Iif (!isPivotTenant(tenant)) {
+    return { status: 400, error: 'Weekly drop is only available for pivot city tenants.' };
+  }
+ 
+  const validation = validateDropConfigPayload(body);
+  Iif (validation.error) {
+    return { status: 400, error: validation.error };
+  }
+ 
+  const saved = await upsertStoredTenantRow(
+    req,
+    {
+      ...tenant,
+      ...validation.patch,
+    },
+    updatedBy
+  );
+ 
+  const batchWeek = isValidIsoWeek(body.batchWeek) ? body.batchWeek.trim() : toIsoWeek();
+  return {
+    tenant: serializeTenantForAdmin(saved),
+    dropSchedule: serializeDropSchedule(saved, batchWeek),
+  };
+}
+ 
+async function sendExpoBatch(messages) {
+  const response = await axios.post(EXPO_PUSH_URL, messages, {
+    headers: {
+      'Content-Type': 'application/json',
+      Accept: 'application/json',
+      'Accept-Encoding': 'gzip, deflate',
+    },
+  });
+ 
+  const tickets = Array.isArray(response.data?.data)
+    ? response.data.data
+    : [response.data?.data].filter(Boolean);
+ 
+  let sent = 0;
+  let failed = 0;
+  const errors = [];
+ 
+  for (const ticket of tickets) {
+    if (ticket?.status === 'ok') {
+      sent += 1;
+    } else E{
+      failed += 1;
+      if (ticket?.message) errors.push(ticket.message);
+    }
+  }
+ 
+  return { sent, failed, errors };
+}
+ 
+async function sendWeeklyDropPush(req, tenantKey, options = {}) {
+  const tenant = await getTenantByKey(req, tenantKey);
+  Iif (!tenant) {
+    return { status: 404, error: 'Tenant not found.' };
+  }
+  Iif (!isPivotTenant(tenant)) {
+    return { status: 400, error: 'Weekly drop is only available for pivot city tenants.' };
+  }
+ 
+  const batchWeek = options.batchWeek || toIsoWeek();
+  Iif (!isValidIsoWeek(batchWeek)) {
+    return { status: 400, error: 'batchWeek must be YYYY-Www.' };
+  }
+ 
+  const dryRun = options.dryRun === true;
+  const force = options.force === true;
+  const now = new Date();
+  const dropSchedule = serializeDropSchedule(tenant, batchWeek, now);
+  const publishedEventCount = await countPublishedEvents(tenantKey, batchWeek);
+  const recipients = await loadPivotPushRecipients(tenantKey);
+ 
+  const warnings = [];
+  Iif (dropSchedule.usingPilotDefaults) {
+    warnings.push(
+      'Tenant has no stored weekly drop config — using pilot defaults (Thu 18:00 America/New_York).'
+    );
+  }
+  Iif (publishedEventCount === 0) {
+    warnings.push(`No published catalog events for ${batchWeek}. Publish in Pivot Lab first.`);
+  }
+  Iif (!force && !dropSchedule.withinDropWindow) {
+    warnings.push(
+      `Now is ${dropSchedule.minutesFromDropAt} minutes from resolved dropAt. Confirm schedule or use force.`
+    );
+    if (!dryRun) {
+      return {
+        status: 409,
+        error: 'Outside drop window. Pass force=true to send anyway.',
+        code: 'OUTSIDE_DROP_WINDOW',
+        data: { dropSchedule, publishedEventCount, pivotPushRecipientCount: recipients.length, warnings },
+      };
+    }
+  }
+ 
+  const messages = recipients.map((recipient) =>
+    buildWeeklyDropPushMessage(recipient.pushToken, batchWeek)
+  );
+ 
+  if (dryRun) {
+    return {
+      dryRun: true,
+      dropSchedule,
+      publishedEventCount,
+      pivotPushRecipientCount: recipients.length,
+      warnings,
+      sampleMessage: messages[0] || null,
+    };
+  }
+ 
+  Iif (recipients.length === 0) {
+    return {
+      status: 400,
+      error: 'No pivot push tokens found for this city.',
+      code: 'NO_RECIPIENTS',
+      data: { dropSchedule, publishedEventCount, warnings },
+    };
+  }
+ 
+  let sent = 0;
+  let failed = 0;
+  const errors = [];
+ 
+  for (let index = 0; index < messages.length; index += EXPO_BATCH_SIZE) {
+    const batch = messages.slice(index, index + EXPO_BATCH_SIZE);
+    const result = await sendExpoBatch(batch);
+    sent += result.sent;
+    failed += result.failed;
+    errors.push(...result.errors);
+  }
+ 
+  // Best-effort: freeze this week's metrics right after the drop so Lab trends
+  // build themselves; a snapshot failure must never mask a successful send.
+  let snapshotRebuilt = false;
+  try {
+    const rebuild = await rebuildWeeklySnapshot(req, { batchWeek });
+    snapshotRebuilt = !rebuild.error;
+  } catch (error) {
+    console.error(
+      `[pivotWeeklyDrop] snapshot rebuild failed after send tenant=${tenantKey} batchWeek=${batchWeek}:`,
+      error,
+    );
+  }
+ 
+  return {
+    dryRun: false,
+    dropSchedule,
+    publishedEventCount,
+    pivotPushRecipientCount: recipients.length,
+    sent,
+    failed,
+    snapshotRebuilt,
+    warnings,
+    errors: errors.slice(0, 5),
+  };
+}
+ 
+module.exports = {
+  PUSH_TITLE,
+  PUSH_BODY,
+  DROP_WINDOW_MS,
+  buildWeeklyDropPushMessage,
+  getWeeklyDropStatus,
+  updateWeeklyDropConfig,
+  sendWeeklyDropPush,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/pivotWeeklySnapshotService.js.html b/backend/coverage/lcov-report/services/pivotWeeklySnapshotService.js.html new file mode 100644 index 00000000..ea88519c --- /dev/null +++ b/backend/coverage/lcov-report/services/pivotWeeklySnapshotService.js.html @@ -0,0 +1,820 @@ + + + + + + Code coverage report for services/pivotWeeklySnapshotService.js + + + + + + + + + +
+
+

All files / services pivotWeeklySnapshotService.js

+
+ +
+ 89.33% + Statements + 67/75 +
+ + +
+ 63.79% + Branches + 37/58 +
+ + +
+ 100% + Functions + 13/13 +
+ + +
+ 88.88% + Lines + 64/72 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +2464x +4x +4x +4x +4x +4x +4x +4x +  +  +4x +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +  +2x +6x +  +  +  +  +2x +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +10x +10x +2x +  +  +  +  +  +8x +  +  +  +1x +  +  +  +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +  +  +  +  +  +  +2x +2x +  +  +  +3x +  +2x +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +2x +2x +2x +  +  +  +  +  +  +2x +2x +2x +  +2x +2x +1x +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +1x +1x +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +1x +  +  +  +1x +1x +  +  +  +1x +1x +  +1x +1x +  +  +  +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +  + 
const getGlobalModels = require('./getGlobalModelService');
+const getModels = require('./getModelService');
+const { getMergedTenants } = require('./tenantConfigService');
+const { isPivotTenant } = require('./pivotReferralCodeService');
+const { connectToDatabase } = require('../connectionsManager');
+const { PIVOT_EVENT_STATUSES } = require('./pivotFeedService');
+const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService');
+const { toIsoWeek, isValidIsoWeek, isoWeekToUtcRange } = require('../utilities/pivotIsoWeek');
+ 
+/** Mobile analytics event names surfaced as Lab engagement metrics. */
+const ENGAGEMENT_EVENTS = {
+  calendarAdds: ['pivot_calendar_add'],
+  inviteShares: ['pivot_invite_share'],
+  interestsSaved: ['pivot_interests_onboarding_completed', 'pivot_interests_updated'],
+};
+ 
+/**
+ * Client-only loop counts (calendar adds, invite shares, interests saved) from the
+ * tenant analytics_events collection, bounded to the ISO week's UTC range.
+ * Best-effort: analytics ingestion must never fail a snapshot, so errors return zeros.
+ */
+async function aggregateEngagementMetrics(tenantReq, batchWeek) {
+  const zeros = { calendarAdds: 0, inviteShares: 0, interestsSaved: 0 };
+  try {
+    const { AnalyticsEvent } = getModels(tenantReq, 'AnalyticsEvent');
+    const { start, end } = isoWeekToUtcRange(batchWeek);
+    const tsFilter = { ts: { $gte: start, $lt: end } };
+ 
+    const entries = await Promise.all(
+      Object.entries(ENGAGEMENT_EVENTS).map(async ([key, eventNames]) => [
+        key,
+        await AnalyticsEvent.countDocuments({ event: { $in: eventNames }, ...tsFilter }),
+      ]),
+    );
+    return Object.fromEntries(entries);
+  } catch (error) {
+    console.error(`[pivotWeeklySnapshot] engagement aggregate failed batchWeek=${batchWeek}:`, error);
+    return zeros;
+  }
+}
+ 
+const PUBLISHED_EVENT_QUERY = (batchWeek) => ({
+  'customFields.pivot.batchWeek': batchWeek,
+  'customFields.pivot.ingestStatus': 'published',
+  status: { $in: PIVOT_EVENT_STATUSES },
+  isDeleted: { $ne: true },
+  'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
+});
+ 
+function normalizeBatchWeek(raw, now = new Date()) {
+  const batchWeek = raw?.trim() || toIsoWeek(now);
+  if (!isValidIsoWeek(batchWeek)) {
+    return {
+      error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).',
+      status: 400,
+      code: 'INVALID_BATCH_WEEK',
+    };
+  }
+  return { batchWeek };
+}
+ 
+function serializeSnapshot(doc) {
+  Iif (!doc) {
+    return null;
+  }
+ 
+  return {
+    batchWeek: doc.batchWeek,
+    generatedAt: doc.generatedAt,
+    tenants: (doc.tenants || []).map((row) => ({
+      tenantKey: row.tenantKey,
+      cityDisplayName: row.cityDisplayName || row.tenantKey,
+      eventCount: row.eventCount ?? 0,
+      interestedCount: row.interestedCount ?? 0,
+      registeredCount: row.registeredCount ?? 0,
+      externalOpenCount: row.externalOpenCount ?? 0,
+      externalOpenUsers: row.externalOpenUsers ?? 0,
+      calendarAdds: row.calendarAdds ?? 0,
+      inviteShares: row.inviteShares ?? 0,
+      interestsSaved: row.interestsSaved ?? 0,
+      swipeCount: row.swipeCount ?? 0,
+      feedbackAvg: row.feedbackAvg ?? null,
+      activeUsers: row.activeUsers ?? 0,
+    })),
+    updatedAt: doc.updatedAt,
+    createdAt: doc.createdAt,
+  };
+}
+ 
+async function aggregateTenantMetrics(tenant, batchWeek) {
+  const tenantKey = tenant.tenantKey;
+  const db = await connectToDatabase(tenantKey);
+  const tenantReq = { db };
+  const { Event, PivotEventIntent, UniversalFeedback } = getModels(
+    tenantReq,
+    'Event',
+    'PivotEventIntent',
+    'UniversalFeedback',
+  );
+ 
+  const eventQuery = PUBLISHED_EVENT_QUERY(batchWeek);
+  const [eventCount, events] = await Promise.all([
+    Event.countDocuments(eventQuery),
+    Event.find(eventQuery).select('_id').lean(),
+  ]);
+  const eventIds = events.map((event) => event._id);
+ 
+  const intentFilter = { batchWeek };
+  const [
+    interestedCount,
+    registeredCount,
+    passedCount,
+    activeUserIds,
+    externalOpenAgg,
+    externalOpenUserIds,
+    engagement,
+  ] = await Promise.all([
+    PivotEventIntent.countDocuments({ ...intentFilter, status: 'interested' }),
+    PivotEventIntent.countDocuments({ ...intentFilter, status: 'registered' }),
+    PivotEventIntent.countDocuments({ ...intentFilter, status: 'passed' }),
+    PivotEventIntent.distinct('userId', intentFilter),
+    PivotEventIntent.aggregate([
+      { $match: intentFilter },
+      { $group: { _id: null, total: { $sum: { $ifNull: ['$externalOpenCount', 0] } } } },
+    ]),
+    PivotEventIntent.distinct('userId', { ...intentFilter, externalOpenAt: { $ne: null } }),
+    aggregateEngagementMetrics(tenantReq, batchWeek),
+  ]);
+ 
+  // Card swipes: pass + interested; registered users previously swiped right.
+  const swipeCount = passedCount + interestedCount + registeredCount;
+ 
+  let feedbackAvg = null;
+  Eif (eventIds.length) {
+    const feedbackRows = await UniversalFeedback.find({
+      feature: PIVOT_EVENT_FEATURE,
+      processId: { $in: eventIds },
+    })
+      .select('responses.rating')
+      .lean();
+ 
+    const ratings = feedbackRows
+      .map((row) => row.responses?.rating)
+      .filter((rating) => typeof rating === 'number' && rating >= 1 && rating <= 5);
+ 
+    if (ratings.length) {
+      const sum = ratings.reduce((acc, rating) => acc + rating, 0);
+      feedbackAvg = Math.round((sum / ratings.length) * 100) / 100;
+    }
+  }
+ 
+  return {
+    tenantKey,
+    cityDisplayName: tenant.location || tenant.name || tenantKey,
+    eventCount,
+    interestedCount,
+    registeredCount,
+    externalOpenCount: externalOpenAgg[0]?.total ?? 0,
+    externalOpenUsers: externalOpenUserIds.length,
+    calendarAdds: engagement.calendarAdds,
+    inviteShares: engagement.inviteShares,
+    interestsSaved: engagement.interestsSaved,
+    swipeCount,
+    feedbackAvg,
+    activeUsers: activeUserIds.length,
+  };
+}
+ 
+async function rebuildWeeklySnapshot(req, options = {}) {
+  const normalized = normalizeBatchWeek(options.batchWeek, options.now);
+  Iif (normalized.error) {
+    return normalized;
+  }
+ 
+  const { batchWeek } = normalized;
+  const generatedAt = options.now || new Date();
+  const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant);
+  const tenants = [];
+ 
+  for (const tenant of pivotTenants) {
+    try {
+      tenants.push(await aggregateTenantMetrics(tenant, batchWeek));
+    } catch (error) {
+      console.error(
+        `[pivotWeeklySnapshot] aggregate failed tenant=${tenant.tenantKey} batchWeek=${batchWeek}:`,
+        error,
+      );
+      tenants.push({
+        tenantKey: tenant.tenantKey,
+        cityDisplayName: tenant.location || tenant.name || tenant.tenantKey,
+        eventCount: 0,
+        interestedCount: 0,
+        registeredCount: 0,
+        externalOpenCount: 0,
+        externalOpenUsers: 0,
+        calendarAdds: 0,
+        inviteShares: 0,
+        interestsSaved: 0,
+        swipeCount: 0,
+        feedbackAvg: null,
+        activeUsers: 0,
+      });
+    }
+  }
+ 
+  const { PivotWeeklySnapshot } = getGlobalModels(req, 'PivotWeeklySnapshot');
+  const doc = await PivotWeeklySnapshot.findOneAndUpdate(
+    { batchWeek },
+    { $set: { generatedAt, tenants } },
+    { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true },
+  ).lean();
+ 
+  return { data: serializeSnapshot(doc) };
+}
+ 
+async function getWeeklySnapshot(req, options = {}) {
+  const normalized = normalizeBatchWeek(options.batchWeek, options.now);
+  Iif (normalized.error) {
+    return normalized;
+  }
+ 
+  const { PivotWeeklySnapshot } = getGlobalModels(req, 'PivotWeeklySnapshot');
+  const doc = await PivotWeeklySnapshot.findOne({ batchWeek: normalized.batchWeek }).lean();
+ 
+  Eif (!doc) {
+    return {
+      error: 'No snapshot found for this batch week.',
+      status: 404,
+      code: 'SNAPSHOT_NOT_FOUND',
+    };
+  }
+ 
+  return { data: serializeSnapshot(doc) };
+}
+ 
+module.exports = {
+  aggregateTenantMetrics,
+  aggregateEngagementMetrics,
+  rebuildWeeklySnapshot,
+  getWeeklySnapshot,
+  normalizeBatchWeek,
+  serializeSnapshot,
+  PUBLISHED_EVENT_QUERY,
+  ENGAGEMENT_EVENTS,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/resendClient.js.html b/backend/coverage/lcov-report/services/resendClient.js.html new file mode 100644 index 00000000..90112bf5 --- /dev/null +++ b/backend/coverage/lcov-report/services/resendClient.js.html @@ -0,0 +1,118 @@ + + + + + + Code coverage report for services/resendClient.js + + + + + + + + + +
+
+

All files / services resendClient.js

+
+ +
+ 37.5% + Statements + 3/8 +
+ + +
+ 0% + Branches + 0/4 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 50% + Lines + 3/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +121x +  +1x +  +  +  +  +  +  +  +1x + 
const { Resend } = require('resend');
+ 
+let _client = null;
+ 
+function getResend() {
+  if (!process.env.RESEND_API_KEY) return null;
+  if (!_client) _client = new Resend(process.env.RESEND_API_KEY);
+  return _client;
+}
+ 
+module.exports = { getResend };
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/reservationMetricsService.js.html b/backend/coverage/lcov-report/services/reservationMetricsService.js.html new file mode 100644 index 00000000..af0c5d10 --- /dev/null +++ b/backend/coverage/lcov-report/services/reservationMetricsService.js.html @@ -0,0 +1,364 @@ + + + + + + Code coverage report for services/reservationMetricsService.js + + + + + + + + + +
+
+

All files / services reservationMetricsService.js

+
+ +
+ 90.47% + Statements + 19/21 +
+ + +
+ 48.38% + Branches + 15/31 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 89.47% + Lines + 17/19 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +941x +  +  +  +1x +1x +  +  +  +2x +2x +  +  +  +  +1x +1x +1x +1x +  +1x +  +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +6x +  +  +  +1x + 
const getModels = require('./getModelService');
+ 
+class ReservationMetricsService {
+    constructor(req) {
+        this.req = req;
+        this.models = getModels(req, 'Event');
+    }
+ 
+    static toDateSafe(value, fallback) {
+        const d = value ? new Date(value) : null;
+        Eif (!d || Number.isNaN(d.getTime())) return fallback;
+        return d;
+    }
+ 
+    async getMetrics({ orgId = null, startDate, endDate } = {}) {
+        const { Event } = this.models;
+        const now = new Date();
+        const start = ReservationMetricsService.toDateSafe(startDate, new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000));
+        const end = ReservationMetricsService.toDateSafe(endDate, now);
+ 
+        const match = {
+            isDeleted: false,
+            start_time: { $gte: start, $lte: end },
+            'reservation.resourceId': { $ne: null }
+        };
+        Iif (orgId) {
+            match.$or = [{ hostingId: orgId }, { 'collaboratorOrgs.orgId': orgId }];
+        }
+ 
+        const [summary] = await Event.aggregate([
+            { $match: match },
+            {
+                $group: {
+                    _id: null,
+                    totalReservations: { $sum: 1 },
+                    conflicts: { $sum: { $cond: ['$reservation.conflictSummary.hasConflict', 1, 0] } },
+                    unresolved: {
+                        $sum: {
+                            $cond: [
+                                {
+                                    $and: [
+                                        '$reservation.conflictSummary.hasConflict',
+                                        { $ne: ['$reservation.resolutionStatus', 'resolved'] }
+                                    ]
+                                },
+                                1,
+                                0
+                            ]
+                        }
+                    },
+                    approvedReservations: { $sum: { $cond: [{ $eq: ['$reservation.state', 'approved'] }, 1, 0] } }
+                }
+            }
+        ]);
+ 
+        const byResource = await Event.aggregate([
+            { $match: match },
+            {
+                $group: {
+                    _id: '$reservation.resourceId',
+                    reservations: { $sum: 1 },
+                    conflicts: { $sum: { $cond: ['$reservation.conflictSummary.hasConflict', 1, 0] } }
+                }
+            },
+            { $sort: { reservations: -1 } },
+            { $limit: 100 }
+        ]);
+ 
+        return {
+            window: { start, end },
+            totalReservations: summary?.totalReservations || 0,
+            conflicts: summary?.conflicts || 0,
+            unresolved: summary?.unresolved || 0,
+            approvedReservations: summary?.approvedReservations || 0,
+            conflictRate: summary?.totalReservations ? (summary.conflicts / summary.totalReservations) : 0,
+            byResource
+        };
+    }
+ 
+    static toCsv(metrics = {}) {
+        const rows = [
+            ['metric', 'value'],
+            ['totalReservations', metrics.totalReservations || 0],
+            ['conflicts', metrics.conflicts || 0],
+            ['unresolved', metrics.unresolved || 0],
+            ['approvedReservations', metrics.approvedReservations || 0],
+            ['conflictRate', metrics.conflictRate || 0]
+        ];
+        return rows.map((row) => row.join(',')).join('\n');
+    }
+}
+ 
+module.exports = ReservationMetricsService;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/resourceReservationService.js.html b/backend/coverage/lcov-report/services/resourceReservationService.js.html new file mode 100644 index 00000000..82454016 --- /dev/null +++ b/backend/coverage/lcov-report/services/resourceReservationService.js.html @@ -0,0 +1,700 @@ + + + + + + Code coverage report for services/resourceReservationService.js + + + + + + + + + +
+
+

All files / services resourceReservationService.js

+
+ +
+ 71.69% + Statements + 76/106 +
+ + +
+ 60.62% + Branches + 77/127 +
+ + +
+ 81.81% + Functions + 9/11 +
+ + +
+ 73.26% + Lines + 74/101 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +2061x +  +1x +  +  +  +5x +5x +  +  +  +4x +4x +4x +  +  +  +2x +1x +1x +  +  +  +  +5x +5x +5x +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +1x +1x +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +1x +  +  +  +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +3x +1x +  +2x +2x +2x +  +  +  +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +  +  +  +  +  +1x +  +  +  +  +  +1x +1x +1x +1x +  +  +  +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +1x +1x +  +1x +1x +  +  +1x +  +  +  +1x + 
const getModels = require('./getModelService');
+ 
+const ACTIVE_EVENT_STATUSES = ['approved', 'not-applicable', 'pending', 'draft'];
+ 
+class ResourceReservationService {
+    constructor(req) {
+        this.req = req;
+        this.models = getModels(req, 'Event', 'Classroom', 'Schedule');
+    }
+ 
+    static parseDate(value) {
+        const parsed = value instanceof Date ? value : new Date(value);
+        Iif (Number.isNaN(parsed.getTime())) return null;
+        return parsed;
+    }
+ 
+    static inferReservationState(eventStatus) {
+        if (eventStatus === 'approved' || eventStatus === 'not-applicable') return 'approved';
+        Iif (eventStatus === 'rejected') return 'rejected';
+        Eif (eventStatus === 'pending') return 'requested';
+        return 'draft';
+    }
+ 
+    normalizeEventReservation(eventLike = {}) {
+        const current = eventLike.reservation || {};
+        const resourceId = current.resourceId || eventLike.classroom_id || null;
+        const state = current.state || ResourceReservationService.inferReservationState(eventLike.status);
+        return {
+            resourceId,
+            state,
+            lastCheckedAt: current.lastCheckedAt || null,
+            conflictSummary: current.conflictSummary || { hasConflict: false, reason: '' },
+            conflictType: current.conflictType || '',
+            conflictSource: current.conflictSource || '',
+            detectedAt: current.detectedAt || null,
+            resolutionStatus: current.resolutionStatus || 'resolved',
+            resolutionNote: current.resolutionNote || '',
+            assignedTo: current.assignedTo || null,
+            history: Array.isArray(current.history) ? current.history : [],
+            sync: {
+                sourceOfTruth: current.sync?.sourceOfTruth || 'internal',
+                externalProvider: current.sync?.externalProvider || '',
+                externalResourceId: current.sync?.externalResourceId || '',
+                lastDryRunAt: current.sync?.lastDryRunAt || null,
+                lastDryRunStatus: current.sync?.lastDryRunStatus || ''
+            }
+        };
+    }
+ 
+    static inferConflictMeta(availability = {}) {
+        const reason = availability?.reason || '';
+        Iif (reason.toLowerCase().includes('class')) {
+            return { conflictType: 'class_schedule_conflict', conflictSource: 'class_schedule' };
+        }
+        Eif (reason.toLowerCase().includes('event')) {
+            return { conflictType: 'event_overlap_conflict', conflictSource: 'event_overlap' };
+        }
+        return { conflictType: 'reservation_conflict', conflictSource: 'manual' };
+    }
+ 
+    appendHistoryEntry(eventDoc, action, actorId = null, note = '', metadata = {}) {
+        const reservation = this.normalizeEventReservation(eventDoc);
+        reservation.history = Array.isArray(reservation.history) ? reservation.history : [];
+        reservation.history.push({
+            action,
+            actorId,
+            at: new Date(),
+            note: note || '',
+            metadata: metadata || {}
+        });
+        eventDoc.reservation = reservation;
+    }
+ 
+    applyExceptionState(eventDoc, { actorId = null, action = 'acknowledged', note = '', assignedTo = null } = {}) {
+        const reservation = this.normalizeEventReservation(eventDoc);
+        Eif (action === 'resolved') {
+            reservation.resolutionStatus = 'resolved';
+            reservation.resolutionNote = note || reservation.resolutionNote || '';
+            reservation.conflictSummary = { hasConflict: false, reason: '' };
+            reservation.conflictType = '';
+            reservation.conflictSource = '';
+            reservation.detectedAt = null;
+            reservation.assignedTo = assignedTo || null;
+            this.appendHistoryEntry(eventDoc, 'exception_resolved', actorId, note, { assignedTo });
+            eventDoc.reservation = this.normalizeEventReservation(eventDoc);
+            eventDoc.reservation.resolutionStatus = reservation.resolutionStatus;
+            eventDoc.reservation.resolutionNote = reservation.resolutionNote;
+            eventDoc.reservation.conflictSummary = reservation.conflictSummary;
+            eventDoc.reservation.conflictType = reservation.conflictType;
+            eventDoc.reservation.conflictSource = reservation.conflictSource;
+            eventDoc.reservation.detectedAt = reservation.detectedAt;
+            eventDoc.reservation.assignedTo = reservation.assignedTo;
+            return;
+        }
+        reservation.resolutionStatus = 'acknowledged';
+        reservation.resolutionNote = note || reservation.resolutionNote || '';
+        reservation.assignedTo = assignedTo || reservation.assignedTo || null;
+        eventDoc.reservation = reservation;
+        this.appendHistoryEntry(eventDoc, 'exception_acknowledged', actorId, note, { assignedTo: reservation.assignedTo });
+    }
+ 
+    async listUnresolvedConflicts({ orgId = null, limit = 50 } = {}) {
+        const { Event } = this.models;
+        const query = {
+            isDeleted: false,
+            'reservation.conflictSummary.hasConflict': true,
+            'reservation.resolutionStatus': { $ne: 'resolved' }
+        };
+        if (orgId) {
+            query.$or = [{ hostingId: orgId }, { 'collaboratorOrgs.orgId': orgId }];
+        }
+        return Event.find(query)
+            .select('_id name start_time end_time status location hostingId reservation')
+            .sort({ 'reservation.detectedAt': -1, updatedAt: -1 })
+            .limit(Math.max(1, Math.min(Number(limit) || 50, 250)));
+    }
+ 
+    async checkAvailability({ startTime, endTime, resourceId, excludeEventId = null }) {
+        const { Event, Schedule } = this.models;
+        if (!resourceId) {
+            return { isAvailable: true, reason: 'No reservable resource linked' };
+        }
+        const start = ResourceReservationService.parseDate(startTime);
+        const end = ResourceReservationService.parseDate(endTime);
+        Iif (!start || !end || start >= end) {
+            return { isAvailable: false, reason: 'Invalid time range' };
+        }
+ 
+        const dayIndex = start.getDay(); // sunday=0
+        const dayKey = ['U', 'M', 'T', 'W', 'R', 'F', 'S'][dayIndex];
+        Eif (dayKey && dayKey !== 'U' && dayKey !== 'S') {
+            const schedule = await Schedule.findOne({ classroom_id: resourceId });
+            Iif (schedule?.weekly_schedule?.[dayKey]?.length) {
+                const startMinutes = start.getHours() * 60 + start.getMinutes();
+                const endMinutes = end.getHours() * 60 + end.getMinutes();
+                const hasClassConflict = schedule.weekly_schedule[dayKey].some((slot) =>
+                    startMinutes < slot.end_time && endMinutes > slot.start_time
+                );
+                if (hasClassConflict) {
+                    return { isAvailable: false, reason: 'Resource has scheduled classes during this time', conflicts: [] };
+                }
+            }
+        }
+ 
+        const eventQuery = {
+            isDeleted: false,
+            status: { $in: ACTIVE_EVENT_STATUSES },
+            $and: [
+                { $or: [{ 'reservation.resourceId': resourceId }, { classroom_id: resourceId }] },
+                {
+                    $or: [
+                        { start_time: { $gte: start, $lt: end } },
+                        { end_time: { $gt: start, $lte: end } },
+                        { start_time: { $lte: start }, end_time: { $gte: end } }
+                    ]
+                }
+            ]
+        };
+        Iif (excludeEventId) eventQuery._id = { $ne: excludeEventId };
+        const conflicts = await Event.find(eventQuery).select('_id name start_time end_time status location classroom_id reservation');
+        Eif (conflicts.length > 0) {
+            return { isAvailable: false, reason: 'Resource has existing event bookings during this time', conflicts };
+        }
+        return { isAvailable: true };
+    }
+ 
+    async applyAvailabilitySnapshot(eventDoc, { startTime, endTime, resourceId, excludeEventId = null }) {
+        const availability = await this.checkAvailability({
+            startTime,
+            endTime,
+            resourceId,
+            excludeEventId
+        });
+        const reservation = this.normalizeEventReservation(eventDoc);
+        reservation.resourceId = resourceId || null;
+        reservation.lastCheckedAt = new Date();
+        reservation.conflictSummary = {
+            hasConflict: !availability.isAvailable,
+            reason: availability.reason || ''
+        };
+        if (!availability.isAvailable) {
+            const meta = ResourceReservationService.inferConflictMeta(availability);
+            reservation.conflictType = meta.conflictType;
+            reservation.conflictSource = meta.conflictSource;
+            reservation.detectedAt = new Date();
+            reservation.resolutionStatus = 'unresolved';
+        } else E{
+            reservation.conflictType = '';
+            reservation.conflictSource = '';
+            reservation.detectedAt = null;
+            reservation.resolutionStatus = 'resolved';
+            reservation.resolutionNote = '';
+        }
+        Eif (!availability.isAvailable && reservation.state === 'approved') {
+            reservation.state = 'requested';
+        }
+        eventDoc.reservation = reservation;
+        Iif (resourceId && !eventDoc.classroom_id) {
+            eventDoc.classroom_id = resourceId;
+        }
+        return availability;
+    }
+}
+ 
+module.exports = ResourceReservationService;
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/rootOperatorUsersService.js.html b/backend/coverage/lcov-report/services/rootOperatorUsersService.js.html new file mode 100644 index 00000000..c3d6c4e5 --- /dev/null +++ b/backend/coverage/lcov-report/services/rootOperatorUsersService.js.html @@ -0,0 +1,640 @@ + + + + + + Code coverage report for services/rootOperatorUsersService.js + + + + + + + + + +
+
+

All files / services rootOperatorUsersService.js

+
+ +
+ 48.88% + Statements + 44/90 +
+ + +
+ 44.92% + Branches + 31/69 +
+ + +
+ 75% + Functions + 6/8 +
+ + +
+ 49.41% + Lines + 42/85 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186  +  +  +  +  +1x +  +  +1x +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +1x +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +1x +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +1x +1x +  +  +  +  +  +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +1x +1x +  +1x +1x +  +1x +  +  +  +  +  +  +  +1x +1x +1x +  +1x +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  + 
/**
+ * Root / community dashboard: search users and manage operator roles + suspension.
+ * Uses getModels(req, 'User') only.
+ */
+ 
+const getModels = require('./getModelService');
+ 
+/** Roles this panel may grant or revoke (end-user operators: admin only). */
+const MANAGEABLE_ROLES = ['admin'];
+ 
+function escapeRegex(str) {
+    return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+ 
+function actorUserId(req) {
+    return String(req.user?.userId || req.user?.tenantUserId || '');
+}
+ 
+/**
+ * Snapshot counts for the People & access dashboard header.
+ * @param {import('express').Request} req
+ */
+async function getRootOperatorUserStats(req) {
+    const { User } = getModels(req, 'User');
+    const [totalUsers, adminCount] = await Promise.all([
+        User.countDocuments({}),
+        User.countDocuments({ roles: 'admin' }),
+    ]);
+    const memberCount = Math.max(0, totalUsers - adminCount);
+    return {
+        totalUsers,
+        adminCount,
+        memberCount,
+    };
+}
+ 
+/**
+ * @param {import('express').Request} req
+ * @param {{ q?: string, limit?: number, role?: string }} opts
+ */
+async function searchRootOperatorUsers(req, opts = {}) {
+    const { User } = getModels(req, 'User');
+    const rawQ = typeof opts.q === 'string' ? opts.q.trim() : '';
+    const limit = Math.min(50, Math.max(1, parseInt(String(opts.limit || 25), 10) || 25));
+    const roleFilter =
+        typeof opts.role === 'string' && opts.role && MANAGEABLE_ROLES.includes(opts.role) ? opts.role : '';
+ 
+    Eif (rawQ.length < 2) {
+        return { users: [], total: 0 };
+    }
+ 
+    const escaped = escapeRegex(rawQ);
+    const searchQuery = {
+        $or: [
+            { username: { $regex: escaped, $options: 'i' } },
+            { name: { $regex: escaped, $options: 'i' } },
+            { email: { $regex: escaped, $options: 'i' } },
+        ],
+    };
+    if (roleFilter) {
+        searchQuery.roles = roleFilter;
+    }
+ 
+    const [users, total] = await Promise.all([
+        User.find(searchQuery)
+            .sort({ username: 1 })
+            .limit(limit)
+            .select('username name email picture roles accessSuspended accessSuspendedAt createdAt')
+            .lean(),
+        User.countDocuments(searchQuery),
+    ]);
+ 
+    return { users, total };
+}
+ 
+/**
+ * @param {import('express').Request} req
+ * @param {{ userId: string, role: string, assign: boolean }} opts
+ */
+async function setRootOperatorUserRole(req, opts) {
+    const actorId = actorUserId(req);
+    const targetId = String(opts.userId || '');
+    const role = opts.role;
+    const assign = opts.assign === true;
+ 
+    Iif (!actorId || !targetId || !MANAGEABLE_ROLES.includes(role)) {
+        const e = new Error('Invalid request');
+        e.statusCode = 400;
+        throw e;
+    }
+ 
+    const { User } = getModels(req, 'User');
+    const [actor, target] = await Promise.all([User.findById(actorId), User.findById(targetId)]);
+    Iif (!actor || !target) {
+        const e = new Error('User not found');
+        e.statusCode = 404;
+        throw e;
+    }
+ 
+    const actorRoles = new Set(actor.roles || []);
+    Iif (!['admin', 'developer', 'beta'].some((r) => actorRoles.has(r))) {
+        const e = new Error('Forbidden');
+        e.statusCode = 403;
+        throw e;
+    }
+ 
+    Iif (!actorRoles.has('admin')) {
+        const e = new Error('Only admins can change admin access');
+        e.statusCode = 403;
+        throw e;
+    }
+ 
+    Iif (!assign && role === 'admin' && (target.roles || []).includes('admin')) {
+        const adminCount = await User.countDocuments({ roles: 'admin' });
+        if (adminCount <= 1) {
+            const e = new Error('Cannot remove the last admin');
+            e.statusCode = 400;
+            throw e;
+        }
+    }
+ 
+    let nextRoles = [...(target.roles || [])];
+    if (assign) {
+        Eif (!nextRoles.includes(role)) nextRoles.push(role);
+    } else E{
+        nextRoles = nextRoles.filter((r) => r !== role);
+    }
+    Iif (nextRoles.length === 0) nextRoles = ['user'];
+    else Iif (!nextRoles.includes('user')) nextRoles.push('user');
+ 
+    target.roles = nextRoles;
+    await target.save();
+ 
+    return { roles: target.roles };
+}
+ 
+/**
+ * @param {import('express').Request} req
+ * @param {{ userId: string, accessSuspended: boolean }} opts
+ */
+async function setRootOperatorAccessSuspended(req, opts) {
+    const actorId = actorUserId(req);
+    const targetId = String(opts.userId || '');
+    const suspended = Boolean(opts.accessSuspended);
+ 
+    Iif (!actorId || !targetId) {
+        const e = new Error('Invalid request');
+        e.statusCode = 400;
+        throw e;
+    }
+    Eif (actorId === targetId) {
+        const e = new Error('You cannot change suspension on your own account');
+        e.statusCode = 400;
+        throw e;
+    }
+ 
+    const { User } = getModels(req, 'User');
+    const actor = await User.findById(actorId);
+    if (!actor?.roles?.includes('admin')) {
+        const e = new Error('Only admins can suspend or restore accounts');
+        e.statusCode = 403;
+        throw e;
+    }
+ 
+    const target = await User.findById(targetId);
+    if (!target) {
+        const e = new Error('User not found');
+        e.statusCode = 404;
+        throw e;
+    }
+ 
+    target.accessSuspended = suspended;
+    target.accessSuspendedAt = suspended ? new Date() : null;
+    await target.save();
+ 
+    return { accessSuspended: target.accessSuspended, accessSuspendedAt: target.accessSuspendedAt };
+}
+ 
+module.exports = {
+    getRootOperatorUserStats,
+    searchRootOperatorUsers,
+    setRootOperatorUserRole,
+    setRootOperatorAccessSuspended,
+    MANAGEABLE_ROLES,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/tenantConfigService.js.html b/backend/coverage/lcov-report/services/tenantConfigService.js.html new file mode 100644 index 00000000..2cd97e7e --- /dev/null +++ b/backend/coverage/lcov-report/services/tenantConfigService.js.html @@ -0,0 +1,1294 @@ + + + + + + Code coverage report for services/tenantConfigService.js + + + + + + + + + +
+
+

All files / services tenantConfigService.js

+
+ +
+ 6.45% + Statements + 10/155 +
+ + +
+ 1.25% + Branches + 2/160 +
+ + +
+ 0% + Functions + 0/27 +
+ + +
+ 7.14% + Lines + 10/140 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +4045x +5x +5x +  +  +  +  +  +  +  +  +  +5x +5x +  +  +  +  +5x +  +5x +5x +  +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const { randomBytes } = require('crypto');
+const bcrypt = require('bcrypt');
+const getGlobalModels = require('./getGlobalModelService');
+const {
+  DEFAULT_TENANTS,
+  normalizeTenantRow,
+  normalizeTenantRows,
+  normalizeTenantOverrides,
+  mergeSparseTenantOverrides,
+  mergeTenantRows,
+  normalizePivotDropFields,
+  normalizePivotDropOverrides,
+} = require('../constants/defaultTenants');
+const { PIVOT_DROP_PILOT_DEFAULTS } = require('../utilities/pivotDropSchedule');
+const {
+  connectToDatabase,
+  setTenantUriCache,
+  deriveMongoUriForTenant,
+} = require('../connectionsManager');
+ 
+const CONFIG_KEY = 'default';
+const BASE_DOMAIN = process.env.MERIDIAN_BASE_DOMAIN || 'meridian.study';
+ 
+const MANUAL_STEP_IDS = {
+  DNS: 'dns',
+  MONGO_ENV: 'mongo_env',
+  PIVOT_CATALOG: 'pivot_catalog',
+  VERIFY_PICKER: 'verify_picker',
+};
+ 
+function buildManualSteps(tenant, context = {}) {
+  const subdomain = tenant.subdomain || tenant.tenantKey;
+  const isPivot = tenant.pivotPilot === true || tenant.tenantType === 'pivot';
+  const envVarName = `MONGO_URI_${tenant.tenantKey.toUpperCase()}`;
+  const confirmations = tenant.provisioningConfirmations || {};
+ 
+  return [
+    {
+      id: MANUAL_STEP_IDS.DNS,
+      title: 'DNS subdomain',
+      description: `Point ${subdomain}.${BASE_DOMAIN} to your app load balancer / hosting (CNAME or A record).`,
+      automated: false,
+      completed: confirmations.dns === true,
+      command: `CNAME ${subdomain}.${BASE_DOMAIN} → <your-app-host>`,
+    },
+    {
+      id: MANUAL_STEP_IDS.MONGO_ENV,
+      title: 'Optional deploy env var',
+      description: `For deployments that prefer env-based DB routing, set ${envVarName}. Dynamic mongoUri in TenantConfig takes precedence when env is unset.`,
+      automated: false,
+      completed: Boolean(process.env[envVarName]) || Boolean(tenant.mongoUri),
+      command: `${envVarName}=mongodb://.../${tenant.mongoDatabaseName || tenant.tenantKey}`,
+    },
+    {
+      id: MANUAL_STEP_IDS.PIVOT_CATALOG,
+      title: 'Pivot Catalog org',
+      description: isPivot
+        ? 'Internal org used as technical host for imported Pivot events. Hidden from org discovery.'
+        : 'Not required for campus tenants.',
+      automated: isPivot,
+      completed: !isPivot || Boolean(tenant.pivotCatalogOrgId),
+      optional: !isPivot,
+      orgId: tenant.pivotCatalogOrgId || null,
+    },
+    {
+      id: MANUAL_STEP_IDS.VERIFY_PICKER,
+      title: 'Verify school picker',
+      description:
+        'Confirm the tenant appears correctly on /select-school (status can still be coming soon while you verify). Activate the subdomain when you are ready to go live.',
+      automated: false,
+      completed: confirmations.pickerVerified === true,
+      requiresActiveStatus: tenant.status !== 'active',
+    },
+  ];
+}
+ 
+function toStoredTenantRow(tenant) {
+  const isDefault = DEFAULT_TENANTS.some((base) => base.tenantKey === tenant.tenantKey);
+  const payload = {
+    tenantKey: tenant.tenantKey,
+    name: tenant.name,
+    subdomain: tenant.subdomain,
+    location: tenant.location,
+    status: tenant.status,
+    statusMessage: tenant.statusMessage,
+    tenantType: tenant.tenantType,
+    pivotPilot: tenant.pivotPilot,
+    mongoUri: tenant.mongoUri,
+    mongoDatabaseName: tenant.mongoDatabaseName,
+    pivotCatalogOrgId: tenant.pivotCatalogOrgId,
+    pivotDropTimezone: tenant.pivotDropTimezone,
+    pivotDropDayOfWeek: tenant.pivotDropDayOfWeek,
+    pivotDropHour: tenant.pivotDropHour,
+    pivotDropMinute: tenant.pivotDropMinute,
+    pivotDropOverrides: tenant.pivotDropOverrides,
+    provisioningConfirmations: tenant.provisioningConfirmations,
+  };
+  if (isDefault) {
+    const base = DEFAULT_TENANTS.find((row) => row.tenantKey === tenant.tenantKey);
+    const defaultConfirmations = { dns: false, cors: false, pickerVerified: false };
+    const override = { tenantKey: tenant.tenantKey };
+    Object.keys(payload).forEach((key) => {
+      if (key === 'tenantKey') return;
+      if (key === 'provisioningConfirmations') {
+        const payloadPc = payload.provisioningConfirmations || defaultConfirmations;
+        if (JSON.stringify(payloadPc) !== JSON.stringify(defaultConfirmations)) {
+          override.provisioningConfirmations = payload.provisioningConfirmations;
+        }
+        return;
+      }
+      if (JSON.stringify(payload[key]) !== JSON.stringify(base?.[key])) {
+        override[key] = payload[key];
+      }
+    });
+    return Object.keys(override).length > 1 ? override : null;
+  }
+  return payload;
+}
+ 
+async function getStoredTenantRows(req) {
+  const doc = await loadTenantConfigDoc(req);
+  return doc?.tenants || [];
+}
+ 
+async function upsertStoredTenantRow(req, tenant, updatedBy = null) {
+  const delta = toStoredTenantRow(tenant);
+  const stored = await getStoredTenantRows(req);
+  const existingStored = stored.find((row) => row.tenantKey === tenant.tenantKey);
+  const without = stored.filter((row) => row.tenantKey !== tenant.tenantKey);
+ 
+  let nextRow = null;
+  if (delta && existingStored) {
+    nextRow = mergeSparseTenantOverrides(existingStored, delta);
+  } else {
+    nextRow = delta;
+  }
+ 
+  const next = nextRow ? [...without, nextRow] : without;
+  await saveTenantRows(req, next, updatedBy);
+  return getTenantByKey(req, tenant.tenantKey);
+}
+ 
+async function loadTenantConfigDoc(req) {
+  const { TenantConfig } = getGlobalModels(req, 'TenantConfig');
+  return TenantConfig.findOne({ configKey: CONFIG_KEY }).lean();
+}
+ 
+async function getMergedTenants(req) {
+  const doc = await loadTenantConfigDoc(req);
+  return mergeTenantRows(DEFAULT_TENANTS, doc?.tenants || []);
+}
+ 
+async function getTenantByKey(req, tenantKeyOrSubdomain, options = {}) {
+  const key = String(tenantKeyOrSubdomain || '').trim().toLowerCase();
+  if (!key) return null;
+  const tenants = await getMergedTenants(req);
+  if (options.exact) {
+    return tenants.find((row) => row.tenantKey === key) || null;
+  }
+  return (
+    tenants.find((row) => row.tenantKey === key) ||
+    tenants.find((row) => (row.subdomain || row.tenantKey) === key) ||
+    null
+  );
+}
+ 
+async function syncTenantUriCache(req) {
+  const tenants = await getMergedTenants(req);
+  const cache = {};
+  tenants.forEach((tenant) => {
+    const uri = deriveMongoUriForTenant(tenant.tenantKey, tenant);
+    if (!uri) return;
+    cache[tenant.tenantKey] = uri;
+    const subdomain = String(tenant.subdomain || tenant.tenantKey).trim().toLowerCase();
+    if (subdomain && subdomain !== tenant.tenantKey) {
+      cache[subdomain] = uri;
+    }
+  });
+  setTenantUriCache(cache);
+  return cache;
+}
+ 
+async function saveTenantRows(req, rows, updatedBy = null) {
+  const { TenantConfig } = getGlobalModels(req, 'TenantConfig');
+  const normalized = normalizeTenantOverrides(rows);
+  const doc = await TenantConfig.findOneAndUpdate(
+    { configKey: CONFIG_KEY },
+    { $set: { tenants: normalized, updatedBy } },
+    { new: true, upsert: true, setDefaultsOnInsert: true }
+  ).lean();
+  await syncTenantUriCache(req);
+  return mergeTenantRows(DEFAULT_TENANTS, doc?.tenants || []);
+}
+ 
+function isMongoPingOk(pingResult) {
+  if (!pingResult || typeof pingResult !== 'object') return false;
+  return pingResult.ok === 1 || pingResult.ok === true;
+}
+ 
+async function pingTenantDatabase(tenantKey, tenantRow = null) {
+  const started = performance.now();
+  try {
+    const db = await connectToDatabase(tenantKey);
+    const ping = await db.db.admin().ping();
+    const latencyMs = Number((performance.now() - started).toFixed(2));
+    return {
+      ok: isMongoPingOk(ping),
+      latencyMs,
+      databaseName: db.name,
+    };
+  } catch (error) {
+    return {
+      ok: false,
+      latencyMs: null,
+      error: error.message,
+      databaseName: tenantRow?.mongoDatabaseName || tenantKey,
+    };
+  }
+}
+ 
+async function ensurePivotSystemUser(reqLike) {
+  const getModels = require('./getModelService');
+  const { User } = getModels(reqLike, 'User');
+  const email = `pivot-catalog@${reqLike.school}.internal`;
+  let user = await User.findOne({ email });
+  if (user) return user;
+ 
+  const randomPassword = randomBytes(32).toString('hex');
+  const hashed = await bcrypt.hash(randomPassword, 10);
+  user = await User.create({
+    email,
+    name: 'Pivot Catalog System',
+    username: `pivot_cat_${reqLike.school}`.slice(0, 24),
+    password: hashed,
+    roles: ['admin'],
+    onboarded: true,
+  });
+  return user;
+}
+ 
+async function provisionPivotCatalogOrg(req, tenantKey, tenantRow) {
+  const getModels = require('./getModelService');
+  const db = await connectToDatabase(tenantKey);
+  const reqLike = { db, school: tenantKey };
+  const { Org, OrgMember } = getModels(reqLike, 'Org', 'OrgMember', 'User');
+  const owner = await ensurePivotSystemUser(reqLike);
+ 
+  const catalogName = `Pivot Catalog — ${tenantRow.location || tenantRow.name || tenantKey}`;
+  if (tenantRow.pivotCatalogOrgId) {
+    const existing = await Org.findById(tenantRow.pivotCatalogOrgId);
+    if (existing && existing.isDeleted !== true) {
+      return { orgId: String(existing._id), orgName: existing.org_name, created: false };
+    }
+  }
+ 
+  let org = await Org.findOne({ org_name: catalogName, isDeleted: { $ne: true } });
+  let created = false;
+  if (!org) {
+    org = await Org.create({
+      org_name: catalogName,
+      org_description: 'Internal technical host for Pivot catalog events. Not shown in Pivot consumer UI.',
+      org_profile_image: '/Logo.svg',
+      owner: owner._id,
+      unlisted: true,
+      positions: [
+        {
+          name: 'owner',
+          displayName: 'Owner',
+          permissions: ['all'],
+          isDefault: false,
+          canManageMembers: true,
+          canManageRoles: true,
+          canManageEvents: true,
+          canViewAnalytics: true,
+          order: 0,
+          color: '#dc2626',
+        },
+        {
+          name: 'member',
+          displayName: 'Member',
+          permissions: ['view_events'],
+          isDefault: true,
+          canManageMembers: false,
+          canManageRoles: false,
+          canManageEvents: false,
+          canViewAnalytics: false,
+          order: 1,
+          color: '#6b7280',
+        },
+      ],
+    });
+    created = true;
+  }
+ 
+  const membership = await OrgMember.findOne({ org_id: org._id, user_id: owner._id });
+  if (!membership) {
+    await OrgMember.create({
+      org_id: org._id,
+      user_id: owner._id,
+      role: 'owner',
+      roles: ['owner'],
+      status: 'active',
+    });
+  }
+ 
+  return { orgId: String(org._id), orgName: org.org_name, created };
+}
+ 
+function serializeTenantForAdmin(tenant, extras = {}) {
+  const { health, manualStepContext, ...rest } = extras;
+  const manualSteps = buildManualSteps(tenant, manualStepContext || {});
+  return {
+    ...tenant,
+    mongoUriConfigured: Boolean(tenant.mongoUri || process.env[`MONGO_URI_${tenant.tenantKey.toUpperCase()}`]),
+    subdomainUrl: `https://${tenant.subdomain || tenant.tenantKey}.${BASE_DOMAIN}`,
+    health: health || null,
+    manualSteps,
+    provisioningComplete: manualSteps.filter((step) => !step.optional).every((step) => step.completed),
+    ...rest,
+  };
+}
+ 
+function validateTenantMetadataUpdate(body = {}) {
+  if (body.name !== undefined && !String(body.name).trim()) {
+    return { error: 'name cannot be empty.' };
+  }
+  if (body.location !== undefined && !String(body.location).trim()) {
+    return { error: 'location cannot be empty.' };
+  }
+  if (body.subdomain !== undefined) {
+    const subdomain = String(body.subdomain).trim().toLowerCase();
+    if (subdomain === 'www') return { error: 'subdomain "www" is reserved.' };
+    if (!/^[a-z][a-z0-9_-]{0,31}$/.test(subdomain)) {
+      return { error: 'subdomain must start with a letter and use lowercase alphanumeric, underscore, or hyphen.' };
+    }
+  }
+  if (body.tenantType !== undefined && !['campus', 'pivot'].includes(body.tenantType)) {
+    return { error: 'tenantType must be campus or pivot.' };
+  }
+ 
+  if (body.pivotDropTimezone !== undefined && !String(body.pivotDropTimezone).trim()) {
+    return { error: 'pivotDropTimezone cannot be empty.' };
+  }
+ 
+  return { ok: true };
+}
+ 
+function validateNewTenantPayload(body = {}) {
+  const tenantKey = String(body.tenantKey || '').trim().toLowerCase();
+  if (!/^[a-z][a-z0-9_-]{1,31}$/.test(tenantKey)) {
+    return { error: 'tenantKey must be 2–32 chars, start with a letter, lowercase alphanumeric/underscore/hyphen.' };
+  }
+  if (tenantKey === 'www') return { error: 'tenantKey "www" is reserved.' };
+  if (!String(body.name || '').trim()) return { error: 'name is required.' };
+  if (!String(body.location || '').trim()) return { error: 'location is required (city display name).' };
+ 
+  const tenantType = body.tenantType === 'pivot' ? 'pivot' : 'campus';
+  const mongoDatabaseName = String(body.mongoDatabaseName || tenantKey).trim().toLowerCase();
+  const mongoUri = String(body.mongoUri || '').trim() || deriveMongoUriForTenant(tenantKey, { mongoDatabaseName });
+ 
+  if (!mongoUri) {
+    return { error: 'Could not derive mongoUri. Provide mongoUri or set DEFAULT_MONGO_URI / MONGO_URI_RPI.' };
+  }
+ 
+  return {
+    row: normalizeTenantRow({
+      tenantKey,
+      name: body.name,
+      subdomain: body.subdomain || tenantKey,
+      location: body.location,
+      status: body.status || 'coming_soon',
+      statusMessage: body.statusMessage || '',
+      tenantType,
+      pivotPilot: tenantType === 'pivot' || body.pivotPilot === true,
+      mongoUri,
+      mongoDatabaseName,
+      ...(tenantType === 'pivot' ? { ...PIVOT_DROP_PILOT_DEFAULTS } : {}),
+    }),
+  };
+}
+ 
+module.exports = {
+  CONFIG_KEY,
+  BASE_DOMAIN,
+  MANUAL_STEP_IDS,
+  buildManualSteps,
+  loadTenantConfigDoc,
+  getMergedTenants,
+  getTenantByKey,
+  syncTenantUriCache,
+  saveTenantRows,
+  pingTenantDatabase,
+  provisionPivotCatalogOrg,
+  serializeTenantForAdmin,
+  validateNewTenantPayload,
+  validateTenantMetadataUpdate,
+  upsertStoredTenantRow,
+  toStoredTenantRow,
+  getStoredTenantRows,
+  DEFAULT_TENANTS,
+  mergeTenantRows,
+  normalizeTenantRows,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/services/tenantKeyRenameService.js.html b/backend/coverage/lcov-report/services/tenantKeyRenameService.js.html new file mode 100644 index 00000000..b98144a5 --- /dev/null +++ b/backend/coverage/lcov-report/services/tenantKeyRenameService.js.html @@ -0,0 +1,652 @@ + + + + + + Code coverage report for services/tenantKeyRenameService.js + + + + + + + + + +
+
+

All files / services tenantKeyRenameService.js

+
+ +
+ 85.93% + Statements + 55/64 +
+ + +
+ 58.97% + Branches + 23/39 +
+ + +
+ 100% + Functions + 11/11 +
+ + +
+ 85% + Lines + 51/60 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +1901x +1x +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +1x +  +2x +1x +  +  +4x +4x +  +  +  +  +  +4x +1x +  +3x +  +  +  +4x +  +  +  +1x +1x +  +  +  +  +1x +  +  +  +1x +  +1x +1x +  +  +  +  +  +  +1x +1x +  +1x +  +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +4x +  +2x +  +  +  +  +  +  +  +2x +2x +2x +  +  +2x +  +2x +  +  +  +2x +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +1x +  +1x +  +  +  +  +  +  +1x +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +1x +  +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  + 
const getGlobalModels = require('./getGlobalModelService');
+const getModels = require('./getModelService');
+const {
+  DEFAULT_TENANTS,
+  normalizeTenantRow,
+} = require('../constants/defaultTenants');
+const {
+  getTenantByKey,
+  getMergedTenants,
+  getStoredTenantRows,
+  saveTenantRows,
+  syncTenantUriCache,
+  toStoredTenantRow,
+} = require('./tenantConfigService');
+const {
+  connectToDatabase,
+  invalidateTenantConnection,
+} = require('../connectionsManager');
+ 
+const DEFAULT_TENANT_KEYS = new Set(DEFAULT_TENANTS.map((row) => row.tenantKey));
+const RESERVED_TENANT_KEYS = new Set([...DEFAULT_TENANT_KEYS, 'www']);
+ 
+function validateTenantKeyFormat(tenantKey) {
+  const key = String(tenantKey || '').trim().toLowerCase();
+  Iif (!/^[a-z][a-z0-9_-]{1,31}$/.test(key)) {
+    return {
+      error:
+        'tenantKey must be 2–32 chars, start with a letter, lowercase alphanumeric/underscore/hyphen.',
+    };
+  }
+  if (key === 'www') {
+    return { error: 'tenantKey "www" is reserved.' };
+  }
+  return { ok: true, tenantKey: key };
+}
+ 
+function isDefaultTenantKey(tenantKey) {
+  return DEFAULT_TENANT_KEYS.has(String(tenantKey || '').trim().toLowerCase());
+}
+ 
+async function updatePivotWeeklySnapshotTenantKeys(req, oldTenantKey, newTenantKey) {
+  const { PivotWeeklySnapshot } = getGlobalModels(req, 'PivotWeeklySnapshot');
+  const result = await PivotWeeklySnapshot.updateMany(
+    { 'tenants.tenantKey': oldTenantKey },
+    { $set: { 'tenants.$[elem].tenantKey': newTenantKey } },
+    { arrayFilters: [{ 'elem.tenantKey': oldTenantKey }] },
+  );
+  return result.modifiedCount || 0;
+}
+ 
+async function updateTenantDbSchoolFields(tenant, oldTenantKey, newTenantKey) {
+  const dbRouteKey = tenant.subdomain || tenant.tenantKey;
+  let db;
+  try {
+    db = await connectToDatabase(dbRouteKey);
+  } catch (error) {
+    return {
+      error: `Could not connect to tenant database to update school fields: ${error.message}`,
+    };
+  }
+ 
+  const reqLike = { db, school: oldTenantKey };
+  const { SAMLConfig, ShuttleConfig } = getModels(reqLike, 'SAMLConfig', 'ShuttleConfig');
+ 
+  const [samlResult, shuttleResult] = await Promise.all([
+    SAMLConfig.updateMany({ school: oldTenantKey }, { $set: { school: newTenantKey } }),
+    ShuttleConfig.updateMany({ school: oldTenantKey }, { $set: { school: newTenantKey } }),
+  ]);
+ 
+  return {
+    samlConfigsUpdated: samlResult.modifiedCount || 0,
+    shuttleConfigsUpdated: shuttleResult.modifiedCount || 0,
+  };
+}
+ 
+function invalidateTenantConnections(tenant, oldTenantKey, newTenantKey) {
+  const keys = new Set(
+    [
+      oldTenantKey,
+      newTenantKey,
+      tenant?.subdomain,
+      tenant?.tenantKey,
+    ]
+      .filter(Boolean)
+      .map((value) => String(value).trim().toLowerCase()),
+  );
+  keys.forEach((key) => invalidateTenantConnection(key));
+}
+ 
+/**
+ * Rename a dynamically provisioned tenant and cascade tenantKey updates to global
+ * and tenant-scoped documents that reference the old key.
+ */
+async function renameTenantKey(req, oldTenantKey, newTenantKey, updatedBy = null) {
+  const oldKey = String(oldTenantKey || '').trim().toLowerCase();
+  const formatValidation = validateTenantKeyFormat(newTenantKey);
+  Iif (formatValidation.error) {
+    return { error: formatValidation.error, status: 400 };
+  }
+  const newKey = formatValidation.tenantKey;
+ 
+  Iif (oldKey === newKey) {
+    return { ok: true, tenantKey: newKey, renamed: false };
+  }
+ 
+  if (isDefaultTenantKey(oldKey)) {
+    return {
+      error: 'Built-in tenants (rpi, tvcog) cannot be renamed.',
+      status: 403,
+      code: 'DEFAULT_TENANT_IMMUTABLE',
+    };
+  }
+ 
+  Iif (RESERVED_TENANT_KEYS.has(newKey)) {
+    return {
+      error: `Tenant key "${newKey}" is reserved.`,
+      status: 400,
+      code: 'TENANT_KEY_RESERVED',
+    };
+  }
+ 
+  const existing = await getTenantByKey(req, oldKey, { exact: true });
+  Iif (!existing) {
+    return { error: 'Tenant not found.', status: 404, code: 'TENANT_NOT_FOUND' };
+  }
+ 
+  const tenants = await getMergedTenants(req);
+  Iif (tenants.some((row) => row.tenantKey === newKey)) {
+    return {
+      error: `Tenant "${newKey}" already exists.`,
+      status: 409,
+      code: 'TENANT_EXISTS',
+    };
+  }
+ 
+  const {
+    TenantMembership,
+    PivotReferralCode,
+  } = getGlobalModels(req, 'TenantMembership', 'PivotReferralCode');
+ 
+  const [membershipResult, referralResult, snapshotsUpdated, tenantDbResult] = await Promise.all([
+    TenantMembership.updateMany({ tenantKey: oldKey }, { $set: { tenantKey: newKey } }),
+    PivotReferralCode.updateMany({ tenantKey: oldKey }, { $set: { tenantKey: newKey } }),
+    updatePivotWeeklySnapshotTenantKeys(req, oldKey, newKey),
+    updateTenantDbSchoolFields(existing, oldKey, newKey),
+  ]);
+ 
+  Iif (tenantDbResult?.error) {
+    return { error: tenantDbResult.error, status: 400, code: 'TENANT_DB_UPDATE_FAILED' };
+  }
+ 
+  const renamedTenant = normalizeTenantRow({ ...existing, tenantKey: newKey });
+  const stored = await getStoredTenantRows(req);
+  const withoutOld = stored.filter((row) => row.tenantKey !== oldKey);
+  const newStoredRow = toStoredTenantRow(renamedTenant);
+  Iif (!newStoredRow) {
+    return {
+      error: 'Unable to persist renamed tenant row.',
+      status: 500,
+      code: 'TENANT_CONFIG_PERSIST_FAILED',
+    };
+  }
+  await saveTenantRows(req, [...withoutOld, newStoredRow], updatedBy);
+ 
+  invalidateTenantConnections(existing, oldKey, newKey);
+  await syncTenantUriCache(req);
+ 
+  return {
+    ok: true,
+    renamed: true,
+    tenantKey: newKey,
+    previousTenantKey: oldKey,
+    updates: {
+      tenantMemberships: membershipResult.modifiedCount || 0,
+      pivotReferralCodes: referralResult.modifiedCount || 0,
+      pivotWeeklySnapshots: snapshotsUpdated,
+      samlConfigs: tenantDbResult.samlConfigsUpdated,
+      shuttleConfigs: tenantDbResult.shuttleConfigsUpdated,
+    },
+  };
+}
+ 
+module.exports = {
+  DEFAULT_TENANT_KEYS,
+  RESERVED_TENANT_KEYS,
+  validateTenantKeyFormat,
+  isDefaultTenantKey,
+  renameTenantKey,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/sort-arrow-sprite.png b/backend/coverage/lcov-report/sort-arrow-sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed68316eb3f65dec9063332d2f69bf3093bbfab GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc literal 0 HcmV?d00001 diff --git a/backend/coverage/lcov-report/sorter.js b/backend/coverage/lcov-report/sorter.js new file mode 100644 index 00000000..4ed70ae5 --- /dev/null +++ b/backend/coverage/lcov-report/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/backend/coverage/lcov-report/utilities/cookieUtils.js.html b/backend/coverage/lcov-report/utilities/cookieUtils.js.html new file mode 100644 index 00000000..55de6422 --- /dev/null +++ b/backend/coverage/lcov-report/utilities/cookieUtils.js.html @@ -0,0 +1,136 @@ + + + + + + Code coverage report for utilities/cookieUtils.js + + + + + + + + + +
+
+

All files / utilities cookieUtils.js

+
+ +
+ 27.27% + Statements + 3/11 +
+ + +
+ 5.55% + Branches + 1/18 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 25% + Lines + 2/8 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +3x + 
/**
+ * Cookie domain helper for multi-tenant auth.
+ * In production, cookies must be scoped to the base domain so they work across subdomains
+ * (e.g. rpi.pinkpulse.org and www.pinkpulse.org share cookies when domain is .pinkpulse.org).
+ * Staging (pinkpulse.org) and production (meridian.study) both need correct domain.
+ */
+function getCookieDomain(req) {
+  Eif (process.env.NODE_ENV !== 'production') return undefined;
+  const host = (req && req.hostname) || (req && req.headers && req.headers.host && req.headers.host.split(':')[0]) || '';
+  if (!host || host === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(host)) return undefined;
+  const parts = host.split('.');
+  if (parts.length < 2) return undefined;
+  const base = parts.slice(-2).join('.');
+  return base ? `.${base}` : undefined;
+}
+ 
+module.exports = { getCookieDomain };
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/friendUtils.js.html b/backend/coverage/lcov-report/utilities/friendUtils.js.html new file mode 100644 index 00000000..3df6a54b --- /dev/null +++ b/backend/coverage/lcov-report/utilities/friendUtils.js.html @@ -0,0 +1,562 @@ + + + + + + Code coverage report for utilities/friendUtils.js + + + + + + + + + +
+
+

All files / utilities friendUtils.js

+
+ +
+ 12.82% + Statements + 5/39 +
+ + +
+ 0% + Branches + 0/17 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 12.82% + Lines + 5/39 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  + 
/**
+ * Friend Utilities
+ * Utilities for managing friend requests and friendship status
+ */
+ 
+/**
+ * Get all pending friend requests for a user (both sent and received)
+ * @param {Object} Friendship - Mongoose Friendship model
+ * @param {string|ObjectId} userId - User ID to fetch friend requests for
+ * @param {Object} options - Optional configuration
+ * @param {string|Array} options.receivedFields - Fields to populate for received requests (default: all)
+ * @param {string|Array} options.sentFields - Fields to populate for sent requests (default: all)
+ * @param {boolean} options.lean - Whether to return plain JavaScript objects (default: false)
+ * @returns {Promise<Object>} Object containing received and sent friend requests
+ */
+const getFriendRequests = async (Friendship, userId, options = {}) => {
+    const {
+        receivedFields = null, // null means populate all fields
+        sentFields = null,
+        lean = false
+    } = options;
+ 
+    try {
+        // Build query for received requests
+        let receivedQuery = Friendship.find({
+            recipient: userId,
+            status: 'pending'
+        });
+ 
+        // Build query for sent requests
+        let sentQuery = Friendship.find({
+            requester: userId,
+            status: 'pending'
+        });
+ 
+        // Apply population based on fields specified
+        if (receivedFields) {
+            receivedQuery = receivedQuery.populate('requester', receivedFields);
+        } else {
+            receivedQuery = receivedQuery.populate('requester');
+        }
+ 
+        if (sentFields) {
+            sentQuery = sentQuery.populate('recipient', sentFields);
+        } else {
+            sentQuery = sentQuery.populate('recipient');
+        }
+ 
+        // Apply lean if specified
+        if (lean) {
+            receivedQuery = receivedQuery.lean();
+            sentQuery = sentQuery.lean();
+        }
+ 
+        // Execute queries in parallel
+        const [receivedRequests, sentRequests] = await Promise.all([
+            receivedQuery.exec(),
+            sentQuery.exec()
+        ]);
+ 
+        return {
+            received: receivedRequests,
+            sent: sentRequests
+        };
+    } catch (error) {
+        throw new Error(`Error fetching friend requests: ${error.message}`);
+    }
+};
+ 
+/**
+ * Get friend request status for a specific user
+ * Checks if there's a pending request between two users
+ * @param {Object} Friendship - Mongoose Friendship model
+ * @param {string|ObjectId} userId1 - First user ID
+ * @param {string|ObjectId} userId2 - Second user ID
+ * @returns {Promise<Object|null>} Friendship object if exists, null otherwise
+ */
+const getFriendshipStatus = async (Friendship, userId1, userId2) => {
+    try {
+        const friendship = await Friendship.findOne({
+            $or: [
+                { requester: userId1, recipient: userId2 },
+                { requester: userId2, recipient: userId1 }
+            ]
+        });
+ 
+        return friendship;
+    } catch (error) {
+        throw new Error(`Error fetching friendship status: ${error.message}`);
+    }
+};
+ 
+/**
+ * Check if a user has sent a friend request to another user
+ * @param {Object} Friendship - Mongoose Friendship model
+ * @param {string|ObjectId} requesterId - User who sent the request
+ * @param {string|ObjectId} recipientId - User who received the request
+ * @returns {Promise<boolean>} True if request exists and is pending
+ */
+const hasPendingRequest = async (Friendship, requesterId, recipientId) => {
+    try {
+        const friendship = await Friendship.findOne({
+            requester: requesterId,
+            recipient: recipientId,
+            status: 'pending'
+        });
+ 
+        return !!friendship;
+    } catch (error) {
+        throw new Error(`Error checking pending request: ${error.message}`);
+    }
+};
+ 
+/**
+ * Get all friendships for a user (accepted, pending, etc.)
+ * @param {Object} Friendship - Mongoose Friendship model
+ * @param {string|ObjectId} userId - User ID
+ * @param {string} status - Optional status filter ('pending', 'accepted', etc.)
+ * @param {Object} options - Optional configuration
+ * @param {boolean} options.lean - Whether to return plain JavaScript objects
+ * @returns {Promise<Array>} Array of friendship objects
+ */
+const getAllFriendships = async (Friendship, userId, status = null, options = {}) => {
+    const { lean = false } = options;
+ 
+    try {
+        let query = Friendship.find({
+            $or: [
+                { requester: userId },
+                { recipient: userId }
+            ]
+        });
+ 
+        if (status) {
+            query = query.where('status').equals(status);
+        }
+ 
+        if (lean) {
+            query = query.lean();
+        }
+ 
+        const friendships = await query
+            .populate('requester', 'username name picture _id')
+            .populate('recipient', 'username name picture _id')
+            .exec();
+ 
+        return friendships;
+    } catch (error) {
+        throw new Error(`Error fetching friendships: ${error.message}`);
+    }
+};
+ 
+module.exports = {
+    getFriendRequests,
+    getFriendshipStatus,
+    hasPendingRequest,
+    getAllFriendships
+};
+ 
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/index.html b/backend/coverage/lcov-report/utilities/index.html new file mode 100644 index 00000000..1d21ec65 --- /dev/null +++ b/backend/coverage/lcov-report/utilities/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for utilities + + + + + + + + + +
+
+

All files utilities

+
+ +
+ 30.6% + Statements + 56/183 +
+ + +
+ 23.43% + Branches + 45/192 +
+ + +
+ 41.46% + Functions + 17/41 +
+ + +
+ 30.16% + Lines + 54/179 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
pivotIsoWeek.js +
+
10.34%3/2918.18%2/1116.66%1/610.34%3/29
pivotLogger.js +
+
25.71%9/3532.14%9/2833.33%3/925.71%9/35
pivotMovieMetadata.js +
+
17.46%11/637.92%8/10127.27%3/1117.74%11/62
pivotTimeSlots.js +
+
58.92%33/5650%26/5266.66%10/1558.49%31/53
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/pivotDropSchedule.js.html b/backend/coverage/lcov-report/utilities/pivotDropSchedule.js.html new file mode 100644 index 00000000..f54f3783 --- /dev/null +++ b/backend/coverage/lcov-report/utilities/pivotDropSchedule.js.html @@ -0,0 +1,691 @@ + + + + + + Code coverage report for utilities/pivotDropSchedule.js + + + + + + + + + +
+
+

All files / utilities pivotDropSchedule.js

+
+ +
+ 94.44% + Statements + 51/54 +
+ + +
+ 72.97% + Branches + 27/37 +
+ + +
+ 100% + Functions + 15/15 +
+ + +
+ 100% + Lines + 50/50 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +2038x +  +  +8x +  +  +  +  +  +  +8x +  +  +19x +  +  +  +13x +13x +13x +  +  +  +13x +13x +13x +  +  +  +13x +13x +13x +  +  +  +14x +  +14x +  +  +  +14x +  +  +  +  +  +  +  +  +14x +  +  +  +  +  +  +  +  +  +  +12x +  +  +  +26x +  +  +  +  +  +  +  +  +  +  +26x +  +  +286x +156x +  +  +26x +  +  +  +  +  +  +  +  +26x +  +  +  +13x +13x +13x +  +  +  +14x +1x +  +  +13x +13x +13x +  +  +  +  +  +  +  +  +  +  +  +  +  +13x +12x +12x +  +12x +  +  +  +  +  +  +  +  +12x +  +  +  +  +  +  +  +  +  +  +  +  +  +11x +  +  +  +  +11x +  +  +  +  +  +11x +  +  +  +  +  +  +11x +  +  +  +  +77x +  +11x +  +  +  +10x +10x +10x +10x +10x +  +10x +  +  +  +  +  +  +8x +  +  +  +  +  +  +  +  +  +  + 
const { isoWeekToMondayUtc } = require('./pivotIsoWeek');
+ 
+/** Pilot suggestion when a pivot tenant has no drop config stored yet (not a runtime constant). */
+const PIVOT_DROP_PILOT_DEFAULTS = Object.freeze({
+  pivotDropTimezone: 'America/New_York',
+  pivotDropDayOfWeek: 4,
+  pivotDropHour: 18,
+  pivotDropMinute: 0,
+});
+ 
+const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+ 
+function isPivotTenant(tenant = {}) {
+  return tenant.pivotPilot === true || tenant.tenantType === 'pivot';
+}
+ 
+function normalizeDropMinute(value, fallback = 0) {
+  const parsed = Number(value);
+  Iif (!Number.isFinite(parsed)) return fallback;
+  return Math.min(59, Math.max(0, Math.trunc(parsed)));
+}
+ 
+function normalizeDropHour(value, fallback = 18) {
+  const parsed = Number(value);
+  Iif (!Number.isFinite(parsed)) return fallback;
+  return Math.min(23, Math.max(0, Math.trunc(parsed)));
+}
+ 
+function normalizeDropDayOfWeek(value, fallback = 4) {
+  const parsed = Number(value);
+  Iif (!Number.isFinite(parsed)) return fallback;
+  return Math.min(6, Math.max(0, Math.trunc(parsed)));
+}
+ 
+function resolvePivotDropConfig(tenant = {}) {
+  const timezone = String(tenant.pivotDropTimezone || '').trim();
+  const hasStoredConfig =
+    Boolean(timezone) &&
+    tenant.pivotDropDayOfWeek !== undefined &&
+    tenant.pivotDropHour !== undefined;
+ 
+  const defaults = hasStoredConfig
+    ? {
+        pivotDropTimezone: timezone,
+        pivotDropDayOfWeek: normalizeDropDayOfWeek(tenant.pivotDropDayOfWeek),
+        pivotDropHour: normalizeDropHour(tenant.pivotDropHour),
+        pivotDropMinute: normalizeDropMinute(tenant.pivotDropMinute, 0),
+      }
+    : { ...PIVOT_DROP_PILOT_DEFAULTS };
+ 
+  return {
+    timezone: defaults.pivotDropTimezone,
+    dayOfWeek: defaults.pivotDropDayOfWeek,
+    hour: defaults.pivotDropHour,
+    minute: defaults.pivotDropMinute,
+    overrides: Array.isArray(tenant.pivotDropOverrides) ? tenant.pivotDropOverrides : [],
+    usingPilotDefaults: !hasStoredConfig,
+  };
+}
+ 
+function daysFromIsoMonday(dayOfWeek) {
+  return dayOfWeek === 0 ? 6 : dayOfWeek - 1;
+}
+ 
+function getTimeZoneOffsetMs(timeZone, date) {
+  const formatter = new Intl.DateTimeFormat('en-US', {
+    timeZone,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+    hour12: false,
+  });
+ 
+  const parts = Object.fromEntries(
+    formatter
+      .formatToParts(date)
+      .filter((part) => part.type !== 'literal')
+      .map((part) => [part.type, part.value])
+  );
+ 
+  const asUtc = Date.UTC(
+    Number(parts.year),
+    Number(parts.month) - 1,
+    Number(parts.day),
+    Number(parts.hour),
+    Number(parts.minute),
+    Number(parts.second)
+  );
+ 
+  return asUtc - date.getTime();
+}
+ 
+function zonedLocalToUtc({ year, month, day, hour, minute, timeZone }) {
+  const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0);
+  const firstPass = new Date(utcGuess - getTimeZoneOffsetMs(timeZone, new Date(utcGuess)));
+  return new Date(utcGuess - getTimeZoneOffsetMs(timeZone, firstPass));
+}
+ 
+function resolvePivotDropInstant(tenant, batchWeek, now = new Date()) {
+  if (!isPivotTenant(tenant)) {
+    throw new Error(`Tenant "${tenant?.tenantKey || 'unknown'}" is not a pivot city`);
+  }
+ 
+  const config = resolvePivotDropConfig(tenant);
+  const override = config.overrides.find((row) => row?.batchWeek === batchWeek);
+  const schedule = override
+    ? {
+        dayOfWeek: normalizeDropDayOfWeek(override.dayOfWeek, config.dayOfWeek),
+        hour: normalizeDropHour(override.hour, config.hour),
+        minute: normalizeDropMinute(override.minute, config.minute),
+        source: 'override',
+      }
+    : {
+        dayOfWeek: config.dayOfWeek,
+        hour: config.hour,
+        minute: config.minute,
+        source: 'default',
+      };
+ 
+  const monday = isoWeekToMondayUtc(batchWeek);
+  const dropDate = new Date(monday);
+  dropDate.setUTCDate(monday.getUTCDate() + daysFromIsoMonday(schedule.dayOfWeek));
+ 
+  const dropAt = zonedLocalToUtc({
+    year: dropDate.getUTCFullYear(),
+    month: dropDate.getUTCMonth() + 1,
+    day: dropDate.getUTCDate(),
+    hour: schedule.hour,
+    minute: schedule.minute,
+    timeZone: config.timezone,
+  });
+ 
+  return {
+    dropAt,
+    batchWeek,
+    timezone: config.timezone,
+    dayOfWeek: schedule.dayOfWeek,
+    hour: schedule.hour,
+    minute: schedule.minute,
+    source: schedule.source,
+    usingPilotDefaults: config.usingPilotDefaults,
+    resolvedAt: now,
+  };
+}
+ 
+function formatPivotDropInstant(dropAt, timeZone) {
+  const weekday = new Intl.DateTimeFormat('en-US', {
+    timeZone,
+    weekday: 'short',
+  }).format(dropAt);
+ 
+  const datePart = new Intl.DateTimeFormat('en-US', {
+    timeZone,
+    month: 'short',
+    day: 'numeric',
+  }).format(dropAt);
+ 
+  const timePart = new Intl.DateTimeFormat('en-US', {
+    timeZone,
+    hour: 'numeric',
+    minute: '2-digit',
+    hour12: true,
+  }).format(dropAt);
+ 
+  const zonePart = new Intl.DateTimeFormat('en-US', {
+    timeZone,
+    timeZoneName: 'short',
+  })
+    .formatToParts(dropAt)
+    .find((part) => part.type === 'timeZoneName')?.value;
+ 
+  return `${weekday} ${datePart}, ${timePart}${zonePart ? ` ${zonePart}` : ''}`;
+}
+ 
+function describePivotDropSchedule(resolved) {
+  const dayLabel = DAY_NAMES[resolved.dayOfWeek] || `day ${resolved.dayOfWeek}`;
+  const minuteLabel = String(resolved.minute).padStart(2, '0');
+  const sourceLabel = resolved.source === 'override' ? 'override' : 'tenant default';
+  const localTime = `${dayLabel} ${resolved.hour}:${minuteLabel} ${resolved.timezone}`;
+  const formatted = formatPivotDropInstant(resolved.dropAt, resolved.timezone);
+ 
+  return {
+    localTime,
+    formatted,
+    sourceLabel,
+  };
+}
+ 
+module.exports = {
+  PIVOT_DROP_PILOT_DEFAULTS,
+  DAY_NAMES,
+  isPivotTenant,
+  resolvePivotDropConfig,
+  resolvePivotDropInstant,
+  formatPivotDropInstant,
+  describePivotDropSchedule,
+  isoWeekToMondayUtc,
+  zonedLocalToUtc,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/pivotIsoWeek.js.html b/backend/coverage/lcov-report/utilities/pivotIsoWeek.js.html new file mode 100644 index 00000000..ac77a709 --- /dev/null +++ b/backend/coverage/lcov-report/utilities/pivotIsoWeek.js.html @@ -0,0 +1,322 @@ + + + + + + Code coverage report for utilities/pivotIsoWeek.js + + + + + + + + + +
+
+

All files / utilities pivotIsoWeek.js

+
+ +
+ 10.34% + Statements + 3/29 +
+ + +
+ 18.18% + Branches + 2/11 +
+ + +
+ 16.66% + Functions + 1/6 +
+ + +
+ 10.34% + Lines + 3/29 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +13x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  + 
/**
+ * ISO 8601 week date string (YYYY-Www) for Pivot batchWeek fields.
+ * @param {Date} [date]
+ * @returns {string}
+ */
+function toIsoWeek(date = new Date()) {
+  const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
+  d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
+  const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
+  const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
+  return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
+}
+ 
+const ISO_WEEK_PATTERN = /^\d{4}-W\d{2}$/;
+ 
+function isValidIsoWeek(value) {
+  return typeof value === 'string' && ISO_WEEK_PATTERN.test(value.trim());
+}
+ 
+/**
+ * UTC Date for the Monday 00:00 that starts the given ISO week.
+ * @param {string} batchWeek - YYYY-Www
+ * @returns {Date}
+ */
+function isoWeekToMondayUtc(batchWeek) {
+  if (!isValidIsoWeek(batchWeek)) {
+    throw new Error(`Invalid batchWeek "${batchWeek}" — expected YYYY-Www`);
+  }
+ 
+  const [, yearStr, weekStr] = batchWeek.match(/^(\d{4})-W(\d{2})$/);
+  const year = Number(yearStr);
+  const week = Number(weekStr);
+  const jan4 = new Date(Date.UTC(year, 0, 4));
+  const jan4IsoDay = jan4.getUTCDay() || 7;
+  const monday = new Date(jan4);
+  monday.setUTCDate(jan4.getUTCDate() - jan4IsoDay + 1 + (week - 1) * 7);
+  return monday;
+}
+ 
+/**
+ * [start, end) UTC range covering the given ISO week (Monday 00:00 → next Monday 00:00).
+ * @param {string} batchWeek - YYYY-Www
+ * @returns {{ start: Date, end: Date }}
+ */
+function isoWeekToUtcRange(batchWeek) {
+  const start = isoWeekToMondayUtc(batchWeek);
+  const end = new Date(start.getTime() + 7 * 24 * 60 * 60 * 1000);
+  return { start, end };
+}
+ 
+/** ISO week string from UTC calendar components (avoids local-timezone getters). */
+function toIsoWeekUtc(date) {
+  const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+  d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
+  const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
+  const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
+  return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
+}
+ 
+/**
+ * Shift an ISO week string by a number of weeks (negative = earlier).
+ * @param {string} batchWeek - YYYY-Www
+ * @param {number} delta
+ * @returns {string}
+ */
+function shiftIsoWeek(batchWeek, delta) {
+  const monday = isoWeekToMondayUtc(batchWeek);
+  monday.setUTCDate(monday.getUTCDate() + delta * 7);
+  return toIsoWeekUtc(monday);
+}
+ 
+module.exports = {
+  toIsoWeek,
+  isValidIsoWeek,
+  isoWeekToMondayUtc,
+  isoWeekToUtcRange,
+  shiftIsoWeek,
+  ISO_WEEK_PATTERN,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/pivotLogger.js.html b/backend/coverage/lcov-report/utilities/pivotLogger.js.html new file mode 100644 index 00000000..8b9d8853 --- /dev/null +++ b/backend/coverage/lcov-report/utilities/pivotLogger.js.html @@ -0,0 +1,388 @@ + + + + + + Code coverage report for utilities/pivotLogger.js + + + + + + + + + +
+
+

All files / utilities pivotLogger.js

+
+ +
+ 25.71% + Statements + 9/35 +
+ + +
+ 32.14% + Branches + 9/28 +
+ + +
+ 33.33% + Functions + 3/9 +
+ + +
+ 25.71% + Lines + 9/35 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +1021x +  +  +12x +12x +  +  +12x +12x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +12x +12x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +12x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  + 
const LOG_PREFIX = '[pivot]';
+ 
+function isPivotLoggingEnabled() {
+  const flag = process.env.PIVOT_LOG;
+  Iif (flag === '0' || flag === 'false') {
+    return false;
+  }
+  Eif (process.env.NODE_ENV === 'test') {
+    return false;
+  }
+  return true;
+}
+ 
+function serializeMeta(meta) {
+  if (meta == null) {
+    return '';
+  }
+  try {
+    return ` ${JSON.stringify(meta)}`;
+  } catch {
+    return ' [meta unserializable]';
+  }
+}
+ 
+function logPivot(level, message, meta) {
+  Eif (!isPivotLoggingEnabled()) {
+    return;
+  }
+ 
+  const line = `${LOG_PREFIX} ${message}${serializeMeta(meta)}`;
+  if (level === 'error') {
+    console.error(line);
+    return;
+  }
+  if (level === 'warn') {
+    console.warn(line);
+    return;
+  }
+  console.log(line);
+}
+ 
+function pivotRequestContext(req) {
+  return {
+    tenant: req.school || undefined,
+    userId: req.user?.userId ? String(req.user.userId) : undefined,
+    method: req.method,
+    path: req.originalUrl || req.url,
+  };
+}
+ 
+/** Express middleware — logs completed pivot requests with latency. */
+function pivotRequestLogger(req, res, next) {
+  if (!isPivotLoggingEnabled()) {
+    next();
+    return;
+  }
+ 
+  const startedAt = Date.now();
+  res.on('finish', () => {
+    const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info';
+    logPivot(level, 'request', {
+      ...pivotRequestContext(req),
+      status: res.statusCode,
+      ms: Date.now() - startedAt,
+    });
+  });
+  next();
+}
+ 
+function logPivotRouteError(routeLabel, err, req) {
+  logPivot('error', `${routeLabel} failed`, {
+    ...pivotRequestContext(req),
+    error: err?.message || String(err),
+  });
+}
+ 
+function logPivotServiceReject(routeLabel, result, req, extra) {
+  logPivot('warn', `${routeLabel} rejected`, {
+    ...pivotRequestContext(req),
+    code: result.code,
+    message: result.error,
+    ...extra,
+  });
+}
+ 
+function logPivotServiceSuccess(routeLabel, req, extra) {
+  logPivot('info', `${routeLabel} ok`, {
+    ...pivotRequestContext(req),
+    ...extra,
+  });
+}
+ 
+module.exports = {
+  logPivot,
+  pivotRequestContext,
+  pivotRequestLogger,
+  logPivotRouteError,
+  logPivotServiceReject,
+  logPivotServiceSuccess,
+  isPivotLoggingEnabled,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/pivotMovieMetadata.js.html b/backend/coverage/lcov-report/utilities/pivotMovieMetadata.js.html new file mode 100644 index 00000000..9d7a214b --- /dev/null +++ b/backend/coverage/lcov-report/utilities/pivotMovieMetadata.js.html @@ -0,0 +1,556 @@ + + + + + + Code coverage report for utilities/pivotMovieMetadata.js + + + + + + + + + +
+
+

All files / utilities pivotMovieMetadata.js

+
+ +
+ 17.46% + Statements + 11/63 +
+ + +
+ 7.92% + Branches + 8/101 +
+ + +
+ 27.27% + Functions + 3/11 +
+ + +
+ 17.74% + Lines + 11/62 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158  +  +  +  +1x +  +  +16x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +32x +32x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +16x +16x +16x +16x +  +  +16x +  +  +16x +  +  +1x +  +  +  +  +  +  + 
/**
+ * Movie metadata stored on `customFields.pivot.movie` (TMDB-backed film listings).
+ */
+ 
+const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p';
+ 
+function trimString(value) {
+  return typeof value === 'string' ? value.trim() : '';
+}
+ 
+function toNumber(value) {
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    return value;
+  }
+  if (typeof value === 'string' && value.trim()) {
+    const parsed = Number(value);
+    return Number.isFinite(parsed) ? parsed : null;
+  }
+  return null;
+}
+ 
+function roundRating(value) {
+  const num = toNumber(value);
+  if (num == null) {
+    return null;
+  }
+  return Math.round(num * 10) / 10;
+}
+ 
+function buildTmdbImageUrl(path, size = 'w500') {
+  const normalized = trimString(path);
+  if (!normalized) {
+    return null;
+  }
+  if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
+    return normalized;
+  }
+  return `${TMDB_IMAGE_BASE}/${size}${normalized.startsWith('/') ? normalized : `/${normalized}`}`;
+}
+ 
+function normalizeRatings(raw) {
+  if (!raw || typeof raw !== 'object') {
+    return null;
+  }
+ 
+  const tmdbScore = roundRating(raw.tmdb?.score ?? raw.tmdbScore);
+  const tmdbVoteCount = toNumber(raw.tmdb?.voteCount ?? raw.tmdbVoteCount);
+  if (tmdbScore == null && tmdbVoteCount == null) {
+    return null;
+  }
+ 
+  return {
+    ...(tmdbScore != null
+      ? {
+          tmdb: {
+            score: tmdbScore,
+            ...(tmdbVoteCount != null ? { voteCount: tmdbVoteCount } : {}),
+          },
+        }
+      : {}),
+  };
+}
+ 
+function normalizeStringArray(raw) {
+  if (!Array.isArray(raw)) {
+    return [];
+  }
+  return raw.map((entry) => trimString(entry)).filter(Boolean);
+}
+ 
+/**
+ * @param {unknown} raw
+ * @returns {object | null}
+ */
+function normalizePivotMovie(raw) {
+  if (!raw || typeof raw !== 'object') {
+    return null;
+  }
+ 
+  const tmdbId = toNumber(raw.tmdbId);
+  const title = trimString(raw.title);
+  if (!tmdbId || !title) {
+    return null;
+  }
+ 
+  const year = toNumber(raw.year);
+  const synopsis = trimString(raw.synopsis || raw.overview);
+  const posterUrl = trimString(raw.posterUrl) || buildTmdbImageUrl(raw.posterPath, 'w500');
+  const backdropUrl =
+    trimString(raw.backdropUrl) || buildTmdbImageUrl(raw.backdropPath, 'w780');
+  const runtimeMinutes = toNumber(raw.runtimeMinutes ?? raw.runtime);
+  const genres = normalizeStringArray(raw.genres);
+  const contentRating = trimString(raw.contentRating);
+  const director = trimString(raw.director);
+  const cast = normalizeStringArray(raw.cast);
+  const imdbId = trimString(raw.imdbId);
+  const ratings = normalizeRatings(raw.ratings);
+ 
+  return {
+    tmdbId,
+    title,
+    ...(year != null ? { year } : {}),
+    ...(synopsis ? { synopsis } : {}),
+    ...(posterUrl ? { posterUrl } : {}),
+    ...(backdropUrl ? { backdropUrl } : {}),
+    ...(runtimeMinutes != null ? { runtimeMinutes } : {}),
+    ...(genres.length ? { genres } : {}),
+    ...(contentRating ? { contentRating } : {}),
+    ...(director ? { director } : {}),
+    ...(cast.length ? { cast } : {}),
+    ...(imdbId ? { imdbId } : {}),
+    ...(ratings ? { ratings } : {}),
+  };
+}
+ 
+function serializePivotMovie(movie) {
+  Eif (!movie || typeof movie !== 'object') {
+    return null;
+  }
+ 
+  return normalizePivotMovie(movie);
+}
+ 
+function applyMovieListingDefaults(merged) {
+  if (!merged?.movie) {
+    return merged;
+  }
+ 
+  const movie = merged.movie;
+  return {
+    ...merged,
+    name: trimString(merged.name) || movie.title,
+    description: trimString(merged.description) || movie.synopsis || '',
+    image: trimString(merged.image) || movie.posterUrl || '',
+  };
+}
+ 
+function resolvePivotCoverImageUrl(event) {
+  const pivot = event?.customFields?.pivot || {};
+  const movie = serializePivotMovie(pivot.movie);
+  const eventImage = trimString(event?.image);
+  Iif (movie?.backdropUrl) {
+    return movie.backdropUrl;
+  }
+  Iif (movie?.posterUrl) {
+    return movie.posterUrl;
+  }
+  return eventImage || null;
+}
+ 
+module.exports = {
+  buildTmdbImageUrl,
+  normalizePivotMovie,
+  serializePivotMovie,
+  applyMovieListingDefaults,
+  resolvePivotCoverImageUrl,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/pivotTimeSlots.js.html b/backend/coverage/lcov-report/utilities/pivotTimeSlots.js.html new file mode 100644 index 00000000..d10c2ac2 --- /dev/null +++ b/backend/coverage/lcov-report/utilities/pivotTimeSlots.js.html @@ -0,0 +1,532 @@ + + + + + + Code coverage report for utilities/pivotTimeSlots.js + + + + + + + + + +
+
+

All files / utilities pivotTimeSlots.js

+
+ +
+ 58.92% + Statements + 33/56 +
+ + +
+ 50% + Branches + 26/52 +
+ + +
+ 66.66% + Functions + 10/15 +
+ + +
+ 58.49% + Lines + 31/53 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150  +  +  +  +  +  +16x +  +  +  +16x +  +  +16x +16x +  +  +  +  +  +  +  +66x +62x +  +  +4x +4x +  +4x +8x +  +  +  +8x +8x +8x +  +  +  +8x +8x +8x +  +8x +  +  +  +  +  +  +  +4x +4x +  +  +  +2x +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +1x +2x +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +20x +20x +19x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Normalize and serialize Pivot catalog showtimes stored on
+ * `customFields.pivot.timeSlots` (movies, theatre, multi-performance events).
+ */
+ 
+function trimString(value) {
+  return typeof value === 'string' ? value.trim() : '';
+}
+ 
+function parseDate(value) {
+  Iif (!value) {
+    return null;
+  }
+  const parsed = new Date(value);
+  return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+ 
+/**
+ * @param {unknown} rawSlots
+ * @returns {Array<{ id: string, start_time: Date, end_time: Date | null, label: string | null }>}
+ */
+function normalizePivotTimeSlots(rawSlots) {
+  if (!Array.isArray(rawSlots) || !rawSlots.length) {
+    return [];
+  }
+ 
+  const slots = [];
+  const seenIds = new Set();
+ 
+  for (const raw of rawSlots) {
+    Iif (!raw || typeof raw !== 'object') {
+      continue;
+    }
+ 
+    const id = trimString(raw.id);
+    const start = parseDate(raw.start_time ?? raw.startTime);
+    Iif (!id || !start || seenIds.has(id)) {
+      continue;
+    }
+ 
+    seenIds.add(id);
+    const end = parseDate(raw.end_time ?? raw.endTime);
+    const label = trimString(raw.label) || null;
+ 
+    slots.push({
+      id,
+      start_time: start,
+      end_time: end,
+      label,
+    });
+  }
+ 
+  slots.sort((a, b) => a.start_time.getTime() - b.start_time.getTime());
+  return slots;
+}
+ 
+function resolveTimeSlotLabel(slot) {
+  Iif (slot.label) {
+    return slot.label;
+  }
+ 
+  return slot.start_time
+    .toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
+    .toLowerCase()
+    .replace(/\s/g, '');
+}
+ 
+/**
+ * @param {Array<{ id: string, start_time: Date, end_time: Date | null, label: string | null }>} slots
+ * @param {Map<string, { friendsGoing: object[], friendsGoingCount: number }>} [socialBySlotId]
+ */
+function serializePivotTimeSlots(slots, socialBySlotId = new Map()) {
+  return slots.map((slot) => {
+    const social = socialBySlotId.get(slot.id) || {
+      friendsGoing: [],
+      friendsGoingCount: 0,
+    };
+ 
+    return {
+      id: slot.id,
+      start_time: slot.start_time.toISOString(),
+      ...(slot.end_time ? { end_time: slot.end_time.toISOString() } : {}),
+      label: resolveTimeSlotLabel(slot),
+      friendsGoing: social.friendsGoing,
+      friendsGoingCount: social.friendsGoingCount,
+    };
+  });
+}
+ 
+function eventHasTimeSlots(pivotMeta) {
+  return normalizePivotTimeSlots(pivotMeta?.timeSlots).length > 0;
+}
+ 
+function findTimeSlotById(pivotMeta, timeSlotId) {
+  const key = trimString(timeSlotId);
+  if (!key) {
+    return null;
+  }
+  return normalizePivotTimeSlots(pivotMeta?.timeSlots).find((slot) => slot.id === key) || null;
+}
+ 
+function isTimeSlotUpcoming(slot, now = new Date()) {
+  const end = slot.end_time || slot.start_time;
+  return end > now;
+}
+ 
+function isUpcomingWithTimeSlots(pivotMeta, now = new Date()) {
+  const slots = normalizePivotTimeSlots(pivotMeta?.timeSlots);
+  if (!slots.length) {
+    return null;
+  }
+  return slots.some((slot) => isTimeSlotUpcoming(slot, now));
+}
+ 
+function resolveEventEarliestStart(pivotMeta, fallbackStart) {
+  const slots = normalizePivotTimeSlots(pivotMeta?.timeSlots);
+  if (!slots.length) {
+    return fallbackStart;
+  }
+  return slots[0].start_time;
+}
+ 
+function resolveEventLatestEnd(pivotMeta, fallbackEnd) {
+  const slots = normalizePivotTimeSlots(pivotMeta?.timeSlots);
+  if (!slots.length) {
+    return fallbackEnd;
+  }
+ 
+  let latest = null;
+  for (const slot of slots) {
+    const candidate = slot.end_time || slot.start_time;
+    if (!latest || candidate > latest) {
+      latest = candidate;
+    }
+  }
+  return latest;
+}
+ 
+module.exports = {
+  normalizePivotTimeSlots,
+  serializePivotTimeSlots,
+  resolveTimeSlotLabel,
+  eventHasTimeSlots,
+  findTimeSlotById,
+  isTimeSlotUpcoming,
+  isUpcomingWithTimeSlots,
+  resolveEventEarliestStart,
+  resolveEventLatestEnd,
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/semesterHelpers.js.html b/backend/coverage/lcov-report/utilities/semesterHelpers.js.html new file mode 100644 index 00000000..4b93f8aa --- /dev/null +++ b/backend/coverage/lcov-report/utilities/semesterHelpers.js.html @@ -0,0 +1,859 @@ + + + + + + Code coverage report for utilities/semesterHelpers.js + + + + + + + + + +
+
+

All files / utilities semesterHelpers.js

+
+ +
+ 81.33% + Statements + 61/75 +
+ + +
+ 46.51% + Branches + 20/43 +
+ + +
+ 90% + Functions + 9/10 +
+ + +
+ 81.33% + Lines + 61/75 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +9x +9x +9x +  +  +9x +  +1x +  +  +  +  +  +8x +  +7x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +4x +4x +  +  +  +  +  +  +  +  +1x +2x +2x +  +2x +  +  +  +  +  +  +  +  +1x +1x +  +1x +  +  +  +  +1x +1x +1x +  +  +1x +18x +18x +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +1x +  +  +  +1x +  +1x +18x +  +18x +18x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +1x +2x +2x +1x +  +  +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +1x +  +  +  +  +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Semester Helper Utilities
+ * Utilities for managing semester dates and academic calendar integration
+ */
+ 
+// placeholder helper, should be replaced with somthing more robust in the future.
+ 
+// Standard academic year structure
+const SEMESTER_CONFIGS = {
+    // Fall semester typically starts in August/September
+    fall: {
+        startMonth: 8, // August (0-indexed)
+        startDay: 15,
+        endMonth: 11, // December
+        endDay: 31
+    },
+    // Spring semester typically starts in January
+    spring: {
+        startMonth: 0, // January (0-indexed)
+        startDay: 1,
+        endMonth: 4, // May
+        endDay: 31
+    },
+    // Summer semester (optional)
+    summer: {
+        startMonth: 5, // June
+        startDay: 1,
+        endMonth: 7, // August
+        endDay: 15
+    }
+};
+ 
+/**
+ * Get the current semester information
+ * @param {Date} date - Optional date to check (defaults to now)
+ * @returns {Object} Semester information
+ */
+const getCurrentSemester = (date = new Date()) => {
+    const month = date.getMonth();
+    const day = date.getDate();
+    const year = date.getFullYear();
+    
+    // Determine current semester
+    if (month >= 8 || (month === 7 && day >= 15)) {
+        // Fall semester
+        return {
+            type: 'fall',
+            year: month >= 8 ? year : year - 1,
+            startDate: new Date(year, SEMESTER_CONFIGS.fall.startMonth, SEMESTER_CONFIGS.fall.startDay),
+            endDate: new Date(year, SEMESTER_CONFIGS.fall.endMonth, SEMESTER_CONFIGS.fall.endDay, 23, 59, 59)
+        };
+    } else if (month <= 4) {
+        // Spring semester
+        return {
+            type: 'spring',
+            year: year,
+            startDate: new Date(year, SEMESTER_CONFIGS.spring.startMonth, SEMESTER_CONFIGS.spring.startDay),
+            endDate: new Date(year, SEMESTER_CONFIGS.spring.endMonth, SEMESTER_CONFIGS.spring.endDay, 23, 59, 59)
+        };
+    } else {
+        // Summer semester
+        return {
+            type: 'summer',
+            year: year,
+            startDate: new Date(year, SEMESTER_CONFIGS.summer.startMonth, SEMESTER_CONFIGS.summer.startDay),
+            endDate: new Date(year, SEMESTER_CONFIGS.summer.endMonth, SEMESTER_CONFIGS.summer.endDay, 23, 59, 59)
+        };
+    }
+};
+ 
+/**
+ * Get the end date of the current semester
+ * @param {Date} date - Optional date to check (defaults to now)
+ * @returns {Date} End date of current semester
+ */
+const getCurrentSemesterEnd = (date = new Date()) => {
+    const semester = getCurrentSemester(date);
+    return semester.endDate;
+};
+ 
+/**
+ * Check if a date is within the current semester
+ * @param {Date} date - Date to check
+ * @param {Date} referenceDate - Reference date for determining "current" semester
+ * @returns {boolean} True if date is within current semester
+ */
+const isWithinCurrentSemester = (date, referenceDate = new Date()) => {
+    const semester = getCurrentSemester(referenceDate);
+    const checkDate = new Date(date);
+    
+    return checkDate >= semester.startDate && checkDate <= semester.endDate;
+};
+ 
+/**
+ * Validate that a recurring event pattern doesn't extend beyond semester end
+ * @param {Object} recurrencePattern - Recurrence pattern object
+ * @param {Date} startDate - Start date of the recurring event
+ * @returns {Object} Validation result
+ */
+const validateRecurrenceWithinSemester = (recurrencePattern, startDate) => {
+    const semesterEnd = getCurrentSemesterEnd(startDate);
+    
+    Iif (!recurrencePattern.frequency) {
+        return { isValid: true, adjustedEndDate: null };
+    }
+    
+    // Calculate the last occurrence based on frequency
+    const start = new Date(startDate);
+    const frequency = recurrencePattern.frequency;
+    let lastOccurrence = new Date(start);
+    
+    // Find the last occurrence that would happen before semester end
+    while (lastOccurrence <= semesterEnd) {
+        if (frequency === 'weekly') {
+            lastOccurrence.setDate(lastOccurrence.getDate() + 7);
+        } else Eif (frequency === 'biweekly') {
+            lastOccurrence.setDate(lastOccurrence.getDate() + 14);
+        } else {
+            break;
+        }
+    }
+    
+    // Step back one occurrence to get the actual last valid date
+    if (frequency === 'weekly') {
+        lastOccurrence.setDate(lastOccurrence.getDate() - 7);
+    } else Eif (frequency === 'biweekly') {
+        lastOccurrence.setDate(lastOccurrence.getDate() - 14);
+    }
+    
+    return {
+        isValid: lastOccurrence <= semesterEnd,
+        adjustedEndDate: lastOccurrence < semesterEnd ? lastOccurrence : semesterEnd,
+        semesterEnd: semesterEnd
+    };
+};
+ 
+/**
+ * Get all occurrence dates for a recurring pattern within the semester
+ * @param {Date} startDate - Start date of recurring event
+ * @param {Object} recurrencePattern - Recurrence pattern
+ * @returns {Array} Array of occurrence dates
+ */
+const getRecurrenceOccurrences = (startDate, recurrencePattern) => {
+    const occurrences = [];
+    const start = new Date(startDate);
+    const semesterEnd = getCurrentSemesterEnd(startDate);
+    
+    Iif (!recurrencePattern.frequency) {
+        return [start];
+    }
+    
+    let currentDate = new Date(start);
+    
+    while (currentDate <= semesterEnd) {
+        occurrences.push(new Date(currentDate));
+        
+        if (recurrencePattern.frequency === 'weekly') {
+            currentDate.setDate(currentDate.getDate() + 7);
+        } else Eif (recurrencePattern.frequency === 'biweekly') {
+            currentDate.setDate(currentDate.getDate() + 14);
+        } else {
+            break;
+        }
+    }
+    
+    return occurrences;
+};
+ 
+/**
+ * Get semester information for a specific academic year
+ * @param {number} year - Academic year (year when fall semester starts)
+ * @param {string} semesterType - 'fall', 'spring', or 'summer'
+ * @returns {Object} Semester information
+ */
+const getSemesterInfo = (year, semesterType) => {
+    const config = SEMESTER_CONFIGS[semesterType];
+    if (!config) {
+        throw new Error(`Invalid semester type: ${semesterType}`);
+    }
+    
+    let semesterYear = year;
+    Iif (semesterType === 'spring' || semesterType === 'summer') {
+        semesterYear = year + 1;
+    }
+    
+    return {
+        type: semesterType,
+        year: semesterYear,
+        startDate: new Date(semesterYear, config.startMonth, config.startDay),
+        endDate: new Date(semesterYear, config.endMonth, config.endDay, 23, 59, 59)
+    };
+};
+ 
+/**
+ * Format semester information for display
+ * @param {Object} semester - Semester object
+ * @returns {string} Formatted semester string
+ */
+const formatSemester = (semester) => {
+    const typeCapitalized = semester.type.charAt(0).toUpperCase() + semester.type.slice(1);
+    return `${typeCapitalized} ${semester.year}`;
+};
+ 
+/**
+ * Check if it's close to semester end (within specified days)
+ * @param {number} daysThreshold - Number of days before semester end
+ * @param {Date} referenceDate - Reference date (defaults to now)
+ * @returns {boolean} True if close to semester end
+ */
+const isNearSemesterEnd = (daysThreshold = 14, referenceDate = new Date()) => {
+    const semesterEnd = getCurrentSemesterEnd(referenceDate);
+    const thresholdDate = new Date(semesterEnd);
+    thresholdDate.setDate(thresholdDate.getDate() - daysThreshold);
+    
+    return referenceDate >= thresholdDate;
+};
+ 
+/**
+ * Get days remaining in current semester
+ * @param {Date} referenceDate - Reference date (defaults to now)
+ * @returns {number} Days remaining in semester
+ */
+const getDaysRemainingInSemester = (referenceDate = new Date()) => {
+    const semesterEnd = getCurrentSemesterEnd(referenceDate);
+    const daysDiff = Math.ceil((semesterEnd - referenceDate) / (1000 * 60 * 60 * 24));
+    return Math.max(0, daysDiff);
+};
+ 
+/**
+ * Custom semester configuration override
+ * Allows schools to set custom semester dates
+ * @param {string} semesterType - 'fall', 'spring', or 'summer'
+ * @param {Object} config - Custom configuration
+ */
+const setSemesterConfig = (semesterType, config) => {
+    if (!SEMESTER_CONFIGS[semesterType]) {
+        throw new Error(`Invalid semester type: ${semesterType}`);
+    }
+    
+    SEMESTER_CONFIGS[semesterType] = {
+        ...SEMESTER_CONFIGS[semesterType],
+        ...config
+    };
+};
+ 
+module.exports = {
+    getCurrentSemester,
+    getCurrentSemesterEnd,
+    isWithinCurrentSemester,
+    validateRecurrenceWithinSemester,
+    getRecurrenceOccurrences,
+    getSemesterInfo,
+    formatSemester,
+    isNearSemesterEnd,
+    getDaysRemainingInSemester,
+    setSemesterConfig,
+    SEMESTER_CONFIGS
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/sessionUtils.js.html b/backend/coverage/lcov-report/utilities/sessionUtils.js.html new file mode 100644 index 00000000..48b49e00 --- /dev/null +++ b/backend/coverage/lcov-report/utilities/sessionUtils.js.html @@ -0,0 +1,1042 @@ + + + + + + Code coverage report for utilities/sessionUtils.js + + + + + + + + + +
+
+

All files / utilities sessionUtils.js

+
+ +
+ 4.09% + Statements + 5/122 +
+ + +
+ 0% + Branches + 0/67 +
+ + +
+ 0% + Functions + 0/15 +
+ + +
+ 4.09% + Lines + 5/122 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +3201x +1x +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const jwt = require('jsonwebtoken');
+const getGlobalModels = require('../services/getGlobalModelService');
+ 
+function getModels(req, ...names) {
+    return require('../services/getModelService')(req, ...names);
+}
+ 
+const REFRESH_TOKEN_EXPIRY_DAYS = 30;
+const REFRESH_TOKEN_EXPIRY_MS = REFRESH_TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
+ 
+/**
+ * Extract device information from request
+ */
+function getDeviceInfo(req) {
+    const userAgent = req.headers['user-agent'] || '';
+    const ipAddress = req.ip || req.connection.remoteAddress || req.headers['x-forwarded-for'] || '';
+    
+    // Determine client type
+    let clientType = 'web';
+    if (userAgent.includes('Mobile') || userAgent.includes('Android') || userAgent.includes('iPhone')) {
+        if (userAgent.includes('iPhone') || userAgent.includes('iPad')) {
+            clientType = 'ios';
+        } else if (userAgent.includes('Android')) {
+            clientType = 'android';
+        } else {
+            clientType = 'mobile';
+        }
+    }
+    
+    // Extract device name (simplified)
+    let deviceInfo = 'Unknown';
+    if (userAgent.includes('iPhone')) {
+        deviceInfo = 'iPhone';
+    } else if (userAgent.includes('iPad')) {
+        deviceInfo = 'iPad';
+    } else if (userAgent.includes('Android')) {
+        deviceInfo = 'Android Device';
+    } else if (userAgent.includes('Chrome')) {
+        deviceInfo = 'Chrome Browser';
+    } else if (userAgent.includes('Firefox')) {
+        deviceInfo = 'Firefox Browser';
+    } else if (userAgent.includes('Safari')) {
+        deviceInfo = 'Safari Browser';
+    }
+    
+    return {
+        deviceInfo,
+        userAgent,
+        ipAddress: Array.isArray(ipAddress) ? ipAddress[0] : ipAddress,
+        clientType
+    };
+}
+ 
+/**
+ * Create a new session for a user (tenant DB - legacy)
+ */
+async function createSession(userId, refreshToken, req) {
+    const { Session } = getModels(req, 'Session');
+    const deviceInfo = getDeviceInfo(req);
+ 
+    const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY_MS);
+ 
+    const session = await Session.findOneAndUpdate(
+        { refreshToken },
+        {
+            $set: {
+                userId,
+                deviceInfo: deviceInfo.deviceInfo,
+                userAgent: deviceInfo.userAgent,
+                ipAddress: deviceInfo.ipAddress,
+                clientType: deviceInfo.clientType,
+                expiresAt,
+                lastUsed: new Date(),
+            },
+            $setOnInsert: {
+                createdAt: new Date(),
+            },
+        },
+        { upsert: true, new: true, setDefaultsOnInsert: true }
+    );
+    return session;
+}
+ 
+/**
+ * Create a new session in global DB (for SSO across tenants)
+ */
+async function createGlobalSession(globalUserId, refreshToken, req) {
+    const { Session } = getGlobalModels(req, 'Session');
+    const deviceInfo = getDeviceInfo(req);
+    const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY_MS);
+    const session = await Session.findOneAndUpdate(
+        { refreshToken },
+        {
+            $set: {
+                globalUserId,
+                deviceInfo: deviceInfo.deviceInfo,
+                userAgent: deviceInfo.userAgent,
+                ipAddress: deviceInfo.ipAddress,
+                clientType: deviceInfo.clientType,
+                expiresAt,
+                lastUsed: new Date(),
+            },
+            $setOnInsert: {
+                createdAt: new Date(),
+            },
+        },
+        { upsert: true, new: true, setDefaultsOnInsert: true }
+    );
+    return session;
+}
+ 
+/**
+ * Validate a refresh token and return the session (tenant DB - legacy)
+ */
+async function validateSession(refreshToken, req) {
+    try {
+        const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET);
+ 
+        if (decoded.globalUserId) {
+            return validateGlobalSession(refreshToken, req);
+        }
+ 
+        const { Session, User } = getModels(req, 'Session', 'User');
+        const session = await Session.findOne({ refreshToken });
+ 
+        if (!session) {
+            return { valid: false, error: 'Session not found' };
+        }
+ 
+        // Check if session is expired
+        if (session.isExpired()) {
+            await Session.deleteOne({ _id: session._id });
+            return { valid: false, error: 'Session expired' };
+        }
+ 
+        // Verify user still exists
+        const user = await User.findById(session.userId);
+        if (!user) {
+            await Session.deleteOne({ _id: session._id });
+            return { valid: false, error: 'User not found' };
+        }
+ 
+        // Update last used timestamp
+        session.lastUsed = new Date();
+        await session.save();
+ 
+        return { valid: true, session, user, decoded };
+    } catch (error) {
+        if (error.name === 'TokenExpiredError') {
+            await Session.deleteOne({ refreshToken }).catch(() => {});
+            return { valid: false, error: 'Token expired' };
+        }
+        if (error.name === 'JsonWebTokenError') {
+            return { valid: false, error: 'Invalid token' };
+        }
+        return { valid: false, error: error.message };
+    }
+}
+ 
+/**
+ * Validate refresh token in global DB and resolve tenant user for req.school.
+ * Returns { valid, session, globalUser, user } where user is the tenant user (or null if no membership).
+ */
+async function validateGlobalSession(refreshToken, req) {
+    const authGlobalService = require('../services/authGlobalService');
+    const { Session, GlobalUser } = getGlobalModels(req, 'Session', 'GlobalUser');
+    try {
+        const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET);
+        if (!decoded.globalUserId) {
+            return { valid: false, error: 'Invalid refresh token' };
+        }
+        const session = await Session.findOne({ refreshToken });
+        if (!session) {
+            return { valid: false, error: 'Session not found' };
+        }
+        if (session.isExpired()) {
+            await Session.deleteOne({ _id: session._id });
+            return { valid: false, error: 'Session expired' };
+        }
+        const globalUser = await GlobalUser.findById(decoded.globalUserId);
+        if (!globalUser) {
+            await Session.deleteOne({ _id: session._id });
+            return { valid: false, error: 'User not found' };
+        }
+        session.lastUsed = new Date();
+        await session.save();
+        const { tenantUser } = await authGlobalService.resolveTenantUserForRequest(req, globalUser._id);
+        return { valid: true, session, globalUser, user: tenantUser, decoded };
+    } catch (error) {
+        if (error.name === 'TokenExpiredError') {
+            return { valid: false, error: 'Token expired' };
+        }
+        if (error.name === 'JsonWebTokenError') {
+            return { valid: false, error: 'Invalid token' };
+        }
+        return { valid: false, error: error.message };
+    }
+}
+ 
+/**
+ * Delete a session by refresh token (tries global first if token has globalUserId, else tenant)
+ */
+async function deleteSession(refreshToken, req) {
+    try {
+        const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET);
+        if (decoded.globalUserId) {
+            const { Session } = getGlobalModels(req, 'Session');
+            await Session.deleteOne({ refreshToken });
+            return;
+        }
+    } catch (_) {
+        // not a valid token or legacy
+    }
+    const { Session } = getModels(req, 'Session');
+    await Session.deleteOne({ refreshToken });
+}
+ 
+/**
+ * Delete all sessions for a user
+ */
+async function deleteAllUserSessions(userId, req) {
+    const { Session } = getModels(req, 'Session');
+    await Session.deleteMany({ userId });
+}
+ 
+/**
+ * Delete a specific session by ID (tenant DB)
+ */
+async function deleteSessionById(sessionId, userId, req) {
+    const { Session } = getModels(req, 'Session');
+    // Ensure user owns the session
+    const session = await Session.findOne({ _id: sessionId, userId });
+    if (session) {
+        await Session.deleteOne({ _id: sessionId });
+        return true;
+    }
+    return false;
+}
+ 
+/**
+ * Delete a specific session by ID (global DB, for users with global identity)
+ */
+async function deleteSessionByIdForGlobalUser(sessionId, globalUserId, req) {
+    const { Session } = getGlobalModels(req, 'Session');
+    const session = await Session.findOne({ _id: sessionId, globalUserId });
+    if (session) {
+        await Session.deleteOne({ _id: sessionId });
+        return true;
+    }
+    return false;
+}
+ 
+/**
+ * Get all active sessions for a user (tenant DB)
+ */
+async function getUserSessions(userId, req) {
+    const { Session } = getModels(req, 'Session');
+    const sessions = await Session.find({
+        userId,
+        expiresAt: { $gt: new Date() }
+    }).sort({ lastUsed: -1 });
+ 
+    return sessions;
+}
+ 
+/**
+ * Get all active sessions for a global user (global DB).
+ * Use when req.user.globalUserId is set and req.user.userId may be null (no tenant membership).
+ */
+async function getUserSessionsForGlobalUser(globalUserId, req) {
+    const { Session } = getGlobalModels(req, 'Session');
+    const sessions = await Session.find({
+        globalUserId,
+        expiresAt: { $gt: new Date() }
+    }).sort({ lastUsed: -1 });
+ 
+    return sessions;
+}
+ 
+/**
+ * Revoke all sessions except the current one for a global user (global DB).
+ */
+async function revokeAllOtherSessionsForGlobalUser(globalUserId, currentRefreshToken, req) {
+    const { Session } = getGlobalModels(req, 'Session');
+    const result = await Session.deleteMany({
+        globalUserId,
+        refreshToken: { $ne: currentRefreshToken }
+    });
+    return result.deletedCount;
+}
+ 
+/**
+ * Clean up expired sessions (can be called periodically)
+ */
+async function cleanupExpiredSessions(req) {
+    const { Session } = getModels(req, 'Session');
+    const result = await Session.deleteMany({ 
+        expiresAt: { $lt: new Date() }
+    });
+    return result.deletedCount;
+}
+ 
+module.exports = {
+    createSession,
+    createGlobalSession,
+    validateSession,
+    validateGlobalSession,
+    deleteSession,
+    deleteAllUserSessions,
+    deleteSessionById,
+    deleteSessionByIdForGlobalUser,
+    getUserSessions,
+    getUserSessionsForGlobalUser,
+    revokeAllOtherSessionsForGlobalUser,
+    cleanupExpiredSessions,
+    getDeviceInfo
+};
+ 
+ 
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov-report/utilities/timeBlockHelper.js.html b/backend/coverage/lcov-report/utilities/timeBlockHelper.js.html new file mode 100644 index 00000000..42c4587d --- /dev/null +++ b/backend/coverage/lcov-report/utilities/timeBlockHelper.js.html @@ -0,0 +1,838 @@ + + + + + + Code coverage report for utilities/timeBlockHelper.js + + + + + + + + + +
+
+

All files / utilities timeBlockHelper.js

+
+ +
+ 71.05% + Statements + 81/114 +
+ + +
+ 39.58% + Branches + 19/48 +
+ + +
+ 80% + Functions + 12/15 +
+ + +
+ 71.81% + Lines + 79/110 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +1x +1x +1x +1x +  +  +  +1x +4x +4x +4x +4x +  +4x +  +  +  +1x +2x +2x +2x +  +  +  +1x +1x +  +1x +  +  +  +  +1x +1x +  +1x +  +  +  +  +1x +  +  +  +1x +1x +  +  +1x +1x +1x +  +  +1x +  +  +  +1x +  +  +  +1x +1x +  +  +1x +2x +  +  +1x +  +1x +2x +2x +  +2x +  +1x +  +  +  +  +1x +  +  +  +1x +  +  +  +1x +1x +  +1x +1x +  +  +1x +  +1x +1x +1x +  +1x +  +1x +1x +  +  +  +  +  +  +  +1x +  +  +  +1x +1x +1x +1x +  +1x +  +  +1x +  +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +1x +1x +  +  +  +1x +1x +1x +  +  +1x +1x +1x +  +  +1x +1x +1x +  +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Time Block Helper Utilities
+ * Utilities for working with time blocks and DateTime objects
+ */
+ 
+// placeholder helper, should be considered for replacement in the future.
+ 
+// Convert time to minutes from midnight (for compatibility with existing schedule system)
+const timeToMinutes = (dateTime) => {
+    const date = new Date(dateTime);
+    return date.getHours() * 60 + date.getMinutes();
+};
+ 
+// Convert minutes from midnight to DateTime object for today
+const minutesToDateTime = (minutes, baseDate = new Date()) => {
+    const date = new Date(baseDate);
+    date.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
+    return date;
+};
+ 
+// Check if two time blocks overlap
+const hasTimeOverlap = (block1, block2) => {
+    const start1 = new Date(block1.startTime);
+    const end1 = new Date(block1.endTime);
+    const start2 = new Date(block2.startTime);
+    const end2 = new Date(block2.endTime);
+    
+    return start1 < end2 && end1 > start2;
+};
+ 
+// Get duration of a time block in minutes
+const getBlockDuration = (block) => {
+    const start = new Date(block.startTime);
+    const end = new Date(block.endTime);
+    return Math.round((end - start) / (1000 * 60)); // Convert ms to minutes
+};
+ 
+// Validate time block structure
+const validateTimeBlock = (block) => {
+    const errors = [];
+    
+    Iif (!block.startTime || !block.endTime) {
+        errors.push('Both startTime and endTime are required');
+        return errors;
+    }
+    
+    const start = new Date(block.startTime);
+    const end = new Date(block.endTime);
+    
+    Iif (isNaN(start.getTime()) || isNaN(end.getTime())) {
+        errors.push('Invalid date format');
+        return errors;
+    }
+    
+    Iif (start >= end) {
+        errors.push('Start time must be before end time');
+    }
+    
+    Eif (start <= new Date()) {
+        errors.push('Time must be in the future');
+    }
+    
+    const duration = getBlockDuration(block);
+    Eif (duration < 15) {
+        errors.push('Minimum duration is 15 minutes');
+    }
+    
+    Iif (duration > 480) { // 8 hours
+        errors.push('Maximum duration is 8 hours');
+    }
+    
+    return errors;
+};
+ 
+// Merge overlapping time blocks
+const mergeOverlappingBlocks = (blocks) => {
+    Iif (blocks.length <= 1) return blocks;
+    
+    // Sort blocks by start time
+    const sortedBlocks = blocks.slice().sort((a, b) => 
+        new Date(a.startTime) - new Date(b.startTime)
+    );
+    
+    const merged = [sortedBlocks[0]];
+    
+    for (let i = 1; i < sortedBlocks.length; i++) {
+        const current = sortedBlocks[i];
+        const lastMerged = merged[merged.length - 1];
+        
+        if (hasTimeOverlap(lastMerged, current)) {
+            // Extend the last merged block
+            lastMerged.endTime = new Date(Math.max(
+                new Date(lastMerged.endTime),
+                new Date(current.endTime)
+            ));
+        } else {
+            merged.push(current);
+        }
+    }
+    
+    return merged;
+};
+ 
+// Find time gaps between blocks
+const findTimeGaps = (blocks, minGapMinutes = 30) => {
+    Iif (blocks.length <= 1) return [];
+    
+    const sortedBlocks = blocks.slice().sort((a, b) => 
+        new Date(a.startTime) - new Date(b.startTime)
+    );
+    
+    const gaps = [];
+    
+    for (let i = 0; i < sortedBlocks.length - 1; i++) {
+        const currentEnd = new Date(sortedBlocks[i].endTime);
+        const nextStart = new Date(sortedBlocks[i + 1].startTime);
+        
+        const gapDuration = (nextStart - currentEnd) / (1000 * 60); // minutes
+        
+        Eif (gapDuration >= minGapMinutes) {
+            gaps.push({
+                startTime: currentEnd,
+                endTime: nextStart,
+                duration: gapDuration
+            });
+        }
+    }
+    
+    return gaps;
+};
+ 
+// Round time to nearest interval (e.g., 15, 30, 60 minutes)
+const roundToInterval = (dateTime, intervalMinutes = 30) => {
+    const date = new Date(dateTime);
+    const minutes = date.getMinutes();
+    const roundedMinutes = Math.round(minutes / intervalMinutes) * intervalMinutes;
+    
+    date.setMinutes(roundedMinutes, 0, 0);
+    
+    // Handle hour overflow
+    Iif (roundedMinutes >= 60) {
+        date.setHours(date.getHours() + 1);
+        date.setMinutes(roundedMinutes - 60);
+    }
+    
+    return date;
+};
+ 
+// Format time block for display
+const formatTimeBlock = (block, options = {}) => {
+    const { includeDate = false, timezone = 'local' } = options;
+    
+    const start = new Date(block.startTime);
+    const end = new Date(block.endTime);
+    
+    const formatOptions = {
+        hour: 'numeric',
+        minute: '2-digit',
+        hour12: true
+    };
+    
+    if (includeDate) {
+        formatOptions.weekday = 'short';
+        formatOptions.month = 'short';
+        formatOptions.day = 'numeric';
+    }
+    
+    const startStr = start.toLocaleString('en-US', formatOptions);
+    const endStr = end.toLocaleString('en-US', formatOptions);
+    
+    if (includeDate && start.toDateString() === end.toDateString()) {
+        // Same day
+        const dateStr = start.toLocaleDateString('en-US', { 
+            weekday: 'short', 
+            month: 'short', 
+            day: 'numeric' 
+        });
+        const timeRange = `${start.toLocaleTimeString('en-US', { 
+            hour: 'numeric', 
+            minute: '2-digit', 
+            hour12: true 
+        })} - ${end.toLocaleTimeString('en-US', { 
+            hour: 'numeric', 
+            minute: '2-digit', 
+            hour12: true 
+        })}`;
+        return `${dateStr}, ${timeRange}`;
+    }
+    
+    return `${startStr} - ${endStr}`;
+};
+ 
+// Check if time block is within business hours
+const isWithinBusinessHours = (block, businessStart = 8, businessEnd = 22) => {
+    const start = new Date(block.startTime);
+    const end = new Date(block.endTime);
+    
+    const startHour = start.getHours();
+    const endHour = end.getHours();
+    const endMinutes = end.getMinutes();
+    
+    // Consider end time of 22:00 as within business hours
+    const effectiveEndHour = endMinutes > 0 ? endHour + 1 : endHour;
+    
+    return startHour >= businessStart && effectiveEndHour <= businessEnd;
+};
+ 
+// Get day of week from date (0 = Sunday, 6 = Saturday)
+const getDayOfWeek = (dateTime) => {
+    return new Date(dateTime).getDay();
+};
+ 
+// Convert day of week to letter format (M, T, W, R, F)
+const dayToLetter = (dayNumber) => {
+    const days = ['S', 'M', 'T', 'W', 'R', 'F', 'S'];
+    return days[dayNumber] || '';
+};
+ 
+// Get week boundaries for a given date
+const getWeekBoundaries = (dateTime) => {
+    const date = new Date(dateTime);
+    const dayOfWeek = date.getDay();
+    
+    // Start of week (Sunday)
+    const startOfWeek = new Date(date);
+    startOfWeek.setDate(date.getDate() - dayOfWeek);
+    startOfWeek.setHours(0, 0, 0, 0);
+    
+    // End of week (Saturday)
+    const endOfWeek = new Date(startOfWeek);
+    endOfWeek.setDate(startOfWeek.getDate() + 6);
+    endOfWeek.setHours(23, 59, 59, 999);
+    
+    return { startOfWeek, endOfWeek };
+};
+ 
+module.exports = {
+    timeToMinutes,
+    minutesToDateTime,
+    hasTimeOverlap,
+    getBlockDuration,
+    validateTimeBlock,
+    mergeOverlappingBlocks,
+    findTimeGaps,
+    roundToInterval,
+    formatTimeBlock,
+    isWithinBusinessHours,
+    getDayOfWeek,
+    dayToLetter,
+    getWeekBoundaries
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/coverage/lcov.info b/backend/coverage/lcov.info new file mode 100644 index 00000000..e1ae2387 --- /dev/null +++ b/backend/coverage/lcov.info @@ -0,0 +1,1610 @@ +TN: +SF:services/feedbackService.js +FN:4,(anonymous_0) +FN:10,(anonymous_1) +FN:24,(anonymous_2) +FN:34,(anonymous_3) +FN:87,(anonymous_4) +FN:101,(anonymous_5) +FN:109,(anonymous_6) +FN:117,(anonymous_7) +FN:245,(anonymous_8) +FN:259,(anonymous_9) +FN:300,(anonymous_10) +FN:313,(anonymous_11) +FN:318,(anonymous_12) +FN:331,(anonymous_13) +FNF:14 +FNH:0 +FNDA:0,(anonymous_0) +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +DA:1,1 +DA:5,0 +DA:6,0 +DA:11,0 +DA:13,0 +DA:16,0 +DA:17,0 +DA:20,0 +DA:25,0 +DA:27,0 +DA:28,0 +DA:30,0 +DA:35,0 +DA:38,0 +DA:41,0 +DA:43,0 +DA:44,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:54,0 +DA:60,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:68,0 +DA:71,0 +DA:82,0 +DA:88,0 +DA:90,0 +DA:97,0 +DA:102,0 +DA:104,0 +DA:105,0 +DA:110,0 +DA:112,0 +DA:113,0 +DA:118,0 +DA:119,0 +DA:122,0 +DA:168,0 +DA:212,0 +DA:217,0 +DA:222,0 +DA:224,0 +DA:225,0 +DA:228,0 +DA:229,0 +DA:232,0 +DA:237,0 +DA:238,0 +DA:241,0 +DA:246,0 +DA:247,0 +DA:251,0 +DA:252,0 +DA:255,0 +DA:256,0 +DA:260,0 +DA:261,0 +DA:296,0 +DA:301,0 +DA:303,0 +DA:304,0 +DA:308,0 +DA:313,0 +DA:319,0 +DA:321,0 +DA:327,0 +DA:332,0 +DA:334,0 +DA:335,0 +DA:336,0 +DA:339,0 +DA:345,0 +DA:349,1 +LF:78 +LH:2 +BRDA:16,0,0,0 +BRDA:16,0,1,0 +BRDA:24,1,0,0 +BRDA:34,2,0,0 +BRDA:43,3,0,0 +BRDA:43,3,1,0 +BRDA:49,4,0,0 +BRDA:49,4,1,0 +BRDA:60,5,0,0 +BRDA:60,5,1,0 +BRDA:101,6,0,0 +BRDA:224,7,0,0 +BRDA:224,7,1,0 +BRDA:228,8,0,0 +BRDA:228,8,1,0 +BRDA:237,9,0,0 +BRDA:237,9,1,0 +BRDA:251,10,0,0 +BRDA:251,10,1,0 +BRDA:300,11,0,0 +BRDA:303,12,0,0 +BRDA:303,12,1,0 +BRDA:331,13,0,0 +BRDA:331,14,0,0 +BRDA:331,15,0,0 +BRDA:335,16,0,0 +BRDA:335,16,1,0 +BRF:27 +BRH:0 +end_of_record +TN: +SF:services/pivotFeedService.js +FN:23,getPilotWindow +FN:32,getFeedPilotWindowFilter +FN:59,isUpcomingPivotEvent +FN:88,getUpcomingEventTimeFilter +FN:104,resolveDisplayHost +FN:118,serializePivotFeedEvent +FN:153,getAcceptedFriendIds +FN:162,(anonymous_7) +FN:167,mapFriendPreview +FN:175,makeEmptySocialMap +FN:177,(anonymous_10) +FN:189,loadFriendSocial +FN:220,(anonymous_12) +FN:255,(anonymous_13) +FN:260,(anonymous_14) +FN:310,normalizeExcludeEventIds +FN:330,normalizeInterestTagSet +FN:348,countInterestOverlap +FN:372,countNegativeTagOverlap +FN:396,compareByFeedRank +FN:401,(anonymous_20) +FN:434,loadUserInterestTags +FN:440,collectCatalogTagsFromEvents +FN:460,loadNegativeFeedbackTags +FN:476,(anonymous_24) +FN:487,getPivotFeed +FN:528,(anonymous_26) +FN:532,(anonymous_27) +FN:551,(anonymous_28) +FN:570,(anonymous_29) +FN:605,getPivotEventFriends +FNF:31 +FNH:31 +FNDA:14,getPilotWindow +FNDA:13,getFeedPilotWindowFilter +FNDA:20,isUpcomingPivotEvent +FNDA:13,getUpcomingEventTimeFilter +FNDA:34,resolveDisplayHost +FNDA:16,serializePivotFeedEvent +FNDA:11,getAcceptedFriendIds +FNDA:2,(anonymous_7) +FNDA:3,mapFriendPreview +FNDA:15,makeEmptySocialMap +FNDA:20,(anonymous_10) +FNDA:13,loadFriendSocial +FNDA:1,(anonymous_12) +FNDA:3,(anonymous_13) +FNDA:2,(anonymous_14) +FNDA:16,normalizeExcludeEventIds +FNDA:17,normalizeInterestTagSet +FNDA:14,countInterestOverlap +FNDA:8,countNegativeTagOverlap +FNDA:14,compareByFeedRank +FNDA:7,(anonymous_20) +FNDA:12,loadUserInterestTags +FNDA:1,collectCatalogTagsFromEvents +FNDA:12,loadNegativeFeedbackTags +FNDA:1,(anonymous_24) +FNDA:13,getPivotFeed +FNDA:17,(anonymous_26) +FNDA:15,(anonymous_27) +FNDA:15,(anonymous_28) +FNDA:15,(anonymous_29) +FNDA:2,getPivotEventFriends +DA:1,1 +DA:2,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:10,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:21,1 +DA:24,14 +DA:27,14 +DA:28,14 +DA:33,13 +DA:35,13 +DA:60,20 +DA:61,0 +DA:64,20 +DA:65,20 +DA:66,1 +DA:70,19 +DA:73,19 +DA:74,5 +DA:78,14 +DA:81,14 +DA:82,14 +DA:85,0 +DA:89,13 +DA:105,34 +DA:106,34 +DA:107,34 +DA:108,2 +DA:111,32 +DA:119,16 +DA:120,16 +DA:121,16 +DA:122,16 +DA:123,16 +DA:127,16 +DA:154,11 +DA:161,11 +DA:162,11 +DA:163,2 +DA:168,3 +DA:176,15 +DA:177,20 +DA:190,13 +DA:192,13 +DA:193,2 +DA:200,11 +DA:207,11 +DA:211,11 +DA:212,10 +DA:215,11 +DA:219,11 +DA:220,1 +DA:229,11 +DA:230,11 +DA:231,9 +DA:238,2 +DA:246,2 +DA:247,0 +DA:254,2 +DA:255,3 +DA:257,2 +DA:260,2 +DA:262,2 +DA:263,2 +DA:265,2 +DA:266,3 +DA:267,3 +DA:268,3 +DA:269,3 +DA:270,0 +DA:273,3 +DA:274,3 +DA:275,2 +DA:276,2 +DA:277,2 +DA:278,2 +DA:280,2 +DA:281,2 +DA:284,2 +DA:285,2 +DA:286,0 +DA:287,0 +DA:288,0 +DA:293,0 +DA:294,0 +DA:295,0 +DA:296,0 +DA:299,1 +DA:300,1 +DA:301,1 +DA:302,1 +DA:307,2 +DA:311,16 +DA:312,13 +DA:315,3 +DA:319,3 +DA:320,3 +DA:321,7 +DA:322,7 +DA:323,5 +DA:327,3 +DA:331,17 +DA:332,0 +DA:335,17 +DA:336,17 +DA:337,10 +DA:338,0 +DA:340,10 +DA:341,10 +DA:342,10 +DA:345,17 +DA:349,14 +DA:350,2 +DA:353,12 +DA:354,12 +DA:355,3 +DA:358,9 +DA:359,9 +DA:360,10 +DA:361,0 +DA:363,10 +DA:364,10 +DA:365,8 +DA:369,9 +DA:373,8 +DA:374,2 +DA:377,6 +DA:378,6 +DA:379,2 +DA:382,4 +DA:383,4 +DA:384,4 +DA:385,0 +DA:387,4 +DA:388,4 +DA:389,2 +DA:393,4 +DA:401,14 +DA:402,7 +DA:403,7 +DA:404,7 +DA:405,7 +DA:406,7 +DA:407,1 +DA:410,6 +DA:411,6 +DA:412,6 +DA:413,1 +DA:416,5 +DA:417,5 +DA:418,5 +DA:419,2 +DA:422,3 +DA:423,3 +DA:424,3 +DA:425,2 +DA:428,1 +DA:429,1 +DA:430,1 +DA:435,12 +DA:436,12 +DA:437,12 +DA:441,1 +DA:442,1 +DA:443,1 +DA:444,1 +DA:445,0 +DA:447,1 +DA:448,1 +DA:449,0 +DA:451,1 +DA:452,1 +DA:453,1 +DA:457,1 +DA:461,12 +DA:462,12 +DA:464,12 +DA:472,12 +DA:473,11 +DA:476,1 +DA:477,1 +DA:484,1 +DA:488,13 +DA:489,13 +DA:490,0 +DA:497,13 +DA:498,13 +DA:499,13 +DA:500,1 +DA:507,12 +DA:508,12 +DA:510,12 +DA:518,12 +DA:519,1 +DA:522,12 +DA:527,12 +DA:529,17 +DA:532,15 +DA:533,12 +DA:541,12 +DA:542,12 +DA:543,12 +DA:547,12 +DA:548,12 +DA:550,12 +DA:551,15 +DA:554,12 +DA:566,12 +DA:571,15 +DA:572,15 +DA:578,15 +DA:579,15 +DA:582,15 +DA:583,15 +DA:584,2 +DA:585,2 +DA:586,0 +DA:590,15 +DA:606,2 +DA:607,2 +DA:608,0 +DA:615,2 +DA:616,2 +DA:617,0 +DA:624,2 +DA:625,2 +DA:635,2 +DA:636,1 +DA:643,1 +DA:649,1 +DA:654,1 +DA:662,1 +LF:238 +LH:217 +BRDA:23,0,0,0 +BRDA:32,1,0,0 +BRDA:59,2,0,0 +BRDA:60,3,0,0 +BRDA:60,3,1,20 +BRDA:65,4,0,1 +BRDA:65,4,1,19 +BRDA:70,5,0,5 +BRDA:70,5,1,14 +BRDA:70,6,0,19 +BRDA:70,6,1,5 +BRDA:73,7,0,5 +BRDA:73,7,1,14 +BRDA:73,8,0,19 +BRDA:73,8,1,5 +BRDA:78,9,0,14 +BRDA:78,9,1,0 +BRDA:78,10,0,14 +BRDA:78,10,1,14 +BRDA:81,11,0,14 +BRDA:81,11,1,0 +BRDA:81,12,0,14 +BRDA:81,12,1,14 +BRDA:88,13,0,0 +BRDA:107,14,0,2 +BRDA:107,14,1,32 +BRDA:113,15,0,1 +BRDA:113,15,1,31 +BRDA:114,16,0,0 +BRDA:114,16,1,32 +BRDA:119,17,0,16 +BRDA:119,17,1,0 +BRDA:123,18,0,1 +BRDA:123,18,1,15 +BRDA:130,19,0,16 +BRDA:130,19,1,16 +BRDA:136,20,0,16 +BRDA:136,20,1,9 +BRDA:137,21,0,9 +BRDA:137,21,1,7 +BRDA:138,22,0,0 +BRDA:138,22,1,16 +BRDA:139,23,0,1 +BRDA:139,23,1,15 +BRDA:140,24,0,0 +BRDA:140,24,1,16 +BRDA:143,25,0,0 +BRDA:143,25,1,16 +BRDA:163,26,0,2 +BRDA:163,26,1,0 +BRDA:170,27,0,3 +BRDA:170,27,1,0 +BRDA:170,27,2,0 +BRDA:171,28,0,3 +BRDA:171,28,1,3 +BRDA:189,29,0,0 +BRDA:189,30,0,1 +BRDA:192,31,0,2 +BRDA:192,31,1,11 +BRDA:211,32,0,10 +BRDA:211,32,1,1 +BRDA:224,33,0,1 +BRDA:224,33,1,1 +BRDA:230,34,0,9 +BRDA:230,34,1,2 +BRDA:246,35,0,0 +BRDA:246,35,1,2 +BRDA:269,36,0,0 +BRDA:269,36,1,3 +BRDA:269,37,0,3 +BRDA:269,37,1,3 +BRDA:274,38,0,2 +BRDA:274,38,1,1 +BRDA:277,39,0,2 +BRDA:277,39,1,0 +BRDA:280,40,0,2 +BRDA:280,40,1,0 +BRDA:284,41,0,0 +BRDA:284,41,1,2 +BRDA:285,42,0,0 +BRDA:285,42,1,2 +BRDA:287,43,0,0 +BRDA:287,43,1,0 +BRDA:295,44,0,0 +BRDA:295,44,1,0 +BRDA:299,45,0,1 +BRDA:299,45,1,0 +BRDA:301,46,0,1 +BRDA:301,46,1,0 +BRDA:311,47,0,13 +BRDA:311,47,1,3 +BRDA:315,48,0,1 +BRDA:315,48,1,2 +BRDA:322,49,0,5 +BRDA:322,49,1,2 +BRDA:322,50,0,7 +BRDA:322,50,1,7 +BRDA:331,51,0,0 +BRDA:331,51,1,17 +BRDA:337,52,0,0 +BRDA:337,52,1,10 +BRDA:341,53,0,10 +BRDA:341,53,1,0 +BRDA:349,54,0,2 +BRDA:349,54,1,12 +BRDA:354,55,0,3 +BRDA:354,55,1,9 +BRDA:354,56,0,12 +BRDA:354,56,1,12 +BRDA:360,57,0,0 +BRDA:360,57,1,10 +BRDA:364,58,0,8 +BRDA:364,58,1,2 +BRDA:364,59,0,10 +BRDA:364,59,1,10 +BRDA:373,60,0,2 +BRDA:373,60,1,6 +BRDA:378,61,0,2 +BRDA:378,61,1,4 +BRDA:378,62,0,6 +BRDA:378,62,1,5 +BRDA:384,63,0,0 +BRDA:384,63,1,4 +BRDA:388,64,0,2 +BRDA:388,64,1,2 +BRDA:388,65,0,4 +BRDA:388,65,1,4 +BRDA:399,66,0,1 +BRDA:404,67,0,7 +BRDA:404,67,1,6 +BRDA:405,68,0,7 +BRDA:405,68,1,7 +BRDA:406,69,0,1 +BRDA:406,69,1,6 +BRDA:410,70,0,6 +BRDA:410,70,1,5 +BRDA:411,71,0,6 +BRDA:411,71,1,6 +BRDA:412,72,0,1 +BRDA:412,72,1,5 +BRDA:418,73,0,2 +BRDA:418,73,1,3 +BRDA:424,74,0,2 +BRDA:424,74,1,1 +BRDA:428,75,0,1 +BRDA:428,75,1,0 +BRDA:429,76,0,1 +BRDA:429,76,1,0 +BRDA:444,77,0,0 +BRDA:444,77,1,1 +BRDA:448,78,0,0 +BRDA:448,78,1,1 +BRDA:452,79,0,1 +BRDA:452,79,1,0 +BRDA:452,80,0,1 +BRDA:452,80,1,1 +BRDA:472,81,0,11 +BRDA:472,81,1,1 +BRDA:487,82,0,0 +BRDA:489,83,0,0 +BRDA:489,83,1,13 +BRDA:497,84,0,13 +BRDA:497,84,1,0 +BRDA:498,85,0,13 +BRDA:498,85,1,0 +BRDA:499,86,0,1 +BRDA:499,86,1,12 +BRDA:499,87,0,13 +BRDA:499,87,1,13 +BRDA:518,88,0,1 +BRDA:518,88,1,11 +BRDA:529,89,0,17 +BRDA:529,89,1,16 +BRDA:548,90,0,12 +BRDA:548,90,1,0 +BRDA:548,90,2,0 +BRDA:572,91,0,15 +BRDA:572,91,1,0 +BRDA:585,92,0,0 +BRDA:585,92,1,2 +BRDA:592,93,0,15 +BRDA:592,93,1,14 +BRDA:593,94,0,15 +BRDA:593,94,1,15 +BRDA:597,95,0,15 +BRDA:597,95,1,13 +BRDA:598,96,0,15 +BRDA:598,96,1,14 +BRDA:607,97,0,0 +BRDA:607,97,1,2 +BRDA:615,98,0,2 +BRDA:615,98,1,0 +BRDA:616,99,0,0 +BRDA:616,99,1,2 +BRDA:635,100,0,1 +BRDA:635,100,1,1 +BRDA:649,101,0,1 +BRDA:649,101,1,0 +BRF:198 +BRH:148 +end_of_record +TN: +SF:services/pivotFeedbackService.js +FN:14,unauthorized +FN:18,serializePendingEvent +FN:29,getPendingEventFeedback +FN:54,(anonymous_3) +FN:68,(anonymous_4) +FN:73,(anonymous_5) +FN:82,(anonymous_6) +FN:83,(anonymous_7) +FN:87,(anonymous_8) +FN:92,submitEventFeedback +FN:198,listUserPivotEventFeedback +FN:219,(anonymous_11) +FN:223,(anonymous_12) +FN:225,(anonymous_13) +FNF:14 +FNH:0 +FNDA:0,unauthorized +FNDA:0,serializePendingEvent +FNDA:0,getPendingEventFeedback +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,submitEventFeedback +FNDA:0,listUserPivotEventFeedback +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +DA:1,1 +DA:2,1 +DA:3,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:12,1 +DA:15,0 +DA:19,0 +DA:20,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:35,0 +DA:36,0 +DA:43,0 +DA:50,0 +DA:51,0 +DA:54,0 +DA:56,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:73,0 +DA:74,0 +DA:82,0 +DA:83,0 +DA:85,0 +DA:87,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:98,0 +DA:99,0 +DA:101,0 +DA:103,0 +DA:104,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:123,0 +DA:130,0 +DA:131,0 +DA:138,0 +DA:139,0 +DA:145,0 +DA:146,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:158,0 +DA:159,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:172,0 +DA:180,0 +DA:181,0 +DA:187,0 +DA:188,0 +DA:194,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:204,0 +DA:205,0 +DA:207,0 +DA:215,0 +DA:216,0 +DA:219,0 +DA:220,0 +DA:223,0 +DA:225,0 +DA:226,0 +DA:227,0 +DA:228,0 +DA:239,0 +DA:242,1 +LF:82 +LH:8 +BRDA:25,0,0,0 +BRDA:25,0,1,0 +BRDA:29,1,0,0 +BRDA:31,2,0,0 +BRDA:31,2,1,0 +BRDA:35,3,0,0 +BRDA:35,3,1,0 +BRDA:50,4,0,0 +BRDA:50,4,1,0 +BRDA:69,5,0,0 +BRDA:69,5,1,0 +BRDA:92,6,0,0 +BRDA:94,7,0,0 +BRDA:94,7,1,0 +BRDA:98,8,0,0 +BRDA:98,8,1,0 +BRDA:101,9,0,0 +BRDA:101,9,1,0 +BRDA:103,10,0,0 +BRDA:103,10,1,0 +BRDA:112,11,0,0 +BRDA:112,11,1,0 +BRDA:112,12,0,0 +BRDA:112,12,1,0 +BRDA:112,12,2,0 +BRDA:120,13,0,0 +BRDA:120,13,1,0 +BRDA:122,14,0,0 +BRDA:122,14,1,0 +BRDA:130,15,0,0 +BRDA:130,15,1,0 +BRDA:130,16,0,0 +BRDA:130,16,1,0 +BRDA:145,17,0,0 +BRDA:145,17,1,0 +BRDA:154,18,0,0 +BRDA:154,18,1,0 +BRDA:158,19,0,0 +BRDA:158,19,1,0 +BRDA:158,19,2,0 +BRDA:180,20,0,0 +BRDA:180,20,1,0 +BRDA:187,21,0,0 +BRDA:187,21,1,0 +BRDA:198,22,0,0 +BRDA:200,23,0,0 +BRDA:200,23,1,0 +BRDA:204,24,0,0 +BRDA:204,24,1,0 +BRDA:215,25,0,0 +BRDA:215,25,1,0 +BRDA:227,26,0,0 +BRDA:227,26,1,0 +BRDA:230,27,0,0 +BRDA:230,27,1,0 +BRDA:232,28,0,0 +BRDA:232,28,1,0 +BRDA:233,29,0,0 +BRDA:233,29,1,0 +BRDA:234,30,0,0 +BRDA:234,30,1,0 +BRF:61 +BRH:0 +end_of_record +TN: +SF:services/pivotIntentService.js +FN:27,unauthorized +FN:36,findPublishedPivotEvent +FN:57,upsertIntent +FN:74,resolveRegisteredTimeSlotId +FN:106,recordFeedAction +FN:171,recordExternalOpen +FN:234,confirmRegistered +FN:289,serializeRecapEvent +FN:310,getWeekRecap +FN:341,(anonymous_9) +FN:366,(anonymous_10) +FN:367,(anonymous_11) +FN:405,resetWeekActions +FNF:13 +FNH:0 +FNDA:0,unauthorized +FNDA:0,findPublishedPivotEvent +FNDA:0,upsertIntent +FNDA:0,resolveRegisteredTimeSlotId +FNDA:0,recordFeedAction +FNDA:0,recordExternalOpen +FNDA:0,confirmRegistered +FNDA:0,serializeRecapEvent +FNDA:0,getWeekRecap +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,resetWeekActions +DA:1,1 +DA:2,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:16,1 +DA:18,1 +DA:23,1 +DA:25,1 +DA:28,0 +DA:37,0 +DA:39,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:52,0 +DA:58,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:65,0 +DA:71,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:80,0 +DA:81,0 +DA:83,0 +DA:84,0 +DA:87,0 +DA:88,0 +DA:95,0 +DA:96,0 +DA:103,0 +DA:107,0 +DA:108,0 +DA:109,0 +DA:112,0 +DA:113,0 +DA:115,0 +DA:116,0 +DA:123,0 +DA:124,0 +DA:125,0 +DA:132,0 +DA:136,0 +DA:137,0 +DA:144,0 +DA:145,0 +DA:153,0 +DA:161,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:195,0 +DA:196,0 +DA:199,0 +DA:201,0 +DA:205,0 +DA:215,0 +DA:223,0 +DA:235,0 +DA:236,0 +DA:237,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:249,0 +DA:250,0 +DA:251,0 +DA:258,0 +DA:259,0 +DA:260,0 +DA:263,0 +DA:264,0 +DA:272,0 +DA:279,0 +DA:290,0 +DA:292,0 +DA:294,0 +DA:298,0 +DA:311,0 +DA:312,0 +DA:313,0 +DA:316,0 +DA:317,0 +DA:318,0 +DA:319,0 +DA:326,0 +DA:328,0 +DA:336,0 +DA:337,0 +DA:340,0 +DA:341,0 +DA:346,0 +DA:348,0 +DA:359,0 +DA:365,0 +DA:366,0 +DA:368,0 +DA:369,0 +DA:375,0 +DA:378,0 +DA:379,0 +DA:380,0 +DA:381,0 +DA:382,0 +DA:386,0 +DA:395,0 +DA:402,0 +DA:406,0 +DA:407,0 +DA:408,0 +DA:411,0 +DA:412,0 +DA:413,0 +DA:414,0 +DA:421,0 +DA:425,0 +DA:430,0 +DA:438,1 +LF:127 +LH:10 +BRDA:36,0,0,0 +BRDA:47,1,0,0 +BRDA:47,1,1,0 +BRDA:61,2,0,0 +BRDA:61,2,1,0 +BRDA:62,3,0,0 +BRDA:62,3,1,0 +BRDA:76,4,0,0 +BRDA:76,4,1,0 +BRDA:81,5,0,0 +BRDA:81,5,1,0 +BRDA:83,6,0,0 +BRDA:83,6,1,0 +BRDA:84,7,0,0 +BRDA:84,7,1,0 +BRDA:87,8,0,0 +BRDA:87,8,1,0 +BRDA:95,9,0,0 +BRDA:95,9,1,0 +BRDA:106,10,0,0 +BRDA:108,11,0,0 +BRDA:108,11,1,0 +BRDA:112,12,0,0 +BRDA:112,12,1,0 +BRDA:113,13,0,0 +BRDA:113,13,1,0 +BRDA:115,14,0,0 +BRDA:115,14,1,0 +BRDA:124,15,0,0 +BRDA:124,15,1,0 +BRDA:136,16,0,0 +BRDA:136,16,1,0 +BRDA:144,17,0,0 +BRDA:144,17,1,0 +BRDA:166,18,0,0 +BRDA:166,18,1,0 +BRDA:171,19,0,0 +BRDA:173,20,0,0 +BRDA:173,20,1,0 +BRDA:177,21,0,0 +BRDA:177,21,1,0 +BRDA:178,22,0,0 +BRDA:178,22,1,0 +BRDA:187,23,0,0 +BRDA:187,23,1,0 +BRDA:195,24,0,0 +BRDA:195,24,1,0 +BRDA:196,25,0,0 +BRDA:196,25,1,0 +BRDA:199,26,0,0 +BRDA:199,26,1,0 +BRDA:234,27,0,0 +BRDA:236,28,0,0 +BRDA:236,28,1,0 +BRDA:240,29,0,0 +BRDA:240,29,1,0 +BRDA:241,30,0,0 +BRDA:241,30,1,0 +BRDA:250,31,0,0 +BRDA:250,31,1,0 +BRDA:259,32,0,0 +BRDA:259,32,1,0 +BRDA:263,33,0,0 +BRDA:263,33,1,0 +BRDA:276,34,0,0 +BRDA:276,34,1,0 +BRDA:284,35,0,0 +BRDA:284,35,1,0 +BRDA:289,36,0,0 +BRDA:290,37,0,0 +BRDA:290,37,1,0 +BRDA:292,38,0,0 +BRDA:292,38,1,0 +BRDA:292,39,0,0 +BRDA:292,39,1,0 +BRDA:294,40,0,0 +BRDA:294,40,1,0 +BRDA:294,40,2,0 +BRDA:294,41,0,0 +BRDA:294,41,1,0 +BRDA:294,42,0,0 +BRDA:294,42,1,0 +BRDA:300,43,0,0 +BRDA:300,43,1,0 +BRDA:302,44,0,0 +BRDA:302,44,1,0 +BRDA:303,45,0,0 +BRDA:303,45,1,0 +BRDA:304,46,0,0 +BRDA:304,46,1,0 +BRDA:305,47,0,0 +BRDA:305,47,1,0 +BRDA:306,48,0,0 +BRDA:306,48,1,0 +BRDA:310,49,0,0 +BRDA:312,50,0,0 +BRDA:312,50,1,0 +BRDA:316,51,0,0 +BRDA:316,51,1,0 +BRDA:317,52,0,0 +BRDA:317,52,1,0 +BRDA:318,53,0,0 +BRDA:318,53,1,0 +BRDA:318,54,0,0 +BRDA:318,54,1,0 +BRDA:336,55,0,0 +BRDA:336,55,1,0 +BRDA:343,56,0,0 +BRDA:343,56,1,0 +BRDA:369,57,0,0 +BRDA:369,57,1,0 +BRDA:381,58,0,0 +BRDA:381,58,1,0 +BRDA:390,59,0,0 +BRDA:390,59,1,0 +BRDA:391,60,0,0 +BRDA:391,60,1,0 +BRDA:405,61,0,0 +BRDA:407,62,0,0 +BRDA:407,62,1,0 +BRDA:411,63,0,0 +BRDA:411,63,1,0 +BRDA:412,64,0,0 +BRDA:412,64,1,0 +BRDA:413,65,0,0 +BRDA:413,65,1,0 +BRDA:413,66,0,0 +BRDA:413,66,1,0 +BRDA:433,67,0,0 +BRDA:433,67,1,0 +BRF:130 +BRH:0 +end_of_record +TN: +SF:utilities/pivotIsoWeek.js +FN:6,toIsoWeek +FN:16,isValidIsoWeek +FN:25,isoWeekToMondayUtc +FN:45,isoWeekToUtcRange +FN:52,toIsoWeekUtc +FN:66,shiftIsoWeek +FNF:6 +FNH:1 +FNDA:0,toIsoWeek +FNDA:13,isValidIsoWeek +FNDA:0,isoWeekToMondayUtc +FNDA:0,isoWeekToUtcRange +FNDA:0,toIsoWeekUtc +FNDA:0,shiftIsoWeek +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:14,1 +DA:17,13 +DA:26,0 +DA:27,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:72,1 +LF:29 +LH:3 +BRDA:6,0,0,0 +BRDA:8,1,0,0 +BRDA:8,1,1,0 +BRDA:17,2,0,13 +BRDA:17,2,1,13 +BRDA:26,3,0,0 +BRDA:26,3,1,0 +BRDA:34,4,0,0 +BRDA:34,4,1,0 +BRDA:54,5,0,0 +BRDA:54,5,1,0 +BRF:11 +BRH:2 +end_of_record +TN: +SF:utilities/pivotLogger.js +FN:3,isPivotLoggingEnabled +FN:14,serializeMeta +FN:25,logPivot +FN:42,pivotRequestContext +FN:52,pivotRequestLogger +FN:59,(anonymous_5) +FN:70,logPivotRouteError +FN:77,logPivotServiceReject +FN:86,logPivotServiceSuccess +FNF:9 +FNH:3 +FNDA:12,isPivotLoggingEnabled +FNDA:0,serializeMeta +FNDA:12,logPivot +FNDA:12,pivotRequestContext +FNDA:0,pivotRequestLogger +FNDA:0,(anonymous_5) +FNDA:0,logPivotRouteError +FNDA:0,logPivotServiceReject +FNDA:0,logPivotServiceSuccess +DA:1,1 +DA:4,12 +DA:5,12 +DA:6,0 +DA:8,12 +DA:9,12 +DA:11,0 +DA:15,0 +DA:16,0 +DA:18,0 +DA:19,0 +DA:21,0 +DA:26,12 +DA:27,12 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:39,0 +DA:43,12 +DA:53,0 +DA:54,0 +DA:55,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:67,0 +DA:71,0 +DA:78,0 +DA:87,0 +DA:93,1 +LF:35 +LH:9 +BRDA:5,0,0,0 +BRDA:5,0,1,12 +BRDA:5,1,0,12 +BRDA:5,1,1,12 +BRDA:8,2,0,12 +BRDA:8,2,1,0 +BRDA:15,3,0,0 +BRDA:15,3,1,0 +BRDA:26,4,0,12 +BRDA:26,4,1,0 +BRDA:31,5,0,0 +BRDA:31,5,1,0 +BRDA:35,6,0,0 +BRDA:35,6,1,0 +BRDA:44,7,0,12 +BRDA:44,7,1,0 +BRDA:45,8,0,12 +BRDA:45,8,1,0 +BRDA:47,9,0,12 +BRDA:47,9,1,12 +BRDA:53,10,0,0 +BRDA:53,10,1,0 +BRDA:60,11,0,0 +BRDA:60,11,1,0 +BRDA:60,12,0,0 +BRDA:60,12,1,0 +BRDA:73,13,0,0 +BRDA:73,13,1,0 +BRF:28 +BRH:9 +end_of_record +TN: +SF:utilities/pivotMovieMetadata.js +FN:7,trimString +FN:11,toNumber +FN:22,roundRating +FN:30,buildTmdbImageUrl +FN:41,normalizeRatings +FN:64,normalizeStringArray +FN:68,(anonymous_6) +FN:75,normalizePivotMovie +FN:116,serializePivotMovie +FN:124,applyMovieListingDefaults +FN:138,resolvePivotCoverImageUrl +FNF:11 +FNH:3 +FNDA:16,trimString +FNDA:0,toNumber +FNDA:0,roundRating +FNDA:0,buildTmdbImageUrl +FNDA:0,normalizeRatings +FNDA:0,normalizeStringArray +FNDA:0,(anonymous_6) +FNDA:0,normalizePivotMovie +FNDA:32,serializePivotMovie +FNDA:0,applyMovieListingDefaults +FNDA:16,resolvePivotCoverImageUrl +DA:5,1 +DA:8,16 +DA:12,0 +DA:13,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:19,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:27,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:35,0 +DA:36,0 +DA:38,0 +DA:42,0 +DA:43,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:52,0 +DA:65,0 +DA:66,0 +DA:68,0 +DA:76,0 +DA:77,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:99,0 +DA:117,32 +DA:118,32 +DA:121,0 +DA:125,0 +DA:126,0 +DA:129,0 +DA:130,0 +DA:139,16 +DA:140,16 +DA:141,16 +DA:142,16 +DA:143,0 +DA:145,16 +DA:146,0 +DA:148,16 +DA:151,1 +LF:62 +LH:11 +BRDA:8,0,0,0 +BRDA:8,0,1,16 +BRDA:12,1,0,0 +BRDA:12,1,1,0 +BRDA:12,2,0,0 +BRDA:12,2,1,0 +BRDA:15,3,0,0 +BRDA:15,3,1,0 +BRDA:15,4,0,0 +BRDA:15,4,1,0 +BRDA:17,5,0,0 +BRDA:17,5,1,0 +BRDA:24,6,0,0 +BRDA:24,6,1,0 +BRDA:30,7,0,0 +BRDA:32,8,0,0 +BRDA:32,8,1,0 +BRDA:35,9,0,0 +BRDA:35,9,1,0 +BRDA:35,10,0,0 +BRDA:35,10,1,0 +BRDA:38,11,0,0 +BRDA:38,11,1,0 +BRDA:42,12,0,0 +BRDA:42,12,1,0 +BRDA:42,13,0,0 +BRDA:42,13,1,0 +BRDA:46,14,0,0 +BRDA:46,14,1,0 +BRDA:47,15,0,0 +BRDA:47,15,1,0 +BRDA:48,16,0,0 +BRDA:48,16,1,0 +BRDA:48,17,0,0 +BRDA:48,17,1,0 +BRDA:53,18,0,0 +BRDA:53,18,1,0 +BRDA:57,19,0,0 +BRDA:57,19,1,0 +BRDA:65,20,0,0 +BRDA:65,20,1,0 +BRDA:76,21,0,0 +BRDA:76,21,1,0 +BRDA:76,22,0,0 +BRDA:76,22,1,0 +BRDA:82,23,0,0 +BRDA:82,23,1,0 +BRDA:82,24,0,0 +BRDA:82,24,1,0 +BRDA:87,25,0,0 +BRDA:87,25,1,0 +BRDA:88,26,0,0 +BRDA:88,26,1,0 +BRDA:90,27,0,0 +BRDA:90,27,1,0 +BRDA:91,28,0,0 +BRDA:91,28,1,0 +BRDA:102,29,0,0 +BRDA:102,29,1,0 +BRDA:103,30,0,0 +BRDA:103,30,1,0 +BRDA:104,31,0,0 +BRDA:104,31,1,0 +BRDA:105,32,0,0 +BRDA:105,32,1,0 +BRDA:106,33,0,0 +BRDA:106,33,1,0 +BRDA:107,34,0,0 +BRDA:107,34,1,0 +BRDA:108,35,0,0 +BRDA:108,35,1,0 +BRDA:109,36,0,0 +BRDA:109,36,1,0 +BRDA:110,37,0,0 +BRDA:110,37,1,0 +BRDA:111,38,0,0 +BRDA:111,38,1,0 +BRDA:112,39,0,0 +BRDA:112,39,1,0 +BRDA:117,40,0,32 +BRDA:117,40,1,0 +BRDA:117,41,0,32 +BRDA:117,41,1,0 +BRDA:125,42,0,0 +BRDA:125,42,1,0 +BRDA:132,43,0,0 +BRDA:132,43,1,0 +BRDA:133,44,0,0 +BRDA:133,44,1,0 +BRDA:133,44,2,0 +BRDA:134,45,0,0 +BRDA:134,45,1,0 +BRDA:134,45,2,0 +BRDA:139,46,0,16 +BRDA:139,46,1,0 +BRDA:142,47,0,0 +BRDA:142,47,1,16 +BRDA:145,48,0,0 +BRDA:145,48,1,16 +BRDA:148,49,0,16 +BRDA:148,49,1,16 +BRF:101 +BRH:8 +end_of_record +TN: +SF:utilities/pivotTimeSlots.js +FN:6,trimString +FN:10,parseDate +FN:22,normalizePivotTimeSlots +FN:53,(anonymous_3) +FN:57,resolveTimeSlotLabel +FN:72,serializePivotTimeSlots +FN:73,(anonymous_6) +FN:90,eventHasTimeSlots +FN:94,findTimeSlotById +FN:99,(anonymous_9) +FN:102,isTimeSlotUpcoming +FN:107,isUpcomingWithTimeSlots +FN:112,(anonymous_12) +FN:115,resolveEventEarliestStart +FN:123,resolveEventLatestEnd +FNF:15 +FNH:10 +FNDA:16,trimString +FNDA:16,parseDate +FNDA:66,normalizePivotTimeSlots +FNDA:4,(anonymous_3) +FNDA:2,resolveTimeSlotLabel +FNDA:1,serializePivotTimeSlots +FNDA:2,(anonymous_6) +FNDA:0,eventHasTimeSlots +FNDA:0,findTimeSlotById +FNDA:0,(anonymous_9) +FNDA:1,isTimeSlotUpcoming +FNDA:20,isUpcomingWithTimeSlots +FNDA:1,(anonymous_12) +FNDA:0,resolveEventEarliestStart +FNDA:0,resolveEventLatestEnd +DA:7,16 +DA:11,16 +DA:12,0 +DA:14,16 +DA:15,16 +DA:23,66 +DA:24,62 +DA:27,4 +DA:28,4 +DA:30,4 +DA:31,8 +DA:32,0 +DA:35,8 +DA:36,8 +DA:37,8 +DA:38,0 +DA:41,8 +DA:42,8 +DA:43,8 +DA:45,8 +DA:53,4 +DA:54,4 +DA:58,2 +DA:59,0 +DA:62,2 +DA:73,1 +DA:74,2 +DA:79,2 +DA:91,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:99,0 +DA:103,1 +DA:104,1 +DA:108,20 +DA:109,20 +DA:110,19 +DA:112,1 +DA:116,0 +DA:117,0 +DA:118,0 +DA:120,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:129,0 +DA:130,0 +DA:131,0 +DA:132,0 +DA:133,0 +DA:136,0 +DA:139,1 +LF:53 +LH:31 +BRDA:7,0,0,8 +BRDA:7,0,1,8 +BRDA:11,1,0,0 +BRDA:11,1,1,16 +BRDA:15,2,0,0 +BRDA:15,2,1,16 +BRDA:23,3,0,62 +BRDA:23,3,1,4 +BRDA:23,4,0,66 +BRDA:23,4,1,4 +BRDA:31,5,0,0 +BRDA:31,5,1,8 +BRDA:31,6,0,8 +BRDA:31,6,1,8 +BRDA:36,7,0,8 +BRDA:36,7,1,0 +BRDA:37,8,0,0 +BRDA:37,8,1,8 +BRDA:37,9,0,8 +BRDA:37,9,1,8 +BRDA:37,9,2,8 +BRDA:42,10,0,8 +BRDA:42,10,1,0 +BRDA:43,11,0,8 +BRDA:43,11,1,8 +BRDA:58,12,0,0 +BRDA:58,12,1,2 +BRDA:72,13,0,0 +BRDA:74,14,0,2 +BRDA:74,14,1,2 +BRDA:82,15,0,2 +BRDA:82,15,1,0 +BRDA:96,16,0,0 +BRDA:96,16,1,0 +BRDA:99,17,0,0 +BRDA:99,17,1,0 +BRDA:102,18,0,0 +BRDA:103,19,0,1 +BRDA:103,19,1,0 +BRDA:107,20,0,0 +BRDA:109,21,0,19 +BRDA:109,21,1,1 +BRDA:117,22,0,0 +BRDA:117,22,1,0 +BRDA:125,23,0,0 +BRDA:125,23,1,0 +BRDA:131,24,0,0 +BRDA:131,24,1,0 +BRDA:132,25,0,0 +BRDA:132,25,1,0 +BRDA:132,26,0,0 +BRDA:132,26,1,0 +BRF:52 +BRH:26 +end_of_record diff --git a/backend/migrations/seedPivotFeedEvents.js b/backend/migrations/seedPivotFeedEvents.js index a6d0f18d..0a4ab34d 100644 --- a/backend/migrations/seedPivotFeedEvents.js +++ b/backend/migrations/seedPivotFeedEvents.js @@ -407,6 +407,24 @@ const DEMO_EVENTS = [ tags: ['wellness'], registrationCount: 21, }, + { + slug: 'pivot-seed-indie-film', + name: 'Indie Film Night — The Last Garden', + description: 'Limited run at Nitehawk. Grab tickets for your showtime.', + location: 'Nitehawk Cinema, Williamsburg, Brooklyn, NY', + dayOffset: 2, + startHour: 18, + durationHours: 6, + externalLink: 'https://partiful.com/e/pivot-seed-indie-film', + host: { name: 'Nitehawk Cinema' }, + tags: ['movies', 'art-and-culture'], + registrationCount: 56, + timeSlots: [ + { id: '6pm', label: '6:00 PM', startHour: 18, durationHours: 2.25 }, + { id: '8-30pm', label: '8:30 PM', startHour: 20.5, durationHours: 2.25 }, + { id: '11pm', label: '11:00 PM', startHour: 23, durationHours: 2.25 }, + ], + }, ]; async function resolveCatalogOrgId(Org) { @@ -430,13 +448,15 @@ async function resolveCatalogOrgId(Org) { } function buildEventWindow(dayOffset, durationHours, now, startHour = 19) { + const wholeHour = Math.floor(startHour); + const minutes = Math.round((startHour - wholeHour) * 60); const start = new Date( Date.UTC( now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + dayOffset, - startHour, - 0, + wholeHour, + minutes, 0, ), ); @@ -444,6 +464,27 @@ function buildEventWindow(dayOffset, durationHours, now, startHour = 19) { return { start, end }; } +function buildPivotTimeSlots(demo, now) { + if (!Array.isArray(demo.timeSlots) || !demo.timeSlots.length) { + return null; + } + + return demo.timeSlots.map((slot) => { + const { start, end } = buildEventWindow( + demo.dayOffset, + slot.durationHours ?? 2, + now, + slot.startHour, + ); + return { + id: slot.id, + label: slot.label, + start_time: start, + end_time: end, + }; + }); +} + async function run() { const now = new Date(); const batchWeek = toIsoWeek(now); @@ -461,6 +502,19 @@ async function run() { demo.startHour ?? 19, ); + const timeSlots = buildPivotTimeSlots(demo, now); + const pivotMeta = { + batchWeek, + source: 'manual', + sourceUrl: demo.externalLink, + host: demo.host, + tags: demo.tags, + ingestStatus: 'published', + importedAt: now.toISOString(), + importedBy: 'seed:pivot-feed-events', + ...(timeSlots ? { timeSlots } : {}), + }; + await Event.findOneAndUpdate( { 'customFields.pivot.sourceUrl': demo.externalLink }, { @@ -480,16 +534,7 @@ async function run() { hostingId: catalogOrgId, isDeleted: false, customFields: { - pivot: { - batchWeek, - source: 'manual', - sourceUrl: demo.externalLink, - host: demo.host, - tags: demo.tags, - ingestStatus: 'published', - importedAt: now.toISOString(), - importedBy: 'seed:pivot-feed-events', - }, + pivot: pivotMeta, }, }, }, diff --git a/backend/migrations/sendPivotWeeklyPush.js b/backend/migrations/sendPivotWeeklyPush.js index 8284eba9..51f4353e 100644 --- a/backend/migrations/sendPivotWeeklyPush.js +++ b/backend/migrations/sendPivotWeeklyPush.js @@ -222,6 +222,21 @@ async function run() { const { sent, failed } = await sendAllMessages(messages); console.log(`[send:pivot-weekly-push] sent=${sent} failed=${failed}`); + + // Freeze this week's Lab metrics now that the drop went out (best-effort). + try { + const { rebuildWeeklySnapshot } = require('../services/pivotWeeklySnapshotService'); + const rebuild = await rebuildWeeklySnapshot(req, { batchWeek }); + if (rebuild.error) { + console.warn(`[send:pivot-weekly-push] snapshot rebuild skipped: ${rebuild.error}`); + } else { + console.log(`[send:pivot-weekly-push] weekly snapshot rebuilt for ${batchWeek}`); + } + } catch (error) { + console.warn( + `[send:pivot-weekly-push] snapshot rebuild failed (send already completed): ${error.message}` + ); + } } run() diff --git a/backend/routes/pivotAdminRoutes.js b/backend/routes/pivotAdminRoutes.js index 9475b9d2..8d92291f 100644 --- a/backend/routes/pivotAdminRoutes.js +++ b/backend/routes/pivotAdminRoutes.js @@ -6,6 +6,7 @@ const { getWeeklySnapshot, } = require('../services/pivotWeeklySnapshotService'); const { getPivotOverview } = require('../services/pivotAdminOverviewService'); +const { getPivotRetention } = require('../services/pivotRetentionService'); const { listPivotLabEvents } = require('../services/pivotLabEventsService'); const { getInterviewNotes, @@ -23,9 +24,21 @@ const { suggestPivotEventTags, suggestPivotEventTagsBatch, } = require('../services/pivotTagSuggestService'); +const { + searchTmdbMovies, + fetchTmdbMovieDetails, +} = require('../services/pivotTmdbService'); +const { + pivotRequestLogger, + logPivotRouteError, + logPivotServiceReject, + logPivotServiceSuccess, +} = require('../utilities/pivotLogger'); const router = express.Router(); +router.use(pivotRequestLogger); + router.get('/tags', verifyToken, requirePlatformAdmin, async (req, res) => { try { const result = await listPivotTags(req); @@ -42,7 +55,7 @@ router.get('/tags', verifyToken, requirePlatformAdmin, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/tags failed:', err); + logPivotRouteError('GET /admin/pivot/tags', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot tag catalog.', @@ -66,7 +79,7 @@ router.post('/tags/seed', verifyToken, requirePlatformAdmin, async (req, res) => data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/tags/seed failed:', err); + logPivotRouteError('POST /admin/pivot/tags/seed', err, req); return res.status(500).json({ success: false, message: 'Unable to seed pivot tag catalog.', @@ -93,7 +106,7 @@ router.get('/events', verifyToken, requirePlatformAdmin, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/events failed:', err); + logPivotRouteError('GET /admin/pivot/events', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot catalog events.', @@ -117,7 +130,7 @@ router.get('/interview-notes', verifyToken, requirePlatformAdmin, async (req, re data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/interview-notes failed:', err); + logPivotRouteError('GET /admin/pivot/interview-notes', err, req); return res.status(500).json({ success: false, message: 'Unable to load interview notes.', @@ -144,7 +157,7 @@ router.put('/interview-notes', verifyToken, requirePlatformAdmin, async (req, re data: result.data, }); } catch (err) { - console.error('PUT /admin/pivot/interview-notes failed:', err); + logPivotRouteError('PUT /admin/pivot/interview-notes', err, req); return res.status(500).json({ success: false, message: 'Unable to save interview notes.', @@ -168,7 +181,7 @@ router.get('/overview', verifyToken, requirePlatformAdmin, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/overview failed:', err); + logPivotRouteError('GET /admin/pivot/overview', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot overview.', @@ -176,6 +189,33 @@ router.get('/overview', verifyToken, requirePlatformAdmin, async (req, res) => { } }); +router.get('/retention', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await getPivotRetention(req, { + batchWeek: req.query?.batchWeek, + weeks: req.query?.weeks, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/retention', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot retention.', + }); + } +}); + router.post('/snapshots/rebuild', verifyToken, requirePlatformAdmin, async (req, res) => { try { const batchWeek = req.body?.batchWeek ?? req.query?.batchWeek; @@ -193,7 +233,7 @@ router.post('/snapshots/rebuild', verifyToken, requirePlatformAdmin, async (req, data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/snapshots/rebuild failed:', err); + logPivotRouteError('POST /admin/pivot/snapshots/rebuild', err, req); return res.status(500).json({ success: false, message: 'Unable to rebuild pivot weekly snapshot.', @@ -245,7 +285,7 @@ router.post('/ingest/suggest-tags', verifyToken, requirePlatformAdmin, async (re data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/ingest/suggest-tags failed:', err); + logPivotRouteError('POST /admin/pivot/ingest/suggest-tags', err, req); return res.status(500).json({ success: false, message: 'Unable to suggest pivot tags.', @@ -253,6 +293,57 @@ router.post('/ingest/suggest-tags', verifyToken, requirePlatformAdmin, async (re } }); +router.get('/tmdb/search', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await searchTmdbMovies({ + query: req.query?.query, + year: req.query?.year, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/tmdb/search', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to search TMDB.', + }); + } +}); + +router.get('/tmdb/movies/:tmdbId', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await fetchTmdbMovieDetails({ tmdbId: req.params.tmdbId }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/tmdb/movies/:tmdbId', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load TMDB movie.', + }); + } +}); + router.post('/ingest/preview', verifyToken, requirePlatformAdmin, async (req, res) => { try { const result = await previewIngestUrl(req, { @@ -272,7 +363,7 @@ router.post('/ingest/preview', verifyToken, requirePlatformAdmin, async (req, re data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/ingest/preview failed:', err); + logPivotRouteError('POST /admin/pivot/ingest/preview', err, req); return res.status(500).json({ success: false, message: 'Unable to preview event import.', @@ -289,6 +380,11 @@ router.post('/ingest', verifyToken, requirePlatformAdmin, async (req, res) => { overrides: req.body?.overrides, }); if (result.error) { + logPivotServiceReject('POST /admin/pivot/ingest', result, req, { + tenantKey: req.body?.tenantKey, + batchWeek: req.body?.batchWeek, + eventName: req.body?.overrides?.name, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -296,12 +392,19 @@ router.post('/ingest', verifyToken, requirePlatformAdmin, async (req, res) => { }); } + logPivotServiceSuccess('POST /admin/pivot/ingest', req, { + tenantKey: req.body?.tenantKey, + batchWeek: req.body?.batchWeek, + eventId: result.data?.event?._id, + eventName: result.data?.event?.name, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/ingest failed:', err); + logPivotRouteError('POST /admin/pivot/ingest', err, req); return res.status(500).json({ success: false, message: 'Unable to publish pivot catalog event.', @@ -317,6 +420,11 @@ router.post('/ingest/batch', verifyToken, requirePlatformAdmin, async (req, res) events: req.body?.events, }); if (result.error && !result.data?.published?.length) { + logPivotServiceReject('POST /admin/pivot/ingest/batch', result, req, { + tenantKey: req.body?.tenantKey, + batchWeek: req.body?.batchWeek, + requestedCount: Array.isArray(req.body?.events) ? req.body.events.length : 0, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -325,12 +433,19 @@ router.post('/ingest/batch', verifyToken, requirePlatformAdmin, async (req, res) }); } + logPivotServiceSuccess('POST /admin/pivot/ingest/batch', req, { + tenantKey: req.body?.tenantKey, + batchWeek: req.body?.batchWeek, + publishedCount: result.data?.publishedCount ?? result.data?.published?.length ?? 0, + failedCount: result.data?.failedCount ?? result.data?.failures?.length ?? 0, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/ingest/batch failed:', err); + logPivotRouteError('POST /admin/pivot/ingest/batch', err, req); return res.status(500).json({ success: false, message: 'Unable to publish pivot catalog events.', @@ -358,7 +473,7 @@ router.patch('/ingest/:eventId', verifyToken, requirePlatformAdmin, async (req, data: result.data, }); } catch (err) { - console.error('PATCH /admin/pivot/ingest/:eventId failed:', err); + logPivotRouteError('PATCH /admin/pivot/ingest/:eventId', err, req); return res.status(500).json({ success: false, message: 'Unable to update pivot catalog event.', @@ -386,7 +501,7 @@ router.post('/dev/purge-catalog', verifyToken, requirePlatformAdmin, async (req, data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/dev/purge-catalog failed:', err); + logPivotRouteError('POST /admin/pivot/dev/purge-catalog', err, req); return res.status(500).json({ success: false, message: 'Unable to purge pivot catalog data.', @@ -410,7 +525,7 @@ router.get('/snapshots/:batchWeek', verifyToken, requirePlatformAdmin, async (re data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/snapshots/:batchWeek failed:', err); + logPivotRouteError('GET /admin/pivot/snapshots/:batchWeek', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot weekly snapshot.', diff --git a/backend/routes/pivotRoutes.js b/backend/routes/pivotRoutes.js index 0f243327..19573367 100644 --- a/backend/routes/pivotRoutes.js +++ b/backend/routes/pivotRoutes.js @@ -33,9 +33,17 @@ const { } = require('../middlewares/pivotReferralValidateRateLimit'); const { verifyToken } = require('../middlewares/verifyToken'); +const { + pivotRequestLogger, + logPivotRouteError, + logPivotServiceReject, + logPivotServiceSuccess, +} = require('../utilities/pivotLogger'); const router = express.Router(); +router.use(pivotRequestLogger); + router.post('/referral/validate', pivotReferralValidateRateLimit, async (req, res) => { try { const result = await validateReferralCode(req, req.body?.code); @@ -52,7 +60,7 @@ router.post('/referral/validate', pivotReferralValidateRateLimit, async (req, re data: result.data, }); } catch (err) { - console.error('POST /pivot/referral/validate failed:', err); + logPivotRouteError('POST /pivot/referral/validate', err, req); return res.status(500).json({ success: false, message: 'Unable to validate referral code.', @@ -76,7 +84,7 @@ router.post('/referral/redeem', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('POST /pivot/referral/redeem failed:', err); + logPivotRouteError('POST /pivot/referral/redeem', err, req); return res.status(500).json({ success: false, message: 'Unable to redeem referral code.', @@ -100,7 +108,7 @@ router.get('/tags', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/tags failed:', err); + logPivotRouteError('GET /pivot/tags', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot tags.', @@ -124,7 +132,7 @@ router.get('/profile/interests', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/profile/interests failed:', err); + logPivotRouteError('GET /pivot/profile/interests', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot interests.', @@ -148,7 +156,7 @@ router.put('/profile/interests', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('PUT /pivot/profile/interests failed:', err); + logPivotRouteError('PUT /pivot/profile/interests', err, req); return res.status(500).json({ success: false, message: 'Unable to save pivot interests.', @@ -172,7 +180,7 @@ router.get('/config', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/config failed:', err); + logPivotRouteError('GET /pivot/config', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot config.', @@ -187,6 +195,9 @@ router.get('/feed', verifyToken, async (req, res) => { excludeEventIds: req.query.excludeEventIds, }); if (result.error) { + logPivotServiceReject('GET /pivot/feed', result, req, { + batchWeek: req.query.batchWeek, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -194,12 +205,18 @@ router.get('/feed', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('GET /pivot/feed', req, { + batchWeek: result.data?.batchWeek, + eventCount: result.data?.events?.length ?? 0, + excludeEventIds: req.query.excludeEventIds || undefined, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('GET /pivot/feed failed:', err); + logPivotRouteError('GET /pivot/feed', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot feed.', @@ -211,6 +228,10 @@ router.post('/feed/action', verifyToken, async (req, res) => { try { const result = await recordFeedAction(req, req.body); if (result.error) { + logPivotServiceReject('POST /pivot/feed/action', result, req, { + eventId: req.body?.eventId, + action: req.body?.action, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -218,12 +239,18 @@ router.post('/feed/action', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('POST /pivot/feed/action', req, { + eventId: result.data?.eventId, + status: result.data?.status, + batchWeek: result.data?.batchWeek, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /pivot/feed/action failed:', err); + logPivotRouteError('POST /pivot/feed/action', err, req); return res.status(500).json({ success: false, message: 'Unable to record pivot intent.', @@ -235,6 +262,9 @@ router.post('/intent/:eventId/external-open', verifyToken, async (req, res) => { try { const result = await recordExternalOpen(req, req.params.eventId, req.body); if (result.error) { + logPivotServiceReject('POST /pivot/intent/:eventId/external-open', result, req, { + eventId: req.params.eventId, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -242,12 +272,18 @@ router.post('/intent/:eventId/external-open', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('POST /pivot/intent/:eventId/external-open', req, { + eventId: result.data?.eventId, + status: result.data?.status, + externalOpenCount: result.data?.externalOpenCount, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /pivot/intent/:eventId/external-open failed:', err); + logPivotRouteError('POST /pivot/intent/:eventId/external-open', err, req); return res.status(500).json({ success: false, message: 'Unable to record external open.', @@ -257,8 +293,12 @@ router.post('/intent/:eventId/external-open', verifyToken, async (req, res) => { router.post('/intent/:eventId/registered', verifyToken, async (req, res) => { try { - const result = await confirmRegistered(req, req.params.eventId); + const result = await confirmRegistered(req, req.params.eventId, req.body); if (result.error) { + logPivotServiceReject('POST /pivot/intent/:eventId/registered', result, req, { + eventId: req.params.eventId, + timeSlotId: req.body?.timeSlotId, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -266,12 +306,19 @@ router.post('/intent/:eventId/registered', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('POST /pivot/intent/:eventId/registered', req, { + eventId: result.data?.eventId, + status: result.data?.status, + timeSlotId: result.data?.timeSlotId, + batchWeek: result.data?.batchWeek, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /pivot/intent/:eventId/registered failed:', err); + logPivotRouteError('POST /pivot/intent/:eventId/registered', err, req); return res.status(500).json({ success: false, message: 'Unable to confirm registration.', @@ -295,7 +342,7 @@ router.get('/friends', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/friends failed:', err); + logPivotRouteError('GET /pivot/friends', err, req); return res.status(500).json({ success: false, message: 'Unable to load friends.', @@ -319,7 +366,7 @@ router.get('/friends/requests', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/friends/requests failed:', err); + logPivotRouteError('GET /pivot/friends/requests', err, req); return res.status(500).json({ success: false, message: 'Unable to load friend requests.', @@ -343,7 +390,7 @@ router.post('/friends/requests/:friendshipId/accept', verifyToken, async (req, r data: result.data, }); } catch (err) { - console.error('POST /pivot/friends/requests/:friendshipId/accept failed:', err); + logPivotRouteError('POST /pivot/friends/requests/:friendshipId/accept', err, req); return res.status(500).json({ success: false, message: 'Unable to accept friend request.', @@ -367,7 +414,7 @@ router.post('/friends/requests/:friendshipId/decline', verifyToken, async (req, data: result.data, }); } catch (err) { - console.error('POST /pivot/friends/requests/:friendshipId/decline failed:', err); + logPivotRouteError('POST /pivot/friends/requests/:friendshipId/decline', err, req); return res.status(500).json({ success: false, message: 'Unable to decline friend request.', @@ -391,7 +438,7 @@ router.get('/friends/search', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/friends/search failed:', err); + logPivotRouteError('GET /pivot/friends/search', err, req); return res.status(500).json({ success: false, message: 'Unable to search friends.', @@ -415,7 +462,7 @@ router.post('/friends/request', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('POST /pivot/friends/request failed:', err); + logPivotRouteError('POST /pivot/friends/request', err, req); return res.status(500).json({ success: false, message: 'Unable to send friend request.', @@ -439,7 +486,7 @@ router.get('/events/:eventId/friends', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/events/:eventId/friends failed:', err); + logPivotRouteError('GET /pivot/events/:eventId/friends', err, req); return res.status(500).json({ success: false, message: 'Unable to load event friends.', @@ -451,6 +498,9 @@ router.get('/week-recap', verifyToken, async (req, res) => { try { const result = await getWeekRecap(req, { batchWeek: req.query.batchWeek }); if (result.error) { + logPivotServiceReject('GET /pivot/week-recap', result, req, { + batchWeek: req.query.batchWeek, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -458,12 +508,17 @@ router.get('/week-recap', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('GET /pivot/week-recap', req, { + batchWeek: result.data?.batchWeek, + eventCount: result.data?.events?.length ?? 0, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('GET /pivot/week-recap failed:', err); + logPivotRouteError('GET /pivot/week-recap', err, req); return res.status(500).json({ success: false, message: 'Unable to load week recap.', @@ -487,7 +542,7 @@ router.get('/feedback/pending', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/feedback/pending failed:', err); + logPivotRouteError('GET /pivot/feedback/pending', err, req); return res.status(500).json({ success: false, message: 'Unable to load pending feedback.', @@ -526,7 +581,7 @@ router.post('/feedback', [ data: result.data, }); } catch (err) { - console.error('POST /pivot/feedback failed:', err); + logPivotRouteError('POST /pivot/feedback', err, req); return res.status(500).json({ success: false, message: 'Unable to submit feedback.', @@ -559,7 +614,7 @@ router.get('/dev/feedback', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/dev/feedback failed:', err); + logPivotRouteError('GET /pivot/dev/feedback', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot feedback.', @@ -590,7 +645,7 @@ router.post('/dev/reset-week-actions', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('POST /pivot/dev/reset-week-actions failed:', err); + logPivotRouteError('POST /pivot/dev/reset-week-actions', err, req); return res.status(500).json({ success: false, message: 'Unable to reset week actions.', diff --git a/backend/schemas/pivotEventIntent.js b/backend/schemas/pivotEventIntent.js index d209aa22..bc90995e 100644 --- a/backend/schemas/pivotEventIntent.js +++ b/backend/schemas/pivotEventIntent.js @@ -33,6 +33,12 @@ const pivotEventIntentSchema = new mongoose.Schema( type: Number, default: 0, }, + /** Showtime id from `customFields.pivot.timeSlots` when user self-confirms. */ + timeSlotId: { + type: String, + default: null, + trim: true, + }, }, { timestamps: true }, ); diff --git a/backend/schemas/pivotWeeklySnapshot.js b/backend/schemas/pivotWeeklySnapshot.js index 2f0f9521..17b383d4 100644 --- a/backend/schemas/pivotWeeklySnapshot.js +++ b/backend/schemas/pivotWeeklySnapshot.js @@ -38,6 +38,26 @@ const pivotWeeklySnapshotTenantSchema = new mongoose.Schema( min: 0, default: 0, }, + externalOpenUsers: { + type: Number, + min: 0, + default: 0, + }, + calendarAdds: { + type: Number, + min: 0, + default: 0, + }, + inviteShares: { + type: Number, + min: 0, + default: 0, + }, + interestsSaved: { + type: Number, + min: 0, + default: 0, + }, swipeCount: { type: Number, required: true, diff --git a/backend/services/pivotAdminOverviewService.js b/backend/services/pivotAdminOverviewService.js index c725f975..40d34c06 100644 --- a/backend/services/pivotAdminOverviewService.js +++ b/backend/services/pivotAdminOverviewService.js @@ -8,6 +8,7 @@ const { normalizeBatchWeek, PUBLISHED_EVENT_QUERY, getWeeklySnapshot, + aggregateEngagementMetrics, } = require('./pivotWeeklySnapshotService'); const { buildDropSchedulePayload } = require('./pivotConfigService'); @@ -91,6 +92,8 @@ async function aggregateTenantOverview(req, tenant, batchWeek) { passedCount, activeUserIds, externalOpenAgg, + externalOpenUserIds, + engagement, feedback, referralCodes, ] = await Promise.all([ @@ -102,6 +105,8 @@ async function aggregateTenantOverview(req, tenant, batchWeek) { { $match: intentFilter }, { $group: { _id: null, total: { $sum: { $ifNull: ['$externalOpenCount', 0] } } } }, ]), + PivotEventIntent.distinct('userId', { ...intentFilter, externalOpenAt: { $ne: null } }), + aggregateEngagementMetrics(tenantReq, batchWeek), aggregateRegisteredFeedback(PivotEventIntent, UniversalFeedback, batchWeek, eventIds), loadReferralCodesForTenant(req, tenantKey), ]); @@ -115,6 +120,10 @@ async function aggregateTenantOverview(req, tenant, batchWeek) { interestedCount, registeredCount, externalOpenCount: externalOpenAgg[0]?.total ?? 0, + externalOpenUsers: externalOpenUserIds.length, + calendarAdds: engagement.calendarAdds, + inviteShares: engagement.inviteShares, + interestsSaved: engagement.interestsSaved, swipeCount, feedbackCount: feedback.feedbackCount, feedbackAvg: feedback.feedbackAvg, @@ -149,6 +158,10 @@ async function getPivotOverview(req, options = {}) { interestedCount: 0, registeredCount: 0, externalOpenCount: 0, + externalOpenUsers: 0, + calendarAdds: 0, + inviteShares: 0, + interestsSaved: 0, swipeCount: 0, feedbackCount: 0, feedbackAvg: null, diff --git a/backend/services/pivotFeedService.js b/backend/services/pivotFeedService.js index 19a94c5d..cbb1a912 100644 --- a/backend/services/pivotFeedService.js +++ b/backend/services/pivotFeedService.js @@ -3,6 +3,16 @@ const getModels = require('./getModelService'); const { getTenantByKey } = require('./tenantConfigService'); const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek'); const { PIVOT_TAG_SLUG_PATTERN } = require('../schemas/pivotTagCatalog'); +const { + normalizePivotTimeSlots, + serializePivotTimeSlots, + isUpcomingWithTimeSlots, +} = require('../utilities/pivotTimeSlots'); +const { + serializePivotMovie, + resolvePivotCoverImageUrl, +} = require('../utilities/pivotMovieMetadata'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); const FRIEND_CAP = 5; const PIVOT_EVENT_STATUSES = ['approved', 'not-applicable']; @@ -12,18 +22,50 @@ const PUBLIC_EVENT_FIELDS = function getPilotWindow(now = new Date()) { const windowStart = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1), + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), ); const windowEnd = new Date(windowStart.getTime() + 7 * 24 * 60 * 60 * 1000); return { windowStart, windowEnd }; } +/** Mongo filter: upcoming events with start (or any showtime) inside the 7-day pilot window. */ +function getFeedPilotWindowFilter(now = new Date()) { + const { windowStart, windowEnd } = getPilotWindow(now); + + return { + $and: [ + getUpcomingEventTimeFilter(now), + { + $or: [ + { + 'customFields.pivot.timeSlots.0': { $exists: false }, + start_time: { $gte: windowStart, $lt: windowEnd }, + }, + { + 'customFields.pivot.timeSlots.0': { $exists: true }, + 'customFields.pivot.timeSlots': { + $elemMatch: { + start_time: { $gte: windowStart, $lt: windowEnd }, + }, + }, + }, + ], + }, + ], + }; +} + /** True when the event has not ended yet — deck should not surface past plans. */ function isUpcomingPivotEvent(event, now = new Date()) { if (!event) { return false; } + const slotUpcoming = isUpcomingWithTimeSlots(event.customFields?.pivot, now); + if (slotUpcoming != null) { + return slotUpcoming; + } + const end = event.end_time != null && event.end_time !== '' ? new Date(event.end_time) @@ -75,13 +117,17 @@ function resolveDisplayHost(pivotMeta) { function serializePivotFeedEvent(event, extras) { const pivot = event.customFields?.pivot || {}; - const coverImageUrl = - typeof event.image === 'string' && event.image.trim() ? event.image.trim() : null; + const coverImageUrl = resolvePivotCoverImageUrl(event); + const movie = serializePivotMovie(pivot.movie); + const normalizedSlots = normalizePivotTimeSlots(pivot.timeSlots); + const timeSlots = normalizedSlots.length + ? serializePivotTimeSlots(normalizedSlots, extras.socialByTimeSlot) + : undefined; return { _id: String(event._id), name: event.name, - description: event.description, + description: movie?.synopsis || event.description, location: event.location, start_time: event.start_time, end_time: event.end_time, @@ -90,8 +136,11 @@ function serializePivotFeedEvent(event, extras) { registrationCount: event.registrationCount ?? 0, tags: Array.isArray(pivot.tags) ? pivot.tags : [], ...(coverImageUrl ? { coverImageUrl } : {}), + ...(timeSlots ? { timeSlots } : {}), + ...(movie ? { movie } : {}), displayHost: extras.displayHost, userIntent: extras.userIntent, + ...(extras.userTimeSlotId ? { userTimeSlotId: extras.userTimeSlotId } : {}), friendsInterested: extras.friendsInterested, friendsGoing: extras.friendsGoing, // Total counts (uncapped) so the client can render "N friends interested" @@ -141,7 +190,11 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, const emptySocial = makeEmptySocialMap(eventIds); if (!eventIds.length) { - return { userIntents: new Map(), socialByEvent: emptySocial }; + return { + userIntents: new Map(), + socialByEvent: emptySocial, + socialByEventAndSlot: new Map(), + }; } const { Friendship, PivotEventIntent, User } = getModels( @@ -160,16 +213,26 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, } const userIntentRows = await PivotEventIntent.find(userIntentQuery) - .select('eventId status') + .select('eventId status timeSlotId') .lean(); const userIntents = new Map( - userIntentRows.map((row) => [String(row.eventId), row.status]), + userIntentRows.map((row) => [ + String(row.eventId), + { + status: row.status, + timeSlotId: row.timeSlotId || null, + }, + ]), ); const friendIds = await getAcceptedFriendIds(Friendship, userId); if (!friendIds.length) { - return { userIntents, socialByEvent: emptySocial }; + return { + userIntents, + socialByEvent: emptySocial, + socialByEventAndSlot: new Map(), + }; } const friendIntentRows = await PivotEventIntent.find({ @@ -177,11 +240,15 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, userId: { $in: friendIds }, status: { $in: ['interested', 'registered'] }, }) - .select('eventId userId status') + .select('eventId userId status timeSlotId') .lean(); if (!friendIntentRows.length) { - return { userIntents, socialByEvent: emptySocial }; + return { + userIntents, + socialByEvent: emptySocial, + socialByEventAndSlot: new Map(), + }; } const friendUserIds = [ @@ -193,6 +260,7 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, const userById = new Map(users.map((user) => [String(user._id), user])); const socialByEvent = makeEmptySocialMap(eventIds); + const socialByEventAndSlot = new Map(); for (const row of friendIntentRows) { const eventKey = String(row.eventId); @@ -212,6 +280,22 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, if (bucket.friendsInterested.length < previewCap) { bucket.friendsInterested.push(preview); } + + const slotId = row.timeSlotId ? String(row.timeSlotId).trim() : ''; + if (slotId) { + const slotKey = `${eventKey}:${slotId}`; + if (!socialByEventAndSlot.has(slotKey)) { + socialByEventAndSlot.set(slotKey, { + friendsGoing: [], + friendsGoingCount: 0, + }); + } + const slotBucket = socialByEventAndSlot.get(slotKey); + slotBucket.friendsGoingCount += 1; + if (slotBucket.friendsGoing.length < previewCap) { + slotBucket.friendsGoing.push(preview); + } + } } else if (row.status === 'interested') { bucket.friendInterestedCount += 1; if (bucket.friendsInterested.length < previewCap) { @@ -220,7 +304,7 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, } } - return { userIntents, socialByEvent }; + return { userIntents, socialByEvent, socialByEventAndSlot }; } function normalizeExcludeEventIds(rawExcludeEventIds) { @@ -421,7 +505,6 @@ async function getPivotFeed(req, options = {}) { } const { Event } = getModels(req, 'Event'); - const { windowStart, windowEnd } = getPilotWindow(now); const excludeEventIds = normalizeExcludeEventIds(options.excludeEventIds); const query = { @@ -429,9 +512,8 @@ async function getPivotFeed(req, options = {}) { 'customFields.pivot.ingestStatus': 'published', status: { $in: PIVOT_EVENT_STATUSES }, isDeleted: { $ne: true }, - start_time: { $gte: windowStart, $lt: windowEnd }, 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, - $and: [getUpcomingEventTimeFilter(now)], + ...getFeedPilotWindowFilter(now), }; if (excludeEventIds.length) { query._id = { $nin: excludeEventIds }; @@ -448,7 +530,7 @@ async function getPivotFeed(req, options = {}) { isUpcomingPivotEvent(event, now), ); const eventIds = validEvents.map((event) => event._id); - const { userIntents, socialByEvent } = await loadFriendSocial( + const { userIntents, socialByEvent, socialByEventAndSlot } = await loadFriendSocial( req, userId, eventIds, @@ -465,6 +547,22 @@ async function getPivotFeed(req, options = {}) { const tenant = await getTenantByKey(req, req.school); const cityDisplayName = tenant?.location || tenant?.name || req.school; + const multiSlotEventCount = validEvents.filter( + (event) => normalizePivotTimeSlots(event.customFields?.pivot?.timeSlots).length > 0, + ).length; + + logPivot('info', 'feed built', { + ...pivotRequestContext(req), + batchWeek, + cityDisplayName, + candidateCount: events.length, + eventCount: validEvents.length, + multiSlotEventCount, + excludedCount: excludeEventIds.length, + interestTagCount: userInterestTags.size, + negativeTagPenaltyCount: negativeFeedbackTags.size, + }); + return { data: { batchWeek, @@ -477,10 +575,23 @@ async function getPivotFeed(req, options = {}) { friendInterestedCount: 0, friendRegisteredCount: 0, }; + const userIntentRow = userIntents.get(id); + const normalizedSlots = normalizePivotTimeSlots( + event.customFields?.pivot?.timeSlots, + ); + const socialByTimeSlot = new Map(); + for (const slot of normalizedSlots) { + const slotSocial = socialByEventAndSlot.get(`${id}:${slot.id}`); + if (slotSocial) { + socialByTimeSlot.set(slot.id, slotSocial); + } + } return serializePivotFeedEvent(event, { displayHost: resolveDisplayHost(event.customFields.pivot), - userIntent: userIntents.get(id) || null, + userIntent: userIntentRow?.status || null, + userTimeSlotId: userIntentRow?.timeSlotId || null, + socialByTimeSlot, friendsInterested: social.friendsInterested, friendsGoing: social.friendsGoing, friendsInterestedCount: social.friendInterestedCount || 0, @@ -552,6 +663,7 @@ module.exports = { getPivotFeed, getPivotEventFriends, getPilotWindow, + getFeedPilotWindowFilter, isUpcomingPivotEvent, getUpcomingEventTimeFilter, resolveDisplayHost, diff --git a/backend/services/pivotIngestPublishService.js b/backend/services/pivotIngestPublishService.js index de032075..77a930d9 100644 --- a/backend/services/pivotIngestPublishService.js +++ b/backend/services/pivotIngestPublishService.js @@ -6,7 +6,7 @@ const { const { isPivotTenant } = require('./pivotReferralCodeService'); const { connectToDatabase } = require('../connectionsManager'); const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); -const { previewIngestUrl, normalizeUrl, sanitizeEventPosterImage } = require('./pivotIngestPreviewService'); +const { previewIngestUrl, sanitizeEventPosterImage } = require('./pivotIngestPreviewService'); const { formatDuplicateWarning, isBlockingDuplicate, @@ -14,6 +14,12 @@ const { } = require('./pivotIngestDuplicateService'); const { serializeLabEvent } = require('./pivotLabEventsService'); const { validatePivotEventTags } = require('./pivotTagCatalogService'); +const { normalizePivotTimeSlots } = require('../utilities/pivotTimeSlots'); +const { + normalizePivotMovie, + applyMovieListingDefaults, +} = require('../utilities/pivotMovieMetadata'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); const DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000; @@ -76,7 +82,58 @@ async function resolveCatalogOrgId(req, tenant) { return { orgId: provisioned.orgId }; } +function detectIngestProvider(hostname) { + const host = hostname.toLowerCase(); + if (host.includes('partiful')) return 'partiful'; + if (host.includes('lu.ma') || host.includes('luma')) return 'luma'; + return null; +} + +function normalizePublishUrl(rawUrl) { + const trimmed = trimString(rawUrl); + if (!trimmed) { + return { url: null, provider: null }; + } + + let parsed; + try { + parsed = new URL(trimmed); + } catch { + return { error: 'Invalid URL.', status: 400, code: 'INVALID_URL' }; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return { error: 'Only HTTP(S) URLs are supported.', status: 400, code: 'INVALID_URL' }; + } + + return { + url: parsed.toString(), + provider: detectIngestProvider(parsed.hostname), + }; +} + +function normalizeIngestTimeSlots(rawSlots) { + if (!Array.isArray(rawSlots) || !rawSlots.length) { + return []; + } + + return normalizePivotTimeSlots(rawSlots).map((slot) => ({ + id: slot.id, + start_time: slot.start_time, + ...(slot.end_time ? { end_time: slot.end_time } : {}), + ...(slot.label ? { label: slot.label } : {}), + })); +} + function mergeDraftWithOverrides(draft = {}, overrides = {}) { + const timeSlots = normalizeIngestTimeSlots( + Array.isArray(overrides.timeSlots) + ? overrides.timeSlots + : Array.isArray(draft.timeSlots) + ? draft.timeSlots + : [], + ); + return { name: firstNonEmpty(overrides.name, draft.name), description: firstNonEmpty(overrides.description, draft.description) || '', @@ -87,18 +144,29 @@ function mergeDraftWithOverrides(draft = {}, overrides = {}) { hostName: firstNonEmpty(overrides.hostName, draft.hostName), hostImageUrl: null, hostProfileUrl: firstNonEmpty(overrides.hostProfileUrl, draft.hostProfileUrl), - source: draft.source || null, - sourceUrl: draft.sourceUrl || null, - tags: Array.isArray(overrides.tags) ? overrides.tags : [], + source: firstNonEmpty(overrides.source, draft.source), + sourceUrl: firstNonEmpty(overrides.sourceUrl, draft.sourceUrl), + tags: Array.isArray(overrides.tags) + ? overrides.tags + : Array.isArray(draft.tags) + ? draft.tags + : [], + timeSlots, + movie: normalizePivotMovie( + overrides.movie !== undefined ? overrides.movie : draft.movie, + ), }; } function validateMergedDraft(merged) { + const withMovieDefaults = applyMovieListingDefaults(merged); const missing = []; - if (!merged.hostName) missing.push('hostName'); - if (!merged.name) missing.push('name'); - if (!merged.location) missing.push('location'); - if (!merged.start_time) missing.push('start_time'); + if (!withMovieDefaults.hostName) missing.push('hostName'); + if (!withMovieDefaults.name) missing.push('name'); + if (!withMovieDefaults.location) missing.push('location'); + if (!withMovieDefaults.start_time && !withMovieDefaults.timeSlots?.length) { + missing.push('start_time'); + } if (missing.length) { return { @@ -108,7 +176,22 @@ function validateMergedDraft(merged) { }; } - const startTime = parseDateTime(merged.start_time); + const slots = normalizePivotTimeSlots(withMovieDefaults.timeSlots); + let startTime = parseDateTime(withMovieDefaults.start_time); + let endTime = parseDateTime(withMovieDefaults.end_time); + + if (slots.length) { + if (!startTime) { + startTime = slots[0].start_time; + } + if (!endTime) { + endTime = slots.reduce((latest, slot) => { + const candidate = slot.end_time || slot.start_time; + return !latest || candidate > latest ? candidate : latest; + }, null); + } + } + if (!startTime) { return { error: 'start_time must be a valid datetime.', @@ -117,12 +200,18 @@ function validateMergedDraft(merged) { }; } - let endTime = parseDateTime(merged.end_time); if (!endTime || endTime <= startTime) { endTime = new Date(startTime.getTime() + DEFAULT_DURATION_MS); } - return { merged: { ...merged, startTime, endTime } }; + return { + merged: { + ...withMovieDefaults, + timeSlots: slots, + startTime, + endTime, + }, + }; } function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }) { @@ -137,6 +226,8 @@ function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }) sourceUrl, host, tags: tags || [], + ...(merged.timeSlots?.length ? { timeSlots: merged.timeSlots } : {}), + ...(merged.movie ? { movie: merged.movie } : {}), ingestStatus: 'published', importedAt: new Date().toISOString(), importedBy, @@ -144,6 +235,7 @@ function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }) } function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importedBy, tags }) { + const listingUrl = trimString(sourceUrl) || null; return { name: merged.name, description: merged.description || '', @@ -155,7 +247,7 @@ function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importe visibility: 'public', registrationEnabled: true, expectedAttendance: 0, - externalLink: sourceUrl, + ...(listingUrl ? { externalLink: listingUrl } : {}), hostingType: 'Org', hostingId: catalogOrgId, isDeleted: false, @@ -166,6 +258,22 @@ function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importe }; } +async function savePublishedCatalogEvent(tenantReq, eventPayload, sourceUrl) { + const { Event } = getModels(tenantReq, 'Event'); + const listingUrl = trimString(sourceUrl); + + if (listingUrl) { + return Event.findOneAndUpdate( + { 'customFields.pivot.sourceUrl': listingUrl }, + { $set: eventPayload }, + { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, + ).lean(); + } + + const created = await Event.create(eventPayload); + return typeof created.toObject === 'function' ? created.toObject() : created; +} + async function publishIngestEvent(req, options = {}) { const batchNormalized = normalizeBatchWeek(options.batchWeek, options.now); if (batchNormalized.error) { @@ -177,32 +285,44 @@ async function publishIngestEvent(req, options = {}) { return tenantResult; } - const urlNormalized = normalizeUrl(options.url); + const overrides = options.overrides || {}; + const urlNormalized = normalizePublishUrl(options.url); if (urlNormalized.error) { return urlNormalized; } - const previewResult = await previewIngestUrl(req, { url: urlNormalized.url }); - if (previewResult.error) { - return previewResult; - } + let previewDraft = {}; + if (urlNormalized.url && urlNormalized.provider) { + const previewResult = await previewIngestUrl(req, { url: urlNormalized.url }); + if (previewResult.error) { + return previewResult; + } - const previewDraft = - previewResult.data?.mode === 'single' - ? previewResult.data.draft - : previewResult.data?.draft; + if (previewResult.data?.mode === 'batch') { + return { + error: 'Explore links must be published from batch import.', + status: 400, + code: 'BATCH_URL_REQUIRES_BATCH_PUBLISH', + }; + } - if (!previewDraft) { - return { - error: 'Explore links must be published from batch import.', - status: 400, - code: 'BATCH_URL_REQUIRES_BATCH_PUBLISH', + previewDraft = previewResult.data?.draft || {}; + } else if (urlNormalized.url) { + previewDraft = { + sourceUrl: urlNormalized.url, + source: firstNonEmpty(overrides.source) || 'manual', + }; + } else { + previewDraft = { + source: firstNonEmpty(overrides.source) || 'manual', + sourceUrl: firstNonEmpty(overrides.sourceUrl), }; } - const mergedInput = mergeDraftWithOverrides(previewDraft, options.overrides || {}); - mergedInput.sourceUrl = urlNormalized.url; - mergedInput.source = previewDraft?.source || urlNormalized.provider; + const mergedInput = mergeDraftWithOverrides(previewDraft, overrides); + mergedInput.sourceUrl = firstNonEmpty(urlNormalized.url, mergedInput.sourceUrl); + mergedInput.source = + firstNonEmpty(mergedInput.source, urlNormalized.provider) || 'manual'; const validated = validateMergedDraft(mergedInput); if (validated.error) { @@ -214,13 +334,14 @@ async function publishIngestEvent(req, options = {}) { return tagResult; } + const listingUrl = trimString(mergedInput.sourceUrl) || null; const { duplicate } = await resolveImportDuplicate(req, { tenantKey: tenantResult.tenant.tenantKey, candidate: { name: validated.merged.name, start_time: validated.merged.start_time, location: validated.merged.location, - sourceUrl: urlNormalized.url, + sourceUrl: listingUrl, }, }); @@ -237,7 +358,7 @@ async function publishIngestEvent(req, options = {}) { const importedBy = resolveImportedBy(req); const eventPayload = buildEventPayload(validated.merged, { catalogOrgId: catalogResult.orgId, - sourceUrl: urlNormalized.url, + sourceUrl: listingUrl, batchWeek: batchNormalized.batchWeek, importedBy, tags: tagResult.tags, @@ -245,13 +366,17 @@ async function publishIngestEvent(req, options = {}) { const db = await connectToDatabase(tenantResult.tenant.tenantKey); const tenantReq = { db }; - const { Event } = getModels(tenantReq, 'Event'); + const event = await savePublishedCatalogEvent(tenantReq, eventPayload, listingUrl); - const event = await Event.findOneAndUpdate( - { 'customFields.pivot.sourceUrl': urlNormalized.url }, - { $set: eventPayload }, - { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, - ).lean(); + logPivot('info', 'catalog event published', { + tenantKey: tenantResult.tenant.tenantKey, + batchWeek: batchNormalized.batchWeek, + eventId: String(event._id), + name: event.name, + source: mergedInput.source, + timeSlotCount: validated.merged.timeSlots?.length ?? 0, + importedBy, + }); return { data: { @@ -285,11 +410,7 @@ async function publishBatchIngestEvents(req, options = {}) { const failures = []; for (const entry of events) { - const url = trimString(entry?.url); - if (!url) { - failures.push({ url: null, message: 'Event URL is required.' }); - continue; - } + const url = trimString(entry?.url) || undefined; const result = await publishIngestEvent(req, { tenantKey: options.tenantKey, @@ -300,7 +421,7 @@ async function publishBatchIngestEvents(req, options = {}) { }); if (result.error) { - failures.push({ url, message: result.error, code: result.code }); + failures.push({ url: url || null, message: result.error, code: result.code }); continue; } @@ -316,6 +437,13 @@ async function publishBatchIngestEvents(req, options = {}) { }; } + logPivot('info', 'batch catalog publish complete', { + tenantKey: tenantResult.tenant.tenantKey, + batchWeek: batchNormalized.batchWeek, + publishedCount: published.length, + failedCount: failures.length, + }); + return { data: { published, @@ -447,6 +575,58 @@ async function updateIngestEvent(req, options = {}) { pivotPatch.tags = tagResult.tags; } + if (overrides.timeSlots !== undefined) { + const slots = normalizeIngestTimeSlots(overrides.timeSlots); + if (slots.length) { + pivotPatch.timeSlots = slots; + if (overrides.start_time === undefined) { + setPayload.start_time = slots[0].start_time; + } + if (overrides.end_time === undefined) { + const latestEnd = slots.reduce((latest, slot) => { + const candidate = slot.end_time || slot.start_time; + return !latest || new Date(candidate).getTime() > new Date(latest).getTime() + ? candidate + : latest; + }, null); + if (latestEnd) { + setPayload.end_time = latestEnd; + } + } + } else { + delete pivotPatch.timeSlots; + } + } + + if (overrides.sourceUrl !== undefined) { + const sourceUrl = trimString(overrides.sourceUrl); + if (sourceUrl) { + pivotPatch.sourceUrl = sourceUrl; + setPayload.externalLink = sourceUrl; + } else { + delete pivotPatch.sourceUrl; + setPayload.externalLink = null; + } + } + + if (overrides.movie !== undefined) { + const movie = normalizePivotMovie(overrides.movie); + if (movie) { + pivotPatch.movie = movie; + if (overrides.name === undefined && movie.title) { + setPayload.name = movie.title; + } + if (overrides.description === undefined && movie.synopsis) { + setPayload.description = movie.synopsis; + } + if (overrides.image === undefined && movie.posterUrl) { + setPayload.image = movie.posterUrl; + } + } else { + delete pivotPatch.movie; + } + } + const nextIngestStatus = pivotPatch.ingestStatus ?? pivot.ingestStatus; const nextTags = pivotPatch.tags ?? pivot.tags ?? []; if (nextIngestStatus === 'published' && nextTags.length === 0) { @@ -487,4 +667,5 @@ module.exports = { mergeDraftWithOverrides, validateMergedDraft, buildEventPayload, + normalizePublishUrl, }; diff --git a/backend/services/pivotIntentService.js b/backend/services/pivotIntentService.js index cad75ede..84520c56 100644 --- a/backend/services/pivotIntentService.js +++ b/backend/services/pivotIntentService.js @@ -3,9 +3,17 @@ const getModels = require('./getModelService'); const { getPilotWindow, resolveDisplayHost, + serializePivotFeedEvent, + loadFriendSocial, PIVOT_EVENT_STATUSES, } = require('./pivotFeedService'); const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); +const { + normalizePivotTimeSlots, + findTimeSlotById, + eventHasTimeSlots, +} = require('../utilities/pivotTimeSlots'); const FEED_ACTION_TO_STATUS = { interested: 'interested', @@ -46,18 +54,55 @@ async function findPublishedPivotEvent(req, eventId, { now, requireWindow } = {} .lean(); } -async function upsertIntent(req, { userId, eventId, status, batchWeek }) { +async function upsertIntent(req, { userId, eventId, status, batchWeek, timeSlotId }) { const { PivotEventIntent } = getModels(req, 'PivotEventIntent'); + const update = { status, batchWeek }; + if (timeSlotId !== undefined) { + update.timeSlotId = timeSlotId || null; + } + const doc = await PivotEventIntent.findOneAndUpdate( { userId, eventId }, - { $set: { status, batchWeek } }, + { $set: update }, { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, ).lean(); return doc; } +function resolveRegisteredTimeSlotId(event, requestedTimeSlotId) { + const pivot = event.customFields?.pivot; + if (!eventHasTimeSlots(pivot)) { + return { timeSlotId: null }; + } + + const slots = normalizePivotTimeSlots(pivot?.timeSlots); + const trimmed = typeof requestedTimeSlotId === 'string' ? requestedTimeSlotId.trim() : ''; + + if (slots.length === 1) { + return { timeSlotId: trimmed || slots[0].id }; + } + + if (!trimmed) { + return { + error: 'A showtime is required for this event.', + status: 400, + code: 'TIME_SLOT_REQUIRED', + }; + } + + if (!findTimeSlotById(pivot, trimmed)) { + return { + error: 'Invalid showtime for this event.', + status: 400, + code: 'INVALID_TIME_SLOT', + }; + } + + return { timeSlotId: trimmed }; +} + async function recordFeedAction(req, body = {}) { const userId = req.user?.userId; if (!userId) { @@ -97,13 +142,28 @@ async function recordFeedAction(req, body = {}) { } const batchWeek = event.customFields?.pivot?.batchWeek || toIsoWeek(); - const doc = await upsertIntent(req, { userId, eventId, status, batchWeek }); + const doc = await upsertIntent(req, { + userId, + eventId, + status, + batchWeek, + timeSlotId: null, + }); + + logPivot('info', 'feed action recorded', { + ...pivotRequestContext(req), + eventId, + status: doc.status, + batchWeek: doc.batchWeek, + action, + }); return { data: { eventId: String(doc.eventId), status: doc.status, batchWeek: doc.batchWeek, + timeSlotId: doc.timeSlotId || null, }, }; } @@ -152,6 +212,14 @@ async function recordExternalOpen(req, rawEventId, body = {}) { { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, ).lean(); + logPivot('info', 'external ticket open recorded', { + ...pivotRequestContext(req), + eventId, + status: doc.status, + externalOpenCount: doc.externalOpenCount, + batchWeek: doc.batchWeek, + }); + return { data: { eventId: String(doc.eventId), @@ -163,7 +231,7 @@ async function recordExternalOpen(req, rawEventId, body = {}) { }; } -async function confirmRegistered(req, rawEventId) { +async function confirmRegistered(req, rawEventId, body = {}) { const userId = req.user?.userId; if (!userId) { return unauthorized(); @@ -187,12 +255,25 @@ async function confirmRegistered(req, rawEventId) { }; } + const slotResolution = resolveRegisteredTimeSlotId(event, body.timeSlotId); + if (slotResolution.error) { + return slotResolution; + } + const batchWeek = event.customFields?.pivot?.batchWeek || toIsoWeek(); const doc = await upsertIntent(req, { userId, eventId, status: 'registered', batchWeek, + timeSlotId: slotResolution.timeSlotId, + }); + + logPivot('info', 'registration confirmed', { + ...pivotRequestContext(req), + eventId, + batchWeek: doc.batchWeek, + timeSlotId: doc.timeSlotId || null, }); return { @@ -200,29 +281,30 @@ async function confirmRegistered(req, rawEventId) { eventId: String(doc.eventId), status: doc.status, batchWeek: doc.batchWeek, + timeSlotId: doc.timeSlotId || null, }, }; } -function serializeRecapEvent(event, userIntent) { +function serializeRecapEvent(event, intentRow, extras = {}) { const pivot = event.customFields?.pivot || {}; - const coverImageUrl = - typeof event.image === 'string' && event.image.trim() ? event.image.trim() : null; - - return { - _id: String(event._id), - name: event.name, - description: event.description, - location: event.location, - start_time: event.start_time, - end_time: event.end_time, - externalLink: event.externalLink, - type: event.type, - tags: Array.isArray(pivot.tags) ? pivot.tags : [], - ...(coverImageUrl ? { coverImageUrl } : {}), + const status = + intentRow && typeof intentRow === 'object' ? intentRow.status : intentRow; + const userTimeSlotId = + (intentRow && typeof intentRow === 'object' ? intentRow.timeSlotId : null) || + extras.userTimeSlotId || + null; + + return serializePivotFeedEvent(event, { displayHost: resolveDisplayHost(pivot), - userIntent, - }; + userIntent: status || null, + userTimeSlotId, + socialByTimeSlot: extras.socialByTimeSlot || new Map(), + friendsInterested: extras.friendsInterested || [], + friendsGoing: extras.friendsGoing || [], + friendsInterestedCount: extras.friendsInterestedCount || 0, + friendsGoingCount: extras.friendsGoingCount || 0, + }); } async function getWeekRecap(req, options = {}) { @@ -248,7 +330,7 @@ async function getWeekRecap(req, options = {}) { batchWeek, status: { $in: RECAP_STATUSES }, }) - .select('eventId status') + .select('eventId status timeSlotId') .lean(); if (!intents.length) { @@ -256,7 +338,10 @@ async function getWeekRecap(req, options = {}) { } const intentByEvent = new Map( - intents.map((intent) => [String(intent.eventId), intent.status]), + intents.map((intent) => [ + String(intent.eventId), + { status: intent.status, timeSlotId: intent.timeSlotId || null }, + ]), ); const eventIds = [...intentByEvent.keys()]; @@ -271,11 +356,48 @@ async function getWeekRecap(req, options = {}) { .sort({ start_time: 1 }) .lean(); + const { socialByEvent, socialByEventAndSlot } = await loadFriendSocial( + req, + userId, + eventIds, + ); + const recapEvents = events .filter((event) => resolveDisplayHost(event.customFields?.pivot)) - .map((event) => - serializeRecapEvent(event, intentByEvent.get(String(event._id)) || null), - ); + .map((event) => { + const id = String(event._id); + const social = socialByEvent.get(id) || { + friendsInterested: [], + friendsGoing: [], + friendInterestedCount: 0, + friendRegisteredCount: 0, + }; + const normalizedSlots = normalizePivotTimeSlots( + event.customFields?.pivot?.timeSlots, + ); + const socialByTimeSlot = new Map(); + for (const slot of normalizedSlots) { + const slotSocial = socialByEventAndSlot.get(`${id}:${slot.id}`); + if (slotSocial) { + socialByTimeSlot.set(slot.id, slotSocial); + } + } + + return serializeRecapEvent(event, intentByEvent.get(id), { + socialByTimeSlot, + friendsInterested: social.friendsInterested, + friendsGoing: social.friendsGoing, + friendsInterestedCount: social.friendInterestedCount || 0, + friendsGoingCount: social.friendRegisteredCount || 0, + }); + }); + + logPivot('info', 'week recap built', { + ...pivotRequestContext(req), + batchWeek, + intentCount: intents.length, + eventCount: recapEvents.length, + }); return { data: { batchWeek, events: recapEvents } }; } @@ -321,4 +443,5 @@ module.exports = { resetWeekActions, findPublishedPivotEvent, serializeRecapEvent, + resolveRegisteredTimeSlotId, }; diff --git a/backend/services/pivotLabEventsService.js b/backend/services/pivotLabEventsService.js index c79412c2..08a5ae6a 100644 --- a/backend/services/pivotLabEventsService.js +++ b/backend/services/pivotLabEventsService.js @@ -5,6 +5,7 @@ const { connectToDatabase } = require('../connectionsManager'); const { normalizeBatchWeek, } = require('./pivotWeeklySnapshotService'); +const { serializePivotMovie } = require('../utilities/pivotMovieMetadata'); function labEventsQuery(batchWeek) { return { @@ -14,26 +15,85 @@ function labEventsQuery(batchWeek) { }; } -function serializeLabEvent(event) { +const EMPTY_INTENT_STATS = Object.freeze({ + interested: 0, + registered: 0, + passed: 0, + externalOpens: 0, + externalOpenUsers: 0, +}); + +function serializeLabEvent(event, intentStatsByEventId) { const pivot = event.customFields?.pivot || {}; const host = pivot.host || {}; + const movie = serializePivotMovie(pivot.movie); + const timeSlots = Array.isArray(pivot.timeSlots) + ? pivot.timeSlots.map((slot) => ({ + id: slot.id, + start_time: slot.start_time, + end_time: slot.end_time || null, + label: slot.label || null, + })) + : []; return { _id: String(event._id), name: event.name, + description: event.description || '', + image: event.image || null, start_time: event.start_time, end_time: event.end_time || null, location: event.location || '', externalLink: event.externalLink || null, + sourceUrl: pivot.sourceUrl || null, ingestStatus: pivot.ingestStatus || null, source: pivot.source || null, batchWeek: pivot.batchWeek || null, tags: Array.isArray(pivot.tags) ? pivot.tags : [], + timeSlots, + ...(movie ? { movie } : {}), organizerName: host.name || '', organizerImageUrl: host.imageUrl || null, + intentStats: intentStatsByEventId?.get(String(event._id)) || EMPTY_INTENT_STATS, }; } +/** Per-event intent counts so Lab can see which catalog events earned the swipes. */ +async function loadIntentStatsByEventId(PivotEventIntent, eventIds) { + if (!eventIds.length) { + return new Map(); + } + + const rows = await PivotEventIntent.aggregate([ + { $match: { eventId: { $in: eventIds } } }, + { + $group: { + _id: '$eventId', + interested: { $sum: { $cond: [{ $eq: ['$status', 'interested'] }, 1, 0] } }, + registered: { $sum: { $cond: [{ $eq: ['$status', 'registered'] }, 1, 0] } }, + passed: { $sum: { $cond: [{ $eq: ['$status', 'passed'] }, 1, 0] } }, + externalOpens: { $sum: { $ifNull: ['$externalOpenCount', 0] } }, + externalOpenUsers: { + $sum: { $cond: [{ $gt: [{ $ifNull: ['$externalOpenCount', 0] }, 0] }, 1, 0] }, + }, + }, + }, + ]); + + return new Map( + rows.map((row) => [ + String(row._id), + { + interested: row.interested ?? 0, + registered: row.registered ?? 0, + passed: row.passed ?? 0, + externalOpens: row.externalOpens ?? 0, + externalOpenUsers: row.externalOpenUsers ?? 0, + }, + ]), + ); +} + async function listPivotLabEvents(req, options = {}) { const normalized = normalizeBatchWeek(options.batchWeek, options.now); if (normalized.error) { @@ -62,21 +122,26 @@ async function listPivotLabEvents(req, options = {}) { const { batchWeek } = normalized; const db = await connectToDatabase(tenantKey); const tenantReq = { db }; - const { Event } = getModels(tenantReq, 'Event'); + const { Event, PivotEventIntent } = getModels(tenantReq, 'Event', 'PivotEventIntent'); const query = labEventsQuery(batchWeek); const events = await Event.find(query) - .select('name start_time end_time location externalLink customFields.pivot') + .select('name description image start_time end_time location externalLink customFields.pivot') .sort({ start_time: 1 }) .lean(); + const intentStatsByEventId = await loadIntentStatsByEventId( + PivotEventIntent, + events.map((event) => event._id), + ); + return { data: { tenantKey, cityDisplayName: tenant.location || tenant.name || tenantKey, batchWeek, - events: events.map(serializeLabEvent), + events: events.map((event) => serializeLabEvent(event, intentStatsByEventId)), }, }; } @@ -84,5 +149,6 @@ async function listPivotLabEvents(req, options = {}) { module.exports = { listPivotLabEvents, serializeLabEvent, + loadIntentStatsByEventId, labEventsQuery, }; diff --git a/backend/services/pivotRetentionService.js b/backend/services/pivotRetentionService.js new file mode 100644 index 00000000..252d389d --- /dev/null +++ b/backend/services/pivotRetentionService.js @@ -0,0 +1,110 @@ +const getModels = require('./getModelService'); +const { getMergedTenants } = require('./tenantConfigService'); +const { isPivotTenant } = require('./pivotReferralCodeService'); +const { connectToDatabase } = require('../connectionsManager'); +const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); +const { shiftIsoWeek } = require('../utilities/pivotIsoWeek'); + +const DEFAULT_WEEKS = 6; +const MAX_WEEKS = 12; + +function normalizeWeeksParam(raw) { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return DEFAULT_WEEKS; + return Math.min(MAX_WEEKS, Math.max(2, Math.trunc(parsed))); +} + +/** + * Week-over-week retention from PivotEventIntent activity: a user is "active" in a + * batchWeek when they have any intent row (interested / registered / passed) for it, + * and "returning" when they were also active the week before. + */ +async function aggregateTenantRetention(tenant, weekList) { + const db = await connectToDatabase(tenant.tenantKey); + const tenantReq = { db }; + const { PivotEventIntent } = getModels(tenantReq, 'PivotEventIntent'); + + const userSets = await Promise.all( + weekList.map(async (batchWeek) => { + const userIds = await PivotEventIntent.distinct('userId', { batchWeek }); + return new Set(userIds.map(String)); + }), + ); + + const weeks = weekList.map((batchWeek, index) => { + const active = userSets[index]; + const previous = index > 0 ? userSets[index - 1] : null; + const returningUsers = previous + ? [...active].filter((userId) => previous.has(userId)).length + : null; + const retentionRate = + previous && previous.size > 0 && returningUsers !== null + ? Math.round((returningUsers / previous.size) * 1000) / 10 + : null; + + return { + batchWeek, + activeUsers: active.size, + returningUsers, + retentionRate, + }; + }); + + return { + tenantKey: tenant.tenantKey, + cityDisplayName: tenant.location || tenant.name || tenant.tenantKey, + weeks, + }; +} + +async function getPivotRetention(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) { + return normalized; + } + + const { batchWeek } = normalized; + const weeksCount = normalizeWeeksParam(options.weeks); + const weekList = Array.from({ length: weeksCount }, (_, index) => + shiftIsoWeek(batchWeek, index - (weeksCount - 1)), + ); + + const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant); + const tenants = []; + + for (const tenant of pivotTenants) { + try { + tenants.push(await aggregateTenantRetention(tenant, weekList)); + } catch (error) { + console.error( + `[pivotRetention] aggregate failed tenant=${tenant.tenantKey} batchWeek=${batchWeek}:`, + error, + ); + tenants.push({ + tenantKey: tenant.tenantKey, + cityDisplayName: tenant.location || tenant.name || tenant.tenantKey, + weeks: weekList.map((week) => ({ + batchWeek: week, + activeUsers: 0, + returningUsers: null, + retentionRate: null, + })), + error: 'AGGREGATION_FAILED', + }); + } + } + + return { + data: { + batchWeek, + weeks: weekList, + tenants, + }, + }; +} + +module.exports = { + getPivotRetention, + aggregateTenantRetention, + normalizeWeeksParam, +}; diff --git a/backend/services/pivotTmdbService.js b/backend/services/pivotTmdbService.js new file mode 100644 index 00000000..bfdb5736 --- /dev/null +++ b/backend/services/pivotTmdbService.js @@ -0,0 +1,250 @@ +const axios = require('axios'); +const { + buildTmdbImageUrl, + normalizePivotMovie, +} = require('../utilities/pivotMovieMetadata'); + +const TMDB_API_BASE = 'https://api.themoviedb.org/3'; +const REQUEST_TIMEOUT_MS = 12_000; + +function trimString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function resolveTmdbCredentials() { + const readAccessToken = + trimString(process.env.TMDB_API_READ_ACCESS_TOKEN) || + trimString(process.env.TMDB_READ_ACCESS_TOKEN) || + trimString(process.env.TMDB_ACCESS_TOKEN); + + if (readAccessToken) { + return { mode: 'bearer', token: readAccessToken }; + } + + const apiKey = trimString(process.env.TMDB_API_KEY); + if (apiKey) { + return { mode: 'api_key', key: apiKey }; + } + + return null; +} + +function buildTmdbRequestConfig(params = {}) { + const credentials = resolveTmdbCredentials(); + if (!credentials) { + return null; + } + + const config = { + timeout: REQUEST_TIMEOUT_MS, + params, + }; + + if (credentials.mode === 'bearer') { + config.headers = { + Authorization: `Bearer ${credentials.token}`, + }; + return config; + } + + config.params = { + api_key: credentials.key, + ...params, + }; + return config; +} + +function tmdbUnavailableResult() { + return { + error: + 'TMDB is not configured. Set TMDB_API_READ_ACCESS_TOKEN (recommended) or TMDB_API_KEY in the backend environment.', + status: 503, + code: 'TMDB_NOT_CONFIGURED', + }; +} + +function extractYear(releaseDate) { + const value = trimString(releaseDate); + if (!value) { + return null; + } + const year = Number(value.slice(0, 4)); + return Number.isFinite(year) ? year : null; +} + +function extractUsContentRating(releaseDates) { + const results = releaseDates?.results; + if (!Array.isArray(results)) { + return null; + } + + const us = results.find((entry) => entry.iso_3166_1 === 'US'); + const release = us?.release_dates?.find((entry) => entry.certification); + return trimString(release?.certification) || null; +} + +function mapTmdbMovieDetails(details) { + if (!details || typeof details !== 'object') { + return null; + } + + const credits = details.credits || {}; + const director = Array.isArray(credits.crew) + ? credits.crew.find((person) => person.job === 'Director')?.name + : null; + const cast = Array.isArray(credits.cast) + ? credits.cast + .slice(0, 5) + .map((person) => trimString(person.name)) + .filter(Boolean) + : []; + + const raw = { + tmdbId: details.id, + title: details.title || details.original_title, + year: extractYear(details.release_date), + synopsis: details.overview, + posterPath: details.poster_path, + backdropPath: details.backdrop_path, + runtimeMinutes: details.runtime, + genres: Array.isArray(details.genres) + ? details.genres.map((genre) => trimString(genre.name)).filter(Boolean) + : [], + contentRating: extractUsContentRating(details.release_dates), + director: director ? trimString(director) : '', + cast, + imdbId: trimString(details.external_ids?.imdb_id), + ratings: { + tmdb: { + score: details.vote_average, + voteCount: details.vote_count, + }, + }, + }; + + return normalizePivotMovie(raw); +} + +function mapTmdbSearchResult(result) { + return { + tmdbId: result.id, + title: trimString(result.title || result.original_title), + year: extractYear(result.release_date), + overview: trimString(result.overview), + posterUrl: buildTmdbImageUrl(result.poster_path, 'w185'), + voteAverage: result.vote_average ?? null, + }; +} + +async function searchTmdbMovies(options = {}) { + const requestConfig = buildTmdbRequestConfig(); + if (!requestConfig) { + return tmdbUnavailableResult(); + } + + const query = trimString(options.query); + if (!query) { + return { + error: 'query is required.', + status: 400, + code: 'TMDB_QUERY_REQUIRED', + }; + } + + const year = trimString(options.year); + + try { + const response = await axios.get(`${TMDB_API_BASE}/search/movie`, { + ...requestConfig, + params: { + ...requestConfig.params, + query, + ...(year ? { year } : {}), + include_adult: false, + language: 'en-US', + }, + }); + + const results = Array.isArray(response.data?.results) + ? response.data.results.map(mapTmdbSearchResult).filter((row) => row.tmdbId && row.title) + : []; + + return { + data: { + query, + year: year || null, + results, + }, + }; + } catch (err) { + const status = err.response?.status; + return { + error: status === 401 ? 'TMDB credentials are invalid.' : 'Unable to search TMDB.', + status: status === 401 ? 503 : 502, + code: status === 401 ? 'TMDB_AUTH_FAILED' : 'TMDB_SEARCH_FAILED', + }; + } +} + +async function fetchTmdbMovieDetails(options = {}) { + const requestConfig = buildTmdbRequestConfig(); + if (!requestConfig) { + return tmdbUnavailableResult(); + } + + const tmdbId = Number(options.tmdbId); + if (!Number.isFinite(tmdbId) || tmdbId <= 0) { + return { + error: 'tmdbId is required.', + status: 400, + code: 'TMDB_ID_REQUIRED', + }; + } + + try { + const response = await axios.get(`${TMDB_API_BASE}/movie/${tmdbId}`, { + ...requestConfig, + params: { + ...requestConfig.params, + language: 'en-US', + append_to_response: 'credits,external_ids,release_dates', + }, + }); + + const movie = mapTmdbMovieDetails(response.data); + if (!movie) { + return { + error: 'TMDB movie not found.', + status: 404, + code: 'TMDB_MOVIE_NOT_FOUND', + }; + } + + return { + data: { + movie, + }, + }; + } catch (err) { + const status = err.response?.status; + if (status === 404) { + return { + error: 'TMDB movie not found.', + status: 404, + code: 'TMDB_MOVIE_NOT_FOUND', + }; + } + return { + error: status === 401 ? 'TMDB credentials are invalid.' : 'Unable to load TMDB movie.', + status: status === 401 ? 503 : 502, + code: status === 401 ? 'TMDB_AUTH_FAILED' : 'TMDB_FETCH_FAILED', + }; + } +} + +module.exports = { + searchTmdbMovies, + fetchTmdbMovieDetails, + mapTmdbMovieDetails, + mapTmdbSearchResult, +}; diff --git a/backend/services/pivotWeeklyDropService.js b/backend/services/pivotWeeklyDropService.js index 97bd265b..8690221d 100644 --- a/backend/services/pivotWeeklyDropService.js +++ b/backend/services/pivotWeeklyDropService.js @@ -5,6 +5,7 @@ const { getTenantByKey, upsertStoredTenantRow, serializeTenantForAdmin } = requi const { normalizePivotDropFields, normalizePivotDropOverrides } = require('../constants/defaultTenants'); const { isValidIsoWeek, toIsoWeek } = require('../utilities/pivotIsoWeek'); const { buildDropSchedulePayload } = require('./pivotConfigService'); +const { rebuildWeeklySnapshot } = require('./pivotWeeklySnapshotService'); const { DAY_NAMES, isPivotTenant, @@ -273,6 +274,19 @@ async function sendWeeklyDropPush(req, tenantKey, options = {}) { errors.push(...result.errors); } + // Best-effort: freeze this week's metrics right after the drop so Lab trends + // build themselves; a snapshot failure must never mask a successful send. + let snapshotRebuilt = false; + try { + const rebuild = await rebuildWeeklySnapshot(req, { batchWeek }); + snapshotRebuilt = !rebuild.error; + } catch (error) { + console.error( + `[pivotWeeklyDrop] snapshot rebuild failed after send tenant=${tenantKey} batchWeek=${batchWeek}:`, + error, + ); + } + return { dryRun: false, dropSchedule, @@ -280,6 +294,7 @@ async function sendWeeklyDropPush(req, tenantKey, options = {}) { pivotPushRecipientCount: recipients.length, sent, failed, + snapshotRebuilt, warnings, errors: errors.slice(0, 5), }; diff --git a/backend/services/pivotWeeklySnapshotService.js b/backend/services/pivotWeeklySnapshotService.js index f5e5911b..8678bddf 100644 --- a/backend/services/pivotWeeklySnapshotService.js +++ b/backend/services/pivotWeeklySnapshotService.js @@ -5,7 +5,39 @@ const { isPivotTenant } = require('./pivotReferralCodeService'); const { connectToDatabase } = require('../connectionsManager'); const { PIVOT_EVENT_STATUSES } = require('./pivotFeedService'); const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService'); -const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const { toIsoWeek, isValidIsoWeek, isoWeekToUtcRange } = require('../utilities/pivotIsoWeek'); + +/** Mobile analytics event names surfaced as Lab engagement metrics. */ +const ENGAGEMENT_EVENTS = { + calendarAdds: ['pivot_calendar_add'], + inviteShares: ['pivot_invite_share'], + interestsSaved: ['pivot_interests_onboarding_completed', 'pivot_interests_updated'], +}; + +/** + * Client-only loop counts (calendar adds, invite shares, interests saved) from the + * tenant analytics_events collection, bounded to the ISO week's UTC range. + * Best-effort: analytics ingestion must never fail a snapshot, so errors return zeros. + */ +async function aggregateEngagementMetrics(tenantReq, batchWeek) { + const zeros = { calendarAdds: 0, inviteShares: 0, interestsSaved: 0 }; + try { + const { AnalyticsEvent } = getModels(tenantReq, 'AnalyticsEvent'); + const { start, end } = isoWeekToUtcRange(batchWeek); + const tsFilter = { ts: { $gte: start, $lt: end } }; + + const entries = await Promise.all( + Object.entries(ENGAGEMENT_EVENTS).map(async ([key, eventNames]) => [ + key, + await AnalyticsEvent.countDocuments({ event: { $in: eventNames }, ...tsFilter }), + ]), + ); + return Object.fromEntries(entries); + } catch (error) { + console.error(`[pivotWeeklySnapshot] engagement aggregate failed batchWeek=${batchWeek}:`, error); + return zeros; + } +} const PUBLISHED_EVENT_QUERY = (batchWeek) => ({ 'customFields.pivot.batchWeek': batchWeek, @@ -42,6 +74,10 @@ function serializeSnapshot(doc) { interestedCount: row.interestedCount ?? 0, registeredCount: row.registeredCount ?? 0, externalOpenCount: row.externalOpenCount ?? 0, + externalOpenUsers: row.externalOpenUsers ?? 0, + calendarAdds: row.calendarAdds ?? 0, + inviteShares: row.inviteShares ?? 0, + interestsSaved: row.interestsSaved ?? 0, swipeCount: row.swipeCount ?? 0, feedbackAvg: row.feedbackAvg ?? null, activeUsers: row.activeUsers ?? 0, @@ -76,6 +112,8 @@ async function aggregateTenantMetrics(tenant, batchWeek) { passedCount, activeUserIds, externalOpenAgg, + externalOpenUserIds, + engagement, ] = await Promise.all([ PivotEventIntent.countDocuments({ ...intentFilter, status: 'interested' }), PivotEventIntent.countDocuments({ ...intentFilter, status: 'registered' }), @@ -85,6 +123,8 @@ async function aggregateTenantMetrics(tenant, batchWeek) { { $match: intentFilter }, { $group: { _id: null, total: { $sum: { $ifNull: ['$externalOpenCount', 0] } } } }, ]), + PivotEventIntent.distinct('userId', { ...intentFilter, externalOpenAt: { $ne: null } }), + aggregateEngagementMetrics(tenantReq, batchWeek), ]); // Card swipes: pass + interested; registered users previously swiped right. @@ -116,6 +156,10 @@ async function aggregateTenantMetrics(tenant, batchWeek) { interestedCount, registeredCount, externalOpenCount: externalOpenAgg[0]?.total ?? 0, + externalOpenUsers: externalOpenUserIds.length, + calendarAdds: engagement.calendarAdds, + inviteShares: engagement.inviteShares, + interestsSaved: engagement.interestsSaved, swipeCount, feedbackAvg, activeUsers: activeUserIds.length, @@ -148,6 +192,10 @@ async function rebuildWeeklySnapshot(req, options = {}) { interestedCount: 0, registeredCount: 0, externalOpenCount: 0, + externalOpenUsers: 0, + calendarAdds: 0, + inviteShares: 0, + interestsSaved: 0, swipeCount: 0, feedbackAvg: null, activeUsers: 0, @@ -187,9 +235,11 @@ async function getWeeklySnapshot(req, options = {}) { module.exports = { aggregateTenantMetrics, + aggregateEngagementMetrics, rebuildWeeklySnapshot, getWeeklySnapshot, normalizeBatchWeek, serializeSnapshot, PUBLISHED_EVENT_QUERY, + ENGAGEMENT_EVENTS, }; diff --git a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js index 1bcbd15a..353152fb 100644 --- a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js +++ b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js @@ -24,6 +24,10 @@ jest.mock('../../services/pivotAdminOverviewService', () => ({ getPivotOverview: jest.fn(), })); +jest.mock('../../services/pivotRetentionService', () => ({ + getPivotRetention: jest.fn(), +})); + jest.mock('../../services/pivotLabEventsService', () => ({ listPivotLabEvents: jest.fn(), })); @@ -62,6 +66,7 @@ const { getWeeklySnapshot, } = require('../../services/pivotWeeklySnapshotService'); const { getPivotOverview } = require('../../services/pivotAdminOverviewService'); +const { getPivotRetention } = require('../../services/pivotRetentionService'); const { listPivotLabEvents } = require('../../services/pivotLabEventsService'); const { getInterviewNotes, @@ -198,6 +203,56 @@ describe('pivotAdminRoutes overview', () => { }); }); +describe('pivotAdminRoutes retention', () => { + beforeEach(() => { + getPivotRetention.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('GET /admin/pivot/retention returns week-over-week rows', async () => { + getPivotRetention.mockResolvedValue({ + data: { + batchWeek: '2026-W27', + weeks: ['2026-W26', '2026-W27'], + tenants: [ + { + tenantKey: 'nyc', + cityDisplayName: 'New York City', + weeks: [ + { batchWeek: '2026-W26', activeUsers: 4, returningUsers: null, retentionRate: null }, + { batchWeek: '2026-W27', activeUsers: 3, returningUsers: 2, retentionRate: 50 }, + ], + }, + ], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/retention?batchWeek=2026-W27&weeks=2', + ); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.tenants[0].weeks[1].retentionRate).toBe(50); + expect(getPivotRetention).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ batchWeek: '2026-W27', weeks: '2' }), + ); + }); + + it('GET /admin/pivot/retention surfaces service errors', async () => { + getPivotRetention.mockResolvedValue({ + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }); + + const response = await request(buildApp()).get('/admin/pivot/retention?batchWeek=bad'); + expect(response.status).toBe(400); + expect(response.body.code).toBe('INVALID_BATCH_WEEK'); + }); +}); + describe('pivotAdminRoutes lab', () => { beforeEach(() => { listPivotLabEvents.mockReset(); diff --git a/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js b/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js index 231eae3a..3239cff0 100644 --- a/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js +++ b/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js @@ -356,6 +356,7 @@ describe('pivotRoutes POST /pivot/intent/:eventId/registered', () => { expect(confirmRegistered).toHaveBeenCalledWith( expect.objectContaining({ school: 'nyc' }), '665a1b2c3d4e5f6789012345', + {}, ); }); diff --git a/backend/tests/unit/pivotAdminOverviewService.test.js b/backend/tests/unit/pivotAdminOverviewService.test.js index 97078f65..116ad2ee 100644 --- a/backend/tests/unit/pivotAdminOverviewService.test.js +++ b/backend/tests/unit/pivotAdminOverviewService.test.js @@ -20,6 +20,7 @@ jest.mock('../../services/pivotWeeklySnapshotService', () => ({ normalizeBatchWeek: jest.requireActual('../../services/pivotWeeklySnapshotService').normalizeBatchWeek, PUBLISHED_EVENT_QUERY: jest.requireActual('../../services/pivotWeeklySnapshotService').PUBLISHED_EVENT_QUERY, getWeeklySnapshot: jest.fn(), + aggregateEngagementMetrics: jest.fn(), })); const getModels = require('../../services/getModelService'); @@ -27,7 +28,10 @@ const { connectToDatabase } = require('../../connectionsManager'); const getGlobalModels = require('../../services/getGlobalModelService'); const { getMergedTenants } = require('../../services/tenantConfigService'); const { isPivotTenant } = require('../../services/pivotReferralCodeService'); -const { getWeeklySnapshot } = require('../../services/pivotWeeklySnapshotService'); +const { + getWeeklySnapshot, + aggregateEngagementMetrics, +} = require('../../services/pivotWeeklySnapshotService'); const { aggregateRegisteredFeedback, getPivotOverview, @@ -40,6 +44,11 @@ describe('pivotAdminOverviewService', () => { (tenant) => tenant?.pivotPilot === true || tenant?.tenantType === 'pivot', ); getWeeklySnapshot.mockResolvedValue({ data: { generatedAt: new Date('2026-06-26T10:00:00.000Z') } }); + aggregateEngagementMetrics.mockResolvedValue({ + calendarAdds: 3, + inviteShares: 1, + interestsSaved: 2, + }); }); describe('aggregateRegisteredFeedback', () => { @@ -139,6 +148,12 @@ describe('pivotAdminOverviewService', () => { expect(result.data.tenants).toHaveLength(2); expect(result.data.tenants.map((row) => row.tenantKey)).toEqual(['nyc', 'brooklyn']); expect(result.data.tenants[0].referralCodes[0].code).toBe('NYC-PILOT-A'); + expect(result.data.tenants[0]).toMatchObject({ + externalOpenUsers: 0, + calendarAdds: 3, + inviteShares: 1, + interestsSaved: 2, + }); expect(result.data.snapshotGeneratedAt).toEqual(new Date('2026-06-26T10:00:00.000Z')); }); }); diff --git a/backend/tests/unit/pivotFeedService.test.js b/backend/tests/unit/pivotFeedService.test.js index ce0f4813..f19266ee 100644 --- a/backend/tests/unit/pivotFeedService.test.js +++ b/backend/tests/unit/pivotFeedService.test.js @@ -9,6 +9,7 @@ const { getPivotFeed, getPivotEventFriends, getPilotWindow, + getFeedPilotWindowFilter, isUpcomingPivotEvent, getUpcomingEventTimeFilter, resolveDisplayHost, @@ -69,11 +70,11 @@ describe('pivotFeedService helpers', () => { expect(resolveDisplayHost({ host: { imageUrl: 'x' } })).toBeNull(); }); - it('getPilotWindow starts tomorrow UTC for seven days', () => { + it('getPilotWindow starts today UTC for seven days', () => { const now = new Date('2026-05-26T15:00:00.000Z'); const { windowStart, windowEnd } = getPilotWindow(now); - expect(windowStart.toISOString()).toBe('2026-05-27T00:00:00.000Z'); - expect(windowEnd.toISOString()).toBe('2026-06-03T00:00:00.000Z'); + expect(windowStart.toISOString()).toBe('2026-05-26T00:00:00.000Z'); + expect(windowEnd.toISOString()).toBe('2026-06-02T00:00:00.000Z'); }); it('isUpcomingPivotEvent treats ended events as past', () => { @@ -337,11 +338,61 @@ describe('getPivotFeed', () => { 'customFields.pivot.batchWeek': '2026-W22', 'customFields.pivot.ingestStatus': 'published', status: { $in: ['approved', 'not-applicable'] }, - $and: [getUpcomingEventTimeFilter(now)], + ...getFeedPilotWindowFilter(now), }), ); }); + it('includes multi-showtime events when a later showtime is in the pilot window', async () => { + const events = [ + { + _id: '665a1b2c3d4e5f6789012347', + name: 'Film Night', + start_time: new Date('2026-05-26T18:00:00.000Z'), + end_time: new Date('2026-05-29T05:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Nitehawk Cinema' }, + tags: ['film-and-tv'], + timeSlots: [ + { + id: '6pm', + start_time: new Date('2026-05-26T18:00:00.000Z'), + end_time: new Date('2026-05-26T20:30:00.000Z'), + }, + { + id: '830pm', + start_time: new Date('2026-05-28T23:30:00.000Z'), + end_time: new Date('2026-05-29T02:00:00.000Z'), + }, + ], + }, + }, + }, + ]; + + const eventFind = mockEventFind(events); + getModels.mockReturnValue(withFeedModels({ + Event: { find: jest.fn(() => eventFind) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel(), + })); + + const result = await getPivotFeed(req, { batchWeek: '2026-W22', now }); + + expect(result.data.events).toHaveLength(1); + expect(result.data.events[0].name).toBe('Film Night'); + expect(result.data.events[0].timeSlots).toHaveLength(2); + }); + it('excludes events that have already ended from the deck feed', async () => { const events = [ { diff --git a/backend/tests/unit/pivotIngestPublishService.test.js b/backend/tests/unit/pivotIngestPublishService.test.js index bf16d719..44aa663b 100644 --- a/backend/tests/unit/pivotIngestPublishService.test.js +++ b/backend/tests/unit/pivotIngestPublishService.test.js @@ -11,7 +11,6 @@ jest.mock('../../connectionsManager', () => ({ })); jest.mock('../../services/pivotIngestPreviewService', () => ({ previewIngestUrl: jest.fn(), - normalizeUrl: jest.fn(), sanitizeEventPosterImage: (raw) => (typeof raw === 'string' && raw.trim() ? raw.trim() : null), })); jest.mock('../../services/pivotIngestDuplicateService', () => ({ @@ -35,7 +34,7 @@ jest.mock('../../services/pivotTagCatalogService', () => ({ const getModels = require('../../services/getModelService'); const { getMergedTenants, provisionPivotCatalogOrg } = require('../../services/tenantConfigService'); const { connectToDatabase } = require('../../connectionsManager'); -const { previewIngestUrl, normalizeUrl } = require('../../services/pivotIngestPreviewService'); +const { previewIngestUrl } = require('../../services/pivotIngestPreviewService'); const { resolveImportDuplicate, isBlockingDuplicate } = require('../../services/pivotIngestDuplicateService'); const { validatePivotEventTags } = require('../../services/pivotTagCatalogService'); const { @@ -75,6 +74,26 @@ describe('pivotIngestPublishService merge helpers', () => { expect(result.code).toBe('MISSING_REQUIRED_FIELDS'); }); + + it('derives event window from showtimes when start_time is omitted', () => { + const merged = mergeDraftWithOverrides( + {}, + { + hostName: 'Nitehawk Cinema', + name: 'Indie Film Night', + location: 'Brooklyn, NY', + tags: ['film-and-tv'], + timeSlots: [ + { id: '6pm', start_time: '2026-05-29T22:00:00.000Z' }, + { id: '830pm', start_time: '2026-05-30T00:30:00.000Z' }, + ], + }, + ); + + const result = validateMergedDraft(merged); + expect(result.merged.timeSlots).toHaveLength(2); + expect(result.merged.startTime.toISOString()).toBe('2026-05-29T22:00:00.000Z'); + }); }); describe('pivotIngestPublishService publishIngestEvent', () => { @@ -83,6 +102,7 @@ describe('pivotIngestPublishService publishIngestEvent', () => { beforeEach(() => { Event = { findOneAndUpdate: jest.fn(), + create: jest.fn(), }; getModels.mockReturnValue({ Event }); getMergedTenants.mockResolvedValue([TENANT]); @@ -90,10 +110,6 @@ describe('pivotIngestPublishService publishIngestEvent', () => { resolveImportDuplicate.mockResolvedValue({ duplicate: null, catalogIndex: [] }); isBlockingDuplicate.mockReturnValue(false); validatePivotEventTags.mockResolvedValue({ tags: ['live-music'] }); - normalizeUrl.mockReturnValue({ - url: 'https://partiful.com/e/sunset-listening', - provider: 'partiful', - }); previewIngestUrl.mockResolvedValue({ data: { draft: { @@ -166,6 +182,69 @@ describe('pivotIngestPublishService publishIngestEvent', () => { expect(provisionPivotCatalogOrg).not.toHaveBeenCalled(); }); + it('persists showtimes from overrides when publishing without provider preview', async () => { + Event.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + name: 'Indie Film Night', + start_time: new Date('2026-05-29T22:00:00.000Z'), + end_time: new Date('2026-05-30T05:15:00.000Z'), + location: 'Brooklyn, NY', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + host: { name: 'Nitehawk Cinema' }, + tags: ['film-and-tv'], + timeSlots: [ + { id: '6pm', start_time: new Date('2026-05-29T22:00:00.000Z') }, + { id: '830pm', start_time: new Date('2026-05-30T00:30:00.000Z') }, + ], + }, + }, + }), + }); + + await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + url: 'https://example.com/events/indie-film-night', + batchWeek: '2026-W26', + overrides: { + hostName: 'Nitehawk Cinema', + name: 'Indie Film Night', + location: 'Brooklyn, NY', + tags: ['film-and-tv'], + start_time: '2026-05-29T22:00:00.000Z', + end_time: '2026-05-30T05:15:00.000Z', + timeSlots: [ + { id: '6pm', label: '6:00 PM', start_time: '2026-05-29T22:00:00.000Z' }, + { id: '830pm', label: '8:30 PM', start_time: '2026-05-30T00:30:00.000Z' }, + ], + }, + }, + ); + + expect(previewIngestUrl).not.toHaveBeenCalled(); + expect(Event.findOneAndUpdate).toHaveBeenCalledWith( + { 'customFields.pivot.sourceUrl': 'https://example.com/events/indie-film-night' }, + expect.objectContaining({ + $set: expect.objectContaining({ + customFields: expect.objectContaining({ + pivot: expect.objectContaining({ + timeSlots: expect.arrayContaining([ + expect.objectContaining({ id: '6pm' }), + expect.objectContaining({ id: '830pm' }), + ]), + }), + }), + }), + }), + expect.any(Object), + ); + }); + it('provisions catalog org when tenant row lacks pivotCatalogOrgId', async () => { getMergedTenants.mockResolvedValue([{ ...TENANT, pivotCatalogOrgId: null }]); provisionPivotCatalogOrg.mockResolvedValue({ orgId: '507f1f77bcf86cd799439099' }); @@ -249,6 +328,94 @@ describe('pivotIngestPublishService publishIngestEvent', () => { expect(result.code).toBe('INVALID_TAG'); expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); + expect(Event.create).not.toHaveBeenCalled(); + }); + + it('creates manual catalog event without a listing URL', async () => { + Event.create.mockResolvedValue({ + toObject: () => ({ + _id: '507f1f77bcf86cd799439013', + name: 'Neighborhood Potluck', + start_time: new Date('2026-07-12T18:00:00.000Z'), + end_time: new Date('2026-07-12T20:00:00.000Z'), + location: 'Prospect Park', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + source: 'manual', + host: { name: 'Park Friends' }, + tags: ['social'], + }, + }, + }), + }); + + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + batchWeek: '2026-W26', + overrides: { + hostName: 'Park Friends', + name: 'Neighborhood Potluck', + location: 'Prospect Park', + start_time: '2026-07-12T18:00:00.000Z', + tags: ['social'], + source: 'manual', + }, + }, + ); + + expect(previewIngestUrl).not.toHaveBeenCalled(); + expect(Event.create).toHaveBeenCalled(); + expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); + expect(result.data.event.name).toBe('Neighborhood Potluck'); + }); + + it('publishes non-Partiful listing URLs from overrides without scraping', async () => { + Event.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439014', + name: 'Jazz on the Roof', + externalLink: 'https://eventbrite.com/e/jazz-on-the-roof', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + source: 'manual', + sourceUrl: 'https://eventbrite.com/e/jazz-on-the-roof', + host: { name: 'Rooftop Venue' }, + tags: ['live-music'], + }, + }, + }), + }); + + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + url: 'https://eventbrite.com/e/jazz-on-the-roof', + batchWeek: '2026-W26', + overrides: { + hostName: 'Rooftop Venue', + name: 'Jazz on the Roof', + location: 'Downtown Brooklyn', + start_time: '2026-07-12T20:00:00.000Z', + tags: ['live-music'], + source: 'manual', + }, + }, + ); + + expect(previewIngestUrl).not.toHaveBeenCalled(); + expect(Event.findOneAndUpdate).toHaveBeenCalledWith( + { 'customFields.pivot.sourceUrl': 'https://eventbrite.com/e/jazz-on-the-roof' }, + expect.any(Object), + expect.objectContaining({ upsert: true }), + ); + expect(result.data.event.name).toBe('Jazz on the Roof'); }); }); diff --git a/backend/tests/unit/pivotIntentService.test.js b/backend/tests/unit/pivotIntentService.test.js index d7f3cf51..78c8dcfe 100644 --- a/backend/tests/unit/pivotIntentService.test.js +++ b/backend/tests/unit/pivotIntentService.test.js @@ -8,6 +8,7 @@ const { getWeekRecap, resetWeekActions, serializeRecapEvent, + resolveRegisteredTimeSlotId, } = require('../../services/pivotIntentService'); const userId = '507f191e810c19729de860eb'; @@ -81,10 +82,11 @@ describe('recordFeedAction', () => { eventId, status: 'passed', batchWeek: '2026-W22', + timeSlotId: null, }); expect(findOneAndUpdate).toHaveBeenCalledWith( { userId, eventId }, - { $set: { status: 'passed', batchWeek: '2026-W22' } }, + { $set: { status: 'passed', batchWeek: '2026-W22', timeSlotId: null } }, expect.objectContaining({ upsert: true }), ); }); @@ -161,7 +163,7 @@ describe('confirmRegistered', () => { expect(result.data.status).toBe('registered'); expect(findOneAndUpdate).toHaveBeenCalledWith( { userId, eventId }, - { $set: { status: 'registered', batchWeek: '2026-W22' } }, + { $set: { status: 'registered', batchWeek: '2026-W22', timeSlotId: null } }, expect.objectContaining({ upsert: true }), ); }); @@ -185,8 +187,12 @@ describe('getWeekRecap', () => { const intentFind = { select: jest.fn().mockReturnThis(), lean: jest.fn().mockResolvedValue([ - { eventId, status: 'interested' }, - { eventId: '665a1b2c3d4e5f6789012346', status: 'registered' }, + { eventId, status: 'interested', timeSlotId: null }, + { + eventId: '665a1b2c3d4e5f6789012346', + status: 'registered', + timeSlotId: '7pm', + }, ]), }; const eventFind = { @@ -203,10 +209,15 @@ describe('getWeekRecap', () => { }), ]), }; + const friendshipFind = { + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + }; getModels.mockReturnValue({ PivotEventIntent: { find: jest.fn(() => intentFind) }, Event: { find: jest.fn(() => eventFind) }, + Friendship: { find: jest.fn(() => friendshipFind) }, }); const result = await getWeekRecap(req, { batchWeek: '2026-W22', now }); @@ -298,3 +309,94 @@ describe('serializeRecapEvent', () => { expect(payload).not.toHaveProperty('hostingId'); }); }); + +describe('resolveRegisteredTimeSlotId', () => { + it('requires a showtime when multiple slots exist', () => { + const event = { + customFields: { + pivot: { + timeSlots: [ + { id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }, + { id: '930pm', start_time: '2026-05-24T01:30:00.000Z' }, + ], + }, + }, + }; + + expect(resolveRegisteredTimeSlotId(event, '')).toMatchObject({ + code: 'TIME_SLOT_REQUIRED', + }); + expect(resolveRegisteredTimeSlotId(event, '930pm')).toEqual({ + timeSlotId: '930pm', + }); + }); + + it('auto-selects the only showtime', () => { + const event = { + customFields: { + pivot: { + timeSlots: [{ id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }], + }, + }, + }; + + expect(resolveRegisteredTimeSlotId(event, undefined)).toEqual({ + timeSlotId: '7pm', + }); + }); +}); + +describe('confirmRegistered time slots', () => { + beforeEach(() => { + getModels.mockReset(); + }); + + it('persists timeSlotId when confirming registration', async () => { + const findOneAndUpdate = jest.fn(() => ({ + lean: jest.fn().mockResolvedValue({ + eventId, + status: 'registered', + batchWeek: '2026-W22', + timeSlotId: '7pm', + }), + })); + getModels.mockImplementation((_req, ...names) => { + if (names.includes('Event')) { + return { + Event: { + findOne: jest.fn(() => + mockEventFindOne( + publishedEvent({ + customFields: { + pivot: { + batchWeek: '2026-W22', + host: { name: 'Venue' }, + timeSlots: [ + { id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }, + { id: '930pm', start_time: '2026-05-24T01:30:00.000Z' }, + ], + }, + }, + }), + ), + ), + }, + }; + } + return { PivotEventIntent: { findOneAndUpdate } }; + }); + + const result = await confirmRegistered(req, eventId, { timeSlotId: '7pm' }); + + expect(result.data).toMatchObject({ + eventId, + status: 'registered', + timeSlotId: '7pm', + }); + expect(findOneAndUpdate).toHaveBeenCalledWith( + { userId, eventId }, + { $set: expect.objectContaining({ timeSlotId: '7pm', status: 'registered' }) }, + expect.objectContaining({ upsert: true }), + ); + }); +}); diff --git a/backend/tests/unit/pivotIsoWeek.test.js b/backend/tests/unit/pivotIsoWeek.test.js index cfaa1927..a8f173e2 100644 --- a/backend/tests/unit/pivotIsoWeek.test.js +++ b/backend/tests/unit/pivotIsoWeek.test.js @@ -1,4 +1,10 @@ -const { toIsoWeek, isValidIsoWeek } = require('../../utilities/pivotIsoWeek'); +const { + toIsoWeek, + isValidIsoWeek, + isoWeekToMondayUtc, + isoWeekToUtcRange, + shiftIsoWeek, +} = require('../../utilities/pivotIsoWeek'); describe('pivotIsoWeek', () => { it('formats known date as ISO week', () => { @@ -10,4 +16,36 @@ describe('pivotIsoWeek', () => { expect(isValidIsoWeek('2026-W1')).toBe(false); expect(isValidIsoWeek('bad')).toBe(false); }); + + describe('isoWeekToMondayUtc', () => { + it('returns the Monday starting the week', () => { + // 2026-W27 starts Monday 2026-06-29. + expect(isoWeekToMondayUtc('2026-W27').toISOString()).toBe('2026-06-29T00:00:00.000Z'); + }); + + it('throws on invalid input', () => { + expect(() => isoWeekToMondayUtc('2026-27')).toThrow(/Invalid batchWeek/); + }); + }); + + describe('isoWeekToUtcRange', () => { + it('covers Monday through the following Monday', () => { + const { start, end } = isoWeekToUtcRange('2026-W27'); + expect(start.toISOString()).toBe('2026-06-29T00:00:00.000Z'); + expect(end.toISOString()).toBe('2026-07-06T00:00:00.000Z'); + }); + }); + + describe('shiftIsoWeek', () => { + it('shifts forward and backward', () => { + expect(shiftIsoWeek('2026-W27', 1)).toBe('2026-W28'); + expect(shiftIsoWeek('2026-W27', -1)).toBe('2026-W26'); + expect(shiftIsoWeek('2026-W27', 0)).toBe('2026-W27'); + }); + + it('crosses ISO year boundaries', () => { + expect(shiftIsoWeek('2026-W01', -1)).toBe('2025-W52'); + expect(shiftIsoWeek('2025-W52', 1)).toBe('2026-W01'); + }); + }); }); diff --git a/backend/tests/unit/pivotLabEventsService.test.js b/backend/tests/unit/pivotLabEventsService.test.js new file mode 100644 index 00000000..7a28e4b4 --- /dev/null +++ b/backend/tests/unit/pivotLabEventsService.test.js @@ -0,0 +1,116 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../services/tenantConfigService', () => ({ + getMergedTenants: jest.fn(), +})); +jest.mock('../../services/pivotReferralCodeService', () => ({ + isPivotTenant: jest.fn(), +})); + +const getModels = require('../../services/getModelService'); +const { connectToDatabase } = require('../../connectionsManager'); +const { getMergedTenants } = require('../../services/tenantConfigService'); +const { isPivotTenant } = require('../../services/pivotReferralCodeService'); +const { + listPivotLabEvents, + serializeLabEvent, + loadIntentStatsByEventId, +} = require('../../services/pivotLabEventsService'); + +describe('pivotLabEventsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + isPivotTenant.mockImplementation((tenant) => tenant?.tenantType === 'pivot'); + connectToDatabase.mockResolvedValue({}); + }); + + describe('serializeLabEvent', () => { + it('defaults intentStats to zeros when no stats map is provided', () => { + const row = serializeLabEvent({ _id: 'e1', name: 'Show', customFields: {} }); + expect(row.intentStats).toEqual({ + interested: 0, + registered: 0, + passed: 0, + externalOpens: 0, + externalOpenUsers: 0, + }); + }); + }); + + describe('loadIntentStatsByEventId', () => { + it('returns an empty map without querying when there are no events', async () => { + const PivotEventIntent = { aggregate: jest.fn() }; + const stats = await loadIntentStatsByEventId(PivotEventIntent, []); + expect(stats.size).toBe(0); + expect(PivotEventIntent.aggregate).not.toHaveBeenCalled(); + }); + }); + + describe('listPivotLabEvents', () => { + it('merges per-event intent stats into catalog rows', async () => { + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + ]); + + const events = [ + { + _id: 'e1', + name: 'Rooftop Jazz', + customFields: { pivot: { ingestStatus: 'published', batchWeek: '2026-W27' } }, + }, + { + _id: 'e2', + name: 'Board Games', + customFields: { pivot: { ingestStatus: 'published', batchWeek: '2026-W27' } }, + }, + ]; + + getModels.mockReturnValue({ + Event: { + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + sort: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(events), + }), + }), + }), + }, + PivotEventIntent: { + aggregate: jest.fn().mockResolvedValue([ + { + _id: 'e1', + interested: 5, + registered: 2, + passed: 7, + externalOpens: 4, + externalOpenUsers: 3, + }, + ]), + }, + }); + + const result = await listPivotLabEvents( + { globalDb: {} }, + { tenantKey: 'nyc', batchWeek: '2026-W27' }, + ); + + expect(result.data.events).toHaveLength(2); + expect(result.data.events[0].intentStats).toEqual({ + interested: 5, + registered: 2, + passed: 7, + externalOpens: 4, + externalOpenUsers: 3, + }); + expect(result.data.events[1].intentStats).toEqual({ + interested: 0, + registered: 0, + passed: 0, + externalOpens: 0, + externalOpenUsers: 0, + }); + }); + }); +}); diff --git a/backend/tests/unit/pivotLogger.test.js b/backend/tests/unit/pivotLogger.test.js new file mode 100644 index 00000000..57c8e401 --- /dev/null +++ b/backend/tests/unit/pivotLogger.test.js @@ -0,0 +1,96 @@ +const { + logPivot, + pivotRequestContext, + pivotRequestLogger, + logPivotRouteError, + isPivotLoggingEnabled, +} = require('../../utilities/pivotLogger'); + +describe('pivotLogger', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv, NODE_ENV: 'development' }; + delete process.env.PIVOT_LOG; + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('logs info messages when enabled', () => { + logPivot('info', 'feed built', { eventCount: 3 }); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[pivot] feed built'), + ); + }); + + it('does not log during tests', () => { + process.env.NODE_ENV = 'test'; + expect(isPivotLoggingEnabled()).toBe(false); + logPivot('info', 'hidden'); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('pivotRequestLogger emits request summary on finish', (done) => { + const req = { + method: 'GET', + originalUrl: '/pivot/feed?batchWeek=2026-W22', + school: 'nyc', + user: { userId: '507f191e810c19729de860eb' }, + }; + const res = { + statusCode: 200, + on(event, handler) { + if (event === 'finish') { + this._finish = handler; + } + }, + emit(event) { + if (event === 'finish' && this._finish) { + this._finish(); + } + }, + }; + + pivotRequestLogger(req, res, () => { + res.emit('finish'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[pivot] request'), + ); + done(); + }); + }); + + it('logPivotRouteError writes error level', () => { + logPivotRouteError('GET /pivot/feed', new Error('boom'), { + method: 'GET', + originalUrl: '/pivot/feed', + school: 'nyc', + }); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('GET /pivot/feed failed'), + ); + }); + + it('pivotRequestContext extracts tenant and user', () => { + expect( + pivotRequestContext({ + method: 'POST', + originalUrl: '/pivot/feed/action', + school: 'nyc', + user: { userId: 'abc' }, + }), + ).toEqual({ + tenant: 'nyc', + userId: 'abc', + method: 'POST', + path: '/pivot/feed/action', + }); + }); +}); diff --git a/backend/tests/unit/pivotMovieMetadata.test.js b/backend/tests/unit/pivotMovieMetadata.test.js new file mode 100644 index 00000000..61cc6feb --- /dev/null +++ b/backend/tests/unit/pivotMovieMetadata.test.js @@ -0,0 +1,139 @@ +const { + buildTmdbImageUrl, + normalizePivotMovie, + applyMovieListingDefaults, + resolvePivotCoverImageUrl, +} = require('../../utilities/pivotMovieMetadata'); +const { + mapTmdbMovieDetails, + mapTmdbSearchResult, +} = require('../../services/pivotTmdbService'); + +describe('pivotMovieMetadata', () => { + it('builds TMDB image URLs from poster paths', () => { + expect(buildTmdbImageUrl('/abc.jpg', 'w500')).toBe( + 'https://image.tmdb.org/t/p/w500/abc.jpg', + ); + }); + + it('normalizes movie metadata with TMDB ratings', () => { + const movie = normalizePivotMovie({ + tmdbId: 123, + title: 'The Last Garden', + year: 2026, + overview: 'A gardener discovers a hidden world.', + posterPath: '/poster.jpg', + runtimeMinutes: 118, + genres: ['Drama', 'Sci-Fi'], + ratings: { tmdb: { score: 7.84, voteCount: 1200 } }, + }); + + expect(movie).toMatchObject({ + tmdbId: 123, + title: 'The Last Garden', + year: 2026, + synopsis: 'A gardener discovers a hidden world.', + posterUrl: 'https://image.tmdb.org/t/p/w500/poster.jpg', + runtimeMinutes: 118, + genres: ['Drama', 'Sci-Fi'], + ratings: { tmdb: { score: 7.8, voteCount: 1200 } }, + }); + }); + + it('applies movie defaults to listing fields', () => { + const merged = applyMovieListingDefaults({ + hostName: 'Nitehawk Cinema', + location: 'Brooklyn', + movie: normalizePivotMovie({ + tmdbId: 1, + title: 'The Last Garden', + overview: 'Synopsis text', + posterUrl: 'https://example.com/poster.jpg', + }), + }); + + expect(merged.name).toBe('The Last Garden'); + expect(merged.description).toBe('Synopsis text'); + expect(merged.image).toBe('https://example.com/poster.jpg'); + }); + + it('prefers movie backdrop for cover image', () => { + const url = resolvePivotCoverImageUrl({ + image: 'https://example.com/listing.jpg', + customFields: { + pivot: { + movie: { + tmdbId: 1, + title: 'Film', + backdropUrl: 'https://example.com/backdrop.jpg', + posterUrl: 'https://example.com/poster.jpg', + }, + }, + }, + }); + + expect(url).toBe('https://example.com/backdrop.jpg'); + }); +}); + +describe('pivotTmdbService mappers', () => { + it('maps TMDB search results', () => { + const row = mapTmdbSearchResult({ + id: 42, + title: 'Garden', + release_date: '2026-05-29', + overview: 'Plot', + poster_path: '/p.jpg', + vote_average: 8.1, + }); + + expect(row).toMatchObject({ + tmdbId: 42, + title: 'Garden', + year: 2026, + posterUrl: 'https://image.tmdb.org/t/p/w185/p.jpg', + voteAverage: 8.1, + }); + }); + + it('maps TMDB movie details into pivot movie metadata', () => { + const movie = mapTmdbMovieDetails({ + id: 99, + title: 'Garden', + release_date: '2026-05-29', + overview: 'Plot summary', + poster_path: '/poster.jpg', + backdrop_path: '/backdrop.jpg', + runtime: 110, + vote_average: 7.6, + vote_count: 500, + genres: [{ name: 'Drama' }], + external_ids: { imdb_id: 'tt1234567' }, + release_dates: { + results: [ + { + iso_3166_1: 'US', + release_dates: [{ certification: 'PG-13' }], + }, + ], + }, + credits: { + crew: [{ job: 'Director', name: 'Jane Doe' }], + cast: [{ name: 'Actor One' }, { name: 'Actor Two' }], + }, + }); + + expect(movie).toMatchObject({ + tmdbId: 99, + title: 'Garden', + year: 2026, + synopsis: 'Plot summary', + runtimeMinutes: 110, + contentRating: 'PG-13', + director: 'Jane Doe', + cast: ['Actor One', 'Actor Two'], + imdbId: 'tt1234567', + ratings: { tmdb: { score: 7.6, voteCount: 500 } }, + }); + }); +}); diff --git a/backend/tests/unit/pivotRetentionService.test.js b/backend/tests/unit/pivotRetentionService.test.js new file mode 100644 index 00000000..de3b2116 --- /dev/null +++ b/backend/tests/unit/pivotRetentionService.test.js @@ -0,0 +1,131 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../services/tenantConfigService', () => ({ + getMergedTenants: jest.fn(), +})); +jest.mock('../../services/pivotReferralCodeService', () => ({ + isPivotTenant: jest.fn(), +})); + +const getModels = require('../../services/getModelService'); +const { connectToDatabase } = require('../../connectionsManager'); +const { getMergedTenants } = require('../../services/tenantConfigService'); +const { isPivotTenant } = require('../../services/pivotReferralCodeService'); +const { + getPivotRetention, + normalizeWeeksParam, +} = require('../../services/pivotRetentionService'); + +describe('pivotRetentionService', () => { + beforeEach(() => { + jest.clearAllMocks(); + isPivotTenant.mockImplementation( + (tenant) => tenant?.pivotPilot === true || tenant?.tenantType === 'pivot', + ); + connectToDatabase.mockResolvedValue({}); + }); + + describe('normalizeWeeksParam', () => { + it('defaults to 6 and clamps to [2, 12]', () => { + expect(normalizeWeeksParam(undefined)).toBe(6); + expect(normalizeWeeksParam('not-a-number')).toBe(6); + expect(normalizeWeeksParam('1')).toBe(2); + expect(normalizeWeeksParam('99')).toBe(12); + expect(normalizeWeeksParam('4')).toBe(4); + }); + }); + + describe('getPivotRetention', () => { + it('computes returning users against the prior week', async () => { + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + { tenantKey: 'rpi', tenantType: 'campus' }, + ]); + + const usersByWeek = { + '2026-W25': ['u1', 'u2', 'u3', 'u4'], + '2026-W26': ['u2', 'u3', 'u5'], + }; + getModels.mockReturnValue({ + PivotEventIntent: { + distinct: jest + .fn() + .mockImplementation((_field, filter) => + Promise.resolve(usersByWeek[filter.batchWeek] || []), + ), + }, + }); + + const result = await getPivotRetention( + { globalDb: {} }, + { batchWeek: '2026-W26', weeks: 2 }, + ); + + expect(result.data.batchWeek).toBe('2026-W26'); + expect(result.data.weeks).toEqual(['2026-W25', '2026-W26']); + expect(result.data.tenants).toHaveLength(1); + + const [firstWeek, secondWeek] = result.data.tenants[0].weeks; + expect(firstWeek).toEqual({ + batchWeek: '2026-W25', + activeUsers: 4, + returningUsers: null, + retentionRate: null, + }); + expect(secondWeek).toEqual({ + batchWeek: '2026-W26', + activeUsers: 3, + returningUsers: 2, + retentionRate: 50, + }); + }); + + it('spans an ISO year boundary', async () => { + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot' }, + ]); + getModels.mockReturnValue({ + PivotEventIntent: { + distinct: jest.fn().mockResolvedValue([]), + }, + }); + + const result = await getPivotRetention( + { globalDb: {} }, + { batchWeek: '2026-W02', weeks: 4 }, + ); + + expect(result.data.weeks).toEqual(['2025-W51', '2025-W52', '2026-W01', '2026-W02']); + }); + + it('marks a tenant row when aggregation fails', async () => { + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot' }, + ]); + getModels.mockReturnValue({ + PivotEventIntent: { + distinct: jest.fn().mockRejectedValue(new Error('db down')), + }, + }); + + const result = await getPivotRetention( + { globalDb: {} }, + { batchWeek: '2026-W26', weeks: 3 }, + ); + + expect(result.data.tenants[0].error).toBe('AGGREGATION_FAILED'); + expect(result.data.tenants[0].weeks).toHaveLength(3); + expect(result.data.tenants[0].weeks[2]).toMatchObject({ + batchWeek: '2026-W26', + activeUsers: 0, + }); + }); + + it('rejects an invalid batch week', async () => { + const result = await getPivotRetention({ globalDb: {} }, { batchWeek: 'nope' }); + expect(result).toMatchObject({ code: 'INVALID_BATCH_WEEK', status: 400 }); + }); + }); +}); diff --git a/backend/tests/unit/pivotTimeSlots.test.js b/backend/tests/unit/pivotTimeSlots.test.js new file mode 100644 index 00000000..aecac915 --- /dev/null +++ b/backend/tests/unit/pivotTimeSlots.test.js @@ -0,0 +1,62 @@ +const { + normalizePivotTimeSlots, + serializePivotTimeSlots, + findTimeSlotById, + isUpcomingWithTimeSlots, + resolveTimeSlotLabel, +} = require('../../utilities/pivotTimeSlots'); + +describe('pivotTimeSlots', () => { + it('normalizes and sorts slots by start time', () => { + const slots = normalizePivotTimeSlots([ + { id: 'late', start_time: '2026-05-24T02:00:00.000Z' }, + { id: 'early', startTime: '2026-05-23T23:00:00.000Z', label: '7:00 PM' }, + ]); + + expect(slots.map((slot) => slot.id)).toEqual(['early', 'late']); + expect(resolveTimeSlotLabel(slots[0])).toBe('7:00 PM'); + }); + + it('findTimeSlotById resolves stored slots', () => { + const pivot = { + timeSlots: [{ id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }], + }; + expect(findTimeSlotById(pivot, '7pm')?.id).toBe('7pm'); + expect(findTimeSlotById(pivot, 'missing')).toBeNull(); + }); + + it('isUpcomingWithTimeSlots returns true when any slot is still upcoming', () => { + const now = new Date('2026-05-23T22:00:00.000Z'); + const pivot = { + timeSlots: [ + { id: 'early', start_time: '2026-05-23T21:00:00.000Z', end_time: '2026-05-23T22:30:00.000Z' }, + { id: 'late', start_time: '2026-05-24T01:00:00.000Z', end_time: '2026-05-24T03:00:00.000Z' }, + ], + }; + + expect(isUpcomingWithTimeSlots(pivot, now)).toBe(true); + }); + + it('serializePivotTimeSlots attaches per-slot friend social', () => { + const slots = normalizePivotTimeSlots([ + { id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }, + ]); + const socialBySlotId = new Map([ + [ + '7pm', + { + friendsGoingCount: 2, + friendsGoing: [{ id: 'a', name: 'Alex', picture: null }], + }, + ], + ]); + + expect(serializePivotTimeSlots(slots, socialBySlotId)).toEqual([ + expect.objectContaining({ + id: '7pm', + friendsGoingCount: 2, + friendsGoing: [{ id: 'a', name: 'Alex', picture: null }], + }), + ]); + }); +}); diff --git a/backend/tests/unit/pivotWeeklyDropService.test.js b/backend/tests/unit/pivotWeeklyDropService.test.js index f25da2ed..4280ec6a 100644 --- a/backend/tests/unit/pivotWeeklyDropService.test.js +++ b/backend/tests/unit/pivotWeeklyDropService.test.js @@ -10,6 +10,16 @@ jest.mock('../../services/tenantConfigService', () => ({ serializeTenantForAdmin: jest.fn((tenant) => tenant), })); +jest.mock('../../services/pivotWeeklySnapshotService', () => ({ + rebuildWeeklySnapshot: jest.fn(), +})); + +jest.mock('axios', () => ({ + post: jest.fn(), +})); + +const axios = require('axios'); +const { rebuildWeeklySnapshot } = require('../../services/pivotWeeklySnapshotService'); const { connectToDatabase } = require('../../connectionsManager'); const getModels = require('../../services/getModelService'); const { getTenantByKey, upsertStoredTenantRow } = require('../../services/tenantConfigService'); @@ -93,5 +103,40 @@ describe('pivotWeeklyDropService', () => { expect(result.dryRun).toBe(true); expect(result.pivotPushRecipientCount).toBe(2); expect(result.sampleMessage?.data?.type).toBe('pivot_week'); + expect(rebuildWeeklySnapshot).not.toHaveBeenCalled(); + }); + + it('sendWeeklyDropPush rebuilds the weekly snapshot after a real send', async () => { + getTenantByKey.mockResolvedValue(nycTenant); + axios.post.mockResolvedValue({ + data: { data: [{ status: 'ok' }, { status: 'ok' }] }, + }); + rebuildWeeklySnapshot.mockResolvedValue({ data: { batchWeek: '2026-W23' } }); + + const req = {}; + const result = await sendWeeklyDropPush(req, 'nyc', { + batchWeek: '2026-W23', + force: true, + }); + + expect(result.sent).toBe(2); + expect(result.snapshotRebuilt).toBe(true); + expect(rebuildWeeklySnapshot).toHaveBeenCalledWith(req, { batchWeek: '2026-W23' }); + }); + + it('sendWeeklyDropPush still reports the send when snapshot rebuild fails', async () => { + getTenantByKey.mockResolvedValue(nycTenant); + axios.post.mockResolvedValue({ + data: { data: [{ status: 'ok' }, { status: 'ok' }] }, + }); + rebuildWeeklySnapshot.mockRejectedValue(new Error('global db down')); + + const result = await sendWeeklyDropPush({}, 'nyc', { + batchWeek: '2026-W23', + force: true, + }); + + expect(result.sent).toBe(2); + expect(result.snapshotRebuilt).toBe(false); }); }); diff --git a/backend/tests/unit/pivotWeeklySnapshotService.test.js b/backend/tests/unit/pivotWeeklySnapshotService.test.js index 88c54669..3314bc82 100644 --- a/backend/tests/unit/pivotWeeklySnapshotService.test.js +++ b/backend/tests/unit/pivotWeeklySnapshotService.test.js @@ -64,9 +64,21 @@ describe('pivotWeeklySnapshotService', () => { .mockResolvedValueOnce(3) .mockResolvedValueOnce(2) .mockResolvedValueOnce(4), - distinct: jest.fn().mockResolvedValue(['u1', 'u2', 'u3']), + distinct: jest + .fn() + .mockImplementation((_field, filter) => + Promise.resolve(filter?.externalOpenAt ? ['u1', 'u2'] : ['u1', 'u2', 'u3']), + ), aggregate: jest.fn().mockResolvedValue([{ total: 7 }]), }, + AnalyticsEvent: { + countDocuments: jest.fn().mockImplementation((filter) => { + const names = filter?.event?.$in || []; + if (names.includes('pivot_calendar_add')) return Promise.resolve(5); + if (names.includes('pivot_invite_share')) return Promise.resolve(2); + return Promise.resolve(4); + }), + }, UniversalFeedback: { find: jest.fn().mockReturnValue({ select: jest.fn().mockReturnValue({ @@ -91,6 +103,10 @@ describe('pivotWeeklySnapshotService', () => { interestedCount: 3, registeredCount: 2, externalOpenCount: 7, + externalOpenUsers: 2, + calendarAdds: 5, + inviteShares: 2, + interestsSaved: 4, swipeCount: 9, feedbackAvg: 4.5, activeUsers: 3, @@ -141,6 +157,9 @@ describe('pivotWeeklySnapshotService', () => { distinct: jest.fn().mockResolvedValue([]), aggregate: jest.fn().mockResolvedValue([]), }, + AnalyticsEvent: { + countDocuments: jest.fn().mockResolvedValue(0), + }, UniversalFeedback: { find: jest.fn().mockReturnValue({ select: jest.fn().mockReturnValue({ diff --git a/backend/utilities/pivotDropSchedule.js b/backend/utilities/pivotDropSchedule.js index 472de23e..173581d7 100644 --- a/backend/utilities/pivotDropSchedule.js +++ b/backend/utilities/pivotDropSchedule.js @@ -1,4 +1,4 @@ -const { isValidIsoWeek } = require('./pivotIsoWeek'); +const { isoWeekToMondayUtc } = require('./pivotIsoWeek'); /** Pilot suggestion when a pivot tenant has no drop config stored yet (not a runtime constant). */ const PIVOT_DROP_PILOT_DEFAULTS = Object.freeze({ @@ -58,21 +58,6 @@ function resolvePivotDropConfig(tenant = {}) { }; } -function isoWeekToMondayUtc(batchWeek) { - if (!isValidIsoWeek(batchWeek)) { - throw new Error(`Invalid batchWeek "${batchWeek}" — expected YYYY-Www`); - } - - const [, yearStr, weekStr] = batchWeek.match(/^(\d{4})-W(\d{2})$/); - const year = Number(yearStr); - const week = Number(weekStr); - const jan4 = new Date(Date.UTC(year, 0, 4)); - const jan4IsoDay = jan4.getUTCDay() || 7; - const monday = new Date(jan4); - monday.setUTCDate(jan4.getUTCDate() - jan4IsoDay + 1 + (week - 1) * 7); - return monday; -} - function daysFromIsoMonday(dayOfWeek) { return dayOfWeek === 0 ? 6 : dayOfWeek - 1; } diff --git a/backend/utilities/pivotIsoWeek.js b/backend/utilities/pivotIsoWeek.js index a94e0e66..eef56896 100644 --- a/backend/utilities/pivotIsoWeek.js +++ b/backend/utilities/pivotIsoWeek.js @@ -17,8 +17,63 @@ function isValidIsoWeek(value) { return typeof value === 'string' && ISO_WEEK_PATTERN.test(value.trim()); } +/** + * UTC Date for the Monday 00:00 that starts the given ISO week. + * @param {string} batchWeek - YYYY-Www + * @returns {Date} + */ +function isoWeekToMondayUtc(batchWeek) { + if (!isValidIsoWeek(batchWeek)) { + throw new Error(`Invalid batchWeek "${batchWeek}" — expected YYYY-Www`); + } + + const [, yearStr, weekStr] = batchWeek.match(/^(\d{4})-W(\d{2})$/); + const year = Number(yearStr); + const week = Number(weekStr); + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4IsoDay = jan4.getUTCDay() || 7; + const monday = new Date(jan4); + monday.setUTCDate(jan4.getUTCDate() - jan4IsoDay + 1 + (week - 1) * 7); + return monday; +} + +/** + * [start, end) UTC range covering the given ISO week (Monday 00:00 → next Monday 00:00). + * @param {string} batchWeek - YYYY-Www + * @returns {{ start: Date, end: Date }} + */ +function isoWeekToUtcRange(batchWeek) { + const start = isoWeekToMondayUtc(batchWeek); + const end = new Date(start.getTime() + 7 * 24 * 60 * 60 * 1000); + return { start, end }; +} + +/** ISO week string from UTC calendar components (avoids local-timezone getters). */ +function toIsoWeekUtc(date) { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`; +} + +/** + * Shift an ISO week string by a number of weeks (negative = earlier). + * @param {string} batchWeek - YYYY-Www + * @param {number} delta + * @returns {string} + */ +function shiftIsoWeek(batchWeek, delta) { + const monday = isoWeekToMondayUtc(batchWeek); + monday.setUTCDate(monday.getUTCDate() + delta * 7); + return toIsoWeekUtc(monday); +} + module.exports = { toIsoWeek, isValidIsoWeek, + isoWeekToMondayUtc, + isoWeekToUtcRange, + shiftIsoWeek, ISO_WEEK_PATTERN, }; diff --git a/backend/utilities/pivotLogger.js b/backend/utilities/pivotLogger.js new file mode 100644 index 00000000..34b98ef9 --- /dev/null +++ b/backend/utilities/pivotLogger.js @@ -0,0 +1,101 @@ +const LOG_PREFIX = '[pivot]'; + +function isPivotLoggingEnabled() { + const flag = process.env.PIVOT_LOG; + if (flag === '0' || flag === 'false') { + return false; + } + if (process.env.NODE_ENV === 'test') { + return false; + } + return true; +} + +function serializeMeta(meta) { + if (meta == null) { + return ''; + } + try { + return ` ${JSON.stringify(meta)}`; + } catch { + return ' [meta unserializable]'; + } +} + +function logPivot(level, message, meta) { + if (!isPivotLoggingEnabled()) { + return; + } + + const line = `${LOG_PREFIX} ${message}${serializeMeta(meta)}`; + if (level === 'error') { + console.error(line); + return; + } + if (level === 'warn') { + console.warn(line); + return; + } + console.log(line); +} + +function pivotRequestContext(req) { + return { + tenant: req.school || undefined, + userId: req.user?.userId ? String(req.user.userId) : undefined, + method: req.method, + path: req.originalUrl || req.url, + }; +} + +/** Express middleware — logs completed pivot requests with latency. */ +function pivotRequestLogger(req, res, next) { + if (!isPivotLoggingEnabled()) { + next(); + return; + } + + const startedAt = Date.now(); + res.on('finish', () => { + const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'; + logPivot(level, 'request', { + ...pivotRequestContext(req), + status: res.statusCode, + ms: Date.now() - startedAt, + }); + }); + next(); +} + +function logPivotRouteError(routeLabel, err, req) { + logPivot('error', `${routeLabel} failed`, { + ...pivotRequestContext(req), + error: err?.message || String(err), + }); +} + +function logPivotServiceReject(routeLabel, result, req, extra) { + logPivot('warn', `${routeLabel} rejected`, { + ...pivotRequestContext(req), + code: result.code, + message: result.error, + ...extra, + }); +} + +function logPivotServiceSuccess(routeLabel, req, extra) { + logPivot('info', `${routeLabel} ok`, { + ...pivotRequestContext(req), + ...extra, + }); +} + +module.exports = { + logPivot, + pivotRequestContext, + pivotRequestLogger, + logPivotRouteError, + logPivotServiceReject, + logPivotServiceSuccess, + isPivotLoggingEnabled, +}; diff --git a/backend/utilities/pivotMovieMetadata.js b/backend/utilities/pivotMovieMetadata.js new file mode 100644 index 00000000..0cf4c9e2 --- /dev/null +++ b/backend/utilities/pivotMovieMetadata.js @@ -0,0 +1,157 @@ +/** + * Movie metadata stored on `customFields.pivot.movie` (TMDB-backed film listings). + */ + +const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p'; + +function trimString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function toNumber(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function roundRating(value) { + const num = toNumber(value); + if (num == null) { + return null; + } + return Math.round(num * 10) / 10; +} + +function buildTmdbImageUrl(path, size = 'w500') { + const normalized = trimString(path); + if (!normalized) { + return null; + } + if (normalized.startsWith('http://') || normalized.startsWith('https://')) { + return normalized; + } + return `${TMDB_IMAGE_BASE}/${size}${normalized.startsWith('/') ? normalized : `/${normalized}`}`; +} + +function normalizeRatings(raw) { + if (!raw || typeof raw !== 'object') { + return null; + } + + const tmdbScore = roundRating(raw.tmdb?.score ?? raw.tmdbScore); + const tmdbVoteCount = toNumber(raw.tmdb?.voteCount ?? raw.tmdbVoteCount); + if (tmdbScore == null && tmdbVoteCount == null) { + return null; + } + + return { + ...(tmdbScore != null + ? { + tmdb: { + score: tmdbScore, + ...(tmdbVoteCount != null ? { voteCount: tmdbVoteCount } : {}), + }, + } + : {}), + }; +} + +function normalizeStringArray(raw) { + if (!Array.isArray(raw)) { + return []; + } + return raw.map((entry) => trimString(entry)).filter(Boolean); +} + +/** + * @param {unknown} raw + * @returns {object | null} + */ +function normalizePivotMovie(raw) { + if (!raw || typeof raw !== 'object') { + return null; + } + + const tmdbId = toNumber(raw.tmdbId); + const title = trimString(raw.title); + if (!tmdbId || !title) { + return null; + } + + const year = toNumber(raw.year); + const synopsis = trimString(raw.synopsis || raw.overview); + const posterUrl = trimString(raw.posterUrl) || buildTmdbImageUrl(raw.posterPath, 'w500'); + const backdropUrl = + trimString(raw.backdropUrl) || buildTmdbImageUrl(raw.backdropPath, 'w780'); + const runtimeMinutes = toNumber(raw.runtimeMinutes ?? raw.runtime); + const genres = normalizeStringArray(raw.genres); + const contentRating = trimString(raw.contentRating); + const director = trimString(raw.director); + const cast = normalizeStringArray(raw.cast); + const imdbId = trimString(raw.imdbId); + const ratings = normalizeRatings(raw.ratings); + + return { + tmdbId, + title, + ...(year != null ? { year } : {}), + ...(synopsis ? { synopsis } : {}), + ...(posterUrl ? { posterUrl } : {}), + ...(backdropUrl ? { backdropUrl } : {}), + ...(runtimeMinutes != null ? { runtimeMinutes } : {}), + ...(genres.length ? { genres } : {}), + ...(contentRating ? { contentRating } : {}), + ...(director ? { director } : {}), + ...(cast.length ? { cast } : {}), + ...(imdbId ? { imdbId } : {}), + ...(ratings ? { ratings } : {}), + }; +} + +function serializePivotMovie(movie) { + if (!movie || typeof movie !== 'object') { + return null; + } + + return normalizePivotMovie(movie); +} + +function applyMovieListingDefaults(merged) { + if (!merged?.movie) { + return merged; + } + + const movie = merged.movie; + return { + ...merged, + name: trimString(merged.name) || movie.title, + description: trimString(merged.description) || movie.synopsis || '', + image: trimString(merged.image) || movie.posterUrl || '', + }; +} + +function resolvePivotCoverImageUrl(event) { + const pivot = event?.customFields?.pivot || {}; + const movie = serializePivotMovie(pivot.movie); + const eventImage = trimString(event?.image); + if (movie?.backdropUrl) { + return movie.backdropUrl; + } + if (movie?.posterUrl) { + return movie.posterUrl; + } + return eventImage || null; +} + +module.exports = { + buildTmdbImageUrl, + normalizePivotMovie, + serializePivotMovie, + applyMovieListingDefaults, + resolvePivotCoverImageUrl, +}; diff --git a/backend/utilities/pivotTimeSlots.js b/backend/utilities/pivotTimeSlots.js new file mode 100644 index 00000000..7430a151 --- /dev/null +++ b/backend/utilities/pivotTimeSlots.js @@ -0,0 +1,149 @@ +/** + * Normalize and serialize Pivot catalog showtimes stored on + * `customFields.pivot.timeSlots` (movies, theatre, multi-performance events). + */ + +function trimString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseDate(value) { + if (!value) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * @param {unknown} rawSlots + * @returns {Array<{ id: string, start_time: Date, end_time: Date | null, label: string | null }>} + */ +function normalizePivotTimeSlots(rawSlots) { + if (!Array.isArray(rawSlots) || !rawSlots.length) { + return []; + } + + const slots = []; + const seenIds = new Set(); + + for (const raw of rawSlots) { + if (!raw || typeof raw !== 'object') { + continue; + } + + const id = trimString(raw.id); + const start = parseDate(raw.start_time ?? raw.startTime); + if (!id || !start || seenIds.has(id)) { + continue; + } + + seenIds.add(id); + const end = parseDate(raw.end_time ?? raw.endTime); + const label = trimString(raw.label) || null; + + slots.push({ + id, + start_time: start, + end_time: end, + label, + }); + } + + slots.sort((a, b) => a.start_time.getTime() - b.start_time.getTime()); + return slots; +} + +function resolveTimeSlotLabel(slot) { + if (slot.label) { + return slot.label; + } + + return slot.start_time + .toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + .toLowerCase() + .replace(/\s/g, ''); +} + +/** + * @param {Array<{ id: string, start_time: Date, end_time: Date | null, label: string | null }>} slots + * @param {Map} [socialBySlotId] + */ +function serializePivotTimeSlots(slots, socialBySlotId = new Map()) { + return slots.map((slot) => { + const social = socialBySlotId.get(slot.id) || { + friendsGoing: [], + friendsGoingCount: 0, + }; + + return { + id: slot.id, + start_time: slot.start_time.toISOString(), + ...(slot.end_time ? { end_time: slot.end_time.toISOString() } : {}), + label: resolveTimeSlotLabel(slot), + friendsGoing: social.friendsGoing, + friendsGoingCount: social.friendsGoingCount, + }; + }); +} + +function eventHasTimeSlots(pivotMeta) { + return normalizePivotTimeSlots(pivotMeta?.timeSlots).length > 0; +} + +function findTimeSlotById(pivotMeta, timeSlotId) { + const key = trimString(timeSlotId); + if (!key) { + return null; + } + return normalizePivotTimeSlots(pivotMeta?.timeSlots).find((slot) => slot.id === key) || null; +} + +function isTimeSlotUpcoming(slot, now = new Date()) { + const end = slot.end_time || slot.start_time; + return end > now; +} + +function isUpcomingWithTimeSlots(pivotMeta, now = new Date()) { + const slots = normalizePivotTimeSlots(pivotMeta?.timeSlots); + if (!slots.length) { + return null; + } + return slots.some((slot) => isTimeSlotUpcoming(slot, now)); +} + +function resolveEventEarliestStart(pivotMeta, fallbackStart) { + const slots = normalizePivotTimeSlots(pivotMeta?.timeSlots); + if (!slots.length) { + return fallbackStart; + } + return slots[0].start_time; +} + +function resolveEventLatestEnd(pivotMeta, fallbackEnd) { + const slots = normalizePivotTimeSlots(pivotMeta?.timeSlots); + if (!slots.length) { + return fallbackEnd; + } + + let latest = null; + for (const slot of slots) { + const candidate = slot.end_time || slot.start_time; + if (!latest || candidate > latest) { + latest = candidate; + } + } + return latest; +} + +module.exports = { + normalizePivotTimeSlots, + serializePivotTimeSlots, + resolveTimeSlotLabel, + eventHasTimeSlots, + findTimeSlotById, + isTimeSlotUpcoming, + isUpcomingWithTimeSlots, + resolveEventEarliestStart, + resolveEventLatestEnd, +}; diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx new file mode 100644 index 00000000..57ce5525 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx @@ -0,0 +1,529 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import Popup from '../../../components/Popup/Popup'; +import PivotTagMultiSelect from './PivotTagMultiSelect'; +import { PivotDeckPhonePreview } from './PivotDeckCardPreview'; +import { formatPivotDeckWhen } from '../../../utils/pivotIsoWeek'; +import { + toDatetimeLocalValue, + datetimeLocalToIso, + createEmptyManualShowtimeSlot, + normalizeManualShowtimeSlots, + deriveEventWindowFromShowtimes, + applyMovieMetadataToDraft, +} from './PivotManualImportModal'; +import PivotTmdbLookup from './PivotTmdbLookup'; +import './PivotManualImportModal.scss'; +import './PivotCatalogEventEditModal.scss'; + +function isoToDatetimeLocal(iso) { + if (!iso) return ''; + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return ''; + return toDatetimeLocalValue(parsed); +} + +function catalogTimeSlotsToDraftSlots(timeSlots) { + if (!Array.isArray(timeSlots) || !timeSlots.length) { + return []; + } + + return timeSlots.map((slot, index) => ({ + key: slot.id || `slot-${index}`, + label: slot.label || '', + startTimeLocal: isoToDatetimeLocal(slot.start_time), + endTimeLocal: isoToDatetimeLocal(slot.end_time), + })); +} + +export function catalogEventToEditDraft(event) { + if (!event) return null; + + const timeSlots = catalogTimeSlotsToDraftSlots(event.timeSlots); + const hasShowtimes = timeSlots.length > 0; + + return { + name: event.name || '', + organizerName: event.organizerName || '', + location: event.location || '', + description: event.description || '', + imageUrl: event.image || '', + sourceUrl: event.sourceUrl || event.externalLink || '', + scheduleMode: hasShowtimes ? 'showtimes' : 'single', + startTimeLocal: isoToDatetimeLocal(event.start_time), + endTimeLocal: isoToDatetimeLocal(event.end_time), + timeSlots: hasShowtimes ? timeSlots : [], + ingestStatus: event.ingestStatus || 'published', + tags: Array.isArray(event.tags) ? [...event.tags] : [], + movie: event.movie || null, + }; +} + +export function catalogEditDraftToOverrides(draft) { + const useShowtimes = draft.scheduleMode === 'showtimes'; + const normalizedSlots = useShowtimes ? normalizeManualShowtimeSlots(draft.timeSlots) : []; + const window = useShowtimes + ? deriveEventWindowFromShowtimes(normalizedSlots) + : { + start_time: datetimeLocalToIso(draft.startTimeLocal), + end_time: datetimeLocalToIso(draft.endTimeLocal), + }; + + return { + name: draft.name?.trim() || '', + hostName: draft.organizerName?.trim() || '', + location: draft.location?.trim() || '', + description: draft.description?.trim() || '', + image: draft.imageUrl?.trim() || '', + sourceUrl: draft.sourceUrl?.trim() || '', + start_time: window.start_time || undefined, + end_time: window.end_time || undefined, + ingestStatus: draft.ingestStatus, + tags: Array.isArray(draft.tags) ? draft.tags : [], + ...(useShowtimes ? { timeSlots: normalizedSlots } : { timeSlots: [] }), + ...(draft.movie ? { movie: draft.movie } : {}), + }; +} + +function validateCatalogEditDraft(draft) { + if (!draft.name?.trim()) return 'Event title is required.'; + if (!draft.organizerName?.trim()) return 'Organizer is required.'; + if (!draft.location?.trim()) return 'Location is required.'; + if (!draft.tags?.length) return 'Select at least one catalog tag.'; + if (draft.scheduleMode === 'showtimes') { + const slots = normalizeManualShowtimeSlots(draft.timeSlots); + if (!slots.length) return 'Add at least one showtime with a valid start.'; + } else if (!datetimeLocalToIso(draft.startTimeLocal)) { + return 'Start time is required.'; + } + return null; +} + +function PivotCatalogEventEditModal({ + open, + event, + onClose, + catalogTags, + cityLabel, + batchWeek, + onSave, + saving, + onSuggestTags, + tagSuggestLoading, +}) { + const [draft, setDraft] = useState(null); + const [formError, setFormError] = useState(''); + + useEffect(() => { + if (open && event) { + setDraft(catalogEventToEditDraft(event)); + setFormError(''); + } else if (!open) { + setDraft(null); + setFormError(''); + } + }, [open, event]); + + const patchDraft = useCallback((patch) => { + setDraft((current) => (current ? { ...current, ...patch } : current)); + setFormError(''); + }, []); + + const deckPreview = useMemo(() => { + if (!draft) return null; + + const useShowtimes = draft.scheduleMode === 'showtimes'; + const normalizedSlots = useShowtimes ? normalizeManualShowtimeSlots(draft.timeSlots) : []; + const window = useShowtimes + ? deriveEventWindowFromShowtimes(normalizedSlots) + : { + start_time: datetimeLocalToIso(draft.startTimeLocal), + end_time: datetimeLocalToIso(draft.endTimeLocal), + }; + + return { + title: draft.name, + hostName: draft.organizerName, + whenLabel: formatPivotDeckWhen(window.start_time, window.end_time), + locationLabel: draft.location, + description: draft.description, + imageUrl: draft.imageUrl?.trim() || undefined, + }; + }, [draft]); + + const setScheduleMode = useCallback((mode) => { + setDraft((current) => { + if (!current || current.scheduleMode === mode) return current; + + if (mode === 'showtimes') { + const seedStart = current.startTimeLocal || toDatetimeLocalValue(new Date()); + return { + ...current, + scheduleMode: 'showtimes', + timeSlots: current.timeSlots?.length + ? current.timeSlots + : [createEmptyManualShowtimeSlot(seedStart)], + }; + } + + return { + ...current, + scheduleMode: 'single', + timeSlots: [], + }; + }); + setFormError(''); + }, []); + + const addShowtime = useCallback(() => { + setDraft((current) => { + if (!current) return current; + const last = current.timeSlots?.[current.timeSlots.length - 1]; + const nextStart = last?.startTimeLocal || current.startTimeLocal || ''; + return { + ...current, + scheduleMode: 'showtimes', + timeSlots: [...(current.timeSlots || []), createEmptyManualShowtimeSlot(nextStart)], + }; + }); + setFormError(''); + }, []); + + const updateShowtime = useCallback((slotKey, patch) => { + setDraft((current) => ({ + ...current, + timeSlots: (current.timeSlots || []).map((slot) => + slot.key === slotKey ? { ...slot, ...patch } : slot, + ), + })); + setFormError(''); + }, []); + + const removeShowtime = useCallback((slotKey) => { + setDraft((current) => ({ + ...current, + timeSlots: (current.timeSlots || []).filter((slot) => slot.key !== slotKey), + })); + setFormError(''); + }, []); + + const handleMovieChange = useCallback( + (movie) => { + patchDraft(applyMovieMetadataToDraft(movie)); + }, + [patchDraft], + ); + + const handleSave = useCallback(async () => { + if (!draft) return; + + const error = validateCatalogEditDraft(draft); + if (error) { + setFormError(error); + return; + } + + const ok = await onSave?.(draft); + if (ok) { + onClose?.(); + } + }, [draft, onClose, onSave]); + + if (!open || !event) { + return null; + } + + return ( + + {draft ? ( +
+
+
+

+ Edit catalog event +

+

+ {cityLabel || 'No city'} · {batchWeek} + {event?.source ? ` · ${event.source}` : ''} +

+
+
+ +
+
+
+

Event

+ +
+ + +
+