diff --git a/apps/obsidian/src/components/ImportNodesModal.tsx b/apps/obsidian/src/components/ImportNodesModal.tsx index c27faf534..c84ef49e1 100644 --- a/apps/obsidian/src/components/ImportNodesModal.tsx +++ b/apps/obsidian/src/components/ImportNodesModal.tsx @@ -4,9 +4,9 @@ import { StrictMode, useState, useEffect, useCallback } from "react"; import type DiscourseGraphPlugin from "../index"; import type { ImportableNode, GroupWithNodes } from "~/types"; import { getUserNameById } from "~/utils/typeUtils"; +import { getAvailableGroupIds } from "@repo/database/lib/groups"; import { fetchUserNames, - getAvailableGroupIds, getPublishedNodesForGroups, getLocalNodeInstanceIds, getSpaceNameFromIds, diff --git a/apps/obsidian/src/components/PublishGroupDropdown.tsx b/apps/obsidian/src/components/PublishGroupDropdown.tsx index fe7655908..93bd6983f 100644 --- a/apps/obsidian/src/components/PublishGroupDropdown.tsx +++ b/apps/obsidian/src/components/PublishGroupDropdown.tsx @@ -11,7 +11,7 @@ import { publishToSelectedGroupWithNotice, withPublishedState, } from "~/utils/publishGroupSelection"; -import type { MyGroup } from "~/utils/importNodes"; +import type { MyGroup } from "@repo/database/lib/groups"; type PublishGroupDropdownProps = { plugin: DiscourseGraphPlugin; diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 1502a3417..9b88ee425 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -21,54 +21,6 @@ import { import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; -export type MyGroup = { - id: string; - name: string; -}; - -export const getAvailableGroupIds = async ( - client: DGSupabaseClient, -): Promise => { - const { data, error } = await client - .from("group_membership") - .select("group_id") - .eq("member_id", (await client.auth.getUser()).data.user?.id || ""); - - if (error) { - console.error("Error fetching groups:", error); - throw new Error(`Failed to fetch groups: ${error.message}`); - } - - return (data || []).map((g) => g.group_id); -}; - -export const getMyGroups = async ( - client: DGSupabaseClient, -): Promise => { - const userId = (await client.auth.getUser()).data.user?.id ?? ""; - const { data, error } = await client - .from("group_membership") - .select("group_id, my_groups!group_id(name)") - .eq("member_id", userId); - - if (error) { - console.error("Error fetching groups:", error); - throw new Error(`Failed to fetch groups: ${error.message}`); - } - - return (data ?? []) - .filter( - (row): row is { group_id: string; my_groups: { name: string | null } } => - typeof row.group_id === "string" && - row.my_groups !== null && - typeof row.my_groups === "object", - ) - .map((row) => ({ - id: row.group_id, - name: row.my_groups.name ?? row.group_id, - })); -}; - type PublishedNode = { source_local_id: string; space_id: number; diff --git a/apps/obsidian/src/utils/publishGroupSelection.ts b/apps/obsidian/src/utils/publishGroupSelection.ts index 09adcba56..5ed5a82e5 100644 --- a/apps/obsidian/src/utils/publishGroupSelection.ts +++ b/apps/obsidian/src/utils/publishGroupSelection.ts @@ -1,11 +1,11 @@ import { Notice, type FrontMatterCache, type TFile } from "obsidian"; -import type DiscourseGraphPlugin from "~/index"; -import { PublishGroupSuggestModal } from "~/components/PublishGroupSuggestModal"; import { getAvailableGroupIds, getMyGroups, type MyGroup, -} from "~/utils/importNodes"; +} from "@repo/database/lib/groups"; +import type DiscourseGraphPlugin from "~/index"; +import { PublishGroupSuggestModal } from "~/components/PublishGroupSuggestModal"; import { getLoggedInClient } from "~/utils/supabaseContext"; import { getPublishedToGroups, diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index 160a27dab..e18cfecf4 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -11,7 +11,7 @@ import { type RelationsFile, } from "./relationsStore"; import type { RelationInstance } from "~/types"; -import { getAvailableGroupIds } from "./importNodes"; +import { getAvailableGroupIds } from "@repo/database/lib/groups"; import { syncAllNodesAndRelations, syncPublishedNodeAssets, diff --git a/apps/obsidian/src/utils/templateImport.ts b/apps/obsidian/src/utils/templateImport.ts index b4ee988d8..36b6daceb 100644 --- a/apps/obsidian/src/utils/templateImport.ts +++ b/apps/obsidian/src/utils/templateImport.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention -- Supabase query results use snake_case column names */ import type { Json } from "@repo/database/dbTypes"; +import { getAvailableGroupIds } from "@repo/database/lib/groups"; import type DiscourseGraphPlugin from "~/index"; import { fetchUserNames, - getAvailableGroupIds, getSpaceNameFromIds, getSpaceUris, } from "./importNodes"; diff --git a/apps/roam/src/components/Export.tsx b/apps/roam/src/components/Export.tsx index 3e902f3d9..bb3ea0fb5 100644 --- a/apps/roam/src/components/Export.tsx +++ b/apps/roam/src/components/Export.tsx @@ -84,6 +84,13 @@ import getDiscourseRelations, { } from "~/utils/getDiscourseRelations"; import { AddReferencedNodeType } from "./canvas/DiscourseRelationShape/DiscourseRelationTool"; import posthog from "posthog-js"; +import { getMyGroups, type MyGroup } from "@repo/database/lib/groups"; +import { + publishNodesToGroups, + type PublishNode, +} from "~/utils/publishNodesToGroups"; +import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext"; +import { isSyncEnabled } from "~/components/settings/utils/accessors"; const ExportProgress = ({ id }: { id: string }) => { const [progress, setProgress] = useState(0); @@ -213,6 +220,30 @@ const ExportDialog: ExportDialogComponent = ({ const [canSendToGitHub, setCanSendToGitHub] = useState(false); + const syncEnabled = useMemo(() => isSyncEnabled(), []); + const [myGroups, setMyGroups] = useState([]); + const [groupsLoading, setGroupsLoading] = useState(false); + const [groupsLoaded, setGroupsLoaded] = useState(false); + const [selectedGroupIds, setSelectedGroupIds] = useState([]); + const [groupsError, setGroupsError] = useState(""); + const [publishError, setPublishError] = useState(""); + + const publishableNodes = useMemo( + () => + syncEnabled + ? results + .map((r) => { + const node = findDiscourseNode({ uid: r.uid }); + return node && node.backedBy === "user" + ? { uid: r.uid, type: node.type } + : null; + }) + .filter((n): n is PublishNode => n !== null) + : [], + [results, syncEnabled], + ); + const nonDiscourseCount = results.length - publishableNodes.length; + const writeFileToRepo = async ({ filename, content, @@ -764,6 +795,98 @@ const ExportDialog: ExportDialogComponent = ({ }); } }; + useEffect(() => { + if ( + !syncEnabled || + !isOpen || + selectedTabId !== "publish" || + groupsLoaded || + groupsLoading + ) + return; + setGroupsLoading(true); + void (async () => { + try { + const client = await getLoggedInClient(); + if (!client) throw new Error("Could not connect to sync."); + const groups = await getMyGroups(client); + setMyGroups(groups); + } catch (e) { + setGroupsError((e as Error).message || "Failed to load groups."); + } finally { + setGroupsLoading(false); + setGroupsLoaded(true); + } + })(); + }, [syncEnabled, isOpen, selectedTabId, groupsLoaded, groupsLoading]); + + const handlePublish = async () => { + setPublishError(""); + setLoading(true); + try { + const client = await getLoggedInClient(); + const context = await getSupabaseContext(); + if (!client || !context) throw new Error("Could not connect to sync."); + const { + publishedNodeUids, + skippedUnsyncedUids, + okGroupIds, + failedGroupIds, + } = await publishNodesToGroups({ + client, + spaceId: context.spaceId, + groupIds: selectedGroupIds, + nodes: publishableNodes, + }); + posthog.capture("Export Dialog: Publish", { + groupCount: okGroupIds.length, + publishedNodeCount: publishedNodeUids.length, + skippedUnsyncedCount: skippedUnsyncedUids.length, + nonDiscourseCount, + failedGroupCount: failedGroupIds.length, + }); + const hasPublishedNodes = publishedNodeUids.length > 0; + const messages = hasPublishedNodes + ? [ + `Published ${publishedNodeUids.length} node${ + publishedNodeUids.length === 1 ? "" : "s" + } to ${okGroupIds.length} group${ + okGroupIds.length === 1 ? "" : "s" + }.`, + ] + : ["No nodes were published."]; + if (skippedUnsyncedUids.length) + messages.push( + `${skippedUnsyncedUids.length} not synced yet — try again shortly.`, + ); + if (nonDiscourseCount) + messages.push(`${nonDiscourseCount} skipped (not discourse nodes).`); + if (failedGroupIds.length) + messages.push( + `${failedGroupIds.length} group${ + failedGroupIds.length === 1 ? "" : "s" + } failed.`, + ); + renderToast({ + content: messages.join(" "), + intent: + failedGroupIds.length || !hasPublishedNodes ? "warning" : "success", + id: "query-builder-publish-success", + }); + if (hasPublishedNodes) onClose(); + } catch (e) { + internalError({ + error: e as Error, + type: "Publish Dialog Failed", + userMessage: + "Looks like there was an error publishing. The team has been notified.", + }); + setPublishError((e as Error).message); + } finally { + setLoading(false); + } + }; + const ExportPanel = ( <>
@@ -1042,6 +1165,68 @@ const ExportDialog: ExportDialogComponent = ({ ); + const PublishPanel = ( + <> +
+ {groupsLoading || !groupsLoaded ? ( +
Loading groups…
+ ) : groupsError ? ( +
{groupsError}
+ ) : myGroups.length === 0 ? ( +
+ You are not a member of any sharing group. +
+ ) : ( + <> + + {myGroups.map((group) => ( + { + const { checked } = e.target as HTMLInputElement; + setSelectedGroupIds((prev) => + checked + ? [...prev, group.id] + : prev.filter((id) => id !== group.id), + ); + }} + /> + ))} +
+ {`Publishing ${publishableNodes.length} discourse node${ + publishableNodes.length === 1 ? "" : "s" + }`} + {nonDiscourseCount > 0 && + ` (${nonDiscourseCount} non-discourse result${ + nonDiscourseCount === 1 ? "" : "s" + } will be skipped)`} +
+ + )} +
+
+
+ {publishError} +
+
+ + ); + return ( <> + {syncEnabled && ( + + )} diff --git a/apps/roam/src/utils/publishNodesToGroups.ts b/apps/roam/src/utils/publishNodesToGroups.ts new file mode 100644 index 000000000..4cba7b06a --- /dev/null +++ b/apps/roam/src/utils/publishNodesToGroups.ts @@ -0,0 +1,127 @@ +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import { getAvailableGroupIds } from "@repo/database/lib/groups"; + +export type PublishNode = { + uid: string; + type: string; +}; + +type PublishNodesResult = { + publishedNodeUids: string[]; + skippedUnsyncedUids: string[]; + okGroupIds: string[]; + failedGroupIds: string[]; +}; + +// 23505 = unique_violation: the grant already exists, which counts as success. +const isIgnorableUpsertError = (error: { code?: string } | null): boolean => + !error || error.code === "23505"; + +const onlyStrings = (values: (string | null)[]): string[] => + values.filter((value): value is string => typeof value === "string"); + +// Grants a group access to already-synced discourse nodes by mirroring the +// Obsidian publish-to-group access model (SpaceAccess + ResourceAccess), +// without its file/frontmatter/relation/asset coupling. +// +// ResourceAccess has no foreign key on source_local_id, so granting access to a +// node that has not synced yet would create an orphaned row. We therefore only +// publish nodes confirmed present as instance concepts in this space, and +// report the rest as not-yet-synced (they self-heal on the next sync). +export const publishNodesToGroups = async ({ + client, + spaceId, + groupIds, + nodes, +}: { + client: DGSupabaseClient; + spaceId: number; + groupIds: string[]; + nodes: PublishNode[]; +}): Promise => { + const result: PublishNodesResult = { + publishedNodeUids: [], + skippedUnsyncedUids: [], + okGroupIds: [], + failedGroupIds: [], + }; + if (nodes.length === 0 || groupIds.length === 0) return result; + + const availableGroupIds = new Set(await getAvailableGroupIds(client)); + const requestedGroupIds = [...new Set(groupIds)]; + const targetGroupIds = requestedGroupIds.filter((groupId) => + availableGroupIds.has(groupId), + ); + result.failedGroupIds = requestedGroupIds.filter( + (groupId) => !availableGroupIds.has(groupId), + ); + if (targetGroupIds.length === 0) return result; + + const uids = [...new Set(nodes.map((node) => node.uid))]; + + const syncedRes = await client + .from("my_concepts") + .select("source_local_id") + .eq("space_id", spaceId) + .eq("is_schema", false) + .in("source_local_id", uids); + if (syncedRes.error) throw syncedRes.error; + const syncedUids = new Set( + onlyStrings((syncedRes.data ?? []).map((row) => row.source_local_id)), + ); + + result.skippedUnsyncedUids = uids.filter((uid) => !syncedUids.has(uid)); + const syncedNodeUids = uids.filter((uid) => syncedUids.has(uid)); + if (syncedNodeUids.length === 0) return result; + + // Required dependency: the node-type schema concept, when it is synced too. + const types = [ + ...new Set( + nodes.filter((node) => syncedUids.has(node.uid)).map((node) => node.type), + ), + ]; + const schemaRes = await client + .from("my_concepts") + .select("source_local_id") + .eq("space_id", spaceId) + .eq("is_schema", true) + .in("source_local_id", types); + if (schemaRes.error) throw schemaRes.error; + const syncedSchemaIds = onlyStrings( + (schemaRes.data ?? []).map((row) => row.source_local_id), + ); + + const resourceIds = [...syncedNodeUids, ...syncedSchemaIds]; + + for (const groupId of targetGroupIds) { + // Existing reader/editor access is broader than partial, so leave it intact. + const spaceAccessRes = await client + .from("SpaceAccess") + .upsert( + { account_uid: groupId, space_id: spaceId, permissions: "partial" }, + { ignoreDuplicates: true }, + ); + if (!isIgnorableUpsertError(spaceAccessRes.error)) { + result.failedGroupIds.push(groupId); + continue; + } + + const grantRes = await client.from("ResourceAccess").upsert( + resourceIds.map((sourceLocalId) => ({ + account_uid: groupId, + source_local_id: sourceLocalId, + space_id: spaceId, + })), + { ignoreDuplicates: true }, + ); + if (!isIgnorableUpsertError(grantRes.error)) { + result.failedGroupIds.push(groupId); + continue; + } + + result.okGroupIds.push(groupId); + } + + result.publishedNodeUids = result.okGroupIds.length > 0 ? syncedNodeUids : []; + return result; +}; diff --git a/packages/database/src/lib/groups.ts b/packages/database/src/lib/groups.ts new file mode 100644 index 000000000..6a9b13f89 --- /dev/null +++ b/packages/database/src/lib/groups.ts @@ -0,0 +1,49 @@ +import type { DGSupabaseClient } from "./client"; + +export type MyGroup = { + id: string; + name: string; +}; + +export const getAvailableGroupIds = async ( + client: DGSupabaseClient, +): Promise => { + const { data, error } = await client + .from("group_membership") + .select("group_id") + .eq("member_id", (await client.auth.getUser()).data.user?.id || ""); + + if (error) { + console.error("Error fetching groups:", error); + throw new Error(`Failed to fetch groups: ${error.message}`); + } + + return (data || []).map((g) => g.group_id); +}; + +export const getMyGroups = async ( + client: DGSupabaseClient, +): Promise => { + const userId = (await client.auth.getUser()).data.user?.id ?? ""; + const { data, error } = await client + .from("group_membership") + .select("group_id, my_groups!group_id(name)") + .eq("member_id", userId); + + if (error) { + console.error("Error fetching groups:", error); + throw new Error(`Failed to fetch groups: ${error.message}`); + } + + return (data ?? []) + .filter( + (row): row is { group_id: string; my_groups: { name: string | null } } => + typeof row.group_id === "string" && + row.my_groups !== null && + typeof row.my_groups === "object", + ) + .map((row) => ({ + id: row.group_id, + name: row.my_groups.name ?? row.group_id, + })); +};