Skip to content
Open
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
167 changes: 167 additions & 0 deletions apps/website/app/api/internal/space/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { NextResponse, NextRequest } from "next/server";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's name this route something more explicit to what it's doing. Right now it only conveys something about the space.
How about /api/internal/space/[id]/graph/upsert or just /graph if we plan on adding the GET here too.

Something to convey this is Concept, Content, and Document but not User, Space, etc


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<NextResponse> => {
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),

@mdroidian mdroidian Jul 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this be passed to the user? If so, as a user, when I receive this message, what should I do with it? Stated differently, what's the use case for when a user would hit this failure route and what action could they take to rectify it?

);
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) {

@mdroidian mdroidian Jul 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maparent My understanding from our discussion on the 17th (discussion ref, specifically this ref) was that the action-specific functions would assemble the data required for the action before calling this generic route. This block instead fetches schemas that weren’t included in the request and converts them back to CrossApp types.

For example, if publishNodes is sharing a Claim and Evidence connected by Supports, the action-specific function should send the nodes, their node schemas, the relation, the Supports relation type schema, and the Claim–Supports–Evidence triple schema. Given that callers are expected to go through these action-specific functions, when would this route receive a relation triple schema without its referenced node and relation type schemas? If that shouldn’t happen, could we validate and reject the incomplete payload instead of fetching the dependencies here? That would also remove the need for the DB-to-CrossApp converters in this PR.

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<number, string> = 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;
});
Comment thread
maparent marked this conversation as resolved.
}
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;
17 changes: 17 additions & 0 deletions apps/website/app/utils/supabase/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | undefined> => {
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<{
Expand Down
8 changes: 8 additions & 0 deletions packages/database/src/crossAppContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
43 changes: 42 additions & 1 deletion packages/database/src/lib/crossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -103,6 +104,26 @@ export const crossAppNodeSchemaToDbConcept = (
});
};

export const dbNodeSchemaToCrossApp = (
schema: Concept,
authorMap: Record<number, string>,
): CrossAppNodeSchema => {
const { template, template_content, ...other } =
schema.literal_content as Record<string, Json>;
Comment thread
maparent marked this conversation as resolved.
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 => {
Expand All @@ -121,6 +142,26 @@ export const crossAppRelationTypeSchemaToDbConcept = (
});
};

export const dbRelationTypeSchemaToCrossApp = (
schema: Concept,
authorMap: Record<number, string>,
): CrossAppRelationTypeSchema => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { roles, label, complement, ...other } =
schema.literal_content as Record<string, Json>;
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,
Expand Down