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..c20f9df 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/revalidate-selectable-company-groups"; 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..a237b74 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/revalidate-selectable-company-groups"; 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..406c68e 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/revalidate-selectable-company-groups"; 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..1ef3bd9 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/revalidate-selectable-company-groups"; 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..c217dbd --- /dev/null +++ b/src/data/__tests__/company-profiles.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { groupSelectableCompanyProfiles } from "@/data/selectable-company-groups"; +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..04ba2fc 100644 --- a/src/data/company-profiles.ts +++ b/src/data/company-profiles.ts @@ -1,6 +1,11 @@ import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { unstable_cache } from "next/cache"; +import { + groupSelectableCompanyProfiles, + SELECTABLE_COMPANY_GROUPS_CACHE_TAG, +} from "@/data/selectable-company-groups"; + export type StyleDna = { tone: string[]; colors: string[]; @@ -250,20 +255,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..f544615 100644 --- a/src/data/generator-profile-options.ts +++ b/src/data/generator-profile-options.ts @@ -1,4 +1,5 @@ import type { CompanyGroup, CompanyProfile } from "@/data/company-profiles"; +import { groupSelectableCompanyProfiles } from "@/data/selectable-company-groups"; import { resolveProfileScreenType } from "@/lib/screen-type"; export type GeneratorProfileOption = { @@ -137,19 +138,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/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 39a6c1c..c8f42cd 100644 --- a/src/lib/feed-filter-options.ts +++ b/src/lib/feed-filter-options.ts @@ -1,6 +1,7 @@ import { unstable_cache } from "next/cache"; import { getSelectableCompanyGroups } from "@/data/company-profiles"; +import { SELECTABLE_COMPANY_GROUPS_CACHE_TAG } from "@/data/selectable-company-groups"; import type { FeedHierarchicalFilterOptions, FeedProfileFilterGroup, @@ -41,5 +42,8 @@ async function fetchFeedHierarchicalFilterOptions(): Promise