Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
415 changes: 415 additions & 0 deletions docs/escalation_report.md

Large diffs are not rendered by default.

427 changes: 427 additions & 0 deletions docs/escalation_report_timestamp_issue.md

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions docs/features/WorkOSAuthentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,50 @@ export default authkitMiddleware({
- Verified all tables created successfully in Supabase
- Tested WorkOS authentication with PostgreSQL user sync
- See [`DATABASE_INTEGRATION.md`](./DATABASE_INTEGRATION.md) for complete database documentation

### v2.1.0 - API Auth Helper & Security Hardening (2024-12-14)
- **Created `src/lib/user-auth.ts`** - Reusable auth helper to eliminate N+1 queries
- Added `getAuthenticatedUser()` for combined WorkOS + DB user lookup
- Added `unauthorizedResponse()`, `forbiddenResponse()`, `notFoundResponse()` helpers
- Refactored `/api/conversations/[id]/route.ts` to use shared helper
- Added explicit 403 responses for ownership violations (PATCH/DELETE)

---

## 14. Auth Helper Reference

### `user-auth.ts` (`src/lib/user-auth.ts`)

**Purpose**: Eliminates N+1 user lookup patterns by combining WorkOS authentication and internal database user resolution in a single reusable helper.

#### Exports

| Export | Type | Description |
|--------|------|-------------|
| `getAuthenticatedUser()` | `async function` | Returns `AuthResult` or `null` |
| `unauthorizedResponse()` | `function` | 401 response |
| `forbiddenResponse(msg?)` | `function` | 403 response |
| `notFoundResponse(resource?)` | `function` | 404 response |

#### Usage Example

```typescript
import { getAuthenticatedUser, unauthorizedResponse, forbiddenResponse } from '@/lib/user-auth';

export async function GET(req: NextRequest) {
const authResult = await getAuthenticatedUser();

if (!authResult) {
return unauthorizedResponse();
}

const { internalUser } = authResult;
const data = await SomeService.getData(internalUser.id);

if (!data) {
return forbiddenResponse('Access denied');
}

return NextResponse.json(data);
}
```
73 changes: 67 additions & 6 deletions docs/features/john-gpt/StorageAndPersistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,40 @@ The system uses a three-tier storage strategy:
* **Benefit:** Instant load times, offline support, zero latency.
* **Library:** `idb` (via `IndexedDBClient`).

2. **Google Drive (Cloud Backup):**
2. **Neon PostgreSQL (Server DB):**
* **Role:** Primary cloud storage for conversations with API sync.
* **Benefit:** Cross-browser sync, persistent storage, real-time availability.
* **Manager:** `DBSyncManager` (`src/lib/storage/db-sync-manager.ts`)
* **Data:** Full conversation metadata and messages stored via Prisma.

3. **Google Drive (Optional Cloud Backup):**
* **Status:** Backend available but **UI disabled** (2025-12-15).
* **Role:** Long-term storage and cross-device sync.
* **Benefit:** User owns their data, accessible outside the app.
* **Format:** JSON files named `[AI Title] - [8-char ID].json`.
* **Library:** Custom `GoogleDriveClient` using Google Drive API v3.

3. **Neon DB (Metadata):**
* **Role:** Lightweight index for the conversation sidebar.
* **Benefit:** Fast listing of conversations without scanning Drive files.
* **Data:** `id`, `title`, `createdAt`, `updatedAt`, `driveFileId`.
## 2.1 Current Status (2025-12-15)

### ✅ Working Features

| Feature | Status | Notes |
|---------|--------|-------|
| **Sidebar loads all conversations** | ✅ Working | Lists all user conversations from DB |
| **Messages load correctly** | ✅ Working | Full message history displays when opening a conversation |
| **New conversations save to DB** | ✅ Working | Auto-syncs with 5s debounce |
| **IndexedDB caching** | ✅ Working | Instant loads, offline support |
| **Cross-browser sync** | ✅ Working | Conversations sync via Neon PostgreSQL |
| **AI title generation** | ✅ Working | Triggers after 6 messages |
| **Conversation deletion** | ✅ Working | Removes from DB and cache |
| **Offline queue** | ✅ Working | Pending syncs retry when online |

### ⚠️ Known Issues

| Issue | Severity | Ticket |
|-------|----------|--------|
| Viewing old conversations updates `updatedAt` timestamp | Low | See `docs/escalation_report_timestamp_issue.md` |
| Google Drive sync UI disabled | Info | Backend works, UI hidden from sidebar |

## 3. Key Components

Expand Down Expand Up @@ -104,8 +128,45 @@ const conversation = await syncManager.loadConversation(conversationId);
await syncManager.initializeGoogleDrive(userId);
```

## 7. Future Improvements
## 7. Message Schema Validation

The `MessagePartSchema` (`src/features/john-gpt/schema.ts`) validates AI SDK message parts before storage.

### Supported Part Types

| Type | Fields |
|------|--------|
| `text` | `text: string` |
| `image` | `image: string`, `mimeType?: string` |
| `file` | `data: string`, `mimeType: string` |
| `tool-call` | `toolCallId`, `toolName`, `args` |
| `tool-result` | `toolCallId`, `toolName`, `result`, `isError?` |
| `reasoning` | `reasoning: string` |
| `source` | `source: { sourceType, id, url?, title? }` |
| `step-start/finish` | *(no additional fields)* |

> **Note:** If new AI SDK part types are added, update the discriminated union in `schema.ts`.

---

## 8. Future Improvements

* **Conflict Resolution UI:** Allow users to choose versions if a conflict occurs.
* **Storage Quota:** Display Drive usage.
* **Export:** Download conversation as Markdown/PDF.

---

## 9. Change Log

| Date | Change |
|------|--------|
| 2025-12-15 | **FIX:** Sidebar now correctly displays all conversations by adding `userId` mismatch check in `refreshConversationList` |
| 2025-12-15 | **FIX:** Sync 400 Bad Request error resolved - Zod `.optional()` accepts `undefined` but not `null` |
| 2025-12-15 | **FIX:** Infinite refresh loop prevented with `isRefreshingList` flag |
| 2025-12-15 | **FIX:** Individual conversation loading now fetches from API if cache has 0 messages (metadata-only) |
| 2025-12-15 | Disabled Google Drive sync UI from sidebar (backend still available, UI hidden) |
| 2025-12-15 | **KNOWN ISSUE:** Viewing old conversations updates their `updatedAt` timestamp - see `docs/escalation_report_timestamp_issue.md` |
| 2024-12-14 | Hardened `MessagePartSchema` with explicit discriminated union (was `.passthrough()`) |
| 2024-12-14 | Added ownership checks (403 responses) to `/api/conversations/[id]` |

64 changes: 42 additions & 22 deletions src/app/api/conversations/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,31 @@ import { NextRequest, NextResponse } from 'next/server';
import { ConversationService } from '@/features/john-gpt/services/conversation.service';
import { UpdateConversationSchema } from '@/features/john-gpt/schema';
import { z } from 'zod';
import { withAuth } from '@workos-inc/authkit-nextjs';
import {
getAuthenticatedUser,
unauthorizedResponse,
notFoundResponse,
forbiddenResponse
} from '@/lib/user-auth';

export async function GET(
req: NextRequest,
props: { params: Promise<{ id: string }> }
) {
const { params } = props;
const { user } = await withAuth();
const { id } = await params;
const authResult = await getAuthenticatedUser();
const { id } = await props.params;

if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (!authResult) {
return unauthorizedResponse();
}

const { internalUser } = authResult;

try {
const conversation = await ConversationService.getConversation(id, user.id);
const conversation = await ConversationService.getConversation(id, internalUser.id);

if (!conversation) {
return NextResponse.json({ error: 'Not Found' }, { status: 404 });
return notFoundResponse('Conversation');
}

return NextResponse.json(conversation);
Expand All @@ -34,25 +40,32 @@ export async function PATCH(
req: NextRequest,
props: { params: Promise<{ id: string }> }
) {
const { params } = props;
const { user } = await withAuth();
const { id } = await params;
const authResult = await getAuthenticatedUser();
const { id } = await props.params;

if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (!authResult) {
return unauthorizedResponse();
}

const { internalUser } = authResult;

try {
const body = await req.json();
const data = UpdateConversationSchema.parse(body);

const conversation = await ConversationService.updateConversation(id, user.id, data);
const conversation = await ConversationService.updateConversation(id, internalUser.id, data);
return NextResponse.json(conversation);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Validation Error', details: (error as any).errors || (error as any).issues }, { status: 400 });
console.error('[API] Zod validation error:', JSON.stringify(error.issues, null, 2));
return NextResponse.json({ error: 'Validation Error', details: error.issues }, { status: 400 });
}
// Handle Prisma "Record not found" error - could be 404 (not exists) or 403 (wrong owner)
// Since our WHERE clause includes userId, P2025 means either the conversation doesn't exist
// or it belongs to a different user. We return 403 to be safe (assumes ID is valid format).
if ((error as any)?.code === 'P2025') {
return forbiddenResponse('Conversation not found or access denied');
}
// Handle specific Prisma errors like "Record not found" if needed
console.error('Failed to update conversation:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
Expand All @@ -62,18 +75,25 @@ export async function DELETE(
req: NextRequest,
props: { params: Promise<{ id: string }> }
) {
const { params } = props;
const { user } = await withAuth();
const { id } = await params;
const authResult = await getAuthenticatedUser();
const { id } = await props.params;

if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (!authResult) {
return unauthorizedResponse();
}

const { internalUser } = authResult;

try {
await ConversationService.deleteConversation(id, user.id);
await ConversationService.deleteConversation(id, internalUser.id);
return NextResponse.json({ success: true });
} catch (error) {
// Handle Prisma "Record not found" error - could be 404 or 403
// Since our WHERE clause includes userId, P2025 means either the conversation doesn't exist
// or it belongs to a different user. We return 403 to be safe.
if ((error as any)?.code === 'P2025') {
return forbiddenResponse('Conversation not found or access denied');
}
console.error('Failed to delete conversation:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
Expand Down
23 changes: 21 additions & 2 deletions src/app/api/conversations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ConversationService } from '@/features/john-gpt/services/conversation.s
import { CreateConversationSchema } from '@/features/john-gpt/schema';
import { z } from 'zod';
import { withAuth } from '@workos-inc/authkit-nextjs';
import { prisma } from '@/lib/prisma';

export async function GET(req: NextRequest) {
const { user } = await withAuth();
Expand All @@ -11,8 +12,17 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const internalUser = await prisma.user.findUnique({
where: { workosId: user.id },
select: { id: true },
});

if (!internalUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}

try {
const conversations = await ConversationService.listConversations(user.id);
const conversations = await ConversationService.listConversations(internalUser.id);
return NextResponse.json(conversations);
} catch (error) {
console.error('Failed to list conversations:', error);
Expand All @@ -27,12 +37,21 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const internalUser = await prisma.user.findUnique({
where: { workosId: user.id },
select: { id: true },
});

if (!internalUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}

try {
const body = await req.json();
// Allow ID to be passed in body for client-side generation
const data = CreateConversationSchema.extend({ id: z.string().optional() }).parse(body);

const conversation = await ConversationService.createConversation(user.id, data);
const conversation = await ConversationService.createConversation(internalUser.id, data);
return NextResponse.json(conversation);
} catch (error) {
if (error instanceof z.ZodError) {
Expand Down
6 changes: 3 additions & 3 deletions src/features/john-gpt/components/ConversationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,8 @@ export function ConversationSidebar({ user, isDriveConnected, className, activeC
</button>
</div>

{/* Drive Connection Status */}
{!isDriveConnected ? (
{/* Google Drive sync disabled - not currently used */}
{/* {!isDriveConnected ? (
<div className="px-4 pt-4">
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-xl">
<p className="text-xs text-blue-600 dark:text-blue-400 mb-2 font-medium">
Expand Down Expand Up @@ -285,7 +285,7 @@ export function ConversationSidebar({ user, isDriveConnected, className, activeC
</button>
</div>
</div>
)}
)} */}

{/* Search */}
<div className="px-4 pt-4 pb-2">
Expand Down
23 changes: 21 additions & 2 deletions src/features/john-gpt/hooks/useBranchingChat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { useChat, UIMessage } from '@ai-sdk/react';
import { useRouter, usePathname } from 'next/navigation';
import { dbSyncManager } from '@/lib/storage/db-sync-manager';
Expand Down Expand Up @@ -45,6 +45,11 @@ export function useBranchingChat(options: UseBranchingChatOptions = {}) {
const [tree, setTree] = useState<MessageTree>({});
const [headId, setHeadId] = useState<string | null>(null);

// Track if messages were just loaded externally (not user-modified)
// This prevents saving (and updating timestamp) when simply viewing a conversation
// We store the COUNT of loaded messages - only save when new messages are ADDED
const loadedMessageCountRef = useRef<number | null>(null);

// Track the "active" child for each node to restore history correctly when navigating back
const [activePathMap, setActivePathMap] = useState<Record<string, string>>({});

Expand Down Expand Up @@ -115,7 +120,15 @@ export function useBranchingChat(options: UseBranchingChatOptions = {}) {
},
}) as any;

const { messages, setMessages, sendMessage } = chatHelpers;
const { messages, setMessages: originalSetMessages, sendMessage } = chatHelpers;

// Wrap setMessages to track when messages are loaded externally vs user-modified
// This prevents saving (and updating timestamp) when simply viewing a conversation
const setMessages = useCallback((msgs: any) => {
// Store the count of externally loaded messages
loadedMessageCountRef.current = Array.isArray(msgs) ? msgs.length : 0;
originalSetMessages(msgs);
}, [originalSetMessages]);

// 🚀 Dynamic model selection wrapper
// useChat memoizes `body` at init, so we pass modelId per-request via sendMessage options
Expand Down Expand Up @@ -245,6 +258,12 @@ export function useBranchingChat(options: UseBranchingChatOptions = {}) {
// Only save if we have a conversation ID and user ID and messages
if (!conversationId || !userId || messages.length === 0) return;

// Skip save if messages count matches what was loaded (no new messages added)
// This prevents updating timestamp when simply viewing a conversation
if (loadedMessageCountRef.current !== null && messages.length <= loadedMessageCountRef.current) {
return;
}

// Skip save during active streaming - only save when streaming completes
// This prevents constant saves during token-by-token updates
const currentStatus = chatHelpers.status;
Expand Down
Loading