Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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
152 changes: 152 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ To build a specific package:
yarn workspace @sourcebot/<package-name> build
```

## File Naming

Files should use camelCase starting with a lowercase letter:

```
// Correct
shareChatPopover.tsx
userAvatar.tsx
apiClient.ts

// Incorrect
ShareChatPopover.tsx
UserAvatar.tsx
share-chat-popover.tsx
```
Comment thread
brendan-kellam marked this conversation as resolved.

Exceptions:
- Special files like `README.md`, `CHANGELOG.md`, `LICENSE`
- Next.js conventions: `page.tsx`, `layout.tsx`, `loading.tsx`, etc.

## Tailwind CSS

Use Tailwind color classes directly instead of CSS variable syntax:
Expand All @@ -30,6 +50,138 @@ className="border-border bg-card text-foreground text-muted-foreground bg-muted
className="border-[var(--border)] bg-[var(--card)] text-[var(--foreground)]"
```

## API Route Handlers

Route handlers should validate inputs using Zod schemas.

**Query parameters** (GET requests):

```ts
import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { z } from "zod";

const myQueryParamsSchema = z.object({
q: z.string().default(''),
page: z.coerce.number().int().positive().default(1),
});

export const GET = apiHandler(async (request: NextRequest) => {
const rawParams = Object.fromEntries(
Object.keys(myQueryParamsSchema.shape).map(key => [
key,
request.nextUrl.searchParams.get(key) ?? undefined
])
);
const parsed = myQueryParamsSchema.safeParse(rawParams);

if (!parsed.success) {
return serviceErrorResponse(
queryParamsSchemaValidationError(parsed.error)
);
}

const { q, page } = parsed.data;
// ... rest of handler
});
```

**Request body** (POST/PUT/PATCH requests):

```ts
import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { z } from "zod";

const myRequestBodySchema = z.object({
name: z.string(),
count: z.number().optional(),
});

export const POST = apiHandler(async (request: NextRequest) => {
const body = await request.json();
const parsed = myRequestBodySchema.safeParse(body);

if (!parsed.success) {
return serviceErrorResponse(
requestBodySchemaValidationError(parsed.error)
);
}

const { name, count } = parsed.data;
// ... rest of handler
});
```

## Data Fetching

For GET requests, prefer using API routes with react-query over server actions. This provides caching benefits and better control over data refetching.

```tsx
// Preferred: API route + react-query
import { useQuery } from "@tanstack/react-query";

const { data, isLoading } = useQuery({
queryKey: ["items", id],
queryFn: () => fetch(`/api/items/${id}`).then(res => res.json()),
});
```

Server actions should be used for mutations (POST/PUT/DELETE operations), not for data fetching.

## Authentication

Use `withAuthV2` or `withOptionalAuthV2` from `@/withAuthV2` to protect server actions and API routes.

- **`withAuthV2`** - Requires authentication. Returns `notAuthenticated()` if user is not logged in.
- **`withOptionalAuthV2`** - Allows anonymous access if the org has anonymous access enabled. `user` may be `undefined`.
- **`withMinimumOrgRole`** - Wrap inside auth context to require a minimum role (e.g., `OrgRole.OWNER`).

**Important:** Always use the `prisma` instance provided by the auth context. This instance has `userScopedPrismaClientExtension` applied, which enforces repository visibility rules (e.g., filtering repos based on user permissions). Do NOT import `prisma` directly from `@/prisma` in actions or routes that return data to the client.

**Server actions** - Wrap with `sew()` for error handling:

```ts
'use server';

import { sew } from "@/actions";
import { withAuthV2 } from "@/withAuthV2";

export const myProtectedAction = async ({ id }: { id: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
// user is guaranteed to be defined
// prisma is scoped to the user
return { success: true };
})
);

export const myPublicAction = async ({ id }: { id: string }) => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
// user may be undefined for anonymous access
return { success: true };
})
);
```

**API routes** - Check `isServiceError` and return `serviceErrorResponse`:

```ts
import { serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { withAuthV2 } from "@/withAuthV2";

export const GET = apiHandler(async (request: NextRequest) => {
const result = await withAuthV2(async ({ org, user, prisma }) => {
// ... your logic
return data;
});

if (isServiceError(result)) {
return serviceErrorResponse(result);
}

return Response.json(result);
});
```

## Branches and Pull Requests

When creating a branch or opening a PR, ask the user for:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
Warnings:

- You are about to drop the column `isReadonly` on the `Chat` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Chat" DROP COLUMN "isReadonly";
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Chat" ADD COLUMN "anonymousCreatorId" TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "ChatAccess" (
"id" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "ChatAccess_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "ChatAccess_chatId_userId_key" ON "ChatAccess"("chatId", "userId");

