Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/admin/products/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export default function ProductDetailPage() {
.map((s) => s.trim())
.filter(Boolean),
defaultVibeTags: textToDnaArray(defaultVibeTags),
researchStatus: "approved",
}),
});

Expand Down
5 changes: 5 additions & 0 deletions src/app/api/admin/companies/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -94,6 +95,8 @@ export async function PUT(
);
}

revalidateSelectableCompanyGroups();

return NextResponse.json({
id: updated.id,
name: updated.name,
Expand Down Expand Up @@ -174,6 +177,8 @@ export async function DELETE(
);
}

revalidateSelectableCompanyGroups();

return NextResponse.json({ deleted: true });
} catch (err) {
if (err instanceof AdminAuthError) {
Expand Down
3 changes: 3 additions & 0 deletions src/app/api/admin/companies/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -188,6 +189,8 @@ export async function POST(request: Request) {
);
}

revalidateSelectableCompanyGroups();

const result = {
id: inserted.id,
name: inserted.name,
Expand Down
22 changes: 22 additions & 0 deletions src/app/api/admin/products/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -66,6 +75,7 @@ type UpdateProductBody = {
archetype?: unknown;
defaultVibeTags?: unknown;
sourceUrls?: unknown;
researchStatus?: unknown;
};

function validateUpdateBody(body: UpdateProductBody): string | null {
Expand All @@ -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;
}

Expand Down Expand Up @@ -129,6 +146,7 @@ export async function PUT(
if (body.archetype != null) updates.archetype = body.archetype as Record<string, unknown>;
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")
Expand All @@ -144,6 +162,8 @@ export async function PUT(
);
}

revalidateSelectableCompanyGroups();

return NextResponse.json({
id: data.id,
name: data.name,
Expand Down Expand Up @@ -193,5 +213,7 @@ export async function DELETE(
);
}

revalidateSelectableCompanyGroups();

return NextResponse.json({ success: true });
}
3 changes: 3 additions & 0 deletions src/app/api/admin/products/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -198,6 +199,8 @@ export async function POST(request: Request) {
);
}

revalidateSelectableCompanyGroups();

return NextResponse.json(
{
id: inserted.id,
Expand Down
39 changes: 39 additions & 0 deletions src/data/__tests__/company-profiles.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
25 changes: 11 additions & 14 deletions src/data/company-profiles.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -250,20 +255,12 @@ export async function getSelectableCompanyGroups(): Promise<CompanyGroup[]> {
.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 },
)();
}
15 changes: 2 additions & 13 deletions src/data/generator-profile-options.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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";
Expand Down
8 changes: 8 additions & 0 deletions src/data/revalidate-selectable-company-groups.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
26 changes: 26 additions & 0 deletions src/data/selectable-company-groups.ts
Original file line number Diff line number Diff line change
@@ -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));
}
6 changes: 5 additions & 1 deletion src/lib/feed-filter-options.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -41,5 +42,8 @@ async function fetchFeedHierarchicalFilterOptions(): Promise<FeedHierarchicalFil
export const getFeedHierarchicalFilterOptions = unstable_cache(
fetchFeedHierarchicalFilterOptions,
["feed-hierarchical-filter-options"],
{ revalidate: FILTER_OPTIONS_REVALIDATE_SECONDS },
{
revalidate: FILTER_OPTIONS_REVALIDATE_SECONDS,
tags: [SELECTABLE_COMPANY_GROUPS_CACHE_TAG],
},
);
3 changes: 3 additions & 0 deletions src/lib/research/db.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { revalidateSelectableCompanyGroups } from "@/data/revalidate-selectable-company-groups";
import { createSupabaseServiceClient } from "@/lib/supabase/service";

import type { ProductResearchAgentInput } from "./schemas";
Expand Down Expand Up @@ -105,5 +106,7 @@ export async function publishDraftToProfile(draftId: string) {
.eq("id", draftId);
if (stErr) throw stErr;

revalidateSelectableCompanyGroups();

return { profileId: draft.product_slug as string };
}
Loading