Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Fix Missing AD Restaurants on Group Landing Page — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Restore AD (sponsored) restaurants that are silently dropped during client-side deduplication on group landing pages.

Architecture: Two coupled fixes: (1) add the missing isMerged field to the Valibot schema in shared-type so the flag survives TypeScript parsing of the compact-restaurant API response, then (2) restore the guard in mergeSearchResult that reads the flag to skip merging when the backend says not to. A third line in the same function restores rankingId propagation. One new test file covers all three changes.

Tech Stack: TypeScript, Valibot (schema/type inference), Vitest 4.x, Turborepo

Global Constraints

  • Tests run only via Turbo: pnpm exec turbo run test --concurrency 2 --filter=@hh-pegasus/shared-api
  • OOM guard before every test run: pgrep -f "turbo run test" && pgrep -f vitest — abort if any PID found
  • Never invoke vitest directly or pnpm --filter <pkg> test
  • Typecheck via Turbo: pnpm exec turbo run typecheck --concurrency 1 --filter=@hh-pegasus/shared-type (after Task 1); same filter for shared-api (after Task 2)
  • OOM guard before typecheck: pgrep -a -f "turbo run typecheck" — abort if any PID found
  • Do not add import.meta.env.* reads to any library package
  • Work on a branch or worktree — never commit to main directly

File Map

FileActionResponsibility
packages/shared-type/src/Restaurant.tsModify lines 252, 370Add isMerged?: boolean to both restaurant attribute Valibot schemas
packages/shared-api/src/common/mergeSearchResult.tsModify lines 107–108Add isMerged === false guard + rankingId propagation
packages/shared-api/src/common/__tests__/mergeSearchResult.test.tsCreateUnit tests: no-duplicates pass-through, normal merge, isMerged: false bypass, rankingId propagation

Task 1: Add isMerged to restaurant type schemas

Files:

  • Modify: packages/shared-type/src/Restaurant.ts (lines 251–252 and 368–369)

Interfaces:

  • Produces: FeaturedRestaurant["attributes"]["isMerged"] typed as boolean | undefined — consumed by Task 2’s guard

Steps

  • Step 1: Add isMerged to the regular restaurant attributes schema

    In packages/shared-type/src/Restaurant.ts, the regular restaurant attributes schema has isAds at line 251. Add isMerged directly after it:

    // Before (line 251):
    isAds: optional(boolean()),
    rank: optional(nullish(number())),
    
    // After:
    isAds: optional(boolean()),
    isMerged: optional(boolean()),
    rank: optional(nullish(number())),
    
  • Step 2: Add isMerged to the featured restaurant attributes schema

    In the same file, featuredRestaurantAttrSchema has isAds at line 368. Add isMerged directly after it:

    // Before (line 368):
    isAds: optional(boolean()),
    position: optional(number()),
    
    // After:
    isAds: optional(boolean()),
    isMerged: optional(boolean()),
    position: optional(number()),
    
  • Step 3: OOM guard, then typecheck shared-type

    pgrep -a -f "turbo run typecheck"
    # Must return nothing. If any PID found: STOP, report to user.
    
    pnpm exec turbo run typecheck --concurrency 1 --filter=@hh-pegasus/shared-type
    

    Expected: exits 0, no errors.

  • Step 4: Commit

    git add packages/shared-type/src/Restaurant.ts
    git commit -m "fix(shared-type): add isMerged field to restaurant attribute schemas"
    

Task 2: Restore isMerged guard, rankingId propagation, and add tests

Files:

  • Modify: packages/shared-api/src/common/mergeSearchResult.ts
  • Create: packages/shared-api/src/common/__tests__/mergeSearchResult.test.ts

Interfaces:

  • Consumes: FeaturedRestaurant["attributes"]["isMerged"] from Task 1 (now typed as boolean | undefined)
  • Consumes: getCompactRestaurants from ../restaurant/getCompactRestaurants (already imported in the file)