-- AddForeignKey
ALTER TABLE "ChatAccess" ADD CONSTRAINT "ChatAccess_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ChatAccess" ADD CONSTRAINT "ChatAccess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
25 changes: 22 additions & 3 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ model User {
apiKeys ApiKey[]

chats Chat[]
sharedChats ChatAccess[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -444,8 +445,9 @@ model Chat {

name String?

createdBy User? @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdById String?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdById String?
anonymousCreatorId String? // For anonymous users, stores a session ID from a cookie

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand All @@ -454,7 +456,24 @@ model Chat {
orgId Int

visibility ChatVisibility @default(PRIVATE)
isReadonly Boolean @default(false)

messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils.

sharedWith ChatAccess[]
}

/// Represents a user's access to a chat that has been shared with them.
/// Unlike Invite, this is not temporary or redeemable - it grants ongoing access.
model ChatAccess {
id String @id @default(cuid())

chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
chatId String

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String

createdAt DateTime @default(now())

@@unique([chatId, userId])
}
2 changes: 2 additions & 0 deletions packages/db/tools/scriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PrismaClient } from "@sourcebot/db";
import { ArgumentParser } from "argparse";
import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connections";
import { injectAuditData } from "./scripts/inject-audit-data";
import { injectUserData } from "./scripts/inject-user-data";
import { confirmAction } from "./utils";
import { injectRepoData } from "./scripts/inject-repo-data";
import { testRepoQueryPerf } from "./scripts/test-repo-query-perf";
Expand All @@ -13,6 +14,7 @@ export interface Script {
export const scripts: Record<string, Script> = {
"migrate-duplicate-connections": migrateDuplicateConnections,
"inject-audit-data": injectAuditData,
"inject-user-data": injectUserData,
"inject-repo-data": injectRepoData,
"test-repo-query-perf": testRepoQueryPerf,
}
Expand Down
99 changes: 99 additions & 0 deletions packages/db/tools/scripts/inject-user-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Script } from "../scriptRunner";
import { PrismaClient } from "../../dist";
import { confirmAction } from "../utils";

const mockUsers = [
{ name: "Alice Johnson", email: "alice.johnson@example.com" },
{ name: "Bob Smith", email: "bob.smith@example.com" },
{ name: "Charlie Brown", email: "charlie.brown@example.com" },
{ name: "Diana Prince", email: "diana.prince@example.com" },
{ name: "Ethan Hunt", email: "ethan.hunt@example.com" },
{ name: "Fiona Green", email: "fiona.green@example.com" },
{ name: "George Miller", email: "george.miller@example.com" },
{ name: "Hannah Lee", email: "hannah.lee@example.com" },
{ name: "Ivan Petrov", email: "ivan.petrov@example.com" },
{ name: "Julia Chen", email: "julia.chen@example.com" },
];

export const injectUserData: Script = {
run: async (prisma: PrismaClient) => {
const orgId = 1;

// Check if org exists
const org = await prisma.org.findUnique({
where: { id: orgId }
});

if (!org) {
console.error(`Organization with id ${orgId} not found. Please create it first.`);
return;
}

console.log(`Injecting ${mockUsers.length} mock users for organization: ${org.name} (${org.domain})`);

confirmAction();
Comment thread
brendan-kellam marked this conversation as resolved.

const createdUsers: { id: string; email: string | null; name: string | null }[] = [];

for (const mockUser of mockUsers) {
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email: mockUser.email }
});

if (existingUser) {
console.log(`User ${mockUser.email} already exists, skipping...`);
createdUsers.push(existingUser);
continue;
}

// Create the user
const user = await prisma.user.create({
data: {
name: mockUser.name,
email: mockUser.email,
}
});

console.log(`Created user: ${user.name} (${user.email})`);
createdUsers.push(user);
}

// Add users to the organization
for (const user of createdUsers) {
// Check if already a member
const existingMembership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId,
userId: user.id,
}
}
});

if (existingMembership) {
console.log(`User ${user.email} is already a member of the org, skipping...`);
continue;
}

await prisma.userToOrg.create({
data: {
orgId,
userId: user.id,
role: "MEMBER",
}
});

console.log(`Added ${user.email} to organization`);
}

console.log(`\nUser data injection complete!`);
console.log(`Total users created/found: ${createdUsers.length}`);

// Show org membership count
const memberCount = await prisma.userToOrg.count({
where: { orgId }
});
console.log(`Total org members: ${memberCount}`);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ interface ChatThreadPanelProps {
searchContexts: SearchContextQuery[];
order: number;
messages: SBChatMessage[];
isChatReadonly: boolean;
isOwner: boolean;
isAuthenticated: boolean;
chatName?: string;
}

export const ChatThreadPanel = ({
Expand All @@ -24,7 +26,9 @@ export const ChatThreadPanel = ({
searchContexts,
order,
messages,
isChatReadonly,
isOwner,
isAuthenticated,
chatName,
}: ChatThreadPanelProps) => {
// @note: we are guaranteed to have a chatId because this component will only be
// mounted when on a /chat/[id] route.
Expand Down Expand Up @@ -69,7 +73,9 @@ export const ChatThreadPanel = ({
searchContexts={searchContexts}
selectedSearchScopes={selectedSearchScopes}
onSelectedSearchScopesChange={setSelectedSearchScopes}
isChatReadonly={isChatReadonly}
isOwner={isOwner}
isAuthenticated={isAuthenticated}
chatName={chatName}
/>
</div>
</ResizablePanel>
Expand Down
Loading