From 9296bc916e9d48e973ed4773082edb79a7d99fe6 Mon Sep 17 00:00:00 2001 From: Asapteejo Date: Fri, 18 Sep 2026 11:37:45 +0100 Subject: [PATCH 1/3] fix(properties): stop serving listings that aged past the hide threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live tenant's public site showed a card labelled "Listing hidden". The query filter was not at fault — it correctly requires isPubliclyVisible and a VERIFIED/STALE status. Those columns are STORED values, though, written only when a property is mutated, and nothing ages them: updateVerificationState runs on mutations and in the presentation layer, and there is no sweep job. The presented status, by contrast, is recomputed from lastVerifiedAt on every render. So once a listing crossed the hide threshold it kept its stored "publicly visible" flag, stayed on the site, and rendered the internal label "Listing hidden" — the policy was enforced in UI copy but not in the query. The homepage promises "no stale or hidden inventory" directly above it. The public entry points (listing, detail, brochure) now pass a cutoff of now - hideDays, using the company's own thresholds, so the same rule the presentation applies is enforced in SQL. Stored flags are still honoured, so manual hiding keeps working, and the cutoff is optional so non-public callers are unaffected. Consequence worth knowing: a listing that has aged out disappears from the public site until someone re-verifies it. That is the intended policy — it is what the "Listing hidden" label already claimed was happening. Co-Authored-By: Claude Opus 5 --- src/modules/properties/queries.ts | 31 ++++++++++++---- src/modules/properties/verification.test.ts | 40 +++++++++++++++++++++ src/modules/properties/verification.ts | 37 +++++++++++++++++-- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/src/modules/properties/queries.ts b/src/modules/properties/queries.ts index f6360a8..96fcd3b 100644 --- a/src/modules/properties/queries.ts +++ b/src/modules/properties/queries.ts @@ -19,6 +19,7 @@ import { properties as demoProperties, getPropertyBySlug } from "@/modules/prope import { buildPropertyVerificationPresentation, buildPublicPropertyVerificationWhere, + resolvePublicVisibilityCutoff, } from "@/modules/properties/verification"; import type { PropertySummary } from "@/types/domain"; @@ -208,13 +209,20 @@ export function buildPublicPropertyWhere( context: TenantContext, where?: Record, _now = new Date(), + /** + * now - hideDays. Supplied by the public entry points so a listing that has + * aged past the hide threshold leaves the site even though its stored + * visibility flag was never rewritten. Omitted, the filter falls back to the + * stored flags alone. + */ + hideBefore?: Date | null, ) { void _now; return scopeTenantWhere(context, { AND: [ where ?? {}, - buildPublicPropertyVerificationWhere(), + buildPublicPropertyVerificationWhere({ hideBefore }), ], status: { in: [...PUBLIC_PROPERTY_STATUSES], @@ -241,6 +249,7 @@ export function buildPublicPropertyFilterWhere( context: TenantContext, filters: PublicPropertyFilters, now = new Date(), + hideBefore?: Date | null, ) { const andFilters: Array> = []; @@ -336,6 +345,7 @@ export function buildPublicPropertyFilterWhere( context, andFilters.length > 0 ? { AND: andFilters } : undefined, now, + hideBefore, ); } @@ -427,7 +437,8 @@ export async function getPublicProperties( }; } - const where = buildPublicPropertyFilterWhere(context, resolvedFilters); + const hideBefore = await resolvePublicVisibilityCutoff(context.companyId); + const where = buildPublicPropertyFilterWhere(context, resolvedFilters, new Date(), hideBefore); const skip = (resolvedFilters.page - 1) * PAGE_SIZE; const radiusSearch = hasRadiusPropertySearch(resolvedFilters); @@ -597,7 +608,12 @@ export async function getPublicPropertyDetailBySlug( prisma.property as ScopedFindFirstDelegate, context, { - where: buildPublicPropertyWhere(context, { slug }), + where: buildPublicPropertyWhere( + context, + { slug }, + new Date(), + await resolvePublicVisibilityCutoff(context.companyId), + ), select: { id: true, slug: true, @@ -800,9 +816,12 @@ export async function getPublicBrochureByPropertySlug( prisma.property as ScopedFindFirstDelegate, context, { - where: buildPublicPropertyWhere(context, { - slug, - }), + where: buildPublicPropertyWhere( + context, + { slug }, + new Date(), + await resolvePublicVisibilityCutoff(context.companyId), + ), select: { brochureDocumentId: true, }, diff --git a/src/modules/properties/verification.test.ts b/src/modules/properties/verification.test.ts index 3178b28..ba84fee 100644 --- a/src/modules/properties/verification.test.ts +++ b/src/modules/properties/verification.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { buildPropertyVerificationPresentation, buildPropertyVerificationUpdateInput, + buildPublicPropertyVerificationWhere, computeVerificationStatus, normalizeVerificationThresholds, updateVerificationState, @@ -129,3 +130,42 @@ test("verification status uses tenant-specific fresh and hide windows", () => { assert.equal(status, "HIDDEN"); }); + +test("public visibility filter falls back to the stored flags without a cutoff", () => { + const where = buildPublicPropertyVerificationWhere(); + + assert.deepEqual(where, { + isPubliclyVisible: true, + verificationStatus: { in: ["VERIFIED", "STALE"] }, + }); +}); + +test("public visibility filter drops listings that aged past the hide window", () => { + // The stored flags are only written when a property is mutated, so a listing + // that crossed the hide threshold kept `isPubliclyVisible: true` and stayed + // public while rendering "Listing hidden". The cutoff applies the same rule + // the presentation uses, in the query. + const thresholds = normalizeVerificationThresholds({ freshDays: 5, staleDays: 10, hideDays: 12 }); + const hideBefore = new Date(now.getTime() - thresholds.hideDays * 24 * 60 * 60 * 1000); + const where = buildPublicPropertyVerificationWhere({ hideBefore }) as { + lastVerifiedAt?: { gte: Date }; + }; + + assert.deepEqual(where.lastVerifiedAt, { gte: hideBefore }); + + // A listing last verified before the cutoff is exactly the one the + // presentation would label "Listing hidden". + const agedOut = new Date("2026-03-20T00:00:00.000Z"); + assert.equal(agedOut < hideBefore, true); + assert.equal( + computeVerificationStatus({ lastVerifiedAt: agedOut }, thresholds, now), + "HIDDEN", + ); + + const stillPublic = new Date("2026-03-25T00:00:00.000Z"); + assert.equal(stillPublic >= hideBefore, true); + assert.notEqual( + computeVerificationStatus({ lastVerifiedAt: stillPublic }, thresholds, now), + "HIDDEN", + ); +}); diff --git a/src/modules/properties/verification.ts b/src/modules/properties/verification.ts index 0e79514..455b853 100644 --- a/src/modules/properties/verification.ts +++ b/src/modules/properties/verification.ts @@ -215,13 +215,46 @@ export function buildPropertyVerificationPresentation( }; } -export function buildPublicPropertyVerificationWhere() { - return { +/** + * Public visibility filter. + * + * `isPubliclyVisible` / `verificationStatus` are STORED values, written only + * when a property is mutated — there is no sweep that ages them. The presented + * status, by contrast, is recomputed from `lastVerifiedAt` on every render. A + * listing that crossed the hide threshold therefore kept its stored "visible" + * flag and stayed on the public site while rendering the label "Listing + * hidden" — the policy was enforced in the UI copy but not in the query. + * + * Passing `hideBefore` (now - hideDays) applies the same rule the presentation + * uses, in SQL, so an unverified-for-too-long listing leaves the public site + * whether or not anything has written to the row since. + */ +export function buildPublicPropertyVerificationWhere(options?: { hideBefore?: Date | null }) { + const stored = { isPubliclyVisible: true, verificationStatus: { in: ["VERIFIED", "STALE"], }, }; + + if (!options?.hideBefore) { + return stored; + } + + return { + ...stored, + // `gte` also excludes rows that were never verified (NULL). + lastVerifiedAt: { gte: options.hideBefore }, + }; +} + +/** Cutoff before which a listing counts as hidden, for the public query. */ +export async function resolvePublicVisibilityCutoff( + companyId?: string | null, + now = new Date(), +) { + const thresholds = await getVerificationThresholdsForCompany(companyId); + return subDays(now, thresholds.hideDays); } export function buildPropertyVerificationUpdateInput( From cf6ea8d33be2e07bcee1c3b8409e1b57b6ee5416 Mon Sep 17 00:00:00 2001 From: Asapteejo Date: Fri, 18 Sep 2026 11:37:45 +0100 Subject: [PATCH 2/3] fix(marketing): keep verification vocabulary off the public site The public property card rendered a "Stale" badge for anything not VERIFIED and printed property.verification.label verbatim, which produces operator strings such as "Listing hidden" and "Verification required" in front of buyers. On a site whose pitch is verified listings, that reads as a warning about the company rather than about the listing. Only the positive state is public now: a "Verified" badge and its label when the listing is verified, and nothing otherwise. Absence is quieter than a warning, and the internal workflow stays internal. Co-Authored-By: Claude Opus 5 --- src/components/marketing/property-card.tsx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/components/marketing/property-card.tsx b/src/components/marketing/property-card.tsx index 82b3637..177577a 100644 --- a/src/components/marketing/property-card.tsx +++ b/src/components/marketing/property-card.tsx @@ -31,15 +31,13 @@ export function PropertyCard({ property }: { property: PropertySummary }) { />
{property.status} - - {property.verification.status === "VERIFIED" ? "Verified" : "Stale"} - + {/* Only the positive state is public. "Stale" and "Hidden" are + operator vocabulary from the verification workflow; to a buyer + they read as a warning about the company, on a site whose pitch + is verified listings. Absence says nothing, which is correct. */} + {property.verification.status === "VERIFIED" ? ( + Verified + ) : null}
@@ -47,7 +45,11 @@ export function PropertyCard({ property }: { property: PropertySummary }) {
{property.locationSummary}

{property.title}

{property.shortDescription}

-

{property.verification.label}

+ {/* verification.label carries internal states ("Listing hidden", + "Verification required"). Only the verified case is shown. */} + {property.verification.status === "VERIFIED" ? ( +

{property.verification.label}

+ ) : null}
{isLand ? ( From 185fe4731a59d3e59144addc59464999cc59cb50 Mon Sep 17 00:00:00 2001 From: Asapteejo Date: Fri, 18 Sep 2026 11:37:45 +0100 Subject: [PATCH 3/3] fix(marketing): restore footer link stacking, with a regression test Regression I introduced in the mobile touch-target pass: the footer links were changed from `block` to `inline-flex` to get a 44px tap target, but their container still separated children with `space-y-2`, which is a margin between BLOCK-level boxes. Inline-level boxes flow onto one line instead, so the footer rendered "ListingsBuyer PortalAdmin Dashboard" and "AboutTeamCareersContact" at every width. The containers are now flex columns, so each link starts its own line while keeping inline-flex + min-h-11 and the 44px target. The E2E sweep now asserts this per column (separate columns legitimately share a line on desktop) and checks the 44px height on phone viewports only, since above `sm` the links deliberately return to desktop density. Verified the test fails against the old markup and passes against the fix. Co-Authored-By: Claude Opus 5 --- e2e/smoke.spec.ts | 56 +++++++++++++++++++ src/components/marketing/marketing-footer.tsx | 4 +- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index d7c91c5..2baa504 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -100,6 +100,62 @@ test("homepage shows the property search and a working listings link", async ({ expect(page.url()).toContain("location=Lekki"); }); +test("footer navigation links stack instead of running together", async ({ page }) => { + await page.goto("/"); + + // Regression: the links were given `inline-flex` for a 44px tap target while + // their container still relied on `space-y-*` (a margin between BLOCK boxes). + // Inline-level boxes flow onto one line, so the footer rendered + // "ListingsBuyer PortalAdmin Dashboard". Each link must start its own line. + // Compared per column: separate footer columns sit side by side on desktop, + // so links in different columns share a line legitimately. + const result = await page.evaluate(() => { + const groups = new Map>(); + for (const link of document.querySelectorAll("footer a")) { + const parent = link.parentElement; + if (!parent) continue; + const rect = link.getBoundingClientRect(); + if (rect.width === 0) continue; + const entry = { + text: (link.textContent || "").trim(), + top: Math.round(rect.top), + left: Math.round(rect.left), + height: Math.round(rect.height), + }; + groups.set(parent, [...(groups.get(parent) ?? []), entry]); + } + + const collisions: string[] = []; + let linkCount = 0; + let shortest = Infinity; + for (const entries of groups.values()) { + if (entries.length < 2) continue; + linkCount += entries.length; + for (const entry of entries) { + shortest = Math.min(shortest, entry.height); + const sibling = entries.find( + (other) => other !== entry && Math.abs(other.top - entry.top) < 4, + ); + if (sibling) collisions.push(`${entry.text} + ${sibling.text}`); + } + } + return { collisions: [...new Set(collisions)], linkCount, shortest }; + }); + + expect(result.linkCount, "expected grouped footer navigation links").toBeGreaterThan(2); + expect( + result.collisions, + "footer links in the same column share a line — inline-level boxes in a non-flex container again", + ).toEqual([]); + + // The 44px tap target applies on phones only: above `sm` the links + // deliberately return to desktop density (sm:min-h-0). + const viewportWidth = page.viewportSize()?.width ?? 0; + if (viewportWidth < 640) { + expect(result.shortest, "footer link lost its 44px touch target on mobile").toBeGreaterThanOrEqual(44); + } +}); + test("tenant site content renders copy rather than blank sections", async ({ page }) => { await page.goto("/"); diff --git a/src/components/marketing/marketing-footer.tsx b/src/components/marketing/marketing-footer.tsx index 956b331..370d2a3 100644 --- a/src/components/marketing/marketing-footer.tsx +++ b/src/components/marketing/marketing-footer.tsx @@ -98,7 +98,7 @@ export function MarketingFooter({

Explore

-
+
Listings Buyer Portal Admin Dashboard @@ -108,7 +108,7 @@ export function MarketingFooter({

Company

-
+
About Team {showCareers ? (