GAP School Module 05 — Customer Portal Lesson 5.3

Consignors are a distinct customer type from buyers. A buyer completes a transaction and is done. A consignor has an ongoing relationship with the business — their unit is listed for sale, and they have a legitimate interest in knowing: is it active, what’s the current price, has anyone inquired? The seller portal gives them that visibility without requiring staff involvement on every check-in.


The situation

The Anchor build had 30–40 active consignments at any given time. Consignors called and emailed regularly to check on their listing: “Is it still showing? Has anyone looked at it? Can we drop the price?” These questions were answerable from the inventory system, but answering them required a staff member to stop what they were doing, look it up, and reply.

At 30 consignors, even two check-in calls per week per consignor is 60 staff-minutes per week on interruptions. The portal answer: let consignors see it themselves.


What I did

The consignor view

The seller portal shows each consignor their unit(s) from the [client]_owned_units table, filtered by relationship = 'consignor'. Each unit card shows live data from the inventory CPT:

Consignor units query — live status + inquiry count
function [client]_get_consignor_units( int $user_id ): array { $lead_id = get_user_meta( $user_id, '[client]_portal_lead_id', true ); if ( ! $lead_id ) return []; global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ou.*, p.post_status FROM [client]_owned_units ou JOIN {$wpdb->posts} p ON p.ID = ou.unit_id WHERE ou.lead_id = %d AND ou.relationship = 'consignor' AND ou.status = 'active' ORDER BY ou.created_at DESC", $lead_id ) ); $units = []; foreach ( $rows as $row ) { $unit_id = (int) $row->unit_id; $units[] = [ 'owned_unit_id' => $row->id, 'unit_id' => $unit_id, 'title' => get_the_title( $unit_id ), 'status' => $row->post_status === 'publish' ? 'Active' : 'Off Market', 'price' => get_post_meta( $unit_id, '[client]_price', true ), 'days_listed' => (int) ( ( time() - strtotime( $row->created_at ) ) / DAY_IN_SECONDS ), 'inquiry_count' => [client]_get_inquiry_count_for_unit( $unit_id ), ]; } return $units; }

Inquiry count without exposing lead data

Consignors should know how many inquiries their unit has received, but they should not see the contact information or messages of those leads. Only the count is exposed — not the underlying records:

Inquiry count — count only, no lead data exposed
function [client]_get_inquiry_count_for_unit( int $unit_id ): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM [client]_leads WHERE source_unit_id = %d AND status NOT IN ('closed_lost')", $unit_id ) ); }

Price change request

Consignors can’t directly change the listing price — that could bypass consignment agreement terms or cause mistakes. Instead they submit a price change request, which creates a CRM note for the sales manager to review:

Price change request — CRM note + staff notification, no direct write
function [client]_handle_price_change_request( int $user_id, int $unit_id, float $requested_price ): bool { $lead_id = get_user_meta( $user_id, '[client]_portal_lead_id', true ); if ( ! $lead_id ) return false; // Verify the unit belongs to this consignor global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( "SELECT id FROM [client]_owned_units WHERE lead_id = %d AND unit_id = %d AND relationship = 'consignor'", $lead_id, $unit_id ) ); if ( ! $row ) return false; $current_price = (float) get_post_meta( $unit_id, '[client]_price', true ); $note = sprintf( 'Consignor requested price change: $%s → $%s (via portal)', number_format( $current_price, 0 ), number_format( $requested_price, 0 ) ); [client]_add_conversation( $lead_id, 'note', $note, 'Price change request', 'portal' ); // Notify sales manager wp_mail( SALES_MANAGER_EMAIL, '[Portal] Price change request: ' . get_the_title( $unit_id ), $note, [ 'Content-Type: text/html; charset=UTF-8' ] ); return true; }

Portal access control

Every portal page uses a gating function that verifies the logged-in user is a portal customer and that the resource they’re requesting belongs to them:

Portal gate — authentication + role check + ownership verification
function [client]_verify_portal_access( int $unit_id = 0 ): bool { if ( ! is_user_logged_in() ) { wp_redirect( [client]_portal_login_url() ); exit; } $user = wp_get_current_user(); if ( ! in_array( '[client]_portal_customer', $user->roles, true ) ) { wp_redirect( home_url() ); exit; } // If a specific unit was requested, verify ownership if ( $unit_id ) { $lead_id = get_user_meta( $user->ID, '[client]_portal_lead_id', true ); global $wpdb; $owns = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM [client]_owned_units WHERE lead_id = %d AND unit_id = %d", $lead_id, $unit_id ) ); if ( ! $owns ) { wp_redirect( [client]_portal_home_url() ); exit; } } return true; }

Why it matters

The seller portal converts a support burden into a self-service feature. Staff time previously spent on status-check calls is redirected to actual sales activities. The consignor gets real-time information without having to catch someone on the phone during business hours.

The inquiry count display — showing how many people have asked about their unit — reduces the emotional frustration of “is anyone even looking?” without giving the consignor access to lead contact information. It’s the right level of transparency: more than they had before, less than could cause problems.


The Anchor build

The seller portal reduced consignor “checking in” calls by approximately 70% in the first month. The most-used feature was the listing status display followed by the inquiry count. The price change request form was used 8 times in the first three months — all legitimate, all reviewed and processed within 24 hours by the sales manager.

No consignor attempted to access another consignor’s data in the first six months. The access control layer was verified but never exercised against a real attack. It exists because it should, not because anyone expected malicious use — but portal authorization bugs surface unexpectedly.


Do this, not that

  • Always verify ownership before serving portal data. The portal URL structure should never serve data based on URL parameter alone. Verify the logged-in user owns the requested resource on every request.
  • Expose counts, not records, when privacy matters. “3 inquiries” is legitimate transparency. “These 3 people inquired” is a privacy exposure. Design the data exposure intentionally.
  • Route change requests through staff review, not direct database writes. Consignors requesting price changes creates a CRM note for the sales manager, not an immediate update_post_meta(). Business decisions should stay in human hands.
  • Gate every portal page before rendering any data. The gating function should be the first line of every portal page template. A template that renders before gating is a portal data exposure waiting to happen.
When you’re ready to build

The lessons are yours. When you want it built, we’re here.

Every lesson stays free — no account, no paywall, no email gate, ever. But if you’d rather have this system standing on your business than wire all 48 lessons yourself, leave your email. We’ll send you a direct line to a build — and you’ll be first to hear when we add new tools to the curriculum.

None of this gates a single lesson. The curriculum was free before you got here and it stays that way.

We’ll use your email to send you a fast-track to a GAP build and occasional notes on how GAP builds digital sales departments. Lessons stay 100% free — no email required to read any of them. We never share or sell your information. Unsubscribe any time. Privacy policy at gapindustriesllc.com/privacy.html.

Done learning how it’s built? We’ll build it.

You came here to understand the system, and now you do. If you’d rather have it standing on your business than spend the next three months wiring it yourself, GAP Concierge is the same architecture from these lessons — a white-label AI agent that knows your catalog and captures your leads — set up for you, from $97/mo.

See GAP Concierge →