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 (the create mutation, 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 (the getPosts and getPost queries)
  • 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:

  1. Update the PostData interface to add dbAuthors:
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 }[];
}
  1. Replace the author byline section (lines 101-125) with logic that prefers dbAuthors, falls back to frontmatter authors, then falls back to post.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"