'POST', 'callback' => array( __CLASS__, 'create' ), 'permission_callback' => '__return_true', ) ); register_rest_route( self::NS, '/applications/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'read' ), 'permission_callback' => array( __CLASS__, 'can_access' ), ), array( 'methods' => 'PATCH, POST', // POST for hosts whose firewall blocks PATCH. 'callback' => array( __CLASS__, 'update' ), 'permission_callback' => array( __CLASS__, 'can_access' ), ), ) ); register_rest_route( self::NS, '/applications/(?P\d+)/submit', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'submit' ), 'permission_callback' => array( __CLASS__, 'can_access' ), ) ); register_rest_route( self::NS, '/applications/(?P\d+)/files', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'upload' ), 'permission_callback' => array( __CLASS__, 'can_access' ), ) ); register_rest_route( self::NS, '/applications/(?P\d+)/files/(?P[A-Za-z0-9_-]{8,40})', array( 'methods' => 'DELETE, POST', 'callback' => array( __CLASS__, 'delete_file' ), 'permission_callback' => array( __CLASS__, 'can_access' ), ) ); register_rest_route( self::NS, '/applications/(?P\d+)/files/done', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'upload_done' ), 'permission_callback' => array( __CLASS__, 'can_access' ), ) ); register_rest_route( self::NS, '/resume', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'resume' ), 'permission_callback' => '__return_true', ) ); register_rest_route( self::NS, '/files/(?P\d+)/(?P[A-Za-z0-9_-]+)', array( 'methods' => 'GET', 'callback' => array( '\Mapp\Files\Endpoint', 'download' ), 'permission_callback' => '__return_true', ) ); } /** * Token in the X-Mapp-Token header, or an editor of the application. * * @param \WP_REST_Request $request Request. * @return bool|\WP_Error */ public static function can_access( $request ) { $id = (int) $request['id']; if ( ! Repository::exists( $id ) ) { return new \WP_Error( 'mapp_not_found', __( 'Application not found.', '1003-mortgage-application' ), array( 'status' => 404 ) ); } $token = (string) $request->get_header( 'x_mapp_token' ); if ( '' !== $token && Tokens::verify( $id, $token ) ) { return true; } if ( is_user_logged_in() && current_user_can( 'edit_post', $id ) ) { return true; } // Same answer as not found: never confirm an application exists. return new \WP_Error( 'mapp_not_found', __( 'Application not found.', '1003-mortgage-application' ), array( 'status' => 404 ) ); } /** * Start a draft. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public static function create( $request ) { nocache_headers(); $ip = RateLimiter::client_ip(); if ( ! RateLimiter::hit( 'create', $ip, 20, HOUR_IN_SECONDS ) ) { return new \WP_Error( 'mapp_rate_limited', __( 'Too many applications from this connection. Please try again later.', '1003-mortgage-application' ), array( 'status' => 429 ) ); } $body = (array) $request->get_json_params(); // Honeypot: a hidden field people never fill in. if ( ! empty( $body['website'] ) ) { return new \WP_Error( 'mapp_rejected', __( 'Something went wrong. Please refresh and try again.', '1003-mortgage-application' ), array( 'status' => 400 ) ); } $turnstile = Turnstile::verify( isset( $body['turnstile'] ) ? (string) $body['turnstile'] : '', $ip ); if ( is_wp_error( $turnstile ) ) { return $turnstile; } $config = Config::verify( isset( $body['config'] ) ? (string) $body['config'] : '' ); $id = Repository::create( array( 'lang' => isset( $body['lang'] ) ? (string) $body['lang'] : 'en', 'route_email' => $config['route_email'], 'page_id' => $config['page_id'], 'source' => isset( $body['source'] ) && is_array( $body['source'] ) ? $body['source'] : array(), ) ); if ( ! $id ) { return new \WP_Error( 'mapp_create_failed', __( 'We could not start your application. Please try again.', '1003-mortgage-application' ), array( 'status' => 500 ) ); } if ( $config['lo_id'] && LoanOfficers::profile( $config['lo_id'] ) ) { update_post_meta( $id, 'mapp_lo_id', $config['lo_id'] ); } $token = Tokens::issue( $id, 'browser' ); $result = array( 'saved' => array(), 'errors' => array(), ); if ( ! empty( $body['answers'] ) && is_array( $body['answers'] ) ) { $result = Repository::save_answers( $id, $body['answers'] ); } self::after_save( $id, isset( $body['step'] ) ? (string) $body['step'] : '' ); return rest_ensure_response( array_merge( self::state( $id ), array( 'token' => $token, 'errors' => (object) $result['errors'], ) ) ); } /** * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public static function read( $request ) { nocache_headers(); return rest_ensure_response( self::state( (int) $request['id'] ) ); } /** * Save answers. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public static function update( $request ) { nocache_headers(); $id = (int) $request['id']; if ( ! RateLimiter::hit( 'save', (string) $id, 600, HOUR_IN_SECONDS ) ) { return new \WP_Error( 'mapp_rate_limited', __( 'Please slow down and try again.', '1003-mortgage-application' ), array( 'status' => 429 ) ); } $body = (array) $request->get_json_params(); $answers = isset( $body['answers'] ) && is_array( $body['answers'] ) ? $body['answers'] : array(); if ( Repository::STAGE_FULL_SUBMITTED === Repository::stage( $id ) ) { return new \WP_Error( 'mapp_locked', __( 'This application has been submitted. Contact your loan officer to make changes.', '1003-mortgage-application' ), array( 'status' => 409 ) ); } if ( isset( $body['lang'] ) && in_array( $body['lang'], array( 'en', 'es' ), true ) ) { update_post_meta( $id, 'mapp_lang', $body['lang'] ); } $result = Repository::save_answers( $id, $answers ); foreach ( $result['saved'] as $key ) { $def = Schema::question( $key ); if ( $def && isset( $def['stage'] ) && 'full' === $def['stage'] ) { self::maybe_start_full( $id ); break; } } if ( isset( $answers['consent_tcpa'] ) && in_array( 'consent_tcpa', $result['saved'], true ) ) { Consent::record( $id, 'consent_tcpa', Copy::consent_text( 'consent_tcpa', $id ), 'yes' === get_post_meta( $id, 'consent_tcpa', true ) ); } self::after_save( $id, isset( $body['step'] ) ? (string) $body['step'] : '' ); return rest_ensure_response( array_merge( self::state( $id ), array( 'errors' => (object) $result['errors'] ) ) ); } /** * Stage changes after any save: contact details complete → contact milestone. * * @param int $id Application ID. * @param string $step Step the borrower is on. */ private static function after_save( $id, $step ) { if ( '' !== $step ) { update_post_meta( $id, 'mapp_last_step', sanitize_key( $step ) ); $seen = get_post_meta( $id, 'mapp_steps_seen', true ); $seen = is_array( $seen ) ? $seen : array(); if ( ! isset( $seen[ sanitize_key( $step ) ] ) ) { $seen[ sanitize_key( $step ) ] = time(); update_post_meta( $id, 'mapp_steps_seen', $seen ); } } $answers = Repository::answers( $id ); if ( Repository::STAGE_STARTED === Repository::stage( $id ) && empty( array_diff( array( 'first_name', 'last_name', 'email', 'phone_number' ), array_keys( $answers ) ) ) ) { Repository::advance( $id, Repository::STAGE_CONTACT ); Events::milestone( $id, 'contact' ); } Repository::refresh_progress( $id, $answers ); } /** * Submit the short app ("short") or the full 1003 ("full"). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public static function submit( $request ) { nocache_headers(); $id = (int) $request['id']; $body = (array) $request->get_json_params(); $what = isset( $body['stage'] ) && 'full' === $body['stage'] ? 'full' : 'short'; if ( Repository::STAGE_FULL_SUBMITTED === Repository::stage( $id ) ) { return new \WP_Error( 'mapp_locked', __( 'This application has been submitted. Contact your loan officer to make changes.', '1003-mortgage-application' ), array( 'status' => 409 ) ); } if ( ! empty( $body['answers'] ) && is_array( $body['answers'] ) ) { $result = Repository::save_answers( $id, $body['answers'] ); if ( $result['errors'] ) { return new \WP_Error( 'mapp_invalid', __( 'Please check the highlighted answers.', '1003-mortgage-application' ), array( 'status' => 422, 'errors' => $result['errors'] ) ); } } $answers = Repository::answers( $id, true ); $stages = 'full' === $what ? array( 'full' ) : array( 'short', 'contact', 'final' ); $missing = Schema::missing( $stages, $answers ); if ( 'full' === $what ) { // SSNs are not returned to the browser, so check what is on file. $missing = array_values( array_filter( $missing, function ( $key ) use ( $id ) { $def = Schema::question( $key ); return ! ( $def && ! empty( $def['sensitive'] ) && '' !== (string) get_post_meta( $id, $key, true ) ); } ) ); } if ( $missing ) { return new \WP_Error( 'mapp_incomplete', __( 'A few answers are still needed.', '1003-mortgage-application' ), array( 'status' => 422, 'missing' => $missing ) ); } if ( 'full' === $what ) { Consent::record( $id, 'full_certify', Copy::consent_text( 'full_certify', $id ) ); Repository::advance( $id, Repository::STAGE_FULL_SUBMITTED ); Events::milestone( $id, 'full_submitted' ); } else { Consent::record( $id, 'agree_access', Copy::consent_text( 'agree_access', $id ) ); Repository::advance( $id, Repository::STAGE_CONTACT ); Repository::advance( $id, Repository::STAGE_SUBMITTED ); Events::milestone( $id, 'contact' ); Events::milestone( $id, 'submitted' ); } Repository::refresh_progress( $id, $answers ); return rest_ensure_response( self::state( $id ) ); } /** * Mark the start of the full 1003 on its first save. * * @param int $id Application ID. */ public static function maybe_start_full( $id ) { if ( Repository::STAGE_SUBMITTED === Repository::stage( $id ) ) { Repository::advance( $id, Repository::STAGE_FULL_STARTED ); } } /** * Upload one document (multipart field "file", optional "doc_type"). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public static function upload( $request ) { nocache_headers(); $id = (int) $request['id']; if ( ! RateLimiter::hit( 'upload', (string) $id, 60, HOUR_IN_SECONDS ) ) { return new \WP_Error( 'mapp_rate_limited', __( 'Too many uploads. Please try again later.', '1003-mortgage-application' ), array( 'status' => 429 ) ); } if ( ! RateLimiter::hit( 'upload_ip', RateLimiter::client_ip(), 120, HOUR_IN_SECONDS ) ) { return new \WP_Error( 'mapp_rate_limited', __( 'Too many uploads. Please try again later.', '1003-mortgage-application' ), array( 'status' => 429 ) ); } $files = $request->get_file_params(); if ( empty( $files['file'] ) || ! empty( $files['file']['error'] ) || ! is_uploaded_file( $files['file']['tmp_name'] ) ) { return new \WP_Error( 'mapp_no_file', __( 'The upload did not arrive. Please try again.', '1003-mortgage-application' ), array( 'status' => 400 ) ); } $file_id = Store::put( $id, $files['file']['tmp_name'], $files['file']['name'], (string) $request->get_param( 'doc_type' ) ); if ( is_wp_error( $file_id ) ) { $file_id->add_data( array( 'status' => 400 ) ); return $file_id; } $record = Store::files_for( $id )[ $file_id ]; self::schedule_documents( $id ); return rest_ensure_response( array( 'id' => $file_id, 'name' => $record['name'], 'size' => $record['size'], 'doc_type' => $record['doc_type'], 'sent' => false, ) ); } /** * The borrower removes a file they uploaded by mistake. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public static function delete_file( $request ) { nocache_headers(); $id = (int) $request['id']; $file_id = (string) $request['file']; if ( 'done' === $file_id ) { return self::upload_done( $request ); } if ( Repository::STAGE_FULL_SUBMITTED === Repository::stage( $id ) ) { return new \WP_Error( 'mapp_locked', __( 'This application has been submitted. Ask your loan officer to remove a file.', '1003-mortgage-application' ), array( 'status' => 409 ) ); } $files = Store::files_for( $id ); if ( ! isset( $files[ $file_id ] ) ) { return new \WP_Error( 'mapp_not_found', __( 'That file is not on this application.', '1003-mortgage-application' ), array( 'status' => 404 ) ); } Store::delete( $id, $file_id ); \Mapp\Files\Access::log( $id, 'borrower_delete', $files[ $file_id ]['name'], 'borrower' ); return rest_ensure_response( self::state( $id ) ); } /** * A batch of uploads finished: notify the LO and send to GHL once. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public static function upload_done( $request ) { $id = (int) $request['id']; self::send_documents( $id ); return rest_ensure_response( self::state( $id ) ); } /** Minutes after the last upload before an unsent batch goes out by itself. */ const DOCUMENTS_QUIET = 30; /** * Push the batch's automatic send back to DOCUMENTS_QUIET minutes from * now, so a borrower adding several documents causes one alert. * * @param int $id Application ID. */ public static function schedule_documents( $id ) { wp_clear_scheduled_hook( 'mapp_documents_flush', array( (int) $id ) ); wp_schedule_single_event( time() + self::DOCUMENTS_QUIET * MINUTE_IN_SECONDS, 'mapp_documents_flush', array( (int) $id ) ); } /** * Send every file not yet sent as one batch: one LO email, one GHL * upload, one webhook. The borrower's "Send" button, or the timer. * * @param int $id Application ID. * @return int Files sent. */ public static function send_documents( $id ) { $id = (int) $id; wp_clear_scheduled_hook( 'mapp_documents_flush', array( $id ) ); $pending = array_filter( Store::files_for( $id ), function ( $file ) { return empty( $file['notified'] ); } ); if ( ! $pending ) { return 0; } $batch = (int) get_post_meta( $id, 'mapp_documents_batch', true ) + 1; foreach ( array_keys( $pending ) as $file_id ) { Store::mark( $id, $file_id, 'notified', time() ); Store::mark( $id, $file_id, 'batch', $batch ); } update_post_meta( $id, 'mapp_documents_batch', $batch ); Events::milestone( $id, 'documents' ); return count( $pending ); } /** * Email a resume link to a borrower. Always answers the same way so it * cannot be used to learn whether someone applied. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public static function resume( $request ) { $body = (array) $request->get_json_params(); $email = isset( $body['email'] ) ? sanitize_email( (string) $body['email'] ) : ''; $ok = rest_ensure_response( array( 'ok' => true ) ); if ( ! is_email( $email ) ) { return $ok; } $ip = RateLimiter::client_ip(); if ( ! RateLimiter::hit( 'resume_ip', $ip, 10, HOUR_IN_SECONDS ) || ! RateLimiter::hit( 'resume_email', strtolower( $email ), 3, HOUR_IN_SECONDS ) ) { return $ok; } $found = get_posts( array( 'post_type' => Repository::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 1, 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', 'date_query' => array( array( 'after' => gmdate( 'Y-m-d', time() - Tokens::lifetime() ) ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => 'email', 'value' => strtolower( $email ), ), ), ) ); if ( $found && Repository::STAGE_FULL_SUBMITTED !== Repository::stage( $found[0] ) ) { $id = (int) $found[0]; $token = Tokens::issue( $id, 'link' ); Mailer::resume_link( $id, self::resume_url( $id, $token ) ); } return $ok; } /** * Link that reopens the form at the borrower's last step. * * @param int $id Application ID. * @param string $token Token. * @param string $go "docs" opens the document checklist. * @return string */ public static function resume_url( $id, $token, $go = '' ) { // The page the borrower used, else where this LO's (or any) form is. // Never the bare home page unless the form is nowhere else. $page = (int) get_post_meta( $id, 'mapp_page_id', true ); if ( ! $page || 'publish' !== get_post_status( $page ) ) { $page = \Mapp\Frontend\Shortcode::form_page( (int) get_post_meta( $id, 'mapp_lo_id', true ) ); } $base = $page ? get_permalink( $page ) : home_url( '/' ); // go=docs: open the document checklist, not where they left off. $go = in_array( $go, array( 'docs' ), true ) ? '&go=' . $go : ''; return (string) apply_filters( 'mapp_resume_url', $base . '#mapp-resume=' . (int) $id . '.' . rawurlencode( $token ) . $go, $id, $token, $base ); } /** * What the browser needs after any call. * * @param int $id Application ID. * @return array */ public static function state( $id ) { $files = array(); foreach ( Store::files_for( $id ) as $file_id => $file ) { $files[] = array( 'id' => $file_id, 'name' => $file['name'], 'size' => $file['size'], 'doc_type' => $file['doc_type'], 'sent' => ! empty( $file['notified'] ), ); } return array( 'id' => (int) $id, 'stage' => Repository::stage( $id ), 'last_step' => (string) get_post_meta( $id, 'mapp_last_step', true ), 'answers' => (object) Repository::answers( $id, false ), 'derived' => array( 'loan_amount' => (string) get_post_meta( $id, 'loan_amount', true ), 'mailing_same_as_property' => (string) get_post_meta( $id, 'mailing_same_as_property', true ), ), 'sensitive_on_file' => Repository::sensitive_on_file( $id ), 'files' => $files, 'loan_officer' => LoanOfficers::public_card( (int) get_post_meta( $id, 'mapp_lo_id', true ) ), 'lang' => 'es' === get_post_meta( $id, 'mapp_lang', true ) ? 'es' : 'en', ); } }