Steps

  • Step 1: Write the failing test

    Create packages/shared-api/src/common/__tests__/mergeSearchResult.test.ts:

    import { beforeEach, describe, expect, it, vi } from "vitest";
    import { getCompactRestaurants } from "../restaurant/getCompactRestaurants";
    import { mergeSearchResult } from "../mergeSearchResult";
    import type {
      FeaturedRestaurant,
      Restaurant,
    } from "@hh-pegasus/shared-type/Restaurant";
    
    vi.mock("../restaurant/getCompactRestaurants");
    
    // Minimal stubs — only the fields mergeSearchResult reads
    function makeRestaurant(
      id: string,
      branchId: number,
      overrides: Partial<{ isAds: boolean; rankingId: string }> = {},
    ): Restaurant {
      return {
        id,
        type: "restaurants",
        rankingId: overrides.rankingId ?? null,
        searchScore: null,
        rankingScore: null,
        highlights: [],
        relationships: undefined,
        attributes: {
          branchId,
          isAds: overrides.isAds ?? false,
        },
      } as unknown as Restaurant;
    }
    
    function makeCompact(
      branchId: number,
      overrides: Partial<{ isMerged: boolean }> = {},
    ): FeaturedRestaurant {
      return {
        id: `compact-${branchId}`,
        type: "featured_restaurants",
        rankingId: null,
        searchScore: null,
        rankingScore: null,
        highlights: [],
        attributes: {
          branchId,
          ...(overrides.isMerged !== undefined
            ? { isMerged: overrides.isMerged }
            : {}),
        },
      } as unknown as FeaturedRestaurant;
    }
    
    beforeEach(() => {
      vi.clearAllMocks();
    });
    
    describe("mergeSearchResult", () => {
      it("returns restaurants unchanged when there are no duplicate branchIds", async () => {
        const r1 = makeRestaurant("1", 10);
        const r2 = makeRestaurant("2", 20);
    
        const result = await mergeSearchResult("en", [r1, r2]);
    
        expect(result.success).toBe(true);
        expect(result.data).toHaveLength(2);
        // getCompactRestaurants should not be called — no duplicates
        expect(getCompactRestaurants).not.toHaveBeenCalled();
      });
    
      it("deduplicates duplicate branchIds using compact version when isMerged is not false", async () => {
        const organic = makeRestaurant("1", 42);
        const duplicate = makeRestaurant("2", 42);
        const compact = makeCompact(42); // isMerged undefined → merge
    
        vi.mocked(getCompactRestaurants).mockResolvedValue({
          success: true,
          message: "",
          data: { data: [compact] },
        });
    
        const result = await mergeSearchResult("en", [organic, duplicate]);
    
        expect(result.success).toBe(true);
        // Two entries collapsed to one (the compact version)
        expect(result.data).toHaveLength(1);
        expect(result.data![0].id).toBe("compact-42");
      });
    
      it("keeps both entries when compact has isMerged: false", async () => {
        const organic = makeRestaurant("1", 55);
        const ad = makeRestaurant("2", 55, { isAds: true });
        const compact = makeCompact(55, { isMerged: false });
    
        vi.mocked(getCompactRestaurants).mockResolvedValue({
          success: true,
          message: "",
          data: { data: [compact] },
        });
    
        const result = await mergeSearchResult("en", [organic, ad]);
    
        expect(result.success).toBe(true);
        // isMerged: false → both entries preserved as originals, not replaced by compact
        expect(result.data).toHaveLength(2);
        expect(result.data![0].id).toBe("1");
        expect(result.data![1].id).toBe("2");
      });
    
      it("propagates rankingId from original to compact when duplicate branchIds trigger merge", async () => {
        const r1 = makeRestaurant("r1", 88, { rankingId: "rank-xyz" });
        const r2 = makeRestaurant("r2", 88);
        const compact = makeCompact(88);
    
        vi.mocked(getCompactRestaurants).mockResolvedValue({
          success: true,
          message: "",
          data: { data: [compact] },
        });
    
        const result = await mergeSearchResult("en", [r1, r2]);
    
        expect(result.success).toBe(true);
        expect(result.data).toHaveLength(1);
        // rankingId from the first (original) entry is copied to compact
        expect((result.data![0] as FeaturedRestaurant).rankingId).toBe(
          "rank-xyz",
        );
      });
    
      it("returns success: false when compact fetch fails", async () => {
        const r1 = makeRestaurant("a", 99);
        const r2 = makeRestaurant("b", 99);
    
        vi.mocked(getCompactRestaurants).mockResolvedValue({
          success: false,
          message: "network error",
          data: null,
        });
    
        const result = await mergeSearchResult("en", [r1, r2]);
    
        expect(result.success).toBe(false);
        expect(result.data).toBeNull();
      });
    });
    
  • Step 2: OOM guard, then run the tests to confirm they fail

    pgrep -f "turbo run test" && pgrep -f vitest
    # Must return nothing. If any PID found: STOP, report to user.
    
    pnpm exec turbo run test --concurrency 2 --filter=@hh-pegasus/shared-api -- src/common/__tests__/mergeSearchResult.test.ts
    

    Expected: the “keeps both entries when compact has isMerged: false” and “propagates rankingId” tests FAIL. The “returns restaurants unchanged” and “returns success: false” tests should pass. If all pass, re-examine — the guard may already exist.

  • Step 3: Add the isMerged guard to mergeSearchResult.ts

    In packages/shared-api/src/common/mergeSearchResult.ts, find the if (compact) { block (line 106). Add the guard as the first thing inside that block, before any existing logic:

    // Before:
    if (compact) {
      const existingIndex = mergedRestaurantIndexMap.get(branchId);
    
    // After:
    if (compact) {
      // Backend sets isMerged: false to prevent deduplication — e.g. an organic
      // entry and a sponsored (isAds: true) entry for the same branch must both
      // appear. Ported from hh-pegasus mergeSearchResult.
      if (compact.attributes.isMerged === false) {
        mergedRestaurants.push(restaurant);
        continue;
      }
      const existingIndex = mergedRestaurantIndexMap.get(branchId);
    
  • Step 4: Add rankingId propagation in the same file

    In the same file, find the first-seen merge block that ends with mergedRestaurants.push(compact) (around line 130). Add rankingId propagation immediately before that push:

    // Before:
    compact.attributes.branchRestaurantIds = [String(restaurant.id)];
    mergedRestaurants.push(compact);
    
    // After:
    compact.attributes.branchRestaurantIds = [String(restaurant.id)];
    compact.rankingId = restaurant.rankingId;
    mergedRestaurants.push(compact);
    
  • Step 5: OOM guard, then run all tests in the file to confirm they pass

    pgrep -f "turbo run test" && pgrep -f vitest
    # Must return nothing.
    
    pnpm exec turbo run test --concurrency 2 --filter=@hh-pegasus/shared-api -- src/common/__tests__/mergeSearchResult.test.ts
    

    Expected: all 6 tests PASS.

  • Step 6: Run the full shared-api test suite to check for regressions

    pgrep -f "turbo run test" && pgrep -f vitest
    # Must return nothing.
    
    pnpm exec turbo run test --concurrency 2 --filter=@hh-pegasus/shared-api
    

    Expected: all tests pass, no regressions.

  • Step 7: OOM guard, then typecheck shared-api

    pgrep -a -f "turbo run typecheck"
    # Must return nothing.
    
    pnpm exec turbo run typecheck --concurrency 1 --filter=@hh-pegasus/shared-api
    

    Expected: exits 0, no type errors.

  • Step 8: Commit

    git add packages/shared-api/src/common/mergeSearchResult.ts \
            packages/shared-api/src/common/__tests__/mergeSearchResult.test.ts
    git commit -m "fix(shared-api): restore isMerged guard and rankingId propagation in mergeSearchResult"
    

Verification (manual, after both tasks)

Start the dev server for this worktree (in a separate terminal — agent-launched servers get reaped):

cd /Users/afifnandya/hungryhub/hh-migrate-project/hh-pegasus-monorepo/.worktree/feature/849-qr-booking-flow
pnpm --filter ionic-app dev

Then open http://localhost:5184/en/famous-and-special-restaurants?city_id=1 on iPhone 14 viewport (390×844) and confirm:

  1. AD restaurants appear in the list — small “Ad” label visible in the upper-right corner of at least one restaurant card image.
  2. The total restaurant count and list order matches https://web.hungryhub.com/en/famous-and-special-restaurants?city_id=1.
  3. Non-AD restaurant cards are unchanged (name, image, price still correct).