diff --git a/bun.lockb b/bun.lockb index 2ed64a0..12b2e8f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/core/application/controllers/assistant/assistantController.test.ts b/src/core/application/controllers/assistant/assistantController.test.ts new file mode 100644 index 0000000..19d2d7b --- /dev/null +++ b/src/core/application/controllers/assistant/assistantController.test.ts @@ -0,0 +1,62 @@ +import { createSuperAdminForTesting } from "@/__tests__/utils"; +import { app } from "@/index"; +import { getNeo4jSession } from "@/infrastructure/adaptaters/neo4jAdapter"; +import { test, expect, describe } from "bun:test"; +import { UNAUTHORIZED_MISSING_TOKEN } from "../../ports/returnValues"; + +describe("assistantController", async () => { + const token = await createSuperAdminForTesting(); + + test("allows creating a assistant and the assistant is saved in the database", async () => { + const name = "test assistant"; + const request = new Request("http://localhost:8080/assistant", { + headers: { + authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + name, + }), + }); + + const response = await app.handle(request); + const responseJson: any = await response.json(); + + expect(responseJson).toHaveProperty("id"); + + const id = responseJson.id; + + const session = getNeo4jSession(); + + const result = await session.run("MATCH (t:Assistant {id: $id}) RETURN t", { + id, + }); + + const singleRecord = result.records[0]; + const node = singleRecord.get(0); + const idInDb = node.properties.id; + const nameInDb = node.properties.name; + + expect(idInDb).toEqual(id); + expect(nameInDb).toBe(name); + }); + + test("prevents from creating a test if there is no api token present", async () => { + const request = new Request("http://localhost:3000/assistant", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "test assistant", + }), + }); + + const response: any = await app + .handle(request) + .then((response) => response.json()); + + expect(response.message).toBe(UNAUTHORIZED_MISSING_TOKEN.message); + }); +}); diff --git a/src/core/application/controllers/assistant/assistantController.ts b/src/core/application/controllers/assistant/assistantController.ts new file mode 100644 index 0000000..b0a6e31 --- /dev/null +++ b/src/core/application/controllers/assistant/assistantController.ts @@ -0,0 +1,73 @@ +import { Elysia, t } from "elysia"; +import { ulid } from "ulid"; + +import { getTokenPermissions, parseToken } from "../../services/tokenService"; +import { UNAUTHORIZED_NO_PERMISSION_CREATE_ASSISTANT } from "./returnValues"; +import { UNAUTHORIZED_MISSING_TOKEN } from "../../ports/returnValues"; +import { + assignRole, + createUser, +} from "@/core/application/services/userService"; +import { createAssistant } from "@/core/application/services/assistantService"; + +type AssistantDecorator = { + request: { + bearer: string | undefined; + }; + store: {}; + derive: {}; + resolve: {}; +}; + +export const assistants = new Elysia<"/assistant", AssistantDecorator>(); + +assistants.post( + "/assistant", + async ({ bearer, set, body }) => { + if (!bearer) { + set.status = 401; + return UNAUTHORIZED_MISSING_TOKEN; + } + const permissions = await getTokenPermissions(bearer!); + const decodedToken = await parseToken(bearer!); + + if ( + !permissions?.some((p) => p.key === "create_assistant" || p.key === "*") + ) { + set.status = 403; + return UNAUTHORIZED_NO_PERMISSION_CREATE_ASSISTANT; + } + + if (decodedToken) { + const { userId } = decodedToken; + const assistantId = ulid(); + + const { name } = body; + + // create user for assistant + await createUser(assistantId, {}); + // give user the proper role + await assignRole(assistantId, "agent"); + + // creat assistant in db + await createAssistant({ + userId, + id: assistantId, + model: "gpt-4", + name, + fileIds: [], + tools: [], + }); + + return { + id: assistantId, + name, + }; + } + }, + { + body: t.Object({ + name: t.String(), + }), + } +); diff --git a/src/core/application/controllers/assistant/returnValues.ts b/src/core/application/controllers/assistant/returnValues.ts new file mode 100644 index 0000000..4b568b7 --- /dev/null +++ b/src/core/application/controllers/assistant/returnValues.ts @@ -0,0 +1,4 @@ +export const UNAUTHORIZED_NO_PERMISSION_CREATE_ASSISTANT = { + message: "Unauthorized: User does not have permission to create assistants", + code: 403, +}; diff --git a/src/core/application/controllers/thread/returnValues.ts b/src/core/application/controllers/thread/returnValues.ts index 18d44f2..6e50cf8 100644 --- a/src/core/application/controllers/thread/returnValues.ts +++ b/src/core/application/controllers/thread/returnValues.ts @@ -1,7 +1,3 @@ -export const UNAUTHORIZED_MISSING_TOKEN = { - message: "Unauthorized: Missing token", - code: 401, -}; export const UNAUTHORIZED_USER_NOT_PARTICIPANT = { message: "Unauthorized: User is not a participant in the thread", code: 403, diff --git a/src/core/application/controllers/thread/threadController.ts b/src/core/application/controllers/thread/threadController.ts index bd33e25..1ec9a1f 100644 --- a/src/core/application/controllers/thread/threadController.ts +++ b/src/core/application/controllers/thread/threadController.ts @@ -10,13 +10,13 @@ import { } from "@/core/application/services/threadService"; import { THREAD_DELETED_SUCCESSFULLY, - UNAUTHORIZED_MISSING_TOKEN, UNAUTHORIZED_NO_PERMISSION_CREATE, UNAUTHORIZED_NO_PERMISSION_DELETE, UNAUTHORIZED_NO_PERMISSION_READ, UNAUTHORIZED_USER_NOT_OWNER, UNAUTHORIZED_USER_NOT_PARTICIPANT, } from "./returnValues"; +import { UNAUTHORIZED_MISSING_TOKEN } from "@/core/application/ports/returnValues"; type ThreadDecorator = { request: { @@ -37,6 +37,11 @@ threads.post("/thread", async ({ bearer, set }) => { const permissions = await getTokenPermissions(bearer!); const decodedToken = await parseToken(bearer!); + if (!permissions?.some((p) => p.key === "create_thread" || p.key === "*")) { + set.status = 403; + return UNAUTHORIZED_NO_PERMISSION_CREATE; + } + if (decodedToken) { const { userId } = decodedToken; // // Create a new thread @@ -54,11 +59,6 @@ threads.post("/thread", async ({ bearer, set }) => { }; } } - - if (!permissions?.some((p) => p.key === "create_thread" || p.key === "*")) { - set.status = 403; - return UNAUTHORIZED_NO_PERMISSION_CREATE; - } }); threads.delete("/thread/:id", async ({ params, bearer, set }) => { @@ -70,7 +70,9 @@ threads.delete("/thread/:id", async ({ params, bearer, set }) => { const threadId = params.id; // Check if the user has the permission to delete their own thread or * permission - if (permissions?.some((p) => p.key === "delete_thread" || p.key === "*")) { + if ( + permissions?.some((p) => p.key === "delete_own_thread" || p.key === "*") + ) { // If the user has * permission, delete the thread without checking ownership if (permissions.some((p) => p.key === "*")) { await deleteThread(threadId, userId); @@ -102,7 +104,9 @@ threads.get("/thread/:id", async ({ params, bearer, set }) => { const threadId = params.id; // Check if the user has the permission to see their own threads or * permission - if (permissions?.some((p) => p.key === "read_thread" || p.key === "*")) { + if ( + permissions?.some((p) => p.key === "view_own_threads" || p.key === "*") + ) { // If the user has * permission or is a participant in the thread, get the thread if ( permissions.some((p) => p.key === "*") || diff --git a/src/core/application/controllers/thread/threadControllet.test.ts b/src/core/application/controllers/thread/threadControllet.test.ts index b056f32..17a7b51 100644 --- a/src/core/application/controllers/thread/threadControllet.test.ts +++ b/src/core/application/controllers/thread/threadControllet.test.ts @@ -1,14 +1,14 @@ import { createSuperAdminForTesting } from "@/__tests__/utils"; import { app } from "@/index"; import { getNeo4jSession } from "@/infrastructure/adaptaters/neo4jAdapter"; -import { test, expect, describe } from "vitest"; -import { UNAUTHORIZED_MISSING_TOKEN } from "./returnValues"; +import { test, expect, describe } from "bun:test"; +import { UNAUTHORIZED_MISSING_TOKEN } from "../../ports/returnValues"; describe("threadController", async () => { const token = await createSuperAdminForTesting(); test("allows creating a thread and the thread is saved in the database", async () => { - const request = new Request("http://localhost:3000/thread", { + const request = new Request("http://localhost:8080/thread", { headers: { authorization: `Bearer ${token}`, }, @@ -36,7 +36,7 @@ describe("threadController", async () => { }); test("prevents from creating a test if there is no api token present", async () => { - const request = new Request("http://localhost:3000/thread", { + const request = new Request("http://localhost:8080/thread", { method: "POST", }); diff --git a/src/core/application/controllers/threadController.http b/src/core/application/controllers/threadController.http deleted file mode 100644 index d3ad94d..0000000 --- a/src/core/application/controllers/threadController.http +++ /dev/null @@ -1,17 +0,0 @@ -@bearer = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIwMUhISkJSOUVSRTczUFRGQzFHWllTOTI1SiIsInBlcm1pc3Npb25zIjpbeyJrZXkiOiIqIiwiZGVzY3JpcHRpb24iOiJBbGxvd3MgdGhlIHVzZXIgdG8gcGVyZm9ybSBhbnkgYWN0aW9uLiJ9XSwiaWF0IjoxNzAyNDk3MTAxfQ.popm-HtCIbLaixGHudOhspUXPJm_NVRG2Iz6IjOEh2k -@baseurl = http://localhost:3000/thread - -# @name createThread -POST {{baseurl}} HTTP/1.1 -Content-Type: application/json -Authorization: Bearer {{bearer}} - -### - -@threadId = {{createThread.response.body.id}} - -# @name getThread -GET {{baseurl}}/{{threadId}} HTTP/1.1 -Content-Type: application/json -Authorization: Bearer {{bearer}} - diff --git a/src/core/application/ports/returnValues.ts b/src/core/application/ports/returnValues.ts new file mode 100644 index 0000000..70acf1c --- /dev/null +++ b/src/core/application/ports/returnValues.ts @@ -0,0 +1,4 @@ +export const UNAUTHORIZED_MISSING_TOKEN = { + message: "Unauthorized: Missing token", + code: 401, +}; diff --git a/src/core/application/services/assistantService.ts b/src/core/application/services/assistantService.ts new file mode 100644 index 0000000..3c62404 --- /dev/null +++ b/src/core/application/services/assistantService.ts @@ -0,0 +1,40 @@ +import type { Assistant } from "@/core/domain/assistant"; +import { getNeo4jSession } from "@/infrastructure/adaptaters/neo4jAdapter"; + +/** + * Creates an assistant in Neo4j if it does not exist and adds a 'CREATED_BY' relationship to the user. + * + * @param {Assistant & { userId: string }} args - The assistant details and the user ID. + * @returns {Promise>} The properties of the created assistant. + * @throws {Error} If there is an error creating the assistant or adding the relationship. + */ +export async function createAssistant(args: Assistant & { userId: string }) { + const { id, fileIds, tools, userId, model, name } = args; + + const session = getNeo4jSession(); + + // implenting file ids and tools will happen later. They should be relations + try { + const result = await session.run( + ` + MERGE (a:Assistant {id: $assistantId}) + ON CREATE SET a.tools = $assistantTools, a.model = $assistantModel, a.name = $assistantName + WITH a + MATCH (u:User {id: $userId}) + MERGE (a)-[:CREATED_BY]->(u) + RETURN a + `, + { + assistantId: id, + assistantTools: tools, + assistantModel: model, + userId: userId, + assistantName: name, + } + ); + + return result.records[0].get("a").properties; + } finally { + await session.close(); + } +} diff --git a/src/core/application/services/tokenService.ts b/src/core/application/services/tokenService.ts index 985910c..57a017b 100644 --- a/src/core/application/services/tokenService.ts +++ b/src/core/application/services/tokenService.ts @@ -1,6 +1,7 @@ import { redis } from "@/infrastructure/adaptaters/redisAdapter"; import { getUserPermissions } from "@/core/application/services/userService"; import jwt from "jsonwebtoken"; +import { PermissionDetailArray } from "@/core/domain/permissions"; /** * Creates a new API token for a user with their associated permissions. @@ -25,7 +26,7 @@ export async function createToken(userId: string): Promise { */ export async function getTokenPermissions( token: string -): Promise<{ key: string; description: string }[] | null> { +): Promise { const tokenData = await redis.hget("api_tokens", token); if (!tokenData) { return null; diff --git a/src/core/domain/assistant.ts b/src/core/domain/assistant.ts new file mode 100644 index 0000000..66d4712 --- /dev/null +++ b/src/core/domain/assistant.ts @@ -0,0 +1,7 @@ +export type Assistant = { + id: string; + name: string; + model: "gpt-4"; + tools: { type: string }[]; + fileIds: string[]; +}; diff --git a/src/core/domain/permissions.ts b/src/core/domain/permissions.ts index cb6471a..434c786 100644 --- a/src/core/domain/permissions.ts +++ b/src/core/domain/permissions.ts @@ -17,12 +17,20 @@ export type Permission = | "create_document" | "view_own_documents" | "edit_own_documents" - | "link_own_documents"; + | "link_own_documents" + | "create_assistant" + | "delete_assistant" + | "view_assistants"; export type PermissionDetails = { description: string; }; +export type PermissionDetailArray = { + key: keyof typeof permissions; + description: string; +}[]; + /** * An object that defines a set of permissions. * @type {Object.} @@ -75,6 +83,15 @@ export const permissions: Record = { link_own_documents: { description: "Allows the user to link their own documents to a thread.", }, + create_assistant: { + description: "Allows the user to create an assistant.", + }, + delete_assistant: { + description: "Allows the user to delete an assistant.", + }, + view_assistants: { + description: "Allows the user to view all assistants.", + }, }; /** diff --git a/src/core/domain/user.ts b/src/core/domain/user.ts new file mode 100644 index 0000000..1523232 --- /dev/null +++ b/src/core/domain/user.ts @@ -0,0 +1,5 @@ +export type User = { + id: string; + name: string; + description: string; +}; diff --git a/src/index.test.ts b/src/index.test.ts index 78ca984..bc2a410 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,5 @@ // test/index.test.ts -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { app } from "."; describe("Elysia", () => { diff --git a/src/index.ts b/src/index.ts index 5774358..9445f52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Elysia } from "elysia"; import { bearer } from "@elysiajs/bearer"; import { threads } from "@/core/application/controllers/thread/threadController"; +import { assistants } from "@/core/application/controllers/assistant/assistantController"; export const name = "Sprout"; @@ -17,6 +18,7 @@ export const healthCheck = async () => { export const app = new Elysia() .use(bearer()) + .use(assistants) .use(threads) .get("/", healthCheck) .listen(process.env.PORT || 8080);