From 1062228589732c614ae8957f20152e9bccb73625 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Fri, 16 Jan 2026 01:02:22 +0530 Subject: [PATCH 1/2] feat: create session constant and helper functions for anonymous session --- packages/db/prisma/schema.prisma | 7 +++ packages/web/src/lib/anonymousSession.ts | 62 ++++++++++++++++++++++++ packages/web/src/lib/constants.ts | 2 + 3 files changed, 71 insertions(+) create mode 100644 packages/web/src/lib/anonymousSession.ts diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index c700e242b..2ac92a332 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -451,4 +451,11 @@ model Chat { isReadonly Boolean @default(false) messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils. + + // Temporary ID for chats created by anonymous users before they sign in + /// Use to migrate chats when anonymous users authenticate + anonSessionId String? + + @@index([anonSessionId]) + @@index([createdById, anonSessionId]) } diff --git a/packages/web/src/lib/anonymousSession.ts b/packages/web/src/lib/anonymousSession.ts new file mode 100644 index 000000000..412446a23 --- /dev/null +++ b/packages/web/src/lib/anonymousSession.ts @@ -0,0 +1,62 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { ANONYMOUS_SESSION_ID_COOKIE_NAME } from './constants'; +import { createLogger } from '@sourcebot/shared'; + +const logger = createLogger('anonymous-session'); + + + +// This ID is used to track chats created before authentication so they can be migrated when the user signs in. It returns A stable UUID that persists across browser sessions +export async function getOrCreateAnonymousSessionId(): Promise { + const cookieStore = await cookies(); + let sessionId = cookieStore.get(ANONYMOUS_SESSION_ID_COOKIE_NAME)?.value; + + if (!sessionId) { + sessionId = crypto.randomUUID(); + + cookieStore.set(ANONYMOUS_SESSION_ID_COOKIE_NAME, sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24, + path: '/', + }); + + logger.info(`Created new anonymous session ID: ${sessionId}`); + } + + return sessionId; +} + +// Gets the current anonymous session ID if it exists. Does not create a new one if it doesn't exist. +export async function getAnonymousSessionId(): Promise { + const cookieStore = await cookies(); + return cookieStore.get(ANONYMOUS_SESSION_ID_COOKIE_NAME)?.value ?? null; +} + + + +// Should be called after migrating anonymous chats to an authenticated user. +export async function clearAnonymousSessionId(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(ANONYMOUS_SESSION_ID_COOKIE_NAME); + logger.info('Cleared anonymous session ID'); +} + + +export function getAnonymousSessionIdFromCookie(): string | null { + if (typeof document === 'undefined') { + return null; + } + + const cookies = document.cookie.split('; '); + const cookie = cookies.find(c => c.startsWith(`${ANONYMOUS_SESSION_ID_COOKIE_NAME}=`)); + + if (!cookie) { + return null; + } + + return cookie.split('=')[1] || null; +} \ No newline at end of file diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts index 21bb97d54..b5be4cc92 100644 --- a/packages/web/src/lib/constants.ts +++ b/packages/web/src/lib/constants.ts @@ -34,4 +34,6 @@ export const SINGLE_TENANT_ORG_ID = 1; export const SINGLE_TENANT_ORG_DOMAIN = '~'; export const SINGLE_TENANT_ORG_NAME = 'default'; +export const ANONYMOUS_SESSION_ID_COOKIE_NAME = 'sourcebot_anon_session_id'; + export { SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared/client"; \ No newline at end of file From de08f1288f44e1ce121dc17f7307cca0510481f1 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 17 Jan 2026 21:23:26 +0530 Subject: [PATCH 2/2] add anonymous chat migration server action --- packages/web/src/actions.ts | 86 ++++++++++++++++++++- packages/web/src/ee/features/audit/types.ts | 5 +- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 056a5fd6d..40d9c8392 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -27,10 +27,11 @@ import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe"; import InviteUserEmail from "./emails/inviteUserEmail"; import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; -import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; +import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { ApiKeyPayload, TenancyMode } from "./lib/types"; import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2"; +import { getAnonymousSessionId, clearAnonymousSessionId } from './lib/anonymousSession'; const logger = createLogger('web-actions'); const auditService = getAuditService(); @@ -1813,3 +1814,86 @@ export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => cookieStore.set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true'); return true; }); + + + + +export const migrateAnonymousChats = async (): Promise<{ success: boolean; migratedCount: number } | ServiceError> => sew(() => + withAuth(async (userId) => { + const anonSessionId = await getAnonymousSessionId(); + + if (!anonSessionId) { + logger.info(`No anonymous session ID found for user ${userId}. Skipping migration.`); + return { success: true, migratedCount: 0 }; + } + + logger.info(`Attempting to migrate anonymous chats for session ${anonSessionId} to user ${userId}`); + + try { + + const result = await prisma.chat.updateMany({ + where: { + anonSessionId: anonSessionId, + createdById: SOURCEBOT_GUEST_USER_ID, + }, + data: { + createdById: userId, + anonSessionId: null, + } + }); + + logger.info(`Migrated ${result.count} anonymous chats for user ${userId}`); + + await clearAnonymousSessionId(); + + await auditService.createAudit({ + action: "user.anonymous_chats_migrated", + actor: { + id: userId, + type: "user" + }, + target: { + id: userId, + type: "user" + }, + orgId: SINGLE_TENANT_ORG_ID, + metadata: { + migratedCount: result.count, + anonSessionId: anonSessionId, + } + }); + + return { + success: true, + migratedCount: result.count, + }; + } catch (error) { + logger.error(`Failed to migrate anonymous chats for user ${userId}:`, error); + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.UNEXPECTED_ERROR, + message: "Failed to migrate anonymous chats", + } satisfies ServiceError; + } + }) +); + + + +// Gets the count of anonymous chats for the current browser session. Used to show UI prompts encouraging sign-in. +export const getAnonymousChatsCount = async (): Promise<{ count: number } | ServiceError> => sew(async () => { + const anonSessionId = await getAnonymousSessionId(); + + if (!anonSessionId) { + return { count: 0 }; + } + + const count = await prisma.chat.count({ + where: { + anonSessionId: anonSessionId, + createdById: SOURCEBOT_GUEST_USER_ID, + } + }); + + return { count }; +}); diff --git a/packages/web/src/ee/features/audit/types.ts b/packages/web/src/ee/features/audit/types.ts index 5cdf02ef3..42f0a70e0 100644 --- a/packages/web/src/ee/features/audit/types.ts +++ b/packages/web/src/ee/features/audit/types.ts @@ -17,6 +17,9 @@ export const auditMetadataSchema = z.object({ message: z.string().optional(), api_key: z.string().optional(), emails: z.string().optional(), // comma separated list of emails + // Anonymous chat migration fields + migratedCount: z.number().optional(), + anonSessionId: z.string().optional(), }) export type AuditMetadata = z.infer; @@ -32,4 +35,4 @@ export type AuditEvent = z.infer; export interface IAuditService { createAudit(event: Omit): Promise; -} \ No newline at end of file +} \ No newline at end of file