From ba733e9e86d3f45ac38d162ea21f93c40c046e89 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 17:04:13 +0000 Subject: [PATCH 1/2] Bust stale generate picker catalog cache Bump the selectable company groups unstable_cache key so the next production deploy drops the hour-old entry hiding newly approved products. Tag the cache and revalidate it from admin catalog writes and research-draft publish. Co-authored-by: Chinmay Kabi --- src/app/admin/products/[id]/page.tsx | 1 + src/app/api/admin/companies/[id]/route.ts | 5 ++ src/app/api/admin/companies/route.ts | 3 ++ src/app/api/admin/products/[id]/route.ts | 22 +++++++++ src/app/api/admin/products/route.ts | 3 ++ src/data/__tests__/company-profiles.test.ts | 39 ++++++++++++++++ src/data/company-profiles.ts | 52 +++++++++++++++------ src/data/generator-profile-options.ts | 20 +++----- src/lib/feed-filter-options.ts | 10 +++- src/lib/research/db.ts | 3 ++ 10 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 src/data/__tests__/company-profiles.test.ts diff --git a/src/app/admin/products/[id]/page.tsx b/src/app/admin/products/[id]/page.tsx index c8eceeb..da9cc16 100644 --- a/src/app/admin/products/[id]/page.tsx +++ b/src/app/admin/products/[id]/page.tsx @@ -205,6 +205,7 @@ export default function ProductDetailPage() { .map((s) => s.trim()) .filter(Boolean), defaultVibeTags: textToDnaArray(defaultVibeTags), + researchStatus: "approved", }), }); diff --git a/src/app/api/admin/companies/[id]/route.ts b/src/app/api/admin/companies/[id]/route.ts index fb5196e..30da4f8 100644 --- a/src/app/api/admin/companies/[id]/route.ts +++ b/src/app/api/admin/companies/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { revalidateSelectableCompanyGroups } from "@/data/company-profiles"; import { AdminAuthError, requireSuperadmin } from "@/lib/admin"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { sanitizeVibeTags } from "@/lib/vibe-tags"; @@ -94,6 +95,8 @@ export async function PUT( ); } + revalidateSelectableCompanyGroups(); + return NextResponse.json({ id: updated.id, name: updated.name, @@ -174,6 +177,8 @@ export async function DELETE( ); } + revalidateSelectableCompanyGroups(); + return NextResponse.json({ deleted: true }); } catch (err) { if (err instanceof AdminAuthError) { diff --git a/src/app/api/admin/companies/route.ts b/src/app/api/admin/companies/route.ts index 61a1049..16fd03d 100644 --- a/src/app/api/admin/companies/route.ts +++ b/src/app/api/admin/companies/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +import { revalidateSelectableCompanyGroups } from "@/data/company-profiles"; import { AdminAuthError, requireSuperadmin } from "@/lib/admin"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { sanitizeVibeTags } from "@/lib/vibe-tags"; @@ -188,6 +189,8 @@ export async function POST(request: Request) { ); } + revalidateSelectableCompanyGroups(); + const result = { id: inserted.id, name: inserted.name, diff --git a/src/app/api/admin/products/[id]/route.ts b/src/app/api/admin/products/[id]/route.ts index cc6724d..312e8e4 100644 --- a/src/app/api/admin/products/[id]/route.ts +++ b/src/app/api/admin/products/[id]/route.ts @@ -1,9 +1,18 @@ import { NextResponse } from "next/server"; +import { revalidateSelectableCompanyGroups } from "@/data/company-profiles"; import { AdminAuthError, requireSuperadmin } from "@/lib/admin"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { sanitizeVibeTags } from "@/lib/vibe-tags"; +const RESEARCH_STATUSES = [ + "seed", + "researched", + "reviewed", + "approved", + "rejected", +] as const; + export const runtime = "nodejs"; export async function GET( @@ -66,6 +75,7 @@ type UpdateProductBody = { archetype?: unknown; defaultVibeTags?: unknown; sourceUrls?: unknown; + researchStatus?: unknown; }; function validateUpdateBody(body: UpdateProductBody): string | null { @@ -84,6 +94,13 @@ function validateUpdateBody(body: UpdateProductBody): string | null { if (body.archetype != null && (typeof body.archetype !== "object" || Array.isArray(body.archetype))) { return 'Field "archetype" must be a JSON object'; } + if ( + body.researchStatus != null && + (typeof body.researchStatus !== "string" || + !(RESEARCH_STATUSES as readonly string[]).includes(body.researchStatus)) + ) { + return `Field "researchStatus" must be one of ${RESEARCH_STATUSES.join(", ")}`; + } return null; } @@ -129,6 +146,7 @@ export async function PUT( if (body.archetype != null) updates.archetype = body.archetype as Record; if (body.defaultVibeTags != null) updates.default_vibe_tags = sanitizeVibeTags(body.defaultVibeTags as string[]); if (body.sourceUrls != null) updates.source_urls = body.sourceUrls as string[]; + if (body.researchStatus != null) updates.research_status = body.researchStatus as string; const { data, error } = await supabase .from("company_profiles") @@ -144,6 +162,8 @@ export async function PUT( ); } + revalidateSelectableCompanyGroups(); + return NextResponse.json({ id: data.id, name: data.name, @@ -193,5 +213,7 @@ export async function DELETE( ); } + revalidateSelectableCompanyGroups(); + return NextResponse.json({ success: true }); } diff --git a/src/app/api/admin/products/route.ts b/src/app/api/admin/products/route.ts index c587e14..4703b35 100644 --- a/src/app/api/admin/products/route.ts +++ b/src/app/api/admin/products/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +import { revalidateSelectableCompanyGroups } from "@/data/company-profiles"; import { AdminAuthError, requireSuperadmin } from "@/lib/admin"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { sanitizeVibeTags } from "@/lib/vibe-tags"; @@ -198,6 +199,8 @@ export async function POST(request: Request) { ); } + revalidateSelectableCompanyGroups(); + return NextResponse.json( { id: inserted.id, diff --git a/src/data/__tests__/company-profiles.test.ts b/src/data/__tests__/company-profiles.test.ts new file mode 100644 index 0000000..5097235 --- /dev/null +++ b/src/data/__tests__/company-profiles.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { groupSelectableCompanyProfiles } from "@/data/company-profiles"; +import { mockCompanyGroups } from "@/data/test-fixtures/profile-groups"; + +describe("groupSelectableCompanyProfiles", () => { + it("keeps every company and only approved products", () => { + const groups = mockCompanyGroups(); + const youtube = groups.find((g) => g.company.id === "google")!.products[0]!; + const seedProduct = { + ...youtube, + id: "google-m3-expressive", + name: "M3 Expressive", + researchStatus: "seed", + }; + const approvedProduct = { + ...youtube, + id: "google-docs", + name: "Google Docs", + researchStatus: "approved", + }; + + const result = groupSelectableCompanyProfiles([ + ...groups.map((g) => g.company), + ...groups.flatMap((g) => g.products), + seedProduct, + approvedProduct, + ]); + + expect(result.map((g) => g.company.id)).toEqual([ + "duolingo", + "google", + "ikea", + ]); + expect( + result.find((g) => g.company.id === "google")?.products.map((p) => p.id), + ).toEqual(["google-docs", "google-youtube"]); + }); +}); diff --git a/src/data/company-profiles.ts b/src/data/company-profiles.ts index fffb810..7e50a96 100644 --- a/src/data/company-profiles.ts +++ b/src/data/company-profiles.ts @@ -1,5 +1,5 @@ import { createSupabaseServiceClient } from "@/lib/supabase/service"; -import { unstable_cache } from "next/cache"; +import { revalidateTag, unstable_cache } from "next/cache"; export type StyleDna = { tone: string[]; @@ -171,6 +171,36 @@ export type CompanyGroup = { const SELECTABLE_COMPANY_PROFILES_SELECT = "id, name, archetype, logo_path, default_vibe_tags, parent_company_id, profile_type, category, popularity_tier, research_status, meme_strength"; +/** Shared Data Cache tag for the generator picker and feed catalog filters. */ +export const SELECTABLE_COMPANY_GROUPS_CACHE_TAG = "selectable-company-groups"; + +/** + * Group companies (always) with approved products only. + * Used by the generator picker and any in-memory catalog grouping. + */ +export function groupSelectableCompanyProfiles( + profiles: CompanyProfile[], +): CompanyGroup[] { + const companies = profiles.filter((p) => p.profileType === "company"); + const products = profiles.filter( + (p) => p.profileType === "product" && p.researchStatus === "approved", + ); + + return companies + .map((company) => ({ + company, + products: products + .filter((p) => p.parentCompanyId === company.id) + .sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.company.name.localeCompare(b.company.name)); +} + +/** Immediate Data Cache bust after catalog create/approve (Route Handlers). */ +export function revalidateSelectableCompanyGroups(): void { + revalidateTag(SELECTABLE_COMPANY_GROUPS_CACHE_TAG, { expire: 0 }); +} + /** * Fetch all profiles grouped by parent company. * Companies with no products still appear with an empty products array. @@ -250,20 +280,12 @@ export async function getSelectableCompanyGroups(): Promise { .order("name"); if (error) throw error; - const all = (data ?? []).map(mapRow); - const companies = all.filter((p) => p.profileType === "company"); - const products = all.filter((p) => p.profileType === "product"); - - return companies - .map((company) => ({ - company, - products: products - .filter((p) => p.parentCompanyId === company.id) - .sort((a, b) => a.name.localeCompare(b.name)), - })) - .sort((a, b) => a.company.name.localeCompare(b.company.name)); + return groupSelectableCompanyProfiles((data ?? []).map(mapRow)); + }, + ["selectable-company-groups-v3"], + { + revalidate: 60 * 60, + tags: [SELECTABLE_COMPANY_GROUPS_CACHE_TAG], }, - ["selectable-company-groups-v2"], - { revalidate: 60 * 60 }, )(); } diff --git a/src/data/generator-profile-options.ts b/src/data/generator-profile-options.ts index bbc7b4c..808af0a 100644 --- a/src/data/generator-profile-options.ts +++ b/src/data/generator-profile-options.ts @@ -1,4 +1,8 @@ -import type { CompanyGroup, CompanyProfile } from "@/data/company-profiles"; +import { + groupSelectableCompanyProfiles, + type CompanyGroup, + type CompanyProfile, +} from "@/data/company-profiles"; import { resolveProfileScreenType } from "@/lib/screen-type"; export type GeneratorProfileOption = { @@ -137,19 +141,7 @@ export function resolveProfileLookup( } export function groupsFromProfiles(all: CompanyProfile[]): CompanyGroup[] { - const companies = all.filter((p) => p.profileType === "company"); - const products = all.filter( - (p) => p.profileType === "product" && p.researchStatus === "approved", - ); - - return companies - .map((company) => ({ - company, - products: products - .filter((p) => p.parentCompanyId === company.id) - .sort((a, b) => a.name.localeCompare(b.name)), - })) - .sort((a, b) => a.company.name.localeCompare(b.company.name)); + return groupSelectableCompanyProfiles(all); } export type ProfileFilterBucket = "all" | "consumer" | "work" | "dev" | "finance" | "social" | "media"; diff --git a/src/lib/feed-filter-options.ts b/src/lib/feed-filter-options.ts index 39a6c1c..7e67d2e 100644 --- a/src/lib/feed-filter-options.ts +++ b/src/lib/feed-filter-options.ts @@ -1,6 +1,9 @@ import { unstable_cache } from "next/cache"; -import { getSelectableCompanyGroups } from "@/data/company-profiles"; +import { + getSelectableCompanyGroups, + SELECTABLE_COMPANY_GROUPS_CACHE_TAG, +} from "@/data/company-profiles"; import type { FeedHierarchicalFilterOptions, FeedProfileFilterGroup, @@ -41,5 +44,8 @@ async function fetchFeedHierarchicalFilterOptions(): Promise Date: Thu, 13 Aug 2026 17:06:43 +0000 Subject: [PATCH 2/2] Keep revalidateTag out of client catalog imports Move cache invalidation into a server-only module and keep grouping in a next/cache-free helper so generator-form no longer pulls revalidateTag into the client bundle. Co-authored-by: Chinmay Kabi --- src/app/api/admin/companies/[id]/route.ts | 2 +- src/app/api/admin/companies/route.ts | 2 +- src/app/api/admin/products/[id]/route.ts | 2 +- src/app/api/admin/products/route.ts | 2 +- src/data/__tests__/company-profiles.test.ts | 2 +- src/data/company-profiles.ts | 37 +++---------------- src/data/generator-profile-options.ts | 7 +--- .../revalidate-selectable-company-groups.ts | 8 ++++ src/data/selectable-company-groups.ts | 26 +++++++++++++ src/lib/feed-filter-options.ts | 6 +-- src/lib/research/db.ts | 2 +- 11 files changed, 50 insertions(+), 46 deletions(-) create mode 100644 src/data/revalidate-selectable-company-groups.ts create mode 100644 src/data/selectable-company-groups.ts diff --git a/src/app/api/admin/companies/[id]/route.ts b/src/app/api/admin/companies/[id]/route.ts index 30da4f8..c20f9df 100644 --- a/src/app/api/admin/companies/[id]/route.ts +++ b/src/app/api/admin/companies/[id]/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { revalidateSelectableCompanyGroups } from "@/data/company-profiles"; +import { revalidateSelectableCompanyGroups } from "@/data/revalidate-selectable-company-groups"; import { AdminAuthError, requireSuperadmin } from "@/lib/admin"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { sanitizeVibeTags } from "@/lib/vibe-tags"; diff --git a/src/app/api/admin/companies/route.ts b/src/app/api/admin/companies/route.ts index 16fd03d..a237b74 100644 --- a/src/app/api/admin/companies/route.ts +++ b/src/app/api/admin/companies/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { revalidateSelectableCompanyGroups } from "@/data/company-profiles"; +import { revalidateSelectableCompanyGroups } from "@/data/revalidate-selectable-company-groups"; import { AdminAuthError, requireSuperadmin } from "@/lib/admin"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { sanitizeVibeTags } from "@/lib/vibe-tags"; diff --git a/src/app/api/admin/products/[id]/route.ts b/src/app/api/admin/products/[id]/route.ts index 312e8e4..406c68e 100644 --- a/src/app/api/admin/products/[id]/route.ts +++ b/src/app/api/admin/products/[id]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { revalidateSelectableCompanyGroups } from "@/data/company-profiles"; +import { revalidateSelectableCompanyGroups } from "@/data/revalidate-selectable-company-groups"; import { AdminAuthError, requireSuperadmin } from "@/lib/admin"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { sanitizeVibeTags } from "@/lib/vibe-tags"; diff --git a/src/app/api/admin/products/route.ts b/src/app/api/admin/products/route.ts index 4703b35..1ef3bd9 100644 --- a/src/app/api/admin/products/route.ts +++ b/src/app/api/admin/products/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { revalidateSelectableCompanyGroups } from "@/data/company-profiles"; +import { revalidateSelectableCompanyGroups } from "@/data/revalidate-selectable-company-groups"; import { AdminAuthError, requireSuperadmin } from "@/lib/admin"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { sanitizeVibeTags } from "@/lib/vibe-tags"; diff --git a/src/data/__tests__/company-profiles.test.ts b/src/data/__tests__/company-profiles.test.ts index 5097235..c217dbd 100644 --- a/src/data/__tests__/company-profiles.test.ts +++ b/src/data/__tests__/company-profiles.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { groupSelectableCompanyProfiles } from "@/data/company-profiles"; +import { groupSelectableCompanyProfiles } from "@/data/selectable-company-groups"; import { mockCompanyGroups } from "@/data/test-fixtures/profile-groups"; describe("groupSelectableCompanyProfiles", () => { diff --git a/src/data/company-profiles.ts b/src/data/company-profiles.ts index 7e50a96..04ba2fc 100644 --- a/src/data/company-profiles.ts +++ b/src/data/company-profiles.ts @@ -1,5 +1,10 @@ import { createSupabaseServiceClient } from "@/lib/supabase/service"; -import { revalidateTag, unstable_cache } from "next/cache"; +import { unstable_cache } from "next/cache"; + +import { + groupSelectableCompanyProfiles, + SELECTABLE_COMPANY_GROUPS_CACHE_TAG, +} from "@/data/selectable-company-groups"; export type StyleDna = { tone: string[]; @@ -171,36 +176,6 @@ export type CompanyGroup = { const SELECTABLE_COMPANY_PROFILES_SELECT = "id, name, archetype, logo_path, default_vibe_tags, parent_company_id, profile_type, category, popularity_tier, research_status, meme_strength"; -/** Shared Data Cache tag for the generator picker and feed catalog filters. */ -export const SELECTABLE_COMPANY_GROUPS_CACHE_TAG = "selectable-company-groups"; - -/** - * Group companies (always) with approved products only. - * Used by the generator picker and any in-memory catalog grouping. - */ -export function groupSelectableCompanyProfiles( - profiles: CompanyProfile[], -): CompanyGroup[] { - const companies = profiles.filter((p) => p.profileType === "company"); - const products = profiles.filter( - (p) => p.profileType === "product" && p.researchStatus === "approved", - ); - - return companies - .map((company) => ({ - company, - products: products - .filter((p) => p.parentCompanyId === company.id) - .sort((a, b) => a.name.localeCompare(b.name)), - })) - .sort((a, b) => a.company.name.localeCompare(b.company.name)); -} - -/** Immediate Data Cache bust after catalog create/approve (Route Handlers). */ -export function revalidateSelectableCompanyGroups(): void { - revalidateTag(SELECTABLE_COMPANY_GROUPS_CACHE_TAG, { expire: 0 }); -} - /** * Fetch all profiles grouped by parent company. * Companies with no products still appear with an empty products array. diff --git a/src/data/generator-profile-options.ts b/src/data/generator-profile-options.ts index 808af0a..f544615 100644 --- a/src/data/generator-profile-options.ts +++ b/src/data/generator-profile-options.ts @@ -1,8 +1,5 @@ -import { - groupSelectableCompanyProfiles, - type CompanyGroup, - type CompanyProfile, -} from "@/data/company-profiles"; +import type { CompanyGroup, CompanyProfile } from "@/data/company-profiles"; +import { groupSelectableCompanyProfiles } from "@/data/selectable-company-groups"; import { resolveProfileScreenType } from "@/lib/screen-type"; export type GeneratorProfileOption = { diff --git a/src/data/revalidate-selectable-company-groups.ts b/src/data/revalidate-selectable-company-groups.ts new file mode 100644 index 0000000..ee25987 --- /dev/null +++ b/src/data/revalidate-selectable-company-groups.ts @@ -0,0 +1,8 @@ +import { revalidateTag } from "next/cache"; + +import { SELECTABLE_COMPANY_GROUPS_CACHE_TAG } from "@/data/selectable-company-groups"; + +/** Immediate Data Cache bust after catalog create/approve (Route Handlers). */ +export function revalidateSelectableCompanyGroups(): void { + revalidateTag(SELECTABLE_COMPANY_GROUPS_CACHE_TAG, { expire: 0 }); +} diff --git a/src/data/selectable-company-groups.ts b/src/data/selectable-company-groups.ts new file mode 100644 index 0000000..bf8290e --- /dev/null +++ b/src/data/selectable-company-groups.ts @@ -0,0 +1,26 @@ +import type { CompanyGroup, CompanyProfile } from "@/data/company-profiles"; + +/** Shared Data Cache tag for the generator picker and feed catalog filters. */ +export const SELECTABLE_COMPANY_GROUPS_CACHE_TAG = "selectable-company-groups"; + +/** + * Group companies (always) with approved products only. + * Used by the generator picker and any in-memory catalog grouping. + */ +export function groupSelectableCompanyProfiles( + profiles: CompanyProfile[], +): CompanyGroup[] { + const companies = profiles.filter((p) => p.profileType === "company"); + const products = profiles.filter( + (p) => p.profileType === "product" && p.researchStatus === "approved", + ); + + return companies + .map((company) => ({ + company, + products: products + .filter((p) => p.parentCompanyId === company.id) + .sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.company.name.localeCompare(b.company.name)); +} diff --git a/src/lib/feed-filter-options.ts b/src/lib/feed-filter-options.ts index 7e67d2e..c8f42cd 100644 --- a/src/lib/feed-filter-options.ts +++ b/src/lib/feed-filter-options.ts @@ -1,9 +1,7 @@ import { unstable_cache } from "next/cache"; -import { - getSelectableCompanyGroups, - SELECTABLE_COMPANY_GROUPS_CACHE_TAG, -} from "@/data/company-profiles"; +import { getSelectableCompanyGroups } from "@/data/company-profiles"; +import { SELECTABLE_COMPANY_GROUPS_CACHE_TAG } from "@/data/selectable-company-groups"; import type { FeedHierarchicalFilterOptions, FeedProfileFilterGroup, diff --git a/src/lib/research/db.ts b/src/lib/research/db.ts index 89d9201..cd4a9fe 100644 --- a/src/lib/research/db.ts +++ b/src/lib/research/db.ts @@ -1,4 +1,4 @@ -import { revalidateSelectableCompanyGroups } from "@/data/company-profiles"; +import { revalidateSelectableCompanyGroups } from "@/data/revalidate-selectable-company-groups"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import type { ProductResearchAgentInput } from "./schemas";