Skip to content
Draft
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
7 changes: 7 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
86 changes: 85 additions & 1 deletion packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 };
});
5 changes: 4 additions & 1 deletion packages/web/src/ee/features/audit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof auditMetadataSchema>;

Expand All @@ -32,4 +35,4 @@ export type AuditEvent = z.infer<typeof auditEventSchema>;

export interface IAuditService {
createAudit(event: Omit<AuditEvent, 'sourcebotVersion'>): Promise<Audit | null>;
}
}
62 changes: 62 additions & 0 deletions packages/web/src/lib/anonymousSession.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string | null> {
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<void> {
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;
}
2 changes: 2 additions & 0 deletions packages/web/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";