diff --git a/apps/website/app/api/internal/space/[id]/route.ts b/apps/website/app/api/internal/space/[id]/route.ts new file mode 100644 index 000000000..98f6104ed --- /dev/null +++ b/apps/website/app/api/internal/space/[id]/route.ts @@ -0,0 +1,167 @@ +import { NextResponse, NextRequest } from "next/server"; + +import { createClient } from "~/utils/supabase/server"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, +} from "~/utils/supabase/apiUtils"; +import { asPostgrestFailure } from "@repo/database/lib/contextFunctions"; +import type { Json, Tables } from "@repo/database/dbTypes"; +import { CrossAppUpsertData } from "@repo/database/crossAppContracts"; +import { LocalConceptDataInput } from "@repo/database/inputTypes"; +import { + crossAppNodeSchemaToDbConcept, + crossAppNodeToDbConcept, + crossAppRelationToDbConcept, + crossAppRelationTripleSchemaToDbConcept, + crossAppRelationTypeSchemaToDbConcept, + dbNodeSchemaToCrossApp, + dbRelationTypeSchemaToCrossApp, +} from "@repo/database/lib/crossAppConverters"; +import { getAccountId } from "~/utils/supabase/account"; + +type Concept = Tables<"Concept">; + +type ApiParams = Promise<{ id: string }>; +export type SegmentDataType = { params: ApiParams }; + +export const POST = async ( + request: NextRequest, + segmentData: SegmentDataType, +): Promise => { + const { id: spaceIdS } = await segmentData.params; + const spaceId = Number.parseInt(spaceIdS); + if (Number.isNaN(spaceId)) + return createApiResponse( + request, + asPostgrestFailure("Cannot parse space id", "invalid", 403), + ); + + try { + const supabase = await createClient(); + const userId = await getAccountId(supabase); + if (userId === undefined) + return createApiResponse( + request, + asPostgrestFailure("Please login", "invalid", 401), + ); + const body = (await request.json()) as CrossAppUpsertData; + // TODO: Zed validator + const nodeSchemasById = Object.fromEntries( + (body.nodeSchemas || []).map((c) => [c.localId, c]), + ); + const relationTypesById = Object.fromEntries( + (body.relationTypeSchemas || []).map((c) => [c.localId, c]), + ); + const neededNodesSchemaIds = new Set( + (body.relationTripleSchemas || []) + .map((c) => [c.sourceType, c.destinationType]) + .flat(), + ); + const neededRelationTypeSchemaIds = new Set( + (body.relationTripleSchemas || []) + .map((c) => c.relation) + .filter((c) => c !== undefined), + ); + const missingNodeSchemaIds = [...neededNodesSchemaIds].filter( + (id) => !(id in nodeSchemasById), + ); + const missingRelationTypeSchemaIds = [ + ...neededRelationTypeSchemaIds, + ].filter((id) => !(id in relationTypesById)); + const missingSchemaIds = [ + ...missingNodeSchemaIds, + ...missingRelationTypeSchemaIds, + ]; + if (missingSchemaIds.length > 0) { + const schemaResult = await supabase + .from("my_concepts") + .select() + .in("source_local_id", missingSchemaIds) + .eq("space_id", spaceId); + if (schemaResult.error) return createApiResponse(request, schemaResult); + if (schemaResult.data.length < missingSchemaIds.length) + throw new Error("Reference to inexistant schemas"); + const authorLocalIds = new Set( + schemaResult.data.map((c) => c.author_id).filter((id) => id !== null), + ); + const authorRes = await supabase + .from("my_accounts") + .select("id, account_local_id") + .in("id", [...authorLocalIds]); + if (authorRes.error) return createApiResponse(request, authorRes); + const authorMap: Record = Object.fromEntries( + authorRes.data + .filter((r) => r.id !== null && r.account_local_id !== null) + .map((r) => [r.id!, r.account_local_id!]), + ); + schemaResult.data + .filter((d) => d.arity === 0) + .map((d) => { + const c = dbNodeSchemaToCrossApp(d as Concept, authorMap); + nodeSchemasById[c.localId] = c; + }); + schemaResult.data + .filter((d) => d.arity === 2) + .map((d) => { + const c = dbRelationTypeSchemaToCrossApp(d as Concept, authorMap); + relationTypesById[c.localId] = c; + }); + } + const content: LocalConceptDataInput[] = [ + ...(body.nodeSchemas || []).map((c) => + crossAppNodeSchemaToDbConcept({ + ...c, + createdAt: new Date(c.createdAt), + modifiedAt: new Date(c.modifiedAt || c.createdAt), + }), + ), + ...(body.relationTypeSchemas || []).map((c) => + crossAppRelationTypeSchemaToDbConcept({ + ...c, + createdAt: new Date(c.createdAt), + modifiedAt: new Date(c.modifiedAt || c.createdAt), + }), + ), + ...(body.relationTripleSchemas || []).map((c) => + crossAppRelationTripleSchemaToDbConcept({ + node: { + ...c, + createdAt: new Date(c.createdAt), + modifiedAt: new Date(c.modifiedAt || c.createdAt), + }, + sourceNodeSchema: nodeSchemasById[c.sourceType]!, + destinationNodeSchema: nodeSchemasById[c.destinationType]!, + relationType: relationTypesById[c.relation || ""], + }), + ), + ...(body.nodes || []).map((c) => + crossAppNodeToDbConcept({ + ...c, + createdAt: new Date(c.createdAt), + modifiedAt: new Date(c.modifiedAt || c.createdAt), + }), + ), + ...(body.relations || []).map((c) => + crossAppRelationToDbConcept({ + ...c, + createdAt: new Date(c.createdAt), + modifiedAt: new Date(c.modifiedAt || c.createdAt), + }), + ), + ].filter((c) => c !== undefined); + if (content.length === 0) throw new Error("Could not translate content"); + const result = await supabase.rpc("upsert_concepts", { + data: content as Json, + v_space_id: spaceId, + v_creator_id: userId, + content_as_document: true, + }); + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/space/[id]"); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/utils/supabase/account.ts b/apps/website/app/utils/supabase/account.ts index e7305f4c9..d6da353ca 100644 --- a/apps/website/app/utils/supabase/account.ts +++ b/apps/website/app/utils/supabase/account.ts @@ -3,6 +3,23 @@ import type { DGSupabaseClient } from "@repo/database/lib/client"; type AgentType = Database["public"]["Enums"]["AgentType"] | "group"; +export const getAccountId = async ( + client: DGSupabaseClient, +): Promise => { + const { data, error } = await client.auth.getUser(); + if (error || !data?.user) return undefined; + const userData = data.user; + if (typeof userData.id !== "string") return undefined; + const id = userData.id; + const accountReq = await client + .from("PlatformAccount") + .select("id") + .eq("dg_account", id) + .maybeSingle(); + if (accountReq.error) throw accountReq.error; + return accountReq.data?.id; +}; + export const getSessionBaseUserData = async ( client: DGSupabaseClient, ): Promise<{ diff --git a/packages/database/src/crossAppContracts.ts b/packages/database/src/crossAppContracts.ts index c1bf4268a..8fbdab42b 100644 --- a/packages/database/src/crossAppContracts.ts +++ b/packages/database/src/crossAppContracts.ts @@ -88,3 +88,11 @@ export type CrossAppRelation = CrossAppBase & { destination: LocalId | Rid; /* eslint-enable @typescript-eslint/no-duplicate-type-constituents */ }; + +export type CrossAppUpsertData = { + nodeSchemas?: CrossAppNodeSchema[]; + relationTypeSchemas?: CrossAppRelationTypeSchema[]; + relationTripleSchemas?: CrossAppRelationTripleSchema[]; + nodes?: CrossAppNode[]; + relations?: CrossAppRelation[]; +}; diff --git a/packages/database/src/lib/crossAppConverters.ts b/packages/database/src/lib/crossAppConverters.ts index 76ead0be6..c88865808 100644 --- a/packages/database/src/lib/crossAppConverters.ts +++ b/packages/database/src/lib/crossAppConverters.ts @@ -8,9 +8,10 @@ import { CrossAppRelation, } from "../crossAppContracts"; import { LocalContentDataInput, LocalConceptDataInput } from "../inputTypes"; -import { Enums, CompositeTypes } from "../dbTypes"; +import { Enums, type CompositeTypes, type Tables, type Json } from "../dbTypes"; type InlineEmbeddingInput = CompositeTypes<"inline_embedding_input">; +type Concept = Tables<"Concept">; const crossAppEmbeddingToDbEmbedding = ( embedding: CrossAppEmbedding | undefined, @@ -103,6 +104,26 @@ export const crossAppNodeSchemaToDbConcept = ( }); }; +export const dbNodeSchemaToCrossApp = ( + schema: Concept, + authorMap: Record, +): CrossAppNodeSchema => { + const { template, template_content, ...other } = + schema.literal_content as Record; + const authorId = authorMap[schema.author_id || 0]; + if (authorId === undefined) throw new Error("Missing author"); + return { + localId: schema.source_local_id!, + createdAt: new Date(schema.created + "Z"), + modifiedAt: new Date(schema.last_modified + "Z"), + label: schema.name, + metadata: other, + template: template_content as string | undefined, + templateTitle: template as string | undefined, + authorId, + }; +}; + export const crossAppRelationTypeSchemaToDbConcept = ( node: CrossAppRelationTypeSchema, ): LocalConceptDataInput => { @@ -121,6 +142,26 @@ export const crossAppRelationTypeSchemaToDbConcept = ( }); }; +export const dbRelationTypeSchemaToCrossApp = ( + schema: Concept, + authorMap: Record, +): CrossAppRelationTypeSchema => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { roles, label, complement, ...other } = + schema.literal_content as Record; + const authorId = authorMap[schema.author_id || 0]; + if (authorId === undefined) throw new Error("Missing author"); + return { + localId: schema.source_local_id!, + createdAt: new Date(schema.created + "Z"), + modifiedAt: new Date(schema.last_modified + "Z"), + metadata: other, + label: label as string, + complement: complement as string, + authorId, + }; +}; + export const crossAppRelationTripleSchemaToDbConcept = ({ node, sourceNodeSchema,