diff --git a/services/web/app/src/models/StudentVerification.mjs b/services/web/app/src/models/StudentVerification.mjs new file mode 100644 index 0000000000..e8211a7535 --- /dev/null +++ b/services/web/app/src/models/StudentVerification.mjs @@ -0,0 +1,123 @@ +import mongoose from '../infrastructure/Mongoose.mjs' + +const { Schema } = mongoose +const { ObjectId } = Schema + +/** + * @typedef {'pending' | 'success' | 'failed'} StudentVerificationStatus + */ + +// Kept separate so retention, validity and the retry block can change +// independently, though all three are 30 days at the moment. + +// How long a success counts as verified. TODO: 30 days is a placeholder; +// SheerID's own model is annual. +export const VALIDITY_IN_SECONDS = 60 * 60 * 24 * 30 + +// How long a failure blocks starting again. TODO: decide whether to allow one +// automatic retry first. +export const BLOCK_DURATION_IN_SECONDS = 60 * 60 * 24 * 30 + +// How long the record is kept, via a Mongo TTL index. +export const EXPIRY_IN_SECONDS = 60 * 60 * 24 * 30 + +// Runs at boot, since every path that touches a StudentVerification loads this +// module. Fails loudly rather than letting a misconfiguration quietly delete +// records that are still in use. +if ( + EXPIRY_IN_SECONDS < Math.max(VALIDITY_IN_SECONDS, BLOCK_DURATION_IN_SECONDS) +) { + throw new Error( + 'StudentVerification: EXPIRY_IN_SECONDS must be >= VALIDITY_IN_SECONDS and BLOCK_DURATION_IN_SECONDS (a record must not be deleted by the TTL while it is still authoritative for the purchase gate or the retry block)' + ) +} + +/** + * @param {number} seconds + */ +function dateSecondsFromNow(seconds) { + const timestamp = new Date() + timestamp.setSeconds(timestamp.getSeconds() + seconds) + return timestamp +} + +export const StudentVerificationSchema = new Schema( + { + // Unique, so it can be the sole lookup key everywhere. + verificationId: { type: String, required: true, unique: true }, + userId: { type: ObjectId, required: true }, + status: { + type: String, + enum: ['pending', 'success', 'failed'], + default: 'pending', + }, + // Only ever set alongside a FAILED status. Distinguishes a permanent + // SheerID block (verificationLimitExceeded) from an ordinary declined + // result (docReviewLimitExceeded), so the frontend can show 'blocked' + // rather than 'declined'. Not a status of its own. + blocked: { type: Boolean, default: false }, + // Upper-case. + country: { type: String, required: true }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, + // Throttles the upstream fetch independently of how often the page polls + // us. Absent means never refreshed. + lastRefreshedAt: { type: Date }, + // When the status stopped being pending. Set once, alongside the move to + // success or failed. Subtracting createdAt gives how long the verification + // took, which updatedAt would not, since any later write moves it. + resolvedAt: { type: Date }, + // Captured at the start so it survives the user leaving and coming back + // later via email link, then reattached to the purchase once they are + // verified. + itm: { + itm_campaign: { type: String, default: null }, + itm_content: { type: String, default: null }, + itm_referrer: { type: String, default: null }, + }, + // How long a SUCCESS counts as verified for the purchase gate. See + // VALIDITY_IN_SECONDS above. The default is only a starting point: + // StudentVerificationManager sets this from the moment SheerID resolved + // the verification, so the window does not start ticking while a document + // review is still going on. + validUntil: { + type: Date, + default: () => dateSecondsFromNow(VALIDITY_IN_SECONDS), + }, + // How long a FAILED record blocks starting a new verification. See + // BLOCK_DURATION_IN_SECONDS above. Checked explicitly by the caller + // (StudentVerificationController.startVerification) rather than + // relying on the record ageing out via the TTL, so the block duration + // can move independently of retention. Set from resolvedAt when the + // verification fails, so the block runs from the failure rather than from + // when the user started. + blockedUntil: { + type: Date, + default: () => dateSecondsFromNow(BLOCK_DURATION_IN_SECONDS), + }, + // How long the record is kept. Mongo deletes it when this passes. Set from + // resolvedAt alongside the other two windows, so a record is never deleted + // while it is still authoritative for the gate or the retry block. + expires: { + type: Date, + default: () => dateSecondsFromNow(EXPIRY_IN_SECONDS), + index: { expireAfterSeconds: 0 }, + }, + }, + { + collection: 'studentVerifications', + minimize: false, + } +) + +// Not created automatically: infrastructure/Mongoose.mjs sets autoIndex: false +// outside dev. Real environments get them from +// tools/migrations/20260808093000_create_studentVerifications_indexes.mjs, +// which says what each one serves. Keep the two in sync. +StudentVerificationSchema.index({ userId: 1, status: 1, createdAt: -1 }) +StudentVerificationSchema.index({ status: 1, lastRefreshedAt: 1 }) + +export const StudentVerification = mongoose.model( + 'StudentVerification', + StudentVerificationSchema +) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index d786618613..1a0d0cb9cf 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -32,6 +32,7 @@ "about_to_leave_project": "", "about_to_leave_projects": "", "about_to_remove_user_preamble": "", + "about_to_switch_to_plan_x_for_price_y": "", "about_to_trash_projects": "", "abstract": "", "accept_all_cookies": "", @@ -157,7 +158,9 @@ "all_these_experiments_are_available_exclusively": "", "allocate_license": "", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "", + "already_changed_your_plan": "", "already_in_your_library": "", + "already_purchased_this_plan": "", "an_email_has_already_been_sent_to": "", "an_error_occured_while_restoring_project": "", "and_much_more": "", @@ -303,6 +306,7 @@ "chat_error": "", "checking_dropbox_status": "", "checking_project_github_status": "", + "checking_your_student_status": "", "choose_a_custom_color": "", "choose_from_group_members": "", "choose_how_you_search_your_references": "", @@ -401,6 +405,7 @@ "continue": "", "continue_github_merge": "", "continue_to": "", + "continue_to_checkout": "", "continue_using_free_features": "", "continue_with_free_plan": "", "conversion_error_details": "", @@ -812,6 +817,7 @@ "get_ready_for_overleaf_at_its_best_pro": "", "get_ready_for_overleaf_standard": "", "get_real_time_track_changes": "", + "get_standard": "", "get_started_with_latex": "", "get_your_hands_on_the_ultimate_research_writing_ai_assistant": "", "git": "", @@ -1524,6 +1530,7 @@ "please_confirm_secondary_email_or_edit": "", "please_confirm_your_email_before_making_it_default": "", "please_contact_support_to_makes_change_to_your_plan": "", + "please_contact_us_if_you_think_this_is_in_error": "", "please_enter_confirmation_code": "", "please_get_in_touch": "", "please_link_before_making_primary": "", @@ -1659,6 +1666,8 @@ "readcube_sync_description": "", "readcube_upgrade_prompt_content": "", "readcube_upgrade_prompt_title": "", + "ready_to_complete_your_plan_change": "", + "ready_to_complete_your_purchase": "", "ready_to_join_x": "", "ready_to_join_x_in_group_y": "", "ready_to_set_up": "", @@ -2066,6 +2075,18 @@ "strongly_agree": "", "strongly_disagree": "", "student": "", + "student_status_verified": "", + "student_verification_blocked": "", + "student_verification_blocked_info": "", + "student_verification_confirm_email_to_continue": "", + "student_verification_declined": "", + "student_verification_declined_info": "", + "student_verification_error": "", + "student_verification_error_info": "", + "student_verification_intro": "", + "student_verification_rate_limited": "", + "student_verification_rate_limited_info": "", + "student_verification_required": "", "subject": "", "subject_area": "", "submit_title": "", @@ -2343,6 +2364,7 @@ "turn_on": "", "turn_on_link_sharing": "", "type_a_command": "", + "unable_to_load_current_plan_details": "", "unable_to_save_check_connection_or_download": "", "unarchive": "", "uncategorized": "", @@ -2584,6 +2606,7 @@ "you_can_also_right_click_a_file_to_set_it_as_main": "", "you_can_buy_this_plan_but_not_as_a_trial": "", "you_can_link_x_in_your_account_settings": "", + "you_can_now_buy_the_student_plan": "", "you_can_now_enable_sso": "", "you_can_now_log_in_sso": "", "you_can_now_search_and_add_references_from_your_rm_library_without_needing_to_import_files": "", @@ -2591,6 +2614,7 @@ "you_can_restore_or_permanently_delete_them_at_any_time": "", "you_can_select_or_invite_collaborator": "", "you_can_select_or_invite_collaborator_plural": "", + "you_can_still_get_the_features_you_need_on_our_standard_plan": "", "you_can_still_use_your_premium_features": "", "you_cant_add_or_change_password_due_to_sso": "", "you_cant_join_this_group_subscription": "", diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 431bedd87d..da0b42f1c7 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -334,6 +334,17 @@ export interface Meta { }> 'ol-stripePublicKeyUK': string 'ol-stripePublicKeyUS': string + 'ol-studentVerificationPage': { + planCode: string + origin: 'change-plan' | undefined + alreadyVerified: boolean + alreadyOnPlan: boolean + // Already translated by the server, because the keys come from the plans + // config and so are not picked up by the frontend translation build. + studentPlanFeatures: string[] + standardPlanFeatures: string[] + ip: string | undefined + } 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionCreationPreview': SubscriptionCreationPreview diff --git a/services/web/locales/en.json b/services/web/locales/en.json index bbf230a147..d529e21b6f 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -38,6 +38,7 @@ "about_to_leave_project": "You are about to leave this project.", "about_to_leave_projects": "You are about to leave the following projects:", "about_to_remove_user_preamble": "You’re about to remove __userName__ (__userEmail__). Doing this will mean:", + "about_to_switch_to_plan_x_for_price_y": "You’re about to switch to <0>__planName__ for __price__.", "about_to_trash_projects": "You are about to trash the following projects:", "abstract": "Abstract", "accept": "Accept", @@ -196,9 +197,11 @@ "all_these_experiments_are_available_exclusively": "All these experiments are available exclusively to members of the Labs program. If you sign up, you can choose which experiments you want to try.", "allocate_license": "Allocate license", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Allows to search by author, title, etc. Possible to pull results directly from your reference manager (if connected).", + "already_changed_your_plan": "You’ve already changed to this plan. <0>Go to your subscription", "already_have_an_account": "Already have an account?", "already_have_sl_account": "Already have an __appName__ account?", "already_in_your_library": "Already in your library", + "already_purchased_this_plan": "You’ve already purchased this plan. <0>Go to your subscription", "also": "Also", "alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.", "an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__. Please wait and try again later.", @@ -387,6 +390,7 @@ "checking": "Checking", "checking_dropbox_status": "Checking Dropbox status", "checking_project_github_status": "Checking project status in GitHub", + "checking_your_student_status": "We’re checking your student status. We’ll email you if your document upload needs review.", "choose_a_custom_color": "Choose a custom color", "choose_a_new_password": "Choose a new password", "choose_from_group_members": "Choose from group members", @@ -523,6 +527,7 @@ "continue": "Continue", "continue_github_merge": "I have manually merged. Continue", "continue_to": "Continue to __appName__", + "continue_to_checkout": "Continue to checkout", "continue_using_free_features": "Continue using our free features", "continue_with_free_plan": "Continue with free plan", "continue_with_service": "Continue with __service__", @@ -2216,6 +2221,8 @@ "readcube_sync_description": "With the ReadCube integration you can import your references from ReadCube into your __appName__ projects.", "readcube_upgrade_prompt_content": "Link your ReadCube account to search and add your references from ReadCube directly in your project—they’ll automatically be added to your .bib file. Or import them as a file into your __appName__ projects.", "readcube_upgrade_prompt_title": "Cite from ReadCube", + "ready_to_complete_your_plan_change": "You’re ready to complete your plan change.", + "ready_to_complete_your_purchase": "You’re ready to complete your purchase.", "ready_to_join_x": "You’re ready to join __inviterName__", "ready_to_join_x_in_group_y": "You’re ready to join __inviterName__ in __groupName__", "ready_to_set_up": "Ready to set up", @@ -2713,6 +2720,18 @@ "strongly_disagree": "Strongly disagree", "student": "Student", "student_discount_tooltip": "Available to students currently enrolled at a degree-granting institution.", + "student_status_verified": "Your student status has been verified", + "student_verification_blocked": "You’ve already used your verification attempt", + "student_verification_blocked_info": "We weren’t able to verify your student status last time, so we can’t try again automatically.", + "student_verification_confirm_email_to_continue": "Please confirm your primary email address to continue. <0>Go to account settings.", + "student_verification_declined": "Your student status couldn’t be verified", + "student_verification_declined_info": "We weren’t able to confirm that you’re a current student.", + "student_verification_error": "Something went wrong", + "student_verification_error_info": "We weren’t able to start student verification just now. This is a problem on our side, not with your details. Please try again in a few minutes.", + "student_verification_intro": "We need to verify that you’re a current student before you can continue.", + "student_verification_rate_limited": "Too many attempts", + "student_verification_rate_limited_info": "We’ve had too many verification requests recently. Please wait a few minutes and try again.", + "student_verification_required": "Student verification required", "students": "Students", "subject": "Subject", "subject_area": "Subject area", @@ -3038,6 +3057,7 @@ "tutorials": "Tutorials", "type_a_command": "Type a command…", "unable_to_extract_the_supplied_zip_file": "Opening this content on Overleaf failed because the zip file could not be extracted. Please ensure that it is a valid zip file. If this keeps happening for links on a particular site, please report this to them.", + "unable_to_load_current_plan_details": "We couldn’t load your current plan details. Please try again.", "unable_to_save_check_connection_or_download": "We couldn’t add this file to your project. Check your connection and try again, or <0>download the file instead.", "unarchive": "Restore", "uncategorized": "Uncategorized", @@ -3314,6 +3334,7 @@ "you_can_also_right_click_a_file_to_set_it_as_main": "You can also right-click a file to set it as main.", "you_can_buy_this_plan_but_not_as_a_trial": "You can buy this plan but not as a trial, as you’ve completed a trial before.", "you_can_link_x_in_your_account_settings": "You can link __managers__ in your <0>account settings.", + "you_can_now_buy_the_student_plan": "You can now buy the student plan", "you_can_now_enable_sso": "You can now enable SSO on your group settings page.", "you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ premium features.", "you_can_now_search_and_add_references_from_your_rm_library_without_needing_to_import_files": "You can now search and add references from your __referenceManager__ library without needing to import files—just type \\cite{} in your .tex file. Learn more", @@ -3323,6 +3344,7 @@ "you_can_restore_or_permanently_delete_them_at_any_time": "You can restore or permanently delete them at any time.", "you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.", "you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.", + "you_can_still_get_the_features_you_need_on_our_standard_plan": "You can still get the features you need on our Standard plan", "you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.", "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO).", "you_cant_join_this_group_subscription": "You can’t join this group subscription", diff --git a/services/web/test/acceptance/config/settings.test.defaults.js b/services/web/test/acceptance/config/settings.test.defaults.js index 33ed4f88fc..bf98d10cf4 100644 --- a/services/web/test/acceptance/config/settings.test.defaults.js +++ b/services/web/test/acceptance/config/settings.test.defaults.js @@ -200,6 +200,26 @@ module.exports = { price_in_cents: 1500, features: features.collaborator, }, + // The real student plans live in settings.overrides.saas.js, but + // @overleaf/settings' merge replaces arrays wholesale rather than + // merging them, so this list (not that one) is what acceptance tests + // see. Without these, PlansLocator.findLocalPlanInSettings cannot + // resolve 'student' and any test of the student-verification gate is + // blocked before the gate is reached. Feature set is deliberately + // borrowed from collaborator: the gate only needs the plan to resolve. + { + planCode: 'student', + name: 'Student monthly', + price_in_cents: 1000, + features: features.collaborator, + }, + { + planCode: 'student-annual', + name: 'Student annual', + price_in_cents: 10000, + annual: true, + features: features.collaborator, + }, { planCode: 'professional', name: 'Pro monthly', diff --git a/services/web/test/unit/src/Settings/Settings.test.mjs b/services/web/test/unit/src/Settings/Settings.test.mjs index e22b20a415..61bf40ee67 100644 --- a/services/web/test/unit/src/Settings/Settings.test.mjs +++ b/services/web/test/unit/src/Settings/Settings.test.mjs @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { createRequire } from 'node:module' const require = createRequire(import.meta.url) @@ -88,3 +88,133 @@ describe('settings.defaults', function () { expect(haveSameStructure(pricings)).to.be.true }) }) + +describe('settings.overrides.saas', function () { + const originalEnv = { + SHEERID_ENABLED: process.env.SHEERID_ENABLED, + SHEERID_MOCK: process.env.SHEERID_MOCK, + SHEERID_CLIENT_ID: process.env.SHEERID_CLIENT_ID, + SHEERID_CLIENT_SECRET: process.env.SHEERID_CLIENT_SECRET, + SHEERID_PROGRAM_ID: process.env.SHEERID_PROGRAM_ID, + SHEERID_API_BASE_URL: process.env.SHEERID_API_BASE_URL, + SHEERID_ENABLED_COUNTRIES: process.env.SHEERID_ENABLED_COUNTRIES, + SHEERID_FALLBACK_COUNTRY: process.env.SHEERID_FALLBACK_COUNTRY, + NODE_ENV: process.env.NODE_ENV, + } + + function setCredentials() { + process.env.SHEERID_CLIENT_ID = 'client-id' + process.env.SHEERID_CLIENT_SECRET = 'client-secret' + process.env.SHEERID_PROGRAM_ID = 'program-id' + } + + afterEach(function () { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + clearSettingsCache() + }) + + it('sets the verification page CSP frame-src from the configured SheerID host', function () { + process.env.SHEERID_API_BASE_URL = 'https://sandbox.sheerid.com' + clearSettingsCache() + + const settings = require('../../../../config/settings.overrides.saas.js') + + expect( + settings.csp.viewDirectives[ + 'modules/student-verification/app/views/student-verification' + ] + ).to.deep.equal(["frame-src 'self' https://sandbox.sheerid.com"]) + }) + + it('upper-cases the SheerID country settings', function () { + process.env.SHEERID_ENABLED_COUNTRIES = 'gb, us ,De' + process.env.SHEERID_FALLBACK_COUNTRY = 'gb' + clearSettingsCache() + + const settings = require('../../../../config/settings.overrides.saas.js') + + expect(settings.sheerId.enabledCountries).to.deep.equal(['GB', 'US', 'DE']) + expect(settings.sheerId.fallbackCountry).to.equal('GB') + }) + + it('refuses to boot with SHEERID_ENABLED and the default mock client in production', function () { + process.env.SHEERID_ENABLED = 'true' + delete process.env.SHEERID_MOCK // defaults to mock: true + process.env.NODE_ENV = 'production' + clearSettingsCache() + + expect(() => + require('../../../../config/settings.overrides.saas.js') + ).to.throw(/SHEERID_ENABLED/) + }) + + it('boots with SHEERID_ENABLED, SHEERID_MOCK=false and credentials in production', function () { + process.env.SHEERID_ENABLED = 'true' + process.env.SHEERID_MOCK = 'false' + process.env.NODE_ENV = 'production' + setCredentials() + clearSettingsCache() + + expect(() => + require('../../../../config/settings.overrides.saas.js') + ).not.to.throw() + }) + + it('refuses to boot with the real client and no credentials in production', function () { + process.env.SHEERID_ENABLED = 'true' + process.env.SHEERID_MOCK = 'false' + process.env.NODE_ENV = 'production' + delete process.env.SHEERID_CLIENT_ID + delete process.env.SHEERID_CLIENT_SECRET + delete process.env.SHEERID_PROGRAM_ID + clearSettingsCache() + + expect(() => + require('../../../../config/settings.overrides.saas.js') + ).to.throw(/SHEERID_CLIENT_ID, SHEERID_CLIENT_SECRET, SHEERID_PROGRAM_ID/) + }) + + it('names only the credentials that are missing', function () { + process.env.SHEERID_ENABLED = 'true' + process.env.SHEERID_MOCK = 'false' + process.env.NODE_ENV = 'production' + setCredentials() + delete process.env.SHEERID_PROGRAM_ID + clearSettingsCache() + + expect(() => + require('../../../../config/settings.overrides.saas.js') + ).to.throw(/requires SHEERID_PROGRAM_ID/) + }) + + it('boots with the real client and no credentials outside production', function () { + process.env.SHEERID_ENABLED = 'true' + process.env.SHEERID_MOCK = 'false' + process.env.NODE_ENV = 'test' + delete process.env.SHEERID_CLIENT_ID + delete process.env.SHEERID_CLIENT_SECRET + delete process.env.SHEERID_PROGRAM_ID + clearSettingsCache() + + expect(() => + require('../../../../config/settings.overrides.saas.js') + ).not.to.throw() + }) + + it('boots with SHEERID_ENABLED and the default mock client outside production', function () { + process.env.SHEERID_ENABLED = 'true' + delete process.env.SHEERID_MOCK + process.env.NODE_ENV = 'test' + clearSettingsCache() + + expect(() => + require('../../../../config/settings.overrides.saas.js') + ).not.to.throw() + }) +}) diff --git a/services/web/types/backend/express/request.d.ts b/services/web/types/backend/express/request.d.ts index bfac639aea..1d2d163573 100644 --- a/services/web/types/backend/express/request.d.ts +++ b/services/web/types/backend/express/request.d.ts @@ -20,6 +20,9 @@ declare module 'express' { userRestrictions?: Set oauth_user?: OAuth2Server.User logger: RequestLogger + // Set by a body-parser `verify` callback on routes that check a webhook + // signature, which needs the bytes exactly as received. + rawBody?: Buffer | string i18n: { language: string translate( diff --git a/tools/migrations/20260808093000_create_studentVerifications_indexes.mjs b/tools/migrations/20260808093000_create_studentVerifications_indexes.mjs new file mode 100644 index 0000000000..884a66807a --- /dev/null +++ b/tools/migrations/20260808093000_create_studentVerifications_indexes.mjs @@ -0,0 +1,59 @@ +import Helpers from './lib/helpers.mjs' +import { getCollectionInternal } from './lib/mongodb.mjs' + +const tags = ['saas'] + +// Keep in sync with the indexes declared on the model +// (services/web/app/src/models/StudentVerification.mjs): Mongoose runs with +// autoIndex: false, so nothing creates these automatically. +const indexes = [ + { + // Every lookup of a single verification is by this id (status polling, the + // webhook, and the mock outcome controls). + key: { verificationId: 1 }, + unique: true, + name: 'verificationId_1', + }, + { + // The purchase gate: the user's latest unexpired SUCCESS verification. + key: { userId: 1, status: 1, createdAt: -1 }, + name: 'userId_1_status_1_createdAt_-1', + }, + { + // Retention: records carry their own `expires` date, so the TTL monitor + // deletes each one at the time stored on it rather than after a fixed + // interval. + key: { expires: 1 }, + expireAfterSeconds: 0, + name: 'expires_1', + }, + { + // The reconciliation poller + // (modules/student-verification/scripts/reconcile_student_verifications.mjs) + // sweeps pending records by refresh staleness, with no userId to narrow + // on, so the {userId, status, createdAt} index cannot serve it and the DB + // runs with notablescan. + key: { status: 1, lastRefreshedAt: 1 }, + name: 'status_1_lastRefreshedAt_1', + }, +] + +const migrate = async () => { + const studentVerifications = await getCollectionInternal( + 'studentVerifications' + ) + await Helpers.addIndexesToCollection(studentVerifications, indexes) +} + +const rollback = async () => { + const studentVerifications = await getCollectionInternal( + 'studentVerifications' + ) + await Helpers.dropIndexesFromCollection(studentVerifications, indexes) +} + +export default { + tags, + migrate, + rollback, +}