Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Added chat duplication to create copies of existing chats. [#888](https://github.com/sourcebot-dev/sourcebot/pull/888)
- Added Open Graph metadata and image generation for shared chat links. [#888](https://github.com/sourcebot-dev/sourcebot/pull/888)
- [EE] Added chat sharing with specific users, allowing chat owners to invite org members to view private chats. [#888](https://github.com/sourcebot-dev/sourcebot/pull/888)

### Changed
- Changed chat permissions model from read-only flag to ownership-based access control. [#888](https://github.com/sourcebot-dev/sourcebot/pull/888)
- Improved anonymous chat experience: anonymous users can now create chats and claim them upon signing in. [#888](https://github.com/sourcebot-dev/sourcebot/pull/888)

## [4.10.30] - 2026-02-12

### Added
Expand Down
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}`);
},
};
Loading