diff --git a/api/src/services/identity.service.ts b/api/src/services/identity.service.ts index 11bfe31d..6d44b4ea 100644 --- a/api/src/services/identity.service.ts +++ b/api/src/services/identity.service.ts @@ -319,6 +319,7 @@ export type ProfileActivityRow = { base_slug: string | null; review_case_id: string | null; revision_id: string | null; + subjects: Array<{ slug: string; name: string }>; }; // Earliest revision id per parent, so a row can be tagged as the "creation" @@ -463,6 +464,7 @@ export async function getProfileActivity( base_slug: guide ? (slugByBase.get(guide.guide_base_id) ?? null) : null, review_case_id: caseId, revision_id: rev.id, + subjects: [], }); } } @@ -509,6 +511,7 @@ export async function getProfileActivity( base_slug: null, review_case_id: null, revision_id: rev.id, + subjects: [], }); } } @@ -615,11 +618,47 @@ export async function getProfileActivity( ? caseId : null, revision_id: rev ?? null, + subjects: [], }); } } } + // Batch-fetch subject tags for all guide revision rows in one query. + const guideRevisionIds = rows + .filter((r) => r.content_kind === "guide" && r.revision_id !== null) + .map((r) => r.revision_id as string); + + if (guideRevisionIds.length > 0) { + const { data: tagData, error: tagError } = await supabase + .from("guide_revision_subjects") + .select("guide_revision_id, subjects(slug, name)") + .in("guide_revision_id", guideRevisionIds); + + if (tagError) fail(tagError); + + // Build a map from revision_id -> subject list, then hydrate the rows. + const subjectsByRev = new Map< + string, + Array<{ slug: string; name: string }> + >(); + for (const tag of tagData ?? []) { + const subj = tag.subjects; + if (!subj || typeof subj !== "object" || Array.isArray(subj)) continue; + const { slug, name } = subj as { slug: string | null; name: string }; + if (slug === null) continue; + const list = subjectsByRev.get(tag.guide_revision_id) ?? []; + list.push({ slug, name }); + subjectsByRev.set(tag.guide_revision_id, list); + } + + for (const row of rows) { + if (row.content_kind === "guide" && row.revision_id !== null) { + row.subjects = subjectsByRev.get(row.revision_id) ?? []; + } + } + } + rows.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); return rows; } diff --git a/api/tests/identity.test.ts b/api/tests/identity.test.ts index df125df7..d7f7f72e 100644 --- a/api/tests/identity.test.ts +++ b/api/tests/identity.test.ts @@ -14,6 +14,7 @@ import { createGuideRevision, createPublishedGuide, } from "./factories/guides"; +import { createSubject, tagGuideRevision } from "./factories/subjects"; import { expectToMatchSpec } from "./openapi"; describe("GET /me", () => { @@ -197,4 +198,46 @@ describe("GET /profiles/{username}", () => { expect(res.status).toBe(404); await expectToMatchSpec(res, "GET", "/profiles/{username}"); }); + + it("includes subject tags on guide revision activity rows", async () => { + const { token, userId } = await makeUser(); + const username = await getUsername(userId); + const subject = await createSubject(); + const { revision } = await createPublishedGuide({ authorId: userId }); + await tagGuideRevision(revision.id, subject.id); + + const res = await app.request(`/profiles/${username}`, auth(token), env); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + activity: Array<{ + revision_id: string | null; + subjects: Array<{ slug: string; name: string }>; + }>; + }; + const row = body.activity.find((r) => r.revision_id === revision.id); + expect(row).toBeDefined(); + expect(row?.subjects).toEqual( + expect.arrayContaining([{ slug: subject.slug, name: subject.name }]) + ); + }); + + it("returns an empty subjects array for untagged guide revisions", async () => { + const { token, userId } = await makeUser(); + const username = await getUsername(userId); + const { revision } = await createPublishedGuide({ authorId: userId }); + + const res = await app.request(`/profiles/${username}`, auth(token), env); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + activity: Array<{ + revision_id: string | null; + subjects: Array<{ slug: string; name: string }>; + }>; + }; + const row = body.activity.find((r) => r.revision_id === revision.id); + expect(row).toBeDefined(); + expect(row?.subjects).toEqual([]); + }); }); diff --git a/app/src/components/ActivityColumnFilters.tsx b/app/src/components/ActivityColumnFilters.tsx index f4ba599e..83ac898e 100644 --- a/app/src/components/ActivityColumnFilters.tsx +++ b/app/src/components/ActivityColumnFilters.tsx @@ -191,9 +191,9 @@ export function ChoiceColumnFilter({ setFilters, }: { label: string; - field: "type" | "status"; + field: "type" | "status" | "subject"; options: ReadonlyArray<{ - value: ActivityTypeFilter | ActivityStatusFilter; + value: ActivityTypeFilter | ActivityStatusFilter | string; label: string; }>; search: ActivityFilters; diff --git a/app/src/components/profile/ActivityTable.tsx b/app/src/components/profile/ActivityTable.tsx index cf2b7899..cfb96ef7 100644 --- a/app/src/components/profile/ActivityTable.tsx +++ b/app/src/components/profile/ActivityTable.tsx @@ -7,6 +7,7 @@ import { activityStatusLabel, activityTypeLabel, filterActivity, + getActivitySubjectOptions, } from "@/lib/profile"; import { formatDate } from "@/lib/guideUtils"; import { cn } from "@/lib/utils"; @@ -95,6 +96,7 @@ export function ActivityTable({ const hasFilters = Boolean( search.type?.length || search.status?.length || + search.subject?.length || search.title || search.summary || search.from || @@ -120,6 +122,10 @@ export function ActivityTable({ ? "No activity matches these filters." : "No activity available yet."; + // Derive subject options from the full activity (before filtering) so all + // available subject choices are always visible, even when other filters are active. + const subjectOptions = getActivitySubjectOptions(activity); + return ( <>
@@ -151,6 +157,15 @@ export function ActivityTable({ search={search} setFilters={setFilters} /> + {subjectOptions.length > 0 && ( + + )}
{pageRows.length === 0 ? ( @@ -188,6 +203,20 @@ export function ActivityTable({

)} + {row.subjects.length > 0 && ( +
+ {row.subjects.map((s) => ( + + {s.name} + + ))} +
+ )} +
{formatDate(new Date(row.created_at))} @@ -256,6 +285,19 @@ export function ActivityTable({ setFilters={setFilters} /> + + {subjectOptions.length > 0 ? ( + + ) : ( + "Subject" + )} + Review Case @@ -265,7 +307,7 @@ export function ActivityTable({ {pageRows.length === 0 ? ( {emptyMessage} @@ -303,6 +345,24 @@ export function ActivityTable({ + + {row.subjects.length > 0 ? ( +
+ {row.subjects.map((s) => ( + + {s.name} + + ))} +
+ ) : ( + + )} +
+ {row.review_case_id ? (