Post Authors Feature Implementation Plan
Post Authors Feature Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Enable explicit attribution of one or more DataHub users as authors on individual posts (Sites), with typeahead selection, avatar bylines, and user profile pages.
Architecture: Add a many-to-many SiteAuthor join table between Site and User. Expose tRPC endpoints for user search and author management. Update the post card to render linked author bylines. Add a HeadlessUI Combobox-based author picker to site settings. Create public user profile pages at /user/[username].
Tech Stack: Prisma (migration), tRPC (API), HeadlessUI Combobox (typeahead), Next.js App Router (profile page), Tailwind CSS (styling), Jest + Testing Library (tests)
Task 1: Prisma Schema — Add SiteAuthor Join Table
Files:
- Modify:
prisma/schema.prisma
Step 1: Add the SiteAuthor model and update relations
In prisma/schema.prisma, add the SiteAuthor model after the Site model, and add reverse relations to User and Site:
model SiteAuthor {
id String @id @default(cuid())
siteId String @map("site_id")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([siteId, userId])
@@index([siteId])
@@index([userId])
}
Add siteAuthors SiteAuthor[] to both the User model and the Site model:
In User:
siteAuthors SiteAuthor[]
In Site:
authors SiteAuthor[]
Step 2: Generate and run the migration
Run: npx prisma migrate dev --name add-site-authors
Expected: Migration created and applied successfully. New SiteAuthor table in DB.
Step 3: Verify the Prisma client regenerated
Run: npx prisma generate
Expected: "Generated Prisma Client"
Step 4: Commit
git add prisma/schema.prisma prisma/migrations/
git commit -m "feat: add SiteAuthor join table for post authorship"
Task 2: Backfill Default Authors on Site Creation
Files:
- Modify:
server/api/routers/site.ts(thecreatemutation, around line 106-118)
Step 1: Write the failing test
Create file server/api/routers/__tests__/site-authors.test.ts:
process.env.SKIP_ENV_VALIDATION = "true";
process.env.NEXTAUTH_URL = "http://localhost";
process.env.POSTGRES_PRISMA_URL = "http://localhost";
process.env.POSTGRES_URL_NON_POOLING = "http://localhost";
process.env.AUTH_GITHUB_SECRET = "test";
process.env.S3_ENDPOINT = "https://s3.example.com";
process.env.S3_ACCESS_KEY_ID = "test";
process.env.S3_SECRET_ACCESS_KEY = "test";
process.env.S3_BUCKET_NAME = "test";
process.env.GH_WEBHOOK_SECRET = "test";
process.env.GH_WEBHOOK_URL = "https://example.com";
process.env.GH_ACCESS_TOKEN = "test";
process.env.GTM_ID = "test";
process.env.GA_MEASUREMENT_ID = "test";
process.env.GA_SECRET = "test";
process.env.BREVO_API_URL = "https://example.com";
process.env.BREVO_API_KEY = "test";
process.env.BREVO_CONTACT_LISTID = "test";
process.env.TURNSTILE_SECRET_KEY = "test";
process.env.INNGEST_APP_ID = "test";
process.env.NEXT_PUBLIC_AUTH_GITHUB_ID = "test";
process.env.NEXT_PUBLIC_ROOT_DOMAIN = "example.com";
process.env.NEXT_PUBLIC_CLOUD_DOMAIN = "example.com";
process.env.NEXT_PUBLIC_VERCEL_DEPLOYMENT_SUFFIX = "vercel.app";
process.env.NEXT_PUBLIC_DNS_DOMAIN = "example.com";
process.env.NEXT_PUBLIC_S3_BUCKET_DOMAIN = "example.com";
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = "test";
jest.mock("superjson", () => ({
__esModule: true,
default: {
stringify: jest.fn((value) => JSON.stringify(value)),
parse: jest.fn((value) => JSON.parse(value as string)),
},
}));
jest.mock("@prisma/client", () => ({
PrismaClient: jest.fn(() => ({})),
Prisma: {
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({
strings,
values,
}),
},
}));
jest.mock("@/inngest/client", () => ({
inngest: { send: jest.fn() },
}));
jest.mock("@/lib/github", () => ({
checkIfBranchExists: jest.fn().mockResolvedValue(true),
createGitHubRepoWebhook: jest.fn().mockResolvedValue({ id: 123 }),
deleteGitHubRepoWebhook: jest.fn(),
fetchGitHubScopes: jest.fn(),
fetchGitHubScopeRepositories: jest.fn(),
}));
describe("site author management", () => {
beforeEach(() => {
jest.clearAllMocks();
});
const createCaller = async (ctx: any) => {
const { appRouter } = await import("@/server/api/root");
return (appRouter as any).createCaller(ctx);
};
test("addAuthor links a user as author on a site", async () => {
const siteAuthors: any[] = [];
const sites = [
{ id: "site-1", publicationId: "pub-1", userId: "user-1", projectName: "post-1" },
];
const ctx = {
db: {
site: {
findUnique: async ({ where }: any) =>
sites.find((s) => s.id === where.id) ?? null,
},
siteAuthor: {
create: async ({ data }: any) => {
const record = { id: `sa-${siteAuthors.length + 1}`, ...data };
siteAuthors.push(record);
return record;
},
findUnique: async () => null,
},
publication: {
findFirst: async () => ({ id: "pub-1", ownerId: "user-1" }),
},
user: {
findUnique: async ({ where }: any) =>
where.id === "user-2" ? { id: "user-2", name: "Jane" } : null,
},
},
session: { user: { id: "user-1" }, accessToken: "token" },
} as any;
const caller = await createCaller(ctx);
const result = await caller.site.addAuthor({
siteId: "site-1",
userId: "user-2",
});
expect(result.siteId).toBe("site-1");
expect(result.userId).toBe("user-2");
expect(siteAuthors).toHaveLength(1);
});
test("removeAuthor unlinks a user from a site", async () => {
let siteAuthors = [
{ id: "sa-1", siteId: "site-1", userId: "user-2" },
];
const sites = [
{ id: "site-1", publicationId: "pub-1", userId: "user-1", projectName: "post-1" },
];
const ctx = {
db: {
site: {
findUnique: async ({ where }: any) =>
sites.find((s) => s.id === where.id) ?? null,
},
siteAuthor: {
delete: async ({ where }: any) => {
const key = where.siteId_userId;
const idx = siteAuthors.findIndex(
(sa) => sa.siteId === key.siteId && sa.userId === key.userId,
);
if (idx >= 0) {
const [deleted] = siteAuthors.splice(idx, 1);
return deleted;
}
return null;
},
},
publication: {
findFirst: async () => ({ id: "pub-1", ownerId: "user-1" }),
},
},
session: { user: { id: "user-1" }, accessToken: "token" },
} as any;
const caller = await createCaller(ctx);
await caller.site.removeAuthor({
siteId: "site-1",
userId: "user-2",
});
expect(siteAuthors).toHaveLength(0);
});
});
Step 2: Run the test to verify it fails
Run: npx jest server/api/routers/__tests__/site-authors.test.ts --no-cache
Expected: FAIL — caller.site.addAuthor is not a function
Step 3: Add addAuthor and removeAuthor mutations to the site router
In server/api/routers/site.ts, add these two mutations inside createTRPCRouter({...}):
addAuthor: protectedProcedure
.input(
z.object({
siteId: z.string().min(1),
userId: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const site = await ctx.db.site.findUnique({
where: { id: input.siteId },
});
if (!site) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
// Only the publication owner can manage authors
const publication = await ctx.db.publication.findFirst({
where: { id: site.publicationId },
});
if (!publication || publication.ownerId !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the publication owner can manage authors",
});
}
// Verify the target user exists
const user = await ctx.db.user.findUnique({
where: { id: input.userId },
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Prevent duplicate
const existing = await ctx.db.siteAuthor.findUnique({
where: {
siteId_userId: {
siteId: input.siteId,
userId: input.userId,
},
},
});
if (existing) {
return existing;
}
return await ctx.db.siteAuthor.create({
data: {
siteId: input.siteId,
userId: input.userId,
},
});
}),
removeAuthor: protectedProcedure
.input(
z.object({
siteId: z.string().min(1),
userId: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const site = await ctx.db.site.findUnique({
where: { id: input.siteId },
});
if (!site) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
const publication = await ctx.db.publication.findFirst({
where: { id: site.publicationId },
});
if (!publication || publication.ownerId !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the publication owner can manage authors",
});
}
return await ctx.db.siteAuthor.delete({
where: {
siteId_userId: {
siteId: input.siteId,
userId: input.userId,
},
},
});
}),
Step 4: Run the test to verify it passes
Run: npx jest server/api/routers/__tests__/site-authors.test.ts --no-cache
Expected: PASS
Step 5: Add default author on site creation
In server/api/routers/site.ts, in the create mutation, after the site is created (after const site = await ctx.db.site.create({...}) around line 118) and before the webhook creation, add:
// Set the creating user as the default author
await ctx.db.siteAuthor.create({
data: {
siteId: site.id,
userId: ctx.session.user.id,
},
});
Step 6: Commit
git add server/api/routers/site.ts server/api/routers/__tests__/site-authors.test.ts
git commit -m "feat: add author management endpoints and default author on site creation"
Task 3: User Search API Endpoint
Files:
- Modify:
server/api/routers/user.ts - Create:
server/api/routers/__tests__/user-search.test.ts
Step 1: Write the failing test
Create file server/api/routers/__tests__/user-search.test.ts:
process.env.SKIP_ENV_VALIDATION = "true";
process.env.NEXTAUTH_URL = "http://localhost";
process.env.POSTGRES_PRISMA_URL = "http://localhost";
process.env.POSTGRES_URL_NON_POOLING = "http://localhost";
process.env.AUTH_GITHUB_SECRET = "test";
process.env.S3_ENDPOINT = "https://s3.example.com";
process.env.S3_ACCESS_KEY_ID = "test";
process.env.S3_SECRET_ACCESS_KEY = "test";
process.env.S3_BUCKET_NAME = "test";
process.env.GH_WEBHOOK_SECRET = "test";
process.env.GH_WEBHOOK_URL = "https://example.com";
process.env.GH_ACCESS_TOKEN = "test";
process.env.GTM_ID = "test";
process.env.GA_MEASUREMENT_ID = "test";
process.env.GA_SECRET = "test";
process.env.BREVO_API_URL = "https://example.com";
process.env.BREVO_API_KEY = "test";
process.env.BREVO_CONTACT_LISTID = "test";
process.env.TURNSTILE_SECRET_KEY = "test";
process.env.INNGEST_APP_ID = "test";
process.env.NEXT_PUBLIC_AUTH_GITHUB_ID = "test";
process.env.NEXT_PUBLIC_ROOT_DOMAIN = "example.com";
process.env.NEXT_PUBLIC_CLOUD_DOMAIN = "example.com";
process.env.NEXT_PUBLIC_VERCEL_DEPLOYMENT_SUFFIX = "vercel.app";
process.env.NEXT_PUBLIC_DNS_DOMAIN = "example.com";
process.env.NEXT_PUBLIC_S3_BUCKET_DOMAIN = "example.com";
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = "test";
jest.mock("superjson", () => ({
__esModule: true,
default: {
stringify: jest.fn((value) => JSON.stringify(value)),
parse: jest.fn((value) => JSON.parse(value as string)),
},
}));
jest.mock("@prisma/client", () => ({
PrismaClient: jest.fn(() => ({})),
Prisma: {
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({
strings,
values,
}),
},
}));
jest.mock("@/inngest/client", () => ({
inngest: { send: jest.fn() },
}));
jest.mock("@/lib/github", () => ({
checkIfBranchExists: jest.fn(),
createGitHubRepoWebhook: jest.fn(),
deleteGitHubRepoWebhook: jest.fn(),
fetchGitHubScopes: jest.fn(),
fetchGitHubScopeRepositories: jest.fn(),
}));
describe("user search", () => {
beforeEach(() => {
jest.clearAllMocks();
});
const createCaller = async (ctx: any) => {
const { appRouter } = await import("@/server/api/root");
return (appRouter as any).createCaller(ctx);
};
test("search returns matching users by name", async () => {
const users = [
{ id: "u-1", name: "Alice Smith", username: "alice", image: null },
{ id: "u-2", name: "Bob Jones", username: "bob", image: null },
];
const ctx = {
db: {
user: {
findMany: async ({ where }: any) => {
const query = where.OR[0].name.contains.toLowerCase();
return users.filter(
(u) =>
u.name.toLowerCase().includes(query) ||
u.username?.toLowerCase().includes(query),
);
},
},
},
session: { user: { id: "u-1" }, accessToken: "token" },
} as any;
const caller = await createCaller(ctx);
const result = await caller.user.search({ query: "alice" });
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Alice Smith");
});
test("search returns empty for no match", async () => {
const ctx = {
db: {
user: {
findMany: async () => [],
},
},
session: { user: { id: "u-1" }, accessToken: "token" },
} as any;
const caller = await createCaller(ctx);
const result = await caller.user.search({ query: "zzz" });
expect(result).toHaveLength(0);
});
});
Step 2: Run the test to verify it fails
Run: npx jest server/api/routers/__tests__/user-search.test.ts --no-cache
Expected: FAIL — caller.user.search is not a function
Step 3: Add the search endpoint to user router
In server/api/routers/user.ts, add a new search procedure:
search: protectedProcedure
.input(z.object({ query: z.string().min(1).max(100) }))
.query(async ({ ctx, input }) => {
return await ctx.db.user.findMany({
where: {
OR: [
{ name: { contains: input.query, mode: "insensitive" } },
{ username: { contains: input.query, mode: "insensitive" } },
],
},
select: {
id: true,
name: true,
username: true,
image: true,
},
take: 10,
});
}),
Don't forget to add the "insensitive" mode import — it's available from Prisma by default in the query, no extra import needed.
Step 4: Run the test to verify it passes
Run: npx jest server/api/routers/__tests__/user-search.test.ts --no-cache
Expected: PASS
Step 5: Commit
git add server/api/routers/user.ts server/api/routers/__tests__/user-search.test.ts
git commit -m "feat: add user search endpoint for author typeahead"
Task 4: Include Authors in getPosts and getPost Responses
Files:
- Modify:
server/api/routers/publication.ts(thegetPostsandgetPostqueries) - Modify:
server/api/routers/__tests__/publication.test.ts
Step 1: Write the failing test
Add this test to server/api/routers/__tests__/publication.test.ts:
test("getPosts includes site authors", async () => {
const publications = [{ id: "pub-1", slug: "test" }];
const sites = [
{
id: "site-1",
publicationId: "pub-1",
projectName: "post-1",
user: {
name: "Owner",
username: "owner",
image: null,
ghUsername: "owner",
},
authors: [
{
user: {
id: "u-1",
name: "Alice",
username: "alice",
image: "https://example.com/alice.png",
},
},
],
_count: { likes: 0 },
stats: [],
},
];
const ctx = {
db: createFakeDb(publications, sites),
session: null,
} as any;
const caller = await createCaller(ctx);
const result = await caller.publication.getPosts({ slug: "test" });
expect(result.posts[0].authors).toHaveLength(1);
expect(result.posts[0].authors[0].user.name).toBe("Alice");
});
Step 2: Run the test to verify it fails
Run: npx jest server/api/routers/__tests__/publication.test.ts --no-cache
Expected: FAIL — authors is undefined on the post
Step 3: Update the getPosts query to include authors
In server/api/routers/publication.ts, in the getPosts query, update the site.findMany include (around line 296) to also include authors:
include: {
user: {
select: {
name: true,
username: true,
image: true,
ghUsername: true,
},
},
authors: {
include: {
user: {
select: {
id: true,
name: true,
username: true,
image: true,
},
},
},
},
_count: {
select: {
likes: true,
},
},
stats: {
select: {
views: true,
downloads: true,
},
},
},
Step 4: Update the getPost query similarly
In the getPost query (around line 452), update the include:
include: {
user: true,
publication: true,
authors: {
include: {
user: {
select: {
id: true,
name: true,
username: true,
image: true,
},
},
},
},
},
Step 5: Run the test to verify it passes
Run: npx jest server/api/routers/__tests__/publication.test.ts --no-cache
Expected: PASS
Step 6: Commit
git add server/api/routers/publication.ts server/api/routers/__tests__/publication.test.ts
git commit -m "feat: include authors relation in getPosts and getPost responses"
Task 5: Update PublicationPostCard to Render DB-Backed Authors
Files:
- Modify:
components/publication-post-card.tsx - Modify:
components/__tests__/publication-post-card.test.tsx
Step 1: Write the failing test
Add this test to components/__tests__/publication-post-card.test.tsx:
it("renders DB-backed authors with avatars and profile links", () => {
render(
<PublicationPostCard
{...defaultProps}
data={{
...mockPostData,
authors: null, // no frontmatter authors
dbAuthors: [
{
user: {
id: "u-1",
name: "Alice Smith",
username: "alice",
image: "https://example.com/alice.png",
},
},
{
user: {
id: "u-2",
name: "Bob Jones",
username: "bob",
image: null,
},
},
],
}}
/>,
);
expect(screen.getByText("Alice Smith")).toBeInTheDocument();
expect(screen.getByText("Bob Jones")).toBeInTheDocument();
expect(screen.getByAltText("Alice Smith")).toHaveAttribute(
"src",
"https://example.com/alice.png",
);
// Bob has no image, should use fallback
expect(screen.getByAltText("Bob Jones")).toHaveAttribute(
"src",
"https://avatar.vercel.sh/bob",
);
});
Step 2: Run the test to verify it fails
Run: npx jest components/__tests__/publication-post-card.test.tsx --no-cache
Expected: FAIL — dbAuthors not recognized / authors not rendering
Step 3: Update PublicationPostCard
In components/publication-post-card.tsx:
- Update the
PostDatainterface to adddbAuthors:
interface DbAuthor {
user: {
id: string;
name: string | null;
username: string | null;
image: string | null;
};
}
interface PostData {
id: string;
projectName: string;
title: string | null;
description: string | null;
authors: string[] | null; // frontmatter authors (legacy)
dbAuthors?: DbAuthor[] | null; // DB-backed authors
date: string | null;
updatedAt: Date;
user: Author | null;
_count?: { likes: number };
stats?: { views: number; downloads: number }[];
}
- Replace the author byline section (lines 101-125) with logic that prefers
dbAuthors, falls back to frontmatterauthors, then falls back topost.user:
{post.dbAuthors?.length ? (
<div className="flex items-center space-x-2">
<div className="flex -space-x-1">
{post.dbAuthors.map(({ user: author }) => (
<Image
key={author.id}
src={
author.image ||
`https://avatar.vercel.sh/${author.username}`
}
alt={author.name || author.username || "Author"}
width={20}
height={20}
className="h-5 w-5 rounded-full ring-2 ring-white dark:ring-stone-900"
/>
))}
</div>
<span className="font-medium text-stone-900 dark:text-stone-200">
{post.dbAuthors
.map(({ user: a }) => a.name || a.username)
.join(", ")}
</span>
</div>
) : post.authors?.length ? (
<div className="flex items-center space-x-2">
<span className="font-medium text-stone-900 dark:text-stone-200">
By {post.authors.join(", ")}
</span>
</div>
) : (
post.user && (
<div className="flex items-center space-x-2">
<Image
src={
post.user.image ||
`https://avatar.vercel.sh/${post.user.username}`
}
alt={post.user.name || post.user.username || "Author"}
width={20}
height={20}
className="h-5 w-5 rounded-full"
/>
<span className="font-medium text-stone-900 dark:text-stone-200">
{post.user.name || post.user.username}
</span>
</div>
)
)}
Step 4: Run the test to verify it passes
Run: npx jest components/__tests__/publication-post-card.test.tsx --no-cache
Expected: PASS
Step 5: Wire up dbAuthors in PublicationPosts
In components/publication-posts.tsx, update the mapping where PublicationPostCard is rendered. The authors relation from the API needs to be mapped to dbAuthors:
posts.map((post) => (
<PublicationPostCard
key={post.id}
data={{
...(post as any),
dbAuthors: (post as any).authors ?? null,
}}
basePath={basePath}
isDashboard={isDashboard}
/>
))
Step 6: Run all existing tests to make sure nothing broke
Run: npx jest --no-cache
Expected: All tests PASS
Step 7: Commit
git add components/publication-post-card.tsx components/__tests__/publication-post-card.test.tsx components/publication-posts.tsx
git commit -m "feat: render DB-backed authors with avatars in post cards"
Task 6: Author Typeahead Component
Files:
- Create:
components/author-search.tsx - Create:
components/__tests__/author-search.test.tsx
Step 1: Write the failing test
Create components/__tests__/author-search.test.tsx:
/**
* @jest-environment jsdom
*/
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { AuthorSearch } from "../author-search";
jest.mock("next/image", () => {
const MockImage = ({ src, alt }: { src: string; alt: string }) => (
<img src={src} alt={alt} />
);
MockImage.displayName = "MockImage";
return MockImage;
});
jest.mock("@/styles/fonts", () => ({
lora: { className: "lora-font" },
}));
jest.mock("@/lib/utils", () => ({
formatNumber: jest.fn((n) => n.toString()),
cn: jest.fn((...args) => args.join(" ")),
}));
describe("AuthorSearch", () => {
const mockOnSelect = jest.fn();
it("renders the search input", () => {
render(
<AuthorSearch
onSelect={mockOnSelect}
existingAuthorIds={[]}
searchUsers={async () => []}
/>,
);
expect(
screen.getByPlaceholderText("Search users..."),
).toBeInTheDocument();
});
it("displays search results", async () => {
const searchUsers = async () => [
{ id: "u-1", name: "Alice", username: "alice", image: null },
];
render(
<AuthorSearch
onSelect={mockOnSelect}
existingAuthorIds={[]}
searchUsers={searchUsers}
/>,
);
const input = screen.getByPlaceholderText("Search users...");
fireEvent.change(input, { target: { value: "ali" } });
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
});
it("filters out existing authors from results", async () => {
const searchUsers = async () => [
{ id: "u-1", name: "Alice", username: "alice", image: null },
{ id: "u-2", name: "Bob", username: "bob", image: null },
];
render(
<AuthorSearch
onSelect={mockOnSelect}
existingAuthorIds={["u-1"]}
searchUsers={searchUsers}
/>,
);
const input = screen.getByPlaceholderText("Search users...");
fireEvent.change(input, { target: { value: "a" } });
await waitFor(() => {
expect(screen.queryByText("Alice")).not.toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
});
});
Step 2: Run the test to verify it fails
Run: npx jest components/__tests__/author-search.test.tsx --no-cache
Expected: FAIL — module not found
Step 3: Create the AuthorSearch component
Create components/author-search.tsx:
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
interface UserResult {
id: string;
name: string | null;
username: string | null;
image: string | null;
}
export function AuthorSearch({
onSelect,
existingAuthorIds,
searchUsers,
}: {
onSelect: (user: UserResult) => void;
existingAuthorIds: string[];
searchUsers: (query: string) => Promise<UserResult[]>;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<UserResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (query.length < 1) {
setResults([]);
setIsOpen(false);
return;
}
const timeout = setTimeout(async () => {
const users = await searchUsers(query);
const filtered = users.filter(
(u) => !existingAuthorIds.includes(u.id),
);
setResults(filtered);
setIsOpen(filtered.length > 0);
}, 200);
return () => clearTimeout(timeout);
}, [query, existingAuthorIds, searchUsers]);
return (
<div className="relative">
<input
type="text"
placeholder="Search users..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-stone-200 px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-stone-500 focus:outline-none focus:ring-1 focus:ring-stone-500 dark:border-stone-700 dark:bg-stone-800 dark:text-stone-100"
/>
{isOpen && (
<ul className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-stone-200 bg-white py-1 shadow-lg dark:border-stone-700 dark:bg-stone-800">
{results.map((user) => (
<li key={user.id}>
<button
type="button"
onClick={() => {
onSelect(user);
setQuery("");
setIsOpen(false);
}}
className="flex w-full items-center space-x-2 px-3 py-2 text-left text-sm hover:bg-stone-100 dark:hover:bg-stone-700"
>
<Image
src={
user.image ||
`https://avatar.vercel.sh/${user.username}`
}
alt={user.name || user.username || "User"}
width={24}
height={24}
className="h-6 w-6 rounded-full"
/>
<div>
<div className="font-medium text-stone-900 dark:text-stone-100">
{user.name || user.username}
</div>
{user.username && (
<div className="text-xs text-stone-500">
@{user.username}
</div>
)}
</div>
</button>
</li>
))}
</ul>
)}
</div>
);
}
Step 4: Run the test to verify it passes
Run: npx jest components/__tests__/author-search.test.tsx --no-cache
Expected: PASS
Step 5: Commit
git add components/author-search.tsx components/__tests__/author-search.test.tsx
git commit -m "feat: add AuthorSearch typeahead component"
Task 7: Author Management UI in Site Settings
Files:
- Create:
components/form/site-authors-form.tsx - Modify:
app/dashboard/sites/[id]/settings/page.tsx
Step 1: Create the SiteAuthorsForm component
Create components/form/site-authors-form.tsx:
"use client";
import { useState } from "react";
import Image from "next/image";
import { X } from "lucide-react";
import { AuthorSearch } from "@/components/author-search";
import { api } from "@/trpc/react";
interface AuthorUser {
id: string;
name: string | null;
username: string | null;
image: string | null;
}
interface SiteAuthor {
user: AuthorUser;
}
export function SiteAuthorsForm({
siteId,
initialAuthors,
}: {
siteId: string;
initialAuthors: SiteAuthor[];
}) {
const [authors, setAuthors] = useState<SiteAuthor[]>(initialAuthors);
const addAuthorMutation = api.site.addAuthor.useMutation({
onSuccess: (_, variables) => {
// Author already added to state optimistically
},
});
const removeAuthorMutation = api.site.removeAuthor.useMutation();
const searchUsers = async (query: string): Promise<AuthorUser[]> => {
// Use fetch to call the tRPC endpoint since we can't use hooks in callbacks
const res = await fetch(
`/api/trpc/user.search?input=${encodeURIComponent(JSON.stringify({ query }))}`,
);
const data = await res.json();
return data?.result?.data ?? [];
};
const handleAddAuthor = (user: AuthorUser) => {
setAuthors((prev) => [...prev, { user }]);
addAuthorMutation.mutate({ siteId, userId: user.id });
};
const handleRemoveAuthor = (userId: string) => {
setAuthors((prev) => prev.filter((a) => a.user.id !== userId));
removeAuthorMutation.mutate({ siteId, userId });
};
return (
<div className="rounded-lg border border-stone-200 bg-white dark:border-stone-700 dark:bg-stone-900">
<div className="flex flex-col space-y-4 p-5 sm:p-10">
<h2 className="text-xl font-cal dark:text-white">Authors</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">
Manage who is credited as an author on this post.
</p>
{/* Current authors */}
<div className="space-y-2">
{authors.map(({ user }) => (
<div
key={user.id}
className="flex items-center justify-between rounded-md border border-stone-200 px-3 py-2 dark:border-stone-700"
>
<div className="flex items-center space-x-2">
<Image
src={
user.image ||
`https://avatar.vercel.sh/${user.username}`
}
alt={user.name || user.username || "Author"}
width={28}
height={28}
className="h-7 w-7 rounded-full"
/>
<div>
<span className="text-sm font-medium text-stone-900 dark:text-stone-100">
{user.name || user.username}
</span>
{user.username && (
<span className="ml-2 text-xs text-stone-500">
@{user.username}
</span>
)}
</div>
</div>
<button
type="button"
onClick={() => handleRemoveAuthor(user.id)}
className="rounded p-1 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:hover:bg-stone-800 dark:hover:text-stone-300"
title="Remove author"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
{/* Add author search */}
<AuthorSearch
onSelect={handleAddAuthor}
existingAuthorIds={authors.map((a) => a.user.id)}
searchUsers={searchUsers}
/>
</div>
</div>
);
}
Step 2: Update the site settings page to fetch and display authors
In app/dashboard/sites/[id]/settings/page.tsx, add the SiteAuthorsForm below the existing forms (before the DeleteSiteForm).
First, update the api.site.getById call to also fetch authors. Since getById currently doesn't include authors, we need to update it.
In server/api/routers/site.ts, update the getById query (around line 438) to include authors:
getById: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
return await ctx.db.site.findFirst({
where: { id: input.id },
include: {
user: true,
publication: true,
authors: {
include: {
user: {
select: {
id: true,
name: true,
username: true,
image: true,
},
},
},
},
},
});
}),
Then in the settings page, add the import and render the form:
import { SiteAuthorsForm } from "@/components/form/site-authors-form";
Add before <DeleteSiteForm>:
<SiteAuthorsForm
siteId={site.id}
initialAuthors={site.authors ?? []}
/>
Step 3: Run the tests
Run: npx jest --no-cache
Expected: All tests PASS
Step 4: Commit
git add components/form/site-authors-form.tsx app/dashboard/sites/[id]/settings/page.tsx server/api/routers/site.ts
git commit -m "feat: add author management UI to site settings"
Task 8: User Profile Page
Files:
- Create:
app/user/[username]/page.tsx
Step 1: Create the profile page
Create app/user/[username]/page.tsx:
import { notFound } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { db } from "@/server/db";
export async function generateMetadata({
params,
}: {
params: { username: string };
}) {
const user = await db.user.findFirst({
where: { username: params.username },
});
if (!user) return { title: "User Not Found" };
return {
title: `${user.name || user.username} - DataHub`,
description: `Profile of ${user.name || user.username}`,
};
}
export default async function UserProfilePage({
params,
}: {
params: { username: string };
}) {
const user = await db.user.findFirst({
where: { username: params.username },
select: {
id: true,
name: true,
username: true,
image: true,
publications: {
select: {
id: true,
slug: true,
name: true,
},
},
siteAuthors: {
select: {
site: {
select: {
id: true,
projectName: true,
title: true,
publication: {
select: {
slug: true,
name: true,
},
},
},
},
},
},
},
});
if (!user) {
notFound();
}
return (
<div className="mx-auto max-w-3xl px-4 py-12 sm:px-6 lg:px-8">
{/* Profile header */}
<div className="flex items-center space-x-4">
<Image
src={
user.image || `https://avatar.vercel.sh/${user.username}`
}
alt={user.name || user.username || "User"}
width={80}
height={80}
className="h-20 w-20 rounded-full"
/>
<div>
<h1 className="text-2xl font-bold text-stone-900 dark:text-stone-100">
{user.name || user.username}
</h1>
{user.username && (
<p className="text-stone-500 dark:text-stone-400">
@{user.username}
</p>
)}
</div>
</div>
{/* Publications owned */}
{user.publications.length > 0 && (
<section className="mt-10">
<h2 className="text-lg font-semibold text-stone-900 dark:text-stone-100">
Publications
</h2>
<ul className="mt-4 space-y-2">
{user.publications.map((pub) => (
<li key={pub.id}>
<Link
href={`/${pub.slug}`}
className="text-stone-700 underline hover:text-stone-900 dark:text-stone-300 dark:hover:text-stone-100"
>
{pub.name || pub.slug}
</Link>
</li>
))}
</ul>
</section>
)}
{/* Posts contributed to */}
{user.siteAuthors.length > 0 && (
<section className="mt-10">
<h2 className="text-lg font-semibold text-stone-900 dark:text-stone-100">
Posts
</h2>
<ul className="mt-4 space-y-2">
{user.siteAuthors.map(({ site }) => (
<li key={site.id}>
<Link
href={`/${site.publication.slug}/${site.projectName}`}
className="text-stone-700 underline hover:text-stone-900 dark:text-stone-300 dark:hover:text-stone-100"
>
{site.title || site.projectName}
</Link>
<span className="ml-2 text-sm text-stone-500">
in {site.publication.name || site.publication.slug}
</span>
</li>
))}
</ul>
</section>
)}
</div>
);
}
Step 2: Verify the page renders (manual check)
Run: npm run dev
Navigate to: http://localhost:3000/user/<your-username>
Expected: Profile page shows name, avatar, publications, and authored posts.
Step 3: Commit
git add app/user/[username]/page.tsx
git commit -m "feat: add user profile page at /user/[username]"
Task 9: Link Author Names to Profile Pages in Post Card
Files:
- Modify:
components/publication-post-card.tsx - Modify:
components/__tests__/publication-post-card.test.tsx
Step 1: Write the failing test
Add to components/__tests__/publication-post-card.test.tsx:
it("links DB-backed author names to profile pages", () => {
render(
<PublicationPostCard
{...defaultProps}
data={{
...mockPostData,
authors: null,
dbAuthors: [
{
user: {
id: "u-1",
name: "Alice Smith",
username: "alice",
image: null,
},
},
],
}}
/>,
);
const authorLink = screen.getByText("Alice Smith").closest("a");
expect(authorLink).toHaveAttribute("href", "/user/alice");
});
Step 2: Run the test to verify it fails
Run: npx jest components/__tests__/publication-post-card.test.tsx --no-cache
Expected: FAIL — author name is not wrapped in a link
Step 3: Update the author rendering to use links
In components/publication-post-card.tsx, update the dbAuthors rendering section. Replace the <span> that joins author names with individual links:
{post.dbAuthors?.length ? (
<div className="flex items-center space-x-2">
<div className="flex -space-x-1">
{post.dbAuthors.map(({ user: author }) => (
<Image
key={author.id}
src={
author.image ||
`https://avatar.vercel.sh/${author.username}`
}
alt={author.name || author.username || "Author"}
width={20}
height={20}
className="h-5 w-5 rounded-full ring-2 ring-white dark:ring-stone-900"
/>
))}
</div>
<span className="font-medium text-stone-900 dark:text-stone-200">
{post.dbAuthors.map(({ user: a }, i) => (
<span key={a.id}>
{i > 0 && ", "}
<Link
href={`/user/${a.username}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{a.name || a.username}
</Link>
</span>
))}
</span>
</div>
) : post.authors?.length ? (
Note: The onClick={(e) => e.stopPropagation()} prevents the parent card link from intercepting the click.
Step 4: Run the test to verify it passes
Run: npx jest components/__tests__/publication-post-card.test.tsx --no-cache
Expected: PASS
Step 5: Run all tests
Run: npx jest --no-cache
Expected: All tests PASS
Step 6: Commit
git add components/publication-post-card.tsx components/__tests__/publication-post-card.test.tsx
git commit -m "feat: link author names to profile pages in post cards"
Task 10: Final Verification
Step 1: Run all tests
Run: npx jest --no-cache
Expected: All tests PASS
Step 2: Run type checking
Run: npx tsc --noEmit
Expected: No type errors
Step 3: Run the linter
Run: npm run lint
Expected: No lint errors (or only pre-existing ones)
Step 4: Run the dev server and manually verify
Run: npm run dev
Verify:
- Post listing page shows author avatars + names
- Author names link to
/user/<username>profile pages - Profile page shows user info, publications, and authored posts
- Site settings page has "Authors" section
- Author typeahead searches users and adds them
- Removing an author works
- Creating a new site auto-assigns the creator as author
Step 5: Final commit if any cleanup needed
git add -A
git commit -m "chore: final cleanup for post authors feature"