diff --git a/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/handlers/run.ts b/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/handlers/run.ts index dfeada8f9..10fa33fa1 100644 --- a/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/handlers/run.ts +++ b/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/handlers/run.ts @@ -25,16 +25,13 @@ export const runHandler = factory.createHandlers( async (stream) => { const { projectId, commitUuid } = c.req.param() const { documentPath, parameters, source } = c.req.valid('json') - const workspace = c.get('workspace') - const { document, commit } = await getData({ workspace, projectId: Number(projectId!), commitUuid: commitUuid!, documentPath: documentPath!, }) - const result = await runDocumentAtCommit({ workspace, document, diff --git a/apps/web/src/actions/documents/destroyDocumentAction/index.test.ts b/apps/web/src/actions/documents/destroyDocumentAction/index.test.ts index be1f2279e..cd1020825 100644 --- a/apps/web/src/actions/documents/destroyDocumentAction/index.test.ts +++ b/apps/web/src/actions/documents/destroyDocumentAction/index.test.ts @@ -31,6 +31,7 @@ let merged: Commit let document: DocumentVersion let project: Project let userData: User + describe('destroyDocumentAction', async () => { beforeEach(async () => { const { @@ -44,6 +45,7 @@ describe('destroyDocumentAction', async () => { doc1: helpers.createPrompt({ provider: 'openai', content: 'Doc 1' }), }, }) + const { commit } = await createDraft({ project: prj, user }) merged = cmt userData = user diff --git a/apps/web/src/helpers/captureException.ts b/apps/web/src/helpers/captureException.ts new file mode 100644 index 000000000..ce03e2d3c --- /dev/null +++ b/apps/web/src/helpers/captureException.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/nextjs' +import env from '$/env' + +export const captureException = (error: Error) => { + if (env.NODE_ENV === 'production') { + Sentry.captureException(error) + } else { + console.error(error) + } +} diff --git a/apps/web/src/services/user/setupService.test.ts b/apps/web/src/services/user/setupService.test.ts new file mode 100644 index 000000000..d0c887eb7 --- /dev/null +++ b/apps/web/src/services/user/setupService.test.ts @@ -0,0 +1,126 @@ +import { Providers } from '@latitude-data/core/browser' +import { database } from '@latitude-data/core/client' +import { createProject, helpers } from '@latitude-data/core/factories' +import { + apiKeys, + commits, + documentVersions, + memberships, + projects, + providerApiKeys, + users, + workspaces, +} from '@latitude-data/core/schema' +import { env } from '@latitude-data/env' +import { eq } from 'drizzle-orm' +import { describe, expect, it, vi } from 'vitest' + +import setupService from './setupService' + +describe('setupService', () => { + it('should create all necessary entities when calling setup service', async () => { + const result = await setupService({ + email: 'test@example.com', + name: 'Test User', + companyName: 'Test Company', + }) + + expect(result.error).toBeUndefined() + expect(result.value).toBeDefined() + expect(result.value?.user).toBeDefined() + expect(result.value?.workspace).toBeDefined() + + const { user, workspace } = result.value! + + // Check user creation + const createdUser = await database.query.users.findFirst({ + // @ts-expect-error - drizzle-orm types are not up to date + where: eq(users.id, user.id), + }) + expect(createdUser).toBeDefined() + expect(createdUser?.email).toBe('test@example.com') + expect(createdUser?.name).toBe('Test User') + + // Check workspace creation + const createdWorkspace = await database.query.workspaces.findFirst({ + // @ts-expect-error - drizzle-orm types are not up to date + where: eq(workspaces.id, workspace.id), + }) + expect(createdWorkspace).toBeDefined() + expect(createdWorkspace?.name).toBe('Test Company') + + // Check membership creation + const createdMembership = await database.query.memberships.findFirst({ + // @ts-expect-error - drizzle-orm types are not up to date + where: eq(memberships.userId, user.id), + }) + expect(createdMembership).toBeDefined() + expect(createdMembership?.workspaceId).toBe(workspace.id) + + // Check API key creation + const createdApiKey = await database.query.apiKeys.findFirst({ + // @ts-expect-error - drizzle-orm types are not up to date + where: eq(apiKeys.workspaceId, workspace.id), + }) + expect(createdApiKey).toBeDefined() + + // Check provider API key creation + const createdProviderApiKey = + await database.query.providerApiKeys.findFirst({ + // @ts-expect-error - drizzle-orm types are not up to date + where: eq(providerApiKeys.workspaceId, workspace.id), + }) + expect(createdProviderApiKey).toBeDefined() + expect(createdProviderApiKey?.authorId).toBe(user.id) + }) + + it('should import the default project when calling setup service', async () => { + const prompt = helpers.createPrompt({ + provider: 'Latitude', + model: 'gpt-4o', + }) + const { project } = await createProject({ + providers: [{ type: Providers.OpenAI, name: 'Latitude' }], + name: 'Default Project', + documents: { + foo: { + content: prompt, + }, + }, + }) + + vi.mocked(env).DEFAULT_PROJECT_ID = project.id + + const result = await setupService({ + email: 'test2@example.com', + name: 'Test User 2', + companyName: 'Test Company 2', + }) + + expect(result.error).toBeUndefined() + expect(result.value).toBeDefined() + expect(result.value?.user).toBeDefined() + expect(result.value?.workspace).toBeDefined() + + const { workspace } = result.value! + + // Check if the default project was imported + const importedProject = await database.query.projects.findFirst({ + // @ts-expect-error - drizzle-orm types are not up to date + where: eq(projects.workspaceId, workspace.id), + }) + expect(importedProject).toBeDefined() + expect(importedProject?.name).toBe('Default Project') + + // Check if the documents were imported + const importedDocuments = await database + .select() + .from(documentVersions) + // @ts-expect-error - drizzle-orm types are not up to date + .innerJoin(commits, eq(commits.id, documentVersions.commitId)) + // @ts-expect-error - drizzle-orm types are not up to date + .where(eq(commits.projectId, importedProject!.id)) + expect(importedDocuments.length).toBe(1) + expect(importedDocuments[0]!.document_versions.content).toEqual(prompt) + }) +}) diff --git a/apps/web/src/services/user/setupService.ts b/apps/web/src/services/user/setupService.ts index 51673d21b..a077bb575 100644 --- a/apps/web/src/services/user/setupService.ts +++ b/apps/web/src/services/user/setupService.ts @@ -4,10 +4,14 @@ import { Result } from '@latitude-data/core/lib/Result' import Transaction, { PromisedResult, } from '@latitude-data/core/lib/Transaction' +import { createApiKey } from '@latitude-data/core/services/apiKeys/create' +import { createMembership } from '@latitude-data/core/services/memberships/create' +import { importDefaultProject } from '@latitude-data/core/services/projects/import' import { createProviderApiKey } from '@latitude-data/core/services/providerApiKeys/create' import { createUser } from '@latitude-data/core/services/users/createUser' import { createWorkspace } from '@latitude-data/core/services/workspaces/create' import { env } from '@latitude-data/env' +import { captureException } from '$/helpers/captureException' export default function setupService({ email, @@ -27,28 +31,41 @@ export default function setupService({ if (userResult.error) return userResult const user = userResult.value - const result = await createWorkspace( + const resultWorkspace = await createWorkspace( { name: companyName, user, }, tx, ) + if (resultWorkspace.error) return resultWorkspace + const workspace = resultWorkspace.value - if (result.error) return result - - const workspace = result.value - const resultProviderApiKey = await createProviderApiKey( - { - workspace, - provider: Providers.OpenAI, - name: env.DEFAULT_PROVIDER_ID, - token: env.DEFAULT_PROVIDER_API_KEY, - authorId: user.id, - }, + const resultImportingDefaultProject = await importDefaultProject( + { workspace, user }, tx, ) - if (resultProviderApiKey.error) return resultProviderApiKey + if (resultImportingDefaultProject.error) { + captureException(resultImportingDefaultProject.error) + } + + const results = await Promise.all([ + createMembership({ confirmedAt: new Date(), user, workspace }, tx), + createApiKey({ workspace }, tx), + createProviderApiKey( + { + workspace, + provider: Providers.OpenAI, + name: env.DEFAULT_PROVIDER_ID, + token: env.DEFAULT_PROVIDER_API_KEY, + authorId: user.id, + }, + tx, + ), + ]) + + const result = Result.findError(results) + if (result) return result return Result.ok({ user, diff --git a/packages/core/src/events/handlers/index.ts b/packages/core/src/events/handlers/index.ts index 781c38870..2dc8b0e48 100644 --- a/packages/core/src/events/handlers/index.ts +++ b/packages/core/src/events/handlers/index.ts @@ -1,11 +1,14 @@ import { ChainCallResponse, + Commit, LogSources, MagicLinkToken, Membership, Message, + Project, ProviderLog, User, + Workspace, } from '../../browser' import { PartialConfig } from '../../services/ai' import { createEvaluationResultJob } from './createEvaluationResultJob' @@ -91,6 +94,22 @@ export type AIProviderCallCompletedEvent = LatitudeEventGeneric< } > +export type WorkspaceCreatedEvent = LatitudeEventGeneric< + 'workspaceCreated', + { + workspace: Workspace + user: User + } +> + +export type ProjectCreated = LatitudeEventGeneric< + 'projectCreated', + { + project: Project + commit: Commit + } +> + export type LatitudeEvent = | MembershipCreatedEvent | UserCreatedEvent @@ -99,6 +118,8 @@ export type LatitudeEvent = | DocumentRunEvent | ProviderLogCreatedEvent | AIProviderCallCompletedEvent + | WorkspaceCreatedEvent + | ProjectCreated export interface IEventsHandlers { magicLinkTokenCreated: EventHandler[] @@ -108,6 +129,8 @@ export interface IEventsHandlers { documentRun: EventHandler[] providerLogCreated: EventHandler[] aiProviderCallCompleted: EventHandler[] + workspaceCreated: EventHandler[] + projectCreated: EventHandler[] } export const EventHandlers: IEventsHandlers = { @@ -118,4 +141,6 @@ export const EventHandlers: IEventsHandlers = { documentRun: [createDocumentLogJob], providerLogCreated: [], aiProviderCallCompleted: [], + workspaceCreated: [], + projectCreated: [], } as const diff --git a/packages/core/src/lib/Result.ts b/packages/core/src/lib/Result.ts index b1eb67d36..d374125ac 100644 --- a/packages/core/src/lib/Result.ts +++ b/packages/core/src/lib/Result.ts @@ -55,9 +55,9 @@ export class Result { return result.ok } - public static findError( - results: TypedResult[], - ): TypedResult | undefined { - return results.find((r) => !r.ok) + public static findError( + results: TypedResult[], + ): ErrorResult | undefined { + return results.find((r) => !r.ok) as ErrorResult | undefined } } diff --git a/packages/core/src/repositories/projectsRepository.test.ts b/packages/core/src/repositories/projectsRepository.test.ts new file mode 100644 index 000000000..095fa248f --- /dev/null +++ b/packages/core/src/repositories/projectsRepository.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { Workspace } from '../browser' +import { Providers } from '../constants' +import { createProject, createWorkspace, helpers } from '../tests/factories' +import { ProjectsRepository } from './projectsRepository' + +describe('ProjectsRepository', async () => { + let repository: ProjectsRepository + let workspace: Workspace + + const provider = { type: Providers.OpenAI, name: 'OpenAI' } + + beforeEach(async () => { + const { workspace: newWorkspace } = await createWorkspace() + workspace = newWorkspace + repository = new ProjectsRepository(workspace.id) + }) + + describe('findAllActiveDocumentsWithAgreggatedData', () => { + it('should return active projects with aggregated data', async () => { + // Create test data + const { project: project1 } = await createProject({ + workspace, + providers: [provider], + documents: { + foo: helpers.createPrompt({ provider: provider.name }), + }, + }) + + const { project: project2 } = await createProject({ + workspace, + documents: { + bar: helpers.createPrompt({ provider: provider.name }), + }, + }) + + // Execute the method + const result = await repository.findAllActiveDocumentsWithAgreggatedData() + + // Assert the result + expect(result.ok).toBe(true) + const projects = result.unwrap() + + expect(projects).toHaveLength(2) + + const project1Result = projects.find((p) => p.id === project1.id) + expect(project1Result).toBeDefined() + expect(project1Result?.documentCount).toBe(1) + expect(project1Result?.lastCreatedAtDocument).toBeDefined() + + const project2Result = projects.find((p) => p.id === project2.id) + expect(project2Result).toBeDefined() + expect(project2Result?.documentCount).toBe(1) + expect(project2Result?.lastCreatedAtDocument).toBeDefined() + }) + + it('should return projects with zero document count when no documents exist', async () => { + const { project } = await createProject({ + workspace, + providers: [provider], + }) + + const result = await repository.findAllActiveDocumentsWithAgreggatedData() + + expect(result.ok).toBe(true) + const projects = result.unwrap() + + expect(projects).toHaveLength(1) + expect(projects[0]?.id).toBe(project.id) + expect(projects[0]?.documentCount).toBe(0) + expect(projects[0]?.lastCreatedAtDocument).toBeNull() + }) + + it('should include projects without merged commits', async () => { + await createProject({ + workspace, + providers: [provider], + skipMerge: true, + }) + + const result = await repository.findAllActiveDocumentsWithAgreggatedData() + + expect(result.ok).toBe(true) + const projects = result.unwrap() + + expect(projects).toHaveLength(1) + }) + }) +}) diff --git a/packages/core/src/repositories/projectsRepository.ts b/packages/core/src/repositories/projectsRepository.ts index 9bf7081a8..d902375ed 100644 --- a/packages/core/src/repositories/projectsRepository.ts +++ b/packages/core/src/repositories/projectsRepository.ts @@ -6,6 +6,7 @@ import { isNotNull, isNull, max, + sql, } from 'drizzle-orm' import { Project } from '../browser' @@ -96,7 +97,10 @@ export class ProjectsRepository extends Repository { .with(aggredatedData) .select({ ...this.scope._.selectedFields, - documentCount: aggredatedData.documentCount, + documentCount: + sql`CAST(CASE WHEN ${aggredatedData.documentCount} IS NULL THEN 0 ELSE ${aggredatedData.documentCount} END AS INTEGER)`.as( + 'documentCount', + ), lastCreatedAtDocument: aggredatedData.lastCreatedAtDocument, }) .from(this.scope) diff --git a/packages/core/src/services/projects/create.ts b/packages/core/src/services/projects/create.ts index f8f262073..2a21050fc 100644 --- a/packages/core/src/services/projects/create.ts +++ b/packages/core/src/services/projects/create.ts @@ -1,5 +1,6 @@ import { Commit, Project, User, Workspace } from '../../browser' import { database } from '../../client' +import { publisher } from '../../events/publisher' import { Result, Transaction } from '../../lib' import { projects } from '../../schema' import { createCommit } from '../commits/create' @@ -38,6 +39,11 @@ export async function createProject( }) if (result.error) return result + publisher.publishLater({ + type: 'projectCreated', + data: { project, commit: result.value }, + }) + return Result.ok({ project, commit: result.value }) }, db) } diff --git a/packages/core/src/services/projects/import.ts b/packages/core/src/services/projects/import.ts index ba1663687..bfa387afe 100644 --- a/packages/core/src/services/projects/import.ts +++ b/packages/core/src/services/projects/import.ts @@ -28,12 +28,14 @@ export async function importDefaultProject( const defaultProjectDocumentsScope = new DocumentVersionsRepository( defaultProject!.workspaceId, + db, ) const defaultDocuments = await defaultProjectDocumentsScope.getDocumentsFromMergedCommits({ projectId: defaultProject!.id, }) + if (defaultDocuments.error) return defaultDocuments return Transaction.call(async (tx) => { @@ -46,19 +48,22 @@ export async function importDefaultProject( tx, ).then((r) => r.unwrap()) - await Promise.all( - defaultDocuments.value.map(async (document) => { - await createNewDocument( + const results = await Promise.all( + defaultDocuments.value.map(async (document) => + createNewDocument( { commit, path: document.path, content: document.content, }, tx, - ).then((r) => r.unwrap()) - }), + ), + ), ) + const result = Result.findError(results) + if (result) return result + return Result.ok(project) }, db) } diff --git a/packages/core/src/services/workspaces/create.ts b/packages/core/src/services/workspaces/create.ts index 065d889da..5064dbe94 100644 --- a/packages/core/src/services/workspaces/create.ts +++ b/packages/core/src/services/workspaces/create.ts @@ -1,10 +1,8 @@ import { User, Workspace } from '../../browser' import { database } from '../../client' +import { publisher } from '../../events/publisher' import { Result, Transaction } from '../../lib' import { workspaces } from '../../schema' -import { createApiKey } from '../apiKeys/create' -import { createMembership } from '../memberships/create' -import { importDefaultProject } from '../projects/import' export async function createWorkspace( { @@ -23,9 +21,10 @@ export async function createWorkspace( .returning() const workspace = insertedWorkspaces[0]! - await createMembership({ confirmedAt: new Date(), user, workspace }, tx) - await createApiKey({ workspace }, tx) - await importDefaultProject({ workspace, user }, tx) + publisher.publishLater({ + type: 'workspaceCreated', + data: { workspace, user }, + }) return Result.ok(workspace) }, db) diff --git a/packages/core/src/tests/factories/projects.ts b/packages/core/src/tests/factories/projects.ts index e9ad3719e..f33cd0154 100644 --- a/packages/core/src/tests/factories/projects.ts +++ b/packages/core/src/tests/factories/projects.ts @@ -5,6 +5,7 @@ import { unsafelyGetUser } from '../../data-access' import { mergeCommit } from '../../services/commits' import { createNewDocument, updateDocument } from '../../services/documents' import { createProject as createProjectFn } from '../../services/projects/create' +import { createApiKey } from './apiKeys' import { createDraft } from './commits' import { createLlmAsJudgeEvaluation, IEvaluationData } from './evaluations' import { createProviderApiKey } from './providerApiKeys' @@ -43,8 +44,10 @@ export type ICreateProject = { providers?: { type: Providers; name: string }[] evaluations?: Omit[] documents?: IDocumentStructure + skipMerge?: boolean } export async function createProject(projectData: Partial = {}) { + const skipMerge = projectData.skipMerge ?? false let workspaceData = projectData.workspace ?? {} let user: User let workspace: Workspace @@ -56,6 +59,8 @@ export async function createProject(projectData: Partial = {}) { const newWorkspace = await createWorkspace(workspaceData) workspace = newWorkspace.workspace user = newWorkspace.userData + + await createApiKey({ workspace }) } const randomName = faker.commerce.department() @@ -108,7 +113,10 @@ export async function createProject(projectData: Partial = {}) { }) documents.push(updatedDoc.unwrap()) } - commit = await mergeCommit(draft).then((r) => r.unwrap()) + + commit = skipMerge + ? commit + : await mergeCommit(draft).then((r) => r.unwrap()) } return { diff --git a/packages/core/src/tests/factories/workspaces.ts b/packages/core/src/tests/factories/workspaces.ts index 013100463..09ad947d6 100644 --- a/packages/core/src/tests/factories/workspaces.ts +++ b/packages/core/src/tests/factories/workspaces.ts @@ -1,6 +1,7 @@ import { faker } from '@faker-js/faker' import { type User } from '../../browser' +import { createMembership } from '../../services/memberships/create' import { createWorkspace as createWorkspaceFn } from '../../services/workspaces/create' import { createUser, type ICreateUser } from './users' @@ -23,6 +24,7 @@ export async function createWorkspace( user: userData, }) const workspace = result.unwrap() + await createMembership({ workspace, user: userData }).then((r) => r.unwrap()) return { workspace, userData } }