Skip to content

Commit 661ece2

Browse files
Add invite users menu to share panel
1 parent ea86117 commit 661ece2

File tree

15 files changed

+1159
-161
lines changed

15 files changed

+1159
-161
lines changed

CLAUDE.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ To build a specific package:
1818
yarn workspace @sourcebot/<package-name> build
1919
```
2020

21+
## File Naming
22+
23+
Files should use camelCase starting with a lowercase letter:
24+
25+
```
26+
// Correct
27+
shareChatPopover.tsx
28+
userAvatar.tsx
29+
apiClient.ts
30+
31+
// Incorrect
32+
ShareChatPopover.tsx
33+
UserAvatar.tsx
34+
share-chat-popover.tsx
35+
```
36+
37+
Exceptions:
38+
- Special files like `README.md`, `CHANGELOG.md`, `LICENSE`
39+
- Next.js conventions: `page.tsx`, `layout.tsx`, `loading.tsx`, etc.
40+
2141
## Tailwind CSS
2242

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

53+
## API Route Handlers
54+
55+
Route handlers should validate inputs using Zod schemas.
56+
57+
**Query parameters** (GET requests):
58+
59+
```ts
60+
import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
61+
import { z } from "zod";
62+
63+
const myQueryParamsSchema = z.object({
64+
q: z.string().default(''),
65+
page: z.coerce.number().int().positive().default(1),
66+
});
67+
68+
export const GET = apiHandler(async (request: NextRequest) => {
69+
const rawParams = Object.fromEntries(
70+
Object.keys(myQueryParamsSchema.shape).map(key => [
71+
key,
72+
request.nextUrl.searchParams.get(key) ?? undefined
73+
])
74+
);
75+
const parsed = myQueryParamsSchema.safeParse(rawParams);
76+
77+
if (!parsed.success) {
78+
return serviceErrorResponse(
79+
queryParamsSchemaValidationError(parsed.error)
80+
);
81+
}
82+
83+
const { q, page } = parsed.data;
84+
// ... rest of handler
85+
});
86+
```
87+
88+
**Request body** (POST/PUT/PATCH requests):
89+
90+
```ts
91+
import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
92+
import { z } from "zod";
93+
94+
const myRequestBodySchema = z.object({
95+
name: z.string(),
96+
count: z.number().optional(),
97+
});
98+
99+
export const POST = apiHandler(async (request: NextRequest) => {
100+
const body = await request.json();
101+
const parsed = myRequestBodySchema.safeParse(body);
102+
103+
if (!parsed.success) {
104+
return serviceErrorResponse(
105+
requestBodySchemaValidationError(parsed.error)
106+
);
107+
}
108+
109+
const { name, count } = parsed.data;
110+
// ... rest of handler
111+
});
112+
```
113+
114+
## Data Fetching
115+
116+
For GET requests, prefer using API routes with react-query over server actions. This provides caching benefits and better control over data refetching.
117+
118+
```tsx
119+
// Preferred: API route + react-query
120+
import { useQuery } from "@tanstack/react-query";
121+
122+
const { data, isLoading } = useQuery({
123+
queryKey: ["items", id],
124+
queryFn: () => fetch(`/api/items/${id}`).then(res => res.json()),
125+
});
126+
```
127+
128+
Server actions should be used for mutations (POST/PUT/DELETE operations), not for data fetching.
129+
130+
## Authentication
131+
132+
Use `withAuthV2` or `withOptionalAuthV2` from `@/withAuthV2` to protect server actions and API routes.
133+
134+
- **`withAuthV2`** - Requires authentication. Returns `notAuthenticated()` if user is not logged in.
135+
- **`withOptionalAuthV2`** - Allows anonymous access if the org has anonymous access enabled. `user` may be `undefined`.
136+
- **`withMinimumOrgRole`** - Wrap inside auth context to require a minimum role (e.g., `OrgRole.OWNER`).
137+
138+
**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.
139+
140+
**Server actions** - Wrap with `sew()` for error handling:
141+
142+
```ts
143+
'use server';
144+
145+
import { sew } from "@/actions";
146+
import { withAuthV2 } from "@/withAuthV2";
147+
148+
export const myProtectedAction = async ({ id }: { id: string }) => sew(() =>
149+
withAuthV2(async ({ org, user, prisma }) => {
150+
// user is guaranteed to be defined
151+
// prisma is scoped to the user
152+
return { success: true };
153+
})
154+
);
155+
156+
export const myPublicAction = async ({ id }: { id: string }) => sew(() =>
157+
withOptionalAuthV2(async ({ org, user, prisma }) => {
158+
// user may be undefined for anonymous access
159+
return { success: true };
160+
})
161+
);
162+
```
163+
164+
**API routes** - Check `isServiceError` and return `serviceErrorResponse`:
165+
166+
```ts
167+
import { serviceErrorResponse } from "@/lib/serviceError";
168+
import { isServiceError } from "@/lib/utils";
169+
import { withAuthV2 } from "@/withAuthV2";
170+
171+
export const GET = apiHandler(async (request: NextRequest) => {
172+
const result = await withAuthV2(async ({ org, user, prisma }) => {
173+
// ... your logic
174+
return data;
175+
});
176+
177+
if (isServiceError(result)) {
178+
return serviceErrorResponse(result);
179+
}
180+
181+
return Response.json(result);
182+
});
183+
```
184+
33185
## Branches and Pull Requests
34186

35187
When creating a branch or opening a PR, ask the user for:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- CreateTable
2+
CREATE TABLE "ChatAccess" (
3+
"id" TEXT NOT NULL,
4+
"chatId" TEXT NOT NULL,
5+
"userId" TEXT NOT NULL,
6+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
8+
CONSTRAINT "ChatAccess_pkey" PRIMARY KEY ("id")
9+
);
10+
11+
-- CreateIndex
12+
CREATE UNIQUE INDEX "ChatAccess_chatId_userId_key" ON "ChatAccess"("chatId", "userId");
13+
14+
-- AddForeignKey
15+
ALTER TABLE "ChatAccess" ADD CONSTRAINT "ChatAccess_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
16+
17+
-- AddForeignKey
18+
ALTER TABLE "ChatAccess" ADD CONSTRAINT "ChatAccess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ model User {
363363
apiKeys ApiKey[]
364364
365365
chats Chat[]
366+
sharedChats ChatAccess[]
366367
367368
createdAt DateTime @default(now())
368369
updatedAt DateTime @updatedAt
@@ -457,4 +458,22 @@ model Chat {
457458
visibility ChatVisibility @default(PRIVATE)
458459
459460
messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils.
461+
462+
sharedWith ChatAccess[]
463+
}
464+
465+
/// Represents a user's access to a chat that has been shared with them.
466+
/// Unlike Invite, this is not temporary or redeemable - it grants ongoing access.
467+
model ChatAccess {
468+
id String @id @default(cuid())
469+
470+
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
471+
chatId String
472+
473+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
474+
userId String
475+
476+
createdAt DateTime @default(now())
477+
478+
@@unique([chatId, userId])
460479
}

packages/db/tools/scriptRunner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PrismaClient } from "@sourcebot/db";
22
import { ArgumentParser } from "argparse";
33
import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connections";
44
import { injectAuditData } from "./scripts/inject-audit-data";
5+
import { injectUserData } from "./scripts/inject-user-data";
56
import { confirmAction } from "./utils";
67
import { injectRepoData } from "./scripts/inject-repo-data";
78
import { testRepoQueryPerf } from "./scripts/test-repo-query-perf";
@@ -13,6 +14,7 @@ export interface Script {
1314
export const scripts: Record<string, Script> = {
1415
"migrate-duplicate-connections": migrateDuplicateConnections,
1516
"inject-audit-data": injectAuditData,
17+
"inject-user-data": injectUserData,
1618
"inject-repo-data": injectRepoData,
1719
"test-repo-query-perf": testRepoQueryPerf,
1820
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Script } from "../scriptRunner";
2+
import { PrismaClient } from "../../dist";
3+
import { confirmAction } from "../utils";
4+
5+
const mockUsers = [
6+
{ name: "Alice Johnson", email: "alice.johnson@example.com" },
7+
{ name: "Bob Smith", email: "bob.smith@example.com" },
8+
{ name: "Charlie Brown", email: "charlie.brown@example.com" },
9+
{ name: "Diana Prince", email: "diana.prince@example.com" },
10+
{ name: "Ethan Hunt", email: "ethan.hunt@example.com" },
11+
{ name: "Fiona Green", email: "fiona.green@example.com" },
12+
{ name: "George Miller", email: "george.miller@example.com" },
13+
{ name: "Hannah Lee", email: "hannah.lee@example.com" },
14+
{ name: "Ivan Petrov", email: "ivan.petrov@example.com" },
15+
{ name: "Julia Chen", email: "julia.chen@example.com" },
16+
];
17+
18+
export const injectUserData: Script = {
19+
run: async (prisma: PrismaClient) => {
20+
const orgId = 1;
21+
22+
// Check if org exists
23+
const org = await prisma.org.findUnique({
24+
where: { id: orgId }
25+
});
26+
27+
if (!org) {
28+
console.error(`Organization with id ${orgId} not found. Please create it first.`);
29+
return;
30+
}
31+
32+
console.log(`Injecting ${mockUsers.length} mock users for organization: ${org.name} (${org.domain})`);
33+
34+
confirmAction();
35+
36+
const createdUsers: { id: string; email: string | null; name: string | null }[] = [];
37+
38+
for (const mockUser of mockUsers) {
39+
// Check if user already exists
40+
const existingUser = await prisma.user.findUnique({
41+
where: { email: mockUser.email }
42+
});
43+
44+
if (existingUser) {
45+
console.log(`User ${mockUser.email} already exists, skipping...`);
46+
createdUsers.push(existingUser);
47+
continue;
48+
}
49+
50+
// Create the user
51+
const user = await prisma.user.create({
52+
data: {
53+
name: mockUser.name,
54+
email: mockUser.email,
55+
}
56+
});
57+
58+
console.log(`Created user: ${user.name} (${user.email})`);
59+
createdUsers.push(user);
60+
}
61+
62+
// Add users to the organization
63+
for (const user of createdUsers) {
64+
// Check if already a member
65+
const existingMembership = await prisma.userToOrg.findUnique({
66+
where: {
67+
orgId_userId: {
68+
orgId,
69+
userId: user.id,
70+
}
71+
}
72+
});
73+
74+
if (existingMembership) {
75+
console.log(`User ${user.email} is already a member of the org, skipping...`);
76+
continue;
77+
}
78+
79+
await prisma.userToOrg.create({
80+
data: {
81+
orgId,
82+
userId: user.id,
83+
role: "MEMBER",
84+
}
85+
});
86+
87+
console.log(`Added ${user.email} to organization`);
88+
}
89+
90+
console.log(`\nUser data injection complete!`);
91+
console.log(`Total users created/found: ${createdUsers.length}`);
92+
93+
// Show org membership count
94+
const memberCount = await prisma.userToOrg.count({
95+
where: { orgId }
96+
});
97+
console.log(`Total org members: ${memberCount}`);
98+
},
99+
};

packages/web/src/app/[domain]/chat/[id]/page.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getRepos, getSearchContexts } from '@/actions';
2-
import { getUserChatHistory, getConfiguredLanguageModelsInfo, getChatInfo, claimAnonymousChats } from '@/features/chat/actions';
2+
import { getUserChatHistory, getConfiguredLanguageModelsInfo, getChatInfo, claimAnonymousChats, getSharedWithUsersForChat } from '@/features/chat/actions';
33
import { ServiceErrorException } from '@/lib/serviceError';
44
import { isServiceError } from '@/lib/utils';
55
import { ChatThreadPanel } from './components/chatThreadPanel';
@@ -117,6 +117,13 @@ export default async function Page(props: PageProps) {
117117

118118
const { messages, name, visibility, isOwner } = chatInfo;
119119

120+
const sharedWithUsers = (session && isOwner) ? await getSharedWithUsersForChat({ chatId: params.id }) : [];
121+
122+
if (isServiceError(sharedWithUsers)) {
123+
throw new ServiceErrorException(sharedWithUsers);
124+
}
125+
126+
120127
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
121128

122129
return (
@@ -132,7 +139,14 @@ export default async function Page(props: PageProps) {
132139
isOwner={isOwner}
133140
/>
134141
}
135-
actions={isOwner ? <ShareChatPopover chatId={params.id} visibility={visibility} isAuthenticated={!!session} /> : undefined}
142+
actions={isOwner ? (
143+
<ShareChatPopover
144+
chatId={params.id}
145+
visibility={visibility}
146+
currentUser={session?.user}
147+
sharedWithUsers={sharedWithUsers}
148+
/>
149+
) : undefined}
136150
/>
137151
<ResizablePanelGroup
138152
direction="horizontal"

0 commit comments

Comments
 (0)