From ecc5a20986bcd75a2f17bb9d63f41f2ec26d9bf4 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 29 Jan 2026 16:41:22 +0100 Subject: [PATCH 1/5] feat: clone community --- .../(user)/communities/AddCommunityDialog.tsx | 137 +++- .../(user)/communities/CommunitiesClient.tsx | 35 + .../app/(user)/communities/CommunityTable.tsx | 15 +- .../communities/ExportTemplateButton.tsx | 153 +++++ .../app/(user)/communities/TemplateEditor.tsx | 167 +++++ .../communities/getCommunityTableColumns.tsx | 15 +- core/app/(user)/communities/page.tsx | 96 ++- .../app/(user)/communities/templateActions.ts | 245 +++++++ core/app/c/[communitySlug]/LoginSwitcher.tsx | 18 +- core/lib/server/communityTemplate/export.ts | 379 +++++++++++ core/lib/server/communityTemplate/index.ts | 4 + core/lib/server/communityTemplate/schema.ts | 614 ++++++++++++++++++ core/lib/server/communityTemplate/types.ts | 219 +++++++ core/lib/server/communityTemplate/validate.ts | 511 +++++++++++++++ core/prisma/seed/seedCommunity.ts | 43 +- 15 files changed, 2617 insertions(+), 34 deletions(-) create mode 100644 core/app/(user)/communities/CommunitiesClient.tsx create mode 100644 core/app/(user)/communities/ExportTemplateButton.tsx create mode 100644 core/app/(user)/communities/TemplateEditor.tsx create mode 100644 core/app/(user)/communities/templateActions.ts create mode 100644 core/lib/server/communityTemplate/export.ts create mode 100644 core/lib/server/communityTemplate/index.ts create mode 100644 core/lib/server/communityTemplate/schema.ts create mode 100644 core/lib/server/communityTemplate/types.ts create mode 100644 core/lib/server/communityTemplate/validate.ts diff --git a/core/app/(user)/communities/AddCommunityDialog.tsx b/core/app/(user)/communities/AddCommunityDialog.tsx index f792711e2f..d5944691c2 100644 --- a/core/app/(user)/communities/AddCommunityDialog.tsx +++ b/core/app/(user)/communities/AddCommunityDialog.tsx @@ -3,18 +3,51 @@ import React from "react" import { Button } from "ui/button" -import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "ui/dialog" -import { ListPlus } from "ui/icon" +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "ui/dialog" +import { CurlyBraces, Loader2, ListPlus } from "ui/icon" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs" import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip" +import { toast } from "ui/use-toast" +import { didSucceed, useServerAction } from "~/lib/serverActions" +import { EXAMPLE_TEMPLATE } from "~/lib/server/communityTemplate/types" import { AddCommunityForm } from "./AddCommunityForm" +import { createCommunityFromTemplateAction } from "./templateActions" +import { TemplateEditor, useTemplateEditor } from "./TemplateEditor" -export const AddCommunity = () => { +type AddCommunityProps = { + initialTemplate?: string +} + +export const AddCommunity = ({ initialTemplate }: AddCommunityProps) => { const [open, setOpen] = React.useState(false) + const [activeTab, setActiveTab] = React.useState("basic") + + // reset tab when dialog closes + React.useEffect(() => { + if (!open) { + setActiveTab("basic") + } + }, [open]) + + // if initial template is provided, open in template mode + React.useEffect(() => { + if (initialTemplate) { + setActiveTab("template") + setOpen(true) + } + }, [initialTemplate]) + return ( - Create a new community + Create a new community ) } + +type TemplateTabContentProps = { + setOpen: (open: boolean) => void + initialTemplate?: string +} + +const TemplateTabContent = ({ setOpen, initialTemplate }: TemplateTabContentProps) => { + const { value, setValue, isValid } = useTemplateEditor( + initialTemplate ?? JSON.stringify(EXAMPLE_TEMPLATE, null, 2) + ) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const runCreateFromTemplate = useServerAction(createCommunityFromTemplateAction) + + const handleSubmit = async () => { + if (!isValid) return + + setIsSubmitting(true) + try { + const result = await runCreateFromTemplate({ templateJson: value }) + if (didSucceed(result)) { + toast.success("Community created successfully") + setOpen(false) + } + } finally { + setIsSubmitting(false) + } + } + + const loadExample = () => { + setValue(JSON.stringify(EXAMPLE_TEMPLATE, null, 2)) + } + + return ( +
+
+

+ Paste or edit a community template JSON below. +

+ +
+ + + +
+ + +
+
+ ) +} diff --git a/core/app/(user)/communities/CommunitiesClient.tsx b/core/app/(user)/communities/CommunitiesClient.tsx new file mode 100644 index 0000000000..f41970b9c9 --- /dev/null +++ b/core/app/(user)/communities/CommunitiesClient.tsx @@ -0,0 +1,35 @@ +"use client" + +import type { TableCommunity } from "./getCommunityTableColumns" + +import * as React from "react" + +import { AddCommunity } from "./AddCommunityDialog" +import { CommunityTable } from "./CommunityTable" + +type CommunitiesClientProps = { + communities: TableCommunity[] +} + +export const CommunitiesClient = ({ communities }: CommunitiesClientProps) => { + const [templateForCopy, setTemplateForCopy] = React.useState() + const [dialogKey, setDialogKey] = React.useState(0) + + const handleCreateCopy = React.useCallback((template: string) => { + setTemplateForCopy(template) + // increment key to force re-mount of AddCommunity with new template + setDialogKey((k) => k + 1) + }, []) + + return ( + <> + + {templateForCopy && ( + + )} + + ) +} diff --git a/core/app/(user)/communities/CommunityTable.tsx b/core/app/(user)/communities/CommunityTable.tsx index dca6f93779..9553076f93 100644 --- a/core/app/(user)/communities/CommunityTable.tsx +++ b/core/app/(user)/communities/CommunityTable.tsx @@ -2,14 +2,25 @@ import type { TableCommunity } from "./getCommunityTableColumns" +import * as React from "react" import { useRouter } from "next/navigation" import { DataTable } from "~/app/components/DataTable/DataTable" import { getCommunityTableColumns } from "./getCommunityTableColumns" -export const CommunityTable = ({ communities }: { communities: TableCommunity[] }) => { - const communityTableColumns = getCommunityTableColumns() +type CommunityTableProps = { + communities: TableCommunity[] + onCreateCopy?: (template: string) => void +} + +export const CommunityTable = ({ communities, onCreateCopy }: CommunityTableProps) => { const router = useRouter() + + const communityTableColumns = React.useMemo( + () => getCommunityTableColumns({ onCreateCopy }), + [onCreateCopy] + ) + return ( void +} + +export const ExportTemplateButton = ({ + communityId, + communityName, + onCreateCopy, +}: ExportTemplateButtonProps) => { + const [open, setOpen] = React.useState(false) + const [template, setTemplate] = React.useState("") + const [isLoading, setIsLoading] = React.useState(false) + const runExport = useServerAction(exportCommunityTemplateAction) + + const handleExport = async () => { + setIsLoading(true) + setOpen(true) + + const result = await runExport({ communityId: communityId as CommunitiesId }) + if (didSucceed(result) && result.template) { + setTemplate(result.template) + } else { + setOpen(false) + } + setIsLoading(false) + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(template) + toast.success("Template copied to clipboard") + } catch { + toast.error("Failed to copy to clipboard") + } + } + + const handleDownload = () => { + const blob = new Blob([template], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${communityName.toLowerCase().replace(/\s+/g, "-")}-template.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast.success("Template downloaded") + } + + const handleCreateCopy = () => { + if (onCreateCopy) { + // modify the template to have a new slug + try { + const parsed = JSON.parse(template) + parsed.community.slug = `${parsed.community.slug}-copy` + parsed.community.name = `${parsed.community.name} (Copy)` + onCreateCopy(JSON.stringify(parsed, null, 2)) + setOpen(false) + } catch { + onCreateCopy(template) + setOpen(false) + } + } + } + + return ( + <> + { + e.preventDefault() + handleExport() + }} + className="gap-2" + > + + Export Template + + + + + Export Community Template + + This template contains the structure of "{communityName}" and can be used to + create a new community with the same configuration. + + + {isLoading ? ( +
+ +
+ ) : ( + <> + + +
+
+ + +
+
+ + {onCreateCopy && ( + + )} +
+
+ + )} +
+
+ + ) +} diff --git a/core/app/(user)/communities/TemplateEditor.tsx b/core/app/(user)/communities/TemplateEditor.tsx new file mode 100644 index 0000000000..5e974b0d93 --- /dev/null +++ b/core/app/(user)/communities/TemplateEditor.tsx @@ -0,0 +1,167 @@ +"use client" + +import type { ValidationResult } from "ui/monaco" + +import * as React from "react" + +import { cn } from "utils" + +import { AlertCircle, CheckCircle, Clipboard, CurlyBraces } from "ui/icon" +import { JsonEditor } from "ui/monaco" +import { toast } from "ui/use-toast" + +import { communityTemplateSchema } from "~/lib/server/communityTemplate/schema" +import { validateCommunityTemplate } from "~/lib/server/communityTemplate/validate" + +type TemplateEditorProps = { + value: string + onChange: (value: string) => void + readOnly?: boolean + height?: string | number + className?: string + showCopyButton?: boolean + showValidationSummary?: boolean +} + +export const TemplateEditor = ({ + value, + onChange, + readOnly = false, + height = "400px", + className, + showCopyButton = true, + showValidationSummary = true, +}: TemplateEditorProps) => { + const [validationResult, setValidationResult] = React.useState({ + valid: true, + errors: [], + }) + const [crossRefErrors, setCrossRefErrors] = React.useState([]) + + const handleValidate = React.useCallback( + (result: ValidationResult) => { + setValidationResult(result) + + // run cross-reference validation + if (result.valid && value.trim()) { + const crossRefResult = validateCommunityTemplate(value) + setCrossRefErrors(crossRefResult.errors.map((e) => e.message)) + } else { + setCrossRefErrors([]) + } + }, + [value] + ) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(value) + toast.success("Copied to clipboard") + } catch { + toast.error("Failed to copy to clipboard") + } + } + + const allErrors = [ + ...validationResult.errors.map((e) => e.message), + ...crossRefErrors, + ] + const isValid = validationResult.valid && crossRefErrors.length === 0 && value.trim().length > 0 + + return ( +
+
+ + {showCopyButton && ( + + )} +
+ + {showValidationSummary && ( +
0 + ? "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300" + : "bg-muted text-muted-foreground" + )} + > + {isValid ? ( + <> + + Template is valid and ready to use + + ) : allErrors.length > 0 ? ( + <> + +
+ + {allErrors.length} validation error{allErrors.length !== 1 ? "s" : ""} + +
    + {allErrors.slice(0, 5).map((error, i) => ( +
  • {error}
  • + ))} + {allErrors.length > 5 && ( +
  • ...and {allErrors.length - 5} more
  • + )} +
+
+ + ) : ( + <> + + Enter a valid community template JSON + + )} +
+ )} +
+ ) +} + +// hook for managing template editor state +export const useTemplateEditor = (initialValue = "") => { + const [value, setValue] = React.useState(initialValue) + const [isValid, setIsValid] = React.useState(false) + + React.useEffect(() => { + if (!value.trim()) { + setIsValid(false) + return + } + + try { + JSON.parse(value) + const crossRefResult = validateCommunityTemplate(value) + setIsValid(crossRefResult.valid) + } catch { + setIsValid(false) + } + }, [value]) + + return { + value, + setValue, + isValid, + } +} diff --git a/core/app/(user)/communities/getCommunityTableColumns.tsx b/core/app/(user)/communities/getCommunityTableColumns.tsx index f2c8d75b1a..6e8fa013f5 100644 --- a/core/app/(user)/communities/getCommunityTableColumns.tsx +++ b/core/app/(user)/communities/getCommunityTableColumns.tsx @@ -17,6 +17,7 @@ import { } from "ui/dropdown-menu" import { MoreVertical } from "ui/icon" +import { ExportTemplateButton } from "./ExportTemplateButton" import { RemoveCommunityButton } from "./RemoveCommunityButton" export type TableCommunity = { @@ -27,7 +28,11 @@ export type TableCommunity = { created: Date } -export const getCommunityTableColumns = () => +type GetCommunityTableColumnsOptions = { + onCreateCopy?: (template: string) => void +} + +export const getCommunityTableColumns = (options?: GetCommunityTableColumnsOptions) => [ { id: "select", @@ -94,7 +99,13 @@ export const getCommunityTableColumns = () => - Menu + Actions + +
diff --git a/core/app/(user)/communities/page.tsx b/core/app/(user)/communities/page.tsx index aa145a5238..95e6dbe539 100644 --- a/core/app/(user)/communities/page.tsx +++ b/core/app/(user)/communities/page.tsx @@ -1,9 +1,11 @@ import type { TableCommunity } from "./getCommunityTableColumns" +import { Layers, Calendar } from "ui/icon" + import { db } from "~/kysely/database" import { getPageLoginData } from "~/lib/authentication/loginData" import { AddCommunity } from "./AddCommunityDialog" -import { CommunityTable } from "./CommunityTable" +import { CommunitiesClient } from "./CommunitiesClient" export const metadata = { title: "Communities", @@ -25,9 +27,10 @@ export default async function Page() { "communities.avatar", "createdAt", ]) + .orderBy("createdAt", "desc") .execute() - const tableMembers = communities.map((community) => { + const tableCommunities = communities.map((community) => { const { id, name, slug, avatar, createdAt } = community return { id, @@ -37,15 +40,90 @@ export default async function Page() { created: new Date(createdAt), } satisfies TableCommunity }) + return ( - <> -
-

Communities

- +
+
+
+
+

+ + Communities +

+

+ Manage all communities on this PubPub instance. Create new communities + from scratch or use templates to duplicate existing configurations. +

+
+ +
-
- + +
+ } + /> + } + /> + } + />
- + + {tableCommunities.length === 0 ? ( + + ) : ( +
+ +
+ )} +
) } + +type StatCardProps = { + label: string + value: number | string + icon: React.ReactNode + isText?: boolean +} + +const StatCard = ({ label, value, icon, isText }: StatCardProps) => ( +
+
+ {icon} + {label} +
+
+ {value} +
+
+) + +const EmptyState = () => ( +
+ +

No communities yet

+

+ Create your first community to get started with PubPub. +

+ +
+) + +const countCreatedThisMonth = (communities: TableCommunity[]) => { + const now = new Date() + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1) + return communities.filter((c) => c.created >= startOfMonth).length +} diff --git a/core/app/(user)/communities/templateActions.ts b/core/app/(user)/communities/templateActions.ts new file mode 100644 index 0000000000..aa70674b76 --- /dev/null +++ b/core/app/(user)/communities/templateActions.ts @@ -0,0 +1,245 @@ +"use server" + +import type { CommunitiesId } from "db/public" + +import { revalidatePath } from "next/cache" + +import { MemberRole } from "db/public" + +import type { CommunityTemplate } from "~/lib/server/communityTemplate" + +import { db } from "~/kysely/database" +import { isUniqueConstraintError } from "~/kysely/errors" +import { getLoginData } from "~/lib/authentication/loginData" +import { + communityTemplateSchema, + exportCommunityTemplate as exportTemplate, + validateCommunityTemplateQuick, +} from "~/lib/server/communityTemplate" +import { defineServerAction } from "~/lib/server/defineServerAction" +import { seedCommunity } from "~/prisma/seed/seedCommunity" +import { logger } from "logger" +import { maybeWithTrx } from "~/lib/server/maybeWithTrx" + +export const exportCommunityTemplateAction = defineServerAction( + async function exportCommunityTemplateAction({ communityId }: { communityId: CommunitiesId }) { + const { user } = await getLoginData() + + if (!user) { + return { + title: "Failed to export template", + error: "Not logged in", + } + } + + if (!user.isSuperAdmin) { + return { + title: "Failed to export template", + error: "User is not a super admin", + } + } + + try { + const template = await exportTemplate(communityId) + return { template: JSON.stringify(template, null, 2) } + } catch (error) { + logger.error({ msg: "Failed to export template", err: error }) + return { + title: "Failed to export template", + error: "An unexpected error occurred while exporting", + cause: error, + } + } + } +) + +export const createCommunityFromTemplateAction = defineServerAction( + async function createCommunityFromTemplateAction({ templateJson }: { templateJson: string }) { + const { user } = await getLoginData() + + if (!user) { + return { + title: "Failed to create community", + error: "Not logged in", + } + } + + if (!user.isSuperAdmin) { + return { + title: "Failed to create community", + error: "User is not a super admin", + } + } + + let template: CommunityTemplate + try { + template = JSON.parse(templateJson) + } catch (error) { + logger.error("Failed to create community", { error }) + return { + title: "Failed to create community", + error: "Invalid JSON", + cause: error, + } + } + + // validate cross-references + const validation = validateCommunityTemplateQuick(template) + if (!validation.valid) { + return { + title: "Failed to create community", + error: `Template validation failed: ${validation.errors.join(", ")}`, + } + } + + // transform template to seedCommunity format + try { + // build the seed input from the template + const seedInput = transformTemplateToSeedInput(template, user.id) + + const result= await maybeWithTrx(db, async (trx) => { + const result = await seedCommunity(seedInput, { randomSlug: false, }, trx) + + // add current user as admin if they werent included + const userIsMember = Object.values(template.users ?? {}).some( + (u) => u.email === user.email + ) + if (!userIsMember) { + await trx + .insertInto("community_memberships") + .values({ + userId: user.id, + communityId: result.community.id, + role: MemberRole.admin, + }) + .execute() + } + + return result + + }) + + revalidatePath("/") + return { communitySlug: result.community.slug } + } catch (error) { + logger.error({ msg: "Failed to create community", err: error }) + if (isUniqueConstraintError(error) && error.constraint === "communities_slug_key") { + return { + title: "Failed to create community", + error: "A community with that slug already exists", + cause: error, + } + } + return { + title: "Failed to create community", + error: "An unexpected error occurred while creating the community", + cause: error, + } + } + } +) + +export const getCommunityTemplateSchemaAction = defineServerAction( + async function getCommunityTemplateSchemaAction() { + return { schema: communityTemplateSchema } + } +) + +// transform the template type to the seed input format +// we use 'any' casts because the template types are intentionally looser +// than the seed input types to allow JSON editing, but we've already validated +function transformTemplateToSeedInput(template: CommunityTemplate, _currentUserId: string) { + const pubFields = template.pubFields ?? {} + const pubTypes = template.pubTypes ?? {} + const users = template.users ?? {} + const stages = template.stages ?? {} + const stageConnections = template.stageConnections ?? {} + const forms = template.forms ?? {} + const pubs = template.pubs ?? [] + const apiTokens = template.apiTokens ?? {} + + // transform users - add passwords if not provided + const transformedUsers: Record = {} + for (const [slug, user] of Object.entries(users)) { + transformedUsers[slug] = { + ...user, + // generate a random password if not provided + password: `temp-${crypto.randomUUID()}`, + } + } + + // // ensure there's at least one admin user + // if (Object.keys(transformedUsers).length === 0) { + // transformedUsers["template-admin"] = { + // email: `template-admin-${crypto.randomUUID()}@temp.local`, + // firstName: "Template", + // lastName: "Admin", + // role: MemberRole.admin, + // password: `temp-${crypto.randomUUID()}`, + // } + // } + + // transform stages with automations + const transformedStages: Record = {} + for (const [name, stage] of Object.entries(stages)) { + const transformedAutomations: Record = {} + if (stage.automations) { + for (const [autoName, automation] of Object.entries(stage.automations)) { + transformedAutomations[autoName] = { + ...automation, + triggers: automation.triggers.map((t) => ({ + ...t, + config: t.config ?? {}, + })), + actions: automation.actions.map((a) => ({ + ...a, + config: a.config ?? {}, + })), + } + } + } + + transformedStages[name] = { + members: stage.members ?? {}, + ...(Object.keys(transformedAutomations).length > 0 + ? { automations: transformedAutomations } + : {}), + } + } + + // transform forms + const transformedForms: Record = {} + for (const [name, form] of Object.entries(forms)) { + transformedForms[name] = { + ...form, + elements: form.elements.map((el) => { + if (el.type === "pubfield") { + return { + ...el, + config: el.config ?? {}, + } + } + return el + }), + } + } + + // transform pubs + const transformedPubs = pubs.map((pub) => ({ + ...pub, + values: pub.values ?? {}, + })) + + // cast to any since template types are intentionally looser than seed types + return { + community: template.community, + pubFields: pubFields as any, + pubTypes: pubTypes as any, + users: transformedUsers, + stages: transformedStages, + stageConnections: stageConnections as any, + forms: transformedForms, + pubs: transformedPubs as any, + apiTokens: apiTokens as any, + } +} diff --git a/core/app/c/[communitySlug]/LoginSwitcher.tsx b/core/app/c/[communitySlug]/LoginSwitcher.tsx index 362cb80fac..aa98f506c8 100644 --- a/core/app/c/[communitySlug]/LoginSwitcher.tsx +++ b/core/app/c/[communitySlug]/LoginSwitcher.tsx @@ -1,7 +1,7 @@ import Link from "next/link" import { Button } from "ui/button" -import { ChevronsUpDown, UserRoundCog } from "ui/icon" +import { ChevronsUpDown, Layers, UserRoundCog } from "ui/icon" import { Popover, PopoverContent, PopoverTrigger } from "ui/popover" import { Separator } from "ui/separator" import { SidebarMenuButton } from "ui/sidebar" @@ -52,6 +52,22 @@ export default async function LoginSwitcher() { Settings + {user.isSuperAdmin && ( + + )} { + return items.map((item) => { + if (item.kind === "condition") { + return { + kind: "condition" as const, + type: "jsonata" as const, + expression: item.expression, + } + } + return { + kind: "block" as const, + type: item.type, + items: transformConditionItems(item.items), + } + }) +} + +export const exportCommunityTemplate = async ( + communityId: CommunitiesId +): Promise => { + // fetch community + const community = await db + .selectFrom("communities") + .select(["name", "slug", "avatar"]) + .where("id", "=", communityId) + .executeTakeFirstOrThrow() + + // fetch pub fields + const pubFields = await db + .selectFrom("pub_fields") + .select(["name", "schemaName", "isRelation"]) + .where("communityId", "=", communityId) + .execute() + + const pubFieldsTemplate: Record = {} + for (const field of pubFields) { + if (!field.schemaName) continue + pubFieldsTemplate[field.name] = { + schemaName: field.schemaName, + ...(field.isRelation ? { relation: true as const } : {}), + } + } + + // fetch pub types with their fields + const pubTypes = await db + .selectFrom("pub_types") + .select(["pub_types.id", "pub_types.name"]) + .select((eb) => + jsonArrayFrom( + eb + .selectFrom("_PubFieldToPubType") + .innerJoin("pub_fields", "pub_fields.id", "_PubFieldToPubType.A") + .select(["pub_fields.name", "_PubFieldToPubType.isTitle"]) + .whereRef("_PubFieldToPubType.B", "=", "pub_types.id") + ).as("fields") + ) + .where("communityId", "=", communityId) + .execute() + + const pubTypesTemplate: Record> = {} + for (const pubType of pubTypes) { + pubTypesTemplate[pubType.name] = {} + for (const field of pubType.fields) { + pubTypesTemplate[pubType.name][field.name] = { isTitle: field.isTitle } + } + } + + // fetch stages with automations + const stages = await db + .selectFrom("stages") + .select(["stages.id", "stages.name"]) + .select((eb) => + jsonArrayFrom( + eb + .selectFrom("stage_memberships") + .innerJoin("users", "users.id", "stage_memberships.userId") + .select(["users.slug", "stage_memberships.role"]) + .whereRef("stage_memberships.stageId", "=", "stages.id") + ).as("members") + ) + .where("communityId", "=", communityId) + .execute() + + // fetch automations + const automations = await db + .selectFrom("automations") + .select([ + "automations.id", + "automations.name", + "automations.stageId", + "automations.icon", + "automations.conditionEvaluationTiming", + "automations.resolver", + ]) + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom("automation_triggers") + .select([ + "automation_triggers.event", + "automation_triggers.config", + "automation_triggers.sourceAutomationId", + ]) + .whereRef("automation_triggers.automationId", "=", "automations.id") + ).as("triggers"), + jsonArrayFrom( + eb + .selectFrom("action_instances") + .select([ + "action_instances.action", + "action_instances.config", + ]) + .whereRef("action_instances.automationId", "=", "automations.id") + .orderBy("action_instances.createdAt", "asc") + ).as("actions"), + jsonArrayFrom( + eb + .selectFrom("automation_condition_blocks") + .select([ + "automation_condition_blocks.type", + "automation_condition_blocks.id", + ]) + .whereRef("automation_condition_blocks.automationId", "=", "automations.id") + .where("automation_condition_blocks.automationConditionBlockId", "is", null) + ).as("conditionBlocks"), + ]) + .where("communityId", "=", communityId) + .execute() + + // build automation id to name map for resolving sourceAutomation references + const automationIdToName = new Map() + for (const automation of automations) { + automationIdToName.set(automation.id, automation.name) + } + + // fetch condition block items + const conditionBlockIds = automations + .flatMap((a) => a.conditionBlocks) + .map((cb) => cb.id) + + let conditionItemsMap = new Map() + if (conditionBlockIds.length > 0) { + // fetch conditions for each block + const conditions = await db + .selectFrom("automation_conditions") + .select([ + "automationConditionBlockId", + "type", + "expression", + ]) + .where("automationConditionBlockId", "in", conditionBlockIds) + .execute() + + // group conditions by block id + for (const condition of conditions) { + const items = conditionItemsMap.get(condition.automationConditionBlockId) ?? [] + items.push({ + kind: "condition" as const, + type: "jsonata" as const, + expression: condition.expression ?? "", + }) + conditionItemsMap.set(condition.automationConditionBlockId, items) + } + } + + // build stages template + const stagesTemplate: Record = {} + for (const stage of stages) { + const stageAutomations = automations.filter((a) => a.stageId === stage.id) + + const automationsTemplate: Record = {} + for (const automation of stageAutomations) { + const conditionBlock = automation.conditionBlocks[0] + let condition: TemplateAutomation["condition"] = undefined + + if (conditionBlock) { + const items = conditionItemsMap.get(conditionBlock.id) ?? [] + condition = { + type: conditionBlock.type, + items: transformConditionItems(items), + } + } + + automationsTemplate[automation.name] = { + ...(automation.icon ? { icon: automation.icon as IconConfig } : {}), + ...(automation.conditionEvaluationTiming + ? { timing: automation.conditionEvaluationTiming } + : {}), + ...(automation.resolver ? { resolver: automation.resolver } : {}), + ...(condition ? { condition } : {}), + triggers: automation.triggers.map((t) => ({ + event: t.event, + config: t.config as Record, + ...(t.sourceAutomationId + ? { sourceAutomation: automationIdToName.get(t.sourceAutomationId) } + : {}), + })), + actions: automation.actions.map((a) => ({ + action: a.action, + config: (a.config ?? {}) as Record, + })), + } + } + + const membersTemplate: Record = {} + for (const member of stage.members) { + membersTemplate[member.slug] = member.role + } + + stagesTemplate[stage.name] = { + ...(Object.keys(membersTemplate).length > 0 ? { members: membersTemplate as any } : {}), + ...(Object.keys(automationsTemplate).length > 0 + ? { automations: automationsTemplate } + : {}), + } + } + + // fetch stage connections + const moveConstraints = await db + .selectFrom("move_constraint") + .innerJoin("stages as source", "source.id", "move_constraint.stageId") + .innerJoin("stages as dest", "dest.id", "move_constraint.destinationId") + .select(["source.name as sourceName", "dest.name as destName"]) + .where("source.communityId", "=", communityId) + .execute() + + const stageConnectionsTemplate: Record = {} + for (const constraint of moveConstraints) { + if (!stageConnectionsTemplate[constraint.sourceName]) { + stageConnectionsTemplate[constraint.sourceName] = {} + } + if (!stageConnectionsTemplate[constraint.sourceName].to) { + stageConnectionsTemplate[constraint.sourceName].to = [] + } + stageConnectionsTemplate[constraint.sourceName].to!.push(constraint.destName) + } + + // fetch forms + const forms = await db + .selectFrom("forms") + .innerJoin("pub_types", "pub_types.id", "forms.pubTypeId") + .select([ + "forms.name", + "forms.slug", + "forms.access", + "forms.isArchived", + "forms.isDefault", + "pub_types.name as pubTypeName", + ]) + .select((eb) => + jsonArrayFrom( + eb + .selectFrom("form_elements") + .leftJoin("pub_fields", "pub_fields.id", "form_elements.fieldId") + .select([ + "form_elements.type", + "form_elements.component", + "form_elements.config", + "form_elements.content", + "form_elements.label", + "form_elements.element", + "pub_fields.name as fieldName", + ]) + .select((eb) => + jsonArrayFrom( + eb + .selectFrom("_FormElementToPubType") + .innerJoin("pub_types", "pub_types.id", "_FormElementToPubType.B") + .select(["pub_types.name"]) + .whereRef("_FormElementToPubType.A", "=", "form_elements.id") + ).as("relatedPubTypes") + ) + .whereRef("form_elements.formId", "=", "forms.id") + .orderBy("form_elements.rank", "asc") + ).as("elements") + ) + .where("forms.communityId", "=", communityId) + .execute() + + const formsTemplate: Record = {} + for (const form of forms) { + const elements: TemplateFormElement[] = form.elements.map((el) => { + if (el.type === ElementType.pubfield) { + return { + type: ElementType.pubfield, + field: el.fieldName ?? "", + component: el.component, + config: (el.config ?? {}) as Record, + ...(el.relatedPubTypes.length > 0 + ? { relatedPubTypes: el.relatedPubTypes.map((rpt) => rpt.name) } + : {}), + } + } + if (el.type === ElementType.structural) { + return { + type: ElementType.structural, + element: el.element!, + content: el.content ?? "", + } + } + // button type - need to find the stage from config + const config = el.config as Record | null + const stageId = config?.stageId as string | undefined + const stageName = stages.find((s) => s.id === stageId)?.name ?? "" + return { + type: ElementType.button, + label: el.label ?? "", + content: el.content ?? "", + stage: stageName, + } + }) + + formsTemplate[form.name] = { + pubType: form.pubTypeName, + ...(form.slug ? { slug: form.slug } : {}), + ...(form.access ? { access: form.access } : {}), + ...(form.isArchived ? { isArchived: form.isArchived } : {}), + ...(form.isDefault ? { isDefault: form.isDefault } : {}), + elements, + } + } + + // build the template + const template: CommunityTemplate = { + community: { + name: community.name, + slug: community.slug, + ...(community.avatar ? { avatar: community.avatar } : {}), + }, + } + + if (Object.keys(pubFieldsTemplate).length > 0) { + template.pubFields = pubFieldsTemplate + } + + if (Object.keys(pubTypesTemplate).length > 0) { + template.pubTypes = pubTypesTemplate + } + + if (Object.keys(stagesTemplate).length > 0) { + template.stages = stagesTemplate + } + + if (Object.keys(stageConnectionsTemplate).length > 0) { + template.stageConnections = stageConnectionsTemplate + } + + if (Object.keys(formsTemplate).length > 0) { + template.forms = formsTemplate + } + + // note: we intentionally do not export users (security) or pubs (can be very large) + // users can be added manually to the template if needed + + return template +} diff --git a/core/lib/server/communityTemplate/index.ts b/core/lib/server/communityTemplate/index.ts new file mode 100644 index 0000000000..22bf770433 --- /dev/null +++ b/core/lib/server/communityTemplate/index.ts @@ -0,0 +1,4 @@ +export * from "./types" +export * from "./schema" +export * from "./validate" +export * from "./export" diff --git a/core/lib/server/communityTemplate/schema.ts b/core/lib/server/communityTemplate/schema.ts new file mode 100644 index 0000000000..182057672d --- /dev/null +++ b/core/lib/server/communityTemplate/schema.ts @@ -0,0 +1,614 @@ +// json schema for community templates +// this schema is used by monaco editor for validation + +export type JSONSchema = { + $schema?: string + $ref?: string + $defs?: Record + title?: string + description?: string + type?: string | string[] + properties?: Record + additionalProperties?: boolean | JSONSchema + items?: JSONSchema | JSONSchema[] + required?: string[] + enum?: (string | number | boolean | null)[] + const?: unknown + allOf?: JSONSchema[] + oneOf?: JSONSchema[] + anyOf?: JSONSchema[] + not?: JSONSchema + if?: JSONSchema + then?: JSONSchema + else?: JSONSchema + minItems?: number + maxItems?: number + minLength?: number + maxLength?: number + pattern?: string + minimum?: number + maximum?: number + default?: unknown + format?: string +} + +const coreSchemaTypes = [ + "String", + "Boolean", + "Vector3", + "DateTime", + "Email", + "URL", + "MemberId", + "FileUpload", + "Null", + "Number", + "NumericArray", + "StringArray", + "RichText", + "Color", +] as const + +const memberRoles = ["admin", "editor", "contributor"] as const + +const automationEvents = [ + "pubEnteredStage", + "pubLeftStage", + "pubInStageForDuration", + "automationSucceeded", + "automationFailed", + "webhook", + "manual", +] as const + +const actions = [ + "log", + "email", + "http", + "move", + "googleDriveImport", + "datacite", + "buildJournalSite", + "createPub", + "buildSite", +] as const + +const elementTypes = ["pubfield", "structural", "button"] as const + +const inputComponents = [ + "textArea", + "textInput", + "datePicker", + "checkbox", + "fileUpload", + "memberSelect", + "confidenceInterval", + "checkboxGroup", + "radioGroup", + "selectDropdown", + "multivalueInput", + "richText", + "relationBlock", + "colorPicker", +] as const + +const structuralFormElements = ["h2", "h3", "p", "hr"] as const + +const formAccessTypes = ["private", "public"] as const + +const conditionBlockTypes = ["AND", "OR", "NOT"] as const + +const conditionEvaluationTimings = ["onTrigger", "onExecution", "both"] as const + +export const createCommunityTemplateSchema = (): JSONSchema => { + const conditionItemRef: JSONSchema = { + $ref: "#/$defs/conditionItem", + } + + const conditionItem: JSONSchema = { + oneOf: [ + { + type: "object", + properties: { + kind: { const: "condition" }, + type: { const: "jsonata" }, + expression: { + type: "string", + description: "JSONata expression to evaluate", + }, + }, + required: ["kind", "type", "expression"], + additionalProperties: false, + }, + { + type: "object", + properties: { + kind: { const: "block" }, + type: { + enum: [...conditionBlockTypes], + description: "Logical operator for combining conditions", + }, + items: { + type: "array", + items: conditionItemRef, + description: "Nested conditions", + }, + }, + required: ["kind", "type", "items"], + additionalProperties: false, + }, + ], + } + + const automationTrigger: JSONSchema = { + type: "object", + properties: { + event: { + enum: [...automationEvents], + description: "The event that triggers this automation", + }, + config: { + type: "object", + description: "Event-specific configuration", + additionalProperties: true, + }, + sourceAutomation: { + type: "string", + description: "Name of source automation for chained triggers", + }, + }, + required: ["event", "config"], + additionalProperties: false, + } + + const automationAction: JSONSchema = { + type: "object", + properties: { + action: { + enum: [...actions], + description: "The action to execute", + }, + name: { + type: "string", + description: "Display name for the action", + }, + config: { + type: "object", + description: "Action-specific configuration", + additionalProperties: true, + }, + }, + required: ["action", "config"], + additionalProperties: false, + } + + const iconConfig: JSONSchema = { + type: "object", + properties: { + name: { type: "string", description: "Icon name from lucide-react" }, + color: { type: "string", description: "Icon color (hex or named)" }, + }, + required: ["name"], + additionalProperties: false, + } + + const automation: JSONSchema = { + type: "object", + properties: { + icon: iconConfig, + sourceAutomation: { + type: "string", + description: "Reference to another automation", + }, + timing: { + enum: [...conditionEvaluationTimings], + description: "When to evaluate conditions", + }, + condition: { + type: "object", + properties: { + type: { + enum: [...conditionBlockTypes], + description: "Root condition block type", + }, + items: { + type: "array", + items: conditionItemRef, + }, + }, + required: ["type", "items"], + additionalProperties: false, + }, + resolver: { + type: "string", + description: "JSONata expression to resolve a different pub", + }, + triggers: { + type: "array", + items: automationTrigger, + minItems: 1, + description: "Events that trigger this automation", + }, + actions: { + type: "array", + items: automationAction, + minItems: 1, + description: "Actions to execute when triggered", + }, + }, + required: ["triggers", "actions"], + additionalProperties: false, + } + + const pubField: JSONSchema = { + type: "object", + properties: { + schemaName: { + enum: [...coreSchemaTypes], + description: "The schema type for this field", + }, + relation: { + type: "boolean", + description: "Whether this field is a relation to other pubs", + }, + }, + required: ["schemaName"], + additionalProperties: false, + } + + const pubTypeField: JSONSchema = { + type: "object", + properties: { + isTitle: { + type: "boolean", + description: "Whether this field is used as the pub title", + }, + }, + required: ["isTitle"], + additionalProperties: false, + } + + const stage: JSONSchema = { + type: "object", + properties: { + members: { + type: "object", + additionalProperties: { + enum: [...memberRoles], + }, + description: "User slugs mapped to their roles in this stage", + }, + automations: { + type: "object", + additionalProperties: automation, + description: "Automations attached to this stage", + }, + }, + additionalProperties: false, + } + + const stageConnections: JSONSchema = { + type: "object", + additionalProperties: { + type: "object", + properties: { + to: { + type: "array", + items: { type: "string" }, + description: "Stages this stage can move pubs to", + }, + from: { + type: "array", + items: { type: "string" }, + description: "Stages that can move pubs to this stage", + }, + }, + additionalProperties: false, + }, + } + + const user: JSONSchema = { + type: "object", + properties: { + email: { + type: "string", + format: "email", + description: "User email address", + }, + firstName: { type: "string", description: "User first name" }, + lastName: { type: "string", description: "User last name" }, + avatar: { + type: "string", + format: "uri", + description: "URL to user avatar", + }, + role: { + oneOf: [{ enum: [...memberRoles] }, { type: "null" }], + description: "Community membership role (null for no membership)", + }, + isSuperAdmin: { + type: "boolean", + description: "Whether user is a super admin", + }, + }, + additionalProperties: false, + } + + const formElementPubField: JSONSchema = { + type: "object", + properties: { + type: { const: "pubfield" }, + field: { + type: "string", + description: "Name of the pub field", + }, + component: { + oneOf: [{ enum: [...inputComponents] }, { type: "null" }], + description: "Input component to use", + }, + config: { + type: "object", + additionalProperties: true, + description: "Component configuration", + }, + relatedPubTypes: { + type: "array", + items: { type: "string" }, + description: "For relation fields, which pub types can be related", + }, + }, + required: ["type", "field", "component", "config"], + additionalProperties: false, + } + + const formElementStructural: JSONSchema = { + type: "object", + properties: { + type: { const: "structural" }, + element: { + enum: [...structuralFormElements], + description: "HTML element type", + }, + content: { + type: "string", + description: "Markdown content", + }, + }, + required: ["type", "element", "content"], + additionalProperties: false, + } + + const formElementButton: JSONSchema = { + type: "object", + properties: { + type: { const: "button" }, + label: { + type: "string", + description: "Button label", + }, + content: { + type: "string", + description: "Success message content", + }, + stage: { + type: "string", + description: "Stage to move pub to on submit", + }, + }, + required: ["type", "label", "content", "stage"], + additionalProperties: false, + } + + const formElement: JSONSchema = { + oneOf: [formElementPubField, formElementStructural, formElementButton], + } + + const form: JSONSchema = { + type: "object", + properties: { + access: { + enum: [...formAccessTypes], + description: "Form access type", + }, + isArchived: { + type: "boolean", + description: "Whether the form is archived", + }, + slug: { + type: "string", + description: "URL-friendly identifier", + }, + pubType: { + type: "string", + description: "Name of the pub type this form creates", + }, + members: { + type: "array", + items: { type: "string" }, + description: "User slugs with form access", + }, + isDefault: { + type: "boolean", + description: "Whether this is the default form for the pub type", + }, + elements: { + type: "array", + items: formElement, + description: "Form elements", + }, + }, + required: ["pubType", "elements"], + additionalProperties: false, + } + + const pubRef: JSONSchema = { + $ref: "#/$defs/pub", + } + + const relatedPub: JSONSchema = { + type: "object", + properties: { + value: { + description: "Relation metadata value", + }, + pub: pubRef, + }, + required: ["pub"], + additionalProperties: false, + } + + const pub: JSONSchema = { + type: "object", + properties: { + id: { + type: "string", + format: "uuid", + description: "Optional fixed UUID for the pub", + }, + pubType: { + type: "string", + description: "Name of the pub type", + }, + values: { + type: "object", + additionalProperties: true, + description: "Field values for the pub", + }, + stage: { + type: "string", + description: "Name of the stage to place the pub in", + }, + members: { + type: "object", + additionalProperties: { + enum: [...memberRoles], + }, + description: "User slugs mapped to their roles on this pub", + }, + relatedPubs: { + type: "object", + additionalProperties: { + type: "array", + items: relatedPub, + }, + description: "Related pubs by field name", + }, + }, + required: ["pubType", "values"], + additionalProperties: false, + } + + const apiToken: JSONSchema = { + type: "object", + properties: { + description: { + type: "string", + description: "Token description", + }, + permissions: { + oneOf: [ + { const: true, description: "Full permissions" }, + { + type: "object", + additionalProperties: true, + description: "Granular permissions", + }, + ], + }, + }, + additionalProperties: false, + } + + const community: JSONSchema = { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + description: "Community display name", + }, + slug: { + type: "string", + minLength: 1, + pattern: "^[a-z0-9-]+$", + description: "URL-friendly identifier (lowercase, numbers, hyphens)", + }, + avatar: { + type: "string", + format: "uri", + description: "URL to community avatar image", + }, + }, + required: ["name", "slug"], + additionalProperties: false, + } + + return { + $schema: "http://json-schema.org/draft-07/schema#", + title: "Community Template", + description: + "Schema for PubPub community templates. Use this to create or copy communities with predefined structure.", + type: "object", + $defs: { + conditionItem, + pub, + }, + properties: { + community: { + ...community, + description: "Basic community information", + }, + pubFields: { + type: "object", + additionalProperties: pubField, + description: + "Pub fields define the schema for data stored in pubs. Keys are field names.", + }, + pubTypes: { + type: "object", + additionalProperties: { + type: "object", + additionalProperties: pubTypeField, + description: "Map of field names to their configuration", + }, + description: + "Pub types define the shape of pubs. Keys are type names, values map field names to config.", + }, + users: { + type: "object", + additionalProperties: user, + description: + "Users to create. Keys are user slugs used for referencing elsewhere.", + }, + stages: { + type: "object", + additionalProperties: stage, + description: "Workflow stages. Keys are stage names.", + }, + stageConnections: { + ...stageConnections, + description: "Define which stages can move pubs to other stages.", + }, + pubs: { + type: "array", + items: pub, + description: "Initial pubs to create in the community.", + }, + forms: { + type: "object", + additionalProperties: form, + description: "Forms for creating and editing pubs. Keys are form titles.", + }, + apiTokens: { + type: "object", + additionalProperties: apiToken, + description: "API tokens for programmatic access. Keys are token names.", + }, + }, + required: ["community"], + additionalProperties: false, + } +} + +// pre-built schema for export +export const communityTemplateSchema = createCommunityTemplateSchema() diff --git a/core/lib/server/communityTemplate/types.ts b/core/lib/server/communityTemplate/types.ts new file mode 100644 index 0000000000..8705166076 --- /dev/null +++ b/core/lib/server/communityTemplate/types.ts @@ -0,0 +1,219 @@ +import type { + Action, + AutomationConditionBlockType, + AutomationEvent, + ConditionEvaluationTiming, + CoreSchemaType, + ElementType, + FormAccessType, + InputComponent, + MemberRole, + StructuralFormElement, +} from "db/public" +import type { IconConfig } from "ui/dynamic-icon" + +// condition items for automations +export type TemplateConditionItem = + | { + kind: "condition" + type: "jsonata" + expression: string + } + | { + kind: "block" + type: AutomationConditionBlockType + items: TemplateConditionItem[] + } + +// automation trigger configuration +export type TemplateAutomationTrigger = { + event: AutomationEvent + config: Record + sourceAutomation?: string +} + +// automation action configuration +export type TemplateAutomationAction = { + action: Action + name?: string + config: Record +} + +// single automation definition +export type TemplateAutomation = { + icon?: IconConfig + sourceAutomation?: string + timing?: ConditionEvaluationTiming + condition?: { + type: AutomationConditionBlockType + items: TemplateConditionItem[] + } + resolver?: string + triggers: TemplateAutomationTrigger[] + actions: TemplateAutomationAction[] +} + +// pub field definition +export type TemplatePubField = { + schemaName: CoreSchemaType + relation?: true +} + +// pub type field mapping +export type TemplatePubTypeField = { + isTitle: boolean +} + +// stage definition +export type TemplateStage = { + members?: Record + automations?: Record +} + +// stage connections +export type TemplateStageConnections = Record< + string, + { + to?: string[] + from?: string[] + } +> + +// user definition (passwords will be handled separately for security) +export type TemplateUser = { + email?: string + firstName?: string + lastName?: string + avatar?: string + role?: MemberRole | null + isSuperAdmin?: boolean +} + +// form element - pub field type +export type TemplateFormElementPubField = { + type: typeof ElementType.pubfield + field: string + component: InputComponent | null + config: Record + relatedPubTypes?: string[] +} + +// form element - structural type +export type TemplateFormElementStructural = { + type: typeof ElementType.structural + element: StructuralFormElement + content: string +} + +// form element - button type +export type TemplateFormElementButton = { + type: typeof ElementType.button + label: string + content: string + stage: string +} + +export type TemplateFormElement = + | TemplateFormElementPubField + | TemplateFormElementStructural + | TemplateFormElementButton + +// form definition +export type TemplateForm = { + access?: FormAccessType + isArchived?: boolean + slug?: string + pubType: string + members?: string[] + isDefault?: boolean + elements: TemplateFormElement[] +} + +// pub value - can be a simple value or a relation reference +export type TemplatePubValue = unknown | Array<{ value: unknown; relatedPubId: string }> + +// related pub inline definition +export type TemplateRelatedPub = { + value?: unknown + pub: TemplatePub +} + +// pub definition +export type TemplatePub = { + id?: string + pubType: string + values: Record + stage?: string + members?: Record + relatedPubs?: Record +} + +// api token definition +export type TemplateApiToken = { + description?: string + permissions?: Record | true +} + +// the main community template type +export type CommunityTemplate = { + community: { + name: string + slug: string + avatar?: string + } + pubFields?: Record + pubTypes?: Record> + users?: Record + stages?: Record + stageConnections?: TemplateStageConnections + pubs?: TemplatePub[] + forms?: Record + apiTokens?: Record +} + +// minimal template for starting from scratch +export const MINIMAL_TEMPLATE: CommunityTemplate = { + community: { + name: "New Community", + slug: "new-community", + }, +} + +// example template with common structure +export const EXAMPLE_TEMPLATE: CommunityTemplate = { + community: { + name: "Example Community", + slug: "example-community", + }, + pubFields: { + Title: { schemaName: "String" as CoreSchemaType }, + Content: { schemaName: "RichText" as CoreSchemaType }, + }, + pubTypes: { + Article: { + Title: { isTitle: true }, + Content: { isTitle: false }, + }, + }, + users: { + admin: { + email: "admin@example.com", + firstName: "Admin", + lastName: "User", + role: "admin" as MemberRole, + }, + }, + stages: { + Draft: { + members: { + admin: "admin" as MemberRole, + }, + }, + Published: {}, + }, + stageConnections: { + Draft: { + to: ["Published"], + }, + }, +} diff --git a/core/lib/server/communityTemplate/validate.ts b/core/lib/server/communityTemplate/validate.ts new file mode 100644 index 0000000000..b5d8b581fb --- /dev/null +++ b/core/lib/server/communityTemplate/validate.ts @@ -0,0 +1,511 @@ +import type { ValidationError } from "ui/monaco" + +import type { CommunityTemplate, TemplateFormElement, TemplatePub } from "./types" + +export type TemplateValidationResult = { + valid: boolean + errors: ValidationError[] +} + +type ValidationContext = { + template: CommunityTemplate + json: string + userSlugs: Set + fieldNames: Set + pubTypeNames: Set + stageNames: Set + formTitles: Set +} + +// find line and column for a json path in the original string +const findPositionForPath = ( + json: string, + path: string[] +): { line: number; column: number } => { + const lines = json.split("\n") + let currentLine = 1 + let currentCol = 1 + let depth = 0 + let inString = false + let escapeNext = false + let currentKey = "" + let buildingKey = false + let pathIndex = 0 + let foundPath: string[] = [] + + for (let i = 0; i < json.length; i++) { + const char = json[i] + + if (char === "\n") { + currentLine++ + currentCol = 1 + continue + } + + currentCol++ + + if (escapeNext) { + escapeNext = false + continue + } + + if (char === "\\") { + escapeNext = true + continue + } + + if (char === '"' && !escapeNext) { + if (!inString) { + inString = true + buildingKey = true + currentKey = "" + } else { + inString = false + if (buildingKey) { + buildingKey = false + } + } + continue + } + + if (inString) { + if (buildingKey) { + currentKey += char + } + continue + } + + if (char === ":") { + if (foundPath.length === pathIndex && currentKey === path[pathIndex]) { + foundPath.push(currentKey) + pathIndex++ + if (pathIndex === path.length) { + return { line: currentLine, column: currentCol } + } + } + } + + if (char === "{" || char === "[") { + depth++ + } + + if (char === "}" || char === "]") { + depth-- + if (foundPath.length > depth) { + foundPath = foundPath.slice(0, depth) + pathIndex = foundPath.length + } + } + } + + return { line: 1, column: 1 } +} + +// simple path finder that looks for a key pattern +const findKeyPosition = ( + json: string, + keyPattern: string +): { line: number; column: number } => { + const lines = json.split("\n") + const regex = new RegExp(`"${keyPattern}"\\s*:`) + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(regex) + if (match && match.index !== undefined) { + return { line: i + 1, column: match.index + 1 } + } + } + + return { line: 1, column: 1 } +} + +const validateUserReferences = (ctx: ValidationContext): ValidationError[] => { + const errors: ValidationError[] = [] + + // check stage members + if (ctx.template.stages) { + for (const [stageName, stage] of Object.entries(ctx.template.stages)) { + if (!stage.members) continue + + for (const userSlug of Object.keys(stage.members)) { + if (!ctx.userSlugs.has(userSlug)) { + const pos = findKeyPosition(ctx.json, userSlug) + errors.push({ + message: `User "${userSlug}" referenced in stage "${stageName}" members does not exist in users`, + ...pos, + severity: "error", + }) + } + } + } + } + + // check pub members + if (ctx.template.pubs) { + for (let i = 0; i < ctx.template.pubs.length; i++) { + const pub = ctx.template.pubs[i] + if (!pub.members) continue + + for (const userSlug of Object.keys(pub.members)) { + if (!ctx.userSlugs.has(userSlug)) { + const pos = findKeyPosition(ctx.json, userSlug) + errors.push({ + message: `User "${userSlug}" referenced in pub #${i + 1} members does not exist in users`, + ...pos, + severity: "error", + }) + } + } + } + } + + // check form members + if (ctx.template.forms) { + for (const [formTitle, form] of Object.entries(ctx.template.forms)) { + if (!form.members) continue + + for (const userSlug of form.members) { + if (!ctx.userSlugs.has(userSlug)) { + const pos = findKeyPosition(ctx.json, userSlug) + errors.push({ + message: `User "${userSlug}" referenced in form "${formTitle}" members does not exist in users`, + ...pos, + severity: "error", + }) + } + } + } + } + + return errors +} + +const validateFieldReferences = (ctx: ValidationContext): ValidationError[] => { + const errors: ValidationError[] = [] + + // check pub type fields reference existing pub fields + if (ctx.template.pubTypes) { + for (const [pubTypeName, fields] of Object.entries(ctx.template.pubTypes)) { + for (const fieldName of Object.keys(fields)) { + if (!ctx.fieldNames.has(fieldName)) { + const pos = findKeyPosition(ctx.json, fieldName) + errors.push({ + message: `Field "${fieldName}" in pub type "${pubTypeName}" does not exist in pubFields`, + ...pos, + severity: "error", + }) + } + } + } + } + + // check form elements reference existing fields + if (ctx.template.forms) { + for (const [formTitle, form] of Object.entries(ctx.template.forms)) { + for (const element of form.elements) { + if (element.type === "pubfield" && element.field) { + if (!ctx.fieldNames.has(element.field)) { + const pos = findKeyPosition(ctx.json, element.field) + errors.push({ + message: `Field "${element.field}" in form "${formTitle}" does not exist in pubFields`, + ...pos, + severity: "error", + }) + } + } + } + } + } + + return errors +} + +const validatePubTypeReferences = (ctx: ValidationContext): ValidationError[] => { + const errors: ValidationError[] = [] + + // check form pubType references + if (ctx.template.forms) { + for (const [formTitle, form] of Object.entries(ctx.template.forms)) { + if (!ctx.pubTypeNames.has(form.pubType)) { + const pos = findKeyPosition(ctx.json, form.pubType) + errors.push({ + message: `Pub type "${form.pubType}" in form "${formTitle}" does not exist in pubTypes`, + ...pos, + severity: "error", + }) + } + + // check relatedPubTypes in form elements + for (const element of form.elements) { + if (element.type === "pubfield" && element.relatedPubTypes) { + for (const relatedType of element.relatedPubTypes) { + if (!ctx.pubTypeNames.has(relatedType)) { + const pos = findKeyPosition(ctx.json, relatedType) + errors.push({ + message: `Related pub type "${relatedType}" in form "${formTitle}" does not exist in pubTypes`, + ...pos, + severity: "error", + }) + } + } + } + } + } + } + + // check pub pubType references + const validatePubType = (pub: TemplatePub, index: number, parentPath: string) => { + if (!ctx.pubTypeNames.has(pub.pubType)) { + const pos = findKeyPosition(ctx.json, pub.pubType) + errors.push({ + message: `Pub type "${pub.pubType}" in ${parentPath} does not exist in pubTypes`, + ...pos, + severity: "error", + }) + } + + // check nested related pubs + if (pub.relatedPubs) { + for (const [fieldName, relatedPubs] of Object.entries(pub.relatedPubs)) { + for (let j = 0; j < relatedPubs.length; j++) { + validatePubType(relatedPubs[j].pub, j, `${parentPath}.relatedPubs.${fieldName}[${j}]`) + } + } + } + } + + if (ctx.template.pubs) { + for (let i = 0; i < ctx.template.pubs.length; i++) { + validatePubType(ctx.template.pubs[i], i, `pubs[${i}]`) + } + } + + return errors +} + +const validateStageReferences = (ctx: ValidationContext): ValidationError[] => { + const errors: ValidationError[] = [] + + // check pub stage references + const validatePubStage = (pub: TemplatePub, index: number, parentPath: string) => { + if (pub.stage && !ctx.stageNames.has(pub.stage)) { + const pos = findKeyPosition(ctx.json, pub.stage) + errors.push({ + message: `Stage "${pub.stage}" in ${parentPath} does not exist in stages`, + ...pos, + severity: "error", + }) + } + + // check nested related pubs + if (pub.relatedPubs) { + for (const [fieldName, relatedPubs] of Object.entries(pub.relatedPubs)) { + for (let j = 0; j < relatedPubs.length; j++) { + validatePubStage(relatedPubs[j].pub, j, `${parentPath}.relatedPubs.${fieldName}[${j}]`) + } + } + } + } + + if (ctx.template.pubs) { + for (let i = 0; i < ctx.template.pubs.length; i++) { + validatePubStage(ctx.template.pubs[i], i, `pubs[${i}]`) + } + } + + // check stage connections + if (ctx.template.stageConnections) { + for (const [stageName, connections] of Object.entries(ctx.template.stageConnections)) { + if (!ctx.stageNames.has(stageName)) { + const pos = findKeyPosition(ctx.json, stageName) + errors.push({ + message: `Stage "${stageName}" in stageConnections does not exist in stages`, + ...pos, + severity: "error", + }) + } + + if (connections.to) { + for (const toStage of connections.to) { + if (!ctx.stageNames.has(toStage)) { + const pos = findKeyPosition(ctx.json, toStage) + errors.push({ + message: `Target stage "${toStage}" in stageConnections.${stageName}.to does not exist in stages`, + ...pos, + severity: "error", + }) + } + } + } + + if (connections.from) { + for (const fromStage of connections.from) { + if (!ctx.stageNames.has(fromStage)) { + const pos = findKeyPosition(ctx.json, fromStage) + errors.push({ + message: `Source stage "${fromStage}" in stageConnections.${stageName}.from does not exist in stages`, + ...pos, + severity: "error", + }) + } + } + } + } + } + + // check form button stage references + if (ctx.template.forms) { + for (const [formTitle, form] of Object.entries(ctx.template.forms)) { + for (const element of form.elements) { + if (element.type === "button" && element.stage) { + if (!ctx.stageNames.has(element.stage)) { + const pos = findKeyPosition(ctx.json, element.stage) + errors.push({ + message: `Stage "${element.stage}" in form "${formTitle}" button does not exist in stages`, + ...pos, + severity: "error", + }) + } + } + } + } + } + + return errors +} + +const validateAutomationReferences = (ctx: ValidationContext): ValidationError[] => { + const errors: ValidationError[] = [] + + if (!ctx.template.stages) return errors + + for (const [stageName, stage] of Object.entries(ctx.template.stages)) { + if (!stage.automations) continue + + const automationNames = new Set(Object.keys(stage.automations)) + + for (const [automationName, automation] of Object.entries(stage.automations)) { + // check sourceAutomation references + if (automation.sourceAutomation) { + if (!automationNames.has(automation.sourceAutomation)) { + const pos = findKeyPosition(ctx.json, automation.sourceAutomation) + errors.push({ + message: `Source automation "${automation.sourceAutomation}" in ${stageName}.${automationName} does not exist`, + ...pos, + severity: "error", + }) + } + } + + // check trigger sourceAutomation references + for (const trigger of automation.triggers) { + if (trigger.sourceAutomation && !automationNames.has(trigger.sourceAutomation)) { + const pos = findKeyPosition(ctx.json, trigger.sourceAutomation) + errors.push({ + message: `Source automation "${trigger.sourceAutomation}" in trigger does not exist`, + ...pos, + severity: "error", + }) + } + } + } + } + + return errors +} + +export const validateCommunityTemplate = ( + jsonString: string +): TemplateValidationResult => { + let template: CommunityTemplate + + try { + template = JSON.parse(jsonString) + } catch (e) { + // json parse errors are handled by monaco's built-in json validation + return { valid: true, errors: [] } + } + + const ctx: ValidationContext = { + template, + json: jsonString, + userSlugs: new Set(Object.keys(template.users ?? {})), + fieldNames: new Set(Object.keys(template.pubFields ?? {})), + pubTypeNames: new Set(Object.keys(template.pubTypes ?? {})), + stageNames: new Set(Object.keys(template.stages ?? {})), + formTitles: new Set(Object.keys(template.forms ?? {})), + } + + const errors: ValidationError[] = [ + ...validateUserReferences(ctx), + ...validateFieldReferences(ctx), + ...validatePubTypeReferences(ctx), + ...validateStageReferences(ctx), + ...validateAutomationReferences(ctx), + ] + + return { + valid: errors.length === 0, + errors, + } +} + +// quick validation without line numbers (for server-side) +export const validateCommunityTemplateQuick = ( + template: CommunityTemplate +): { valid: boolean; errors: string[] } => { + const errors: string[] = [] + + const userSlugs = new Set(Object.keys(template.users ?? {})) + const fieldNames = new Set(Object.keys(template.pubFields ?? {})) + const pubTypeNames = new Set(Object.keys(template.pubTypes ?? {})) + const stageNames = new Set(Object.keys(template.stages ?? {})) + + // check pub type field references + if (template.pubTypes) { + for (const [pubTypeName, fields] of Object.entries(template.pubTypes)) { + for (const fieldName of Object.keys(fields)) { + if (!fieldNames.has(fieldName)) { + errors.push(`Field "${fieldName}" in pub type "${pubTypeName}" does not exist`) + } + } + } + } + + // check form pub type references + if (template.forms) { + for (const [formTitle, form] of Object.entries(template.forms)) { + if (!pubTypeNames.has(form.pubType)) { + errors.push(`Pub type "${form.pubType}" in form "${formTitle}" does not exist`) + } + } + } + + // check stage member references + if (template.stages) { + for (const [stageName, stage] of Object.entries(template.stages)) { + if (stage.members) { + for (const userSlug of Object.keys(stage.members)) { + if (!userSlugs.has(userSlug)) { + errors.push(`User "${userSlug}" in stage "${stageName}" does not exist`) + } + } + } + } + } + + // check pub references + if (template.pubs) { + for (let i = 0; i < template.pubs.length; i++) { + const pub = template.pubs[i] + if (!pubTypeNames.has(pub.pubType)) { + errors.push(`Pub type "${pub.pubType}" in pubs[${i}] does not exist`) + } + if (pub.stage && !stageNames.has(pub.stage)) { + errors.push(`Stage "${pub.stage}" in pubs[${i}] does not exist`) + } + } + } + + return { valid: errors.length === 0, errors } +} diff --git a/core/prisma/seed/seedCommunity.ts b/core/prisma/seed/seedCommunity.ts index 28ba880171..853a2e1bff 100644 --- a/core/prisma/seed/seedCommunity.ts +++ b/core/prisma/seed/seedCommunity.ts @@ -982,19 +982,6 @@ export async function seedCommunity< ]) ) as PubTypesByName - await Promise.all( - Object.values(pubTypesWithPubFieldsByName).map((pubType) => - insertForm( - { ...pubType, fields: Object.values(pubType.fields) }, - `${pubType.name} Editor (Default)`, - pubType.defaultForm.slug, - communityId, - true, - trx - ).executeTakeFirst() - ) - ) - const newUsers = Object.entries(props.users ?? {}).filter( (user): user is [string, (typeof user)[1] & { existing: false }] => !user[1].existing ) @@ -1016,6 +1003,7 @@ export async function seedCommunity< })) ) + console.log("newUserValues", newUserValues) const createdUsers = newUserValues.length ? await trx.insertInto("users").values(newUserValues).returningAll().execute() : [] @@ -1117,8 +1105,10 @@ export async function seedCommunity< .execute() : [] + console.log("consolidatedStages", consolidatedStages) + console.log("stageConnectionsList", props.stageConnections) const stageConnectionsList = props.stageConnections - ? await db + ? await trx .insertInto("move_constraint") .values( Object.entries(props.stageConnections).flatMap(([stage, destinations]) => { @@ -1224,6 +1214,7 @@ export async function seedCommunity< })) ) + console.log("formList", formList) const createdForms = formList.length > 0 ? await trx @@ -1301,6 +1292,30 @@ export async function seedCommunity< .execute() : [] + // check if we don't end up creating duplicate forms (mostly relevant when importing a template) + + const toBeInsertedDefaultForms = Object.values(pubTypesWithPubFieldsByName).filter( + (pubType) => + !createdForms.some( + (form) => + form.pubTypeId === pubType.id && + (form.isDefault || form.name === `${pubType.name} Editor (Default)`) + ) + ) + + await Promise.all( + toBeInsertedDefaultForms.map((pubType) => + insertForm( + { ...pubType, fields: Object.values(pubType.fields) }, + `${pubType.name} Editor (Default)`, + pubType.defaultForm.slug, + communityId, + true, + trx + ).executeTakeFirst() + ) + ) + if (createdForms.length && formElementsWithRelatedPubTypes.length) { const feee = createdForms.flatMap((form) => form.elements.flatMap((fe, feIdx) => { From ae5a70cbe658001920d0ad44b121ed80168e5d6c Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 2 Feb 2026 13:50:01 +0100 Subject: [PATCH 2/5] feat: cloning community ish --- .../communities/CloneCommunityButton.tsx | 233 +++++++++ core/app/(user)/communities/cloneActions.ts | 140 ++++++ .../communities/getCommunityTableColumns.tsx | 6 + .../app/(user)/communities/templateActions.ts | 38 +- core/lib/server/communityClone/export.ts | 273 ++++++++++ core/lib/server/communityClone/import.ts | 467 ++++++++++++++++++ core/lib/server/communityClone/index.ts | 3 + core/lib/server/communityClone/types.ts | 274 ++++++++++ core/lib/server/communityTemplate/export.ts | 188 ++++++- core/lib/server/communityTemplate/schema.ts | 22 + core/lib/server/communityTemplate/types.ts | 31 +- core/lib/server/member.ts | 5 +- core/lib/server/stages.ts | 12 +- core/prisma/seed/seedCommunity.ts | 279 ++++++----- 14 files changed, 1794 insertions(+), 177 deletions(-) create mode 100644 core/app/(user)/communities/CloneCommunityButton.tsx create mode 100644 core/app/(user)/communities/cloneActions.ts create mode 100644 core/lib/server/communityClone/export.ts create mode 100644 core/lib/server/communityClone/import.ts create mode 100644 core/lib/server/communityClone/index.ts create mode 100644 core/lib/server/communityClone/types.ts diff --git a/core/app/(user)/communities/CloneCommunityButton.tsx b/core/app/(user)/communities/CloneCommunityButton.tsx new file mode 100644 index 0000000000..ec4a0570e3 --- /dev/null +++ b/core/app/(user)/communities/CloneCommunityButton.tsx @@ -0,0 +1,233 @@ +"use client" + +import type { CommunitiesId } from "db/public" + +import * as React from "react" + +import { Button } from "ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "ui/dialog" +import { DropdownMenuItem } from "ui/dropdown-menu" +import { Input } from "ui/input" +import { Clipboard, Download, Layers, Loader2 } from "ui/icon" +import { Label } from "ui/label" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs" +import { toast } from "ui/use-toast" + +import { didSucceed, useServerAction } from "~/lib/serverActions" +import { exportCommunityCloneAction, importCommunityCloneAction } from "./cloneActions" +import { JsonEditor } from "ui/monaco" + +type CloneCommunityButtonProps = { + communityId: string + communityName: string + communitySlug: string +} + +export const CloneCommunityButton = ({ + communityId, + communityName, + communitySlug, +}: CloneCommunityButtonProps) => { + const [open, setOpen] = React.useState(false) + const [activeTab, setActiveTab] = React.useState<"export" | "import">("export") + const [cloneData, setCloneData] = React.useState("") + const [isLoading, setIsLoading] = React.useState(false) + + // import state + const [importJson, setImportJson] = React.useState("") + const [newSlug, setNewSlug] = React.useState("") + const [newName, setNewName] = React.useState("") + const [isImporting, setIsImporting] = React.useState(false) + + const runExport = useServerAction(exportCommunityCloneAction) + const runImport = useServerAction(importCommunityCloneAction) + + const handleExport = async () => { + setIsLoading(true) + setOpen(true) + setActiveTab("export") + + const result = await runExport({ communityId: communityId as CommunitiesId }) + if (didSucceed(result) && result.clone) { + setCloneData(result.clone) + // pre-fill import fields + try { + const parsed = JSON.parse(result.clone) + setNewSlug(`${parsed.sourceCommunity.slug}-clone`) + setNewName(`${parsed.sourceCommunity.name} (Clone)`) + } catch { + setNewSlug(`${communitySlug}-clone`) + setNewName(`${communityName} (Clone)`) + } + } else { + setOpen(false) + } + setIsLoading(false) + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(cloneData) + toast.success("Clone data copied to clipboard") + } catch { + toast.error("Failed to copy to clipboard") + } + } + + const handleDownload = () => { + const blob = new Blob([cloneData], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${communitySlug}-clone.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast.success("Clone data downloaded") + } + + const handleImport = async () => { + if (!newSlug.trim()) { + toast.error("Please enter a slug for the new community") + return + } + + setIsImporting(true) + const result = await runImport({ + cloneJson: cloneData || importJson, + newSlug: newSlug.trim(), + newName: newName.trim() || undefined, + }) + + if (didSucceed(result) && result.communitySlug) { + toast.success(`Community "${newName || newSlug}" created successfully`) + setOpen(false) + // navigate to new community + window.location.href = `/c/${result.communitySlug}` + } + setIsImporting(false) + } + + return ( + <> + { + e.preventDefault() + handleExport() + }} + className="gap-2" + > + + Clone Community + + + + + Clone Community + + Create a full copy of "{communityName}" including all pubs, automations, and + configurations. This is useful for debugging or creating test environments. + + + setActiveTab(v as "export" | "import")}> + + Export + Import + + + + {isLoading ? ( +
+ +
+ ) : ( + <> +
+ +
+ +
+
+ + +
+ +
+ + )} +
+ + +
+
+ + setNewSlug(e.target.value)} + placeholder="my-community-clone" + /> +

+ Must be unique. Use lowercase letters, numbers, and hyphens only. +

+
+ +
+ + setNewName(e.target.value)} + placeholder="My Community (Clone)" + /> +
+ + {!cloneData && ( +
+ +
+ +
+
+ )} +
+ +
+ + +
+
+
+
+
+ + ) +} diff --git a/core/app/(user)/communities/cloneActions.ts b/core/app/(user)/communities/cloneActions.ts new file mode 100644 index 0000000000..7c64f110d5 --- /dev/null +++ b/core/app/(user)/communities/cloneActions.ts @@ -0,0 +1,140 @@ +"use server" + +import type { CommunitiesId } from "db/public" + +import { revalidatePath } from "next/cache" + +import { MemberRole } from "db/public" + +import type { CommunityClone } from "~/lib/server/communityClone" + +import { db } from "~/kysely/database" +import { isUniqueConstraintError } from "~/kysely/errors" +import { getLoginData } from "~/lib/authentication/loginData" +import { exportCommunityClone, importCommunityClone } from "~/lib/server/communityClone" +import { defineServerAction } from "~/lib/server/defineServerAction" +import { maybeWithTrx } from "~/lib/server/maybeWithTrx" +import { logger } from "logger" + +export const exportCommunityCloneAction = defineServerAction( + async function exportCommunityCloneAction({ communityId }: { communityId: CommunitiesId }) { + const { user } = await getLoginData() + + if (!user) { + return { + title: "Failed to export clone", + error: "Not logged in", + } + } + + if (!user.isSuperAdmin) { + return { + title: "Failed to export clone", + error: "User is not a super admin", + } + } + + try { + const clone = await exportCommunityClone(communityId) + return { clone: JSON.stringify(clone, null, 2) } + } catch (error) { + logger.error({ msg: "Failed to export clone", err: error }) + return { + title: "Failed to export clone", + error: "An unexpected error occurred while exporting", + cause: error, + } + } + } +) + +export const importCommunityCloneAction = defineServerAction( + async function importCommunityCloneAction({ + cloneJson, + newSlug, + newName, + }: { + cloneJson: string + newSlug: string + newName?: string + }) { + const { user } = await getLoginData() + + if (!user) { + return { + title: "Failed to import clone", + error: "Not logged in", + } + } + + if (!user.isSuperAdmin) { + return { + title: "Failed to import clone", + error: "User is not a super admin", + } + } + + let clone: CommunityClone + try { + clone = JSON.parse(cloneJson) + } catch (error) { + logger.error("Failed to import clone", { error }) + return { + title: "Failed to import clone", + error: "Invalid JSON", + cause: error, + } + } + + // validate version + if (clone.version !== "1.0") { + return { + title: "Failed to import clone", + error: `Unsupported clone version: ${clone.version}`, + } + } + + try { + const result = await maybeWithTrx(db, async (trx) => { + const { communityId, mapping } = await importCommunityClone( + clone, + { + newSlug, + newName, + issuedById: user.id, + }, + trx + ) + + // add current user as admin + await trx + .insertInto("community_memberships") + .values({ + userId: user.id, + communityId, + role: MemberRole.admin, + }) + .execute() + + return { communityId, slug: newSlug } + }) + + revalidatePath("/") + return { communitySlug: result.slug } + } catch (error) { + logger.error({ msg: "Failed to import clone", err: error }) + if (isUniqueConstraintError(error) && error.constraint === "communities_slug_key") { + return { + title: "Failed to import clone", + error: "A community with that slug already exists", + cause: error, + } + } + return { + title: "Failed to import clone", + error: "An unexpected error occurred while importing the clone", + cause: error, + } + } + } +) diff --git a/core/app/(user)/communities/getCommunityTableColumns.tsx b/core/app/(user)/communities/getCommunityTableColumns.tsx index 6e8fa013f5..172b3ca4fc 100644 --- a/core/app/(user)/communities/getCommunityTableColumns.tsx +++ b/core/app/(user)/communities/getCommunityTableColumns.tsx @@ -17,6 +17,7 @@ import { } from "ui/dropdown-menu" import { MoreVertical } from "ui/icon" +import { CloneCommunityButton } from "./CloneCommunityButton" import { ExportTemplateButton } from "./ExportTemplateButton" import { RemoveCommunityButton } from "./RemoveCommunityButton" @@ -106,6 +107,11 @@ export const getCommunityTableColumns = (options?: GetCommunityTableColumnsOptio communityName={row.original.name} onCreateCopy={options?.onCreateCopy} /> +
diff --git a/core/app/(user)/communities/templateActions.ts b/core/app/(user)/communities/templateActions.ts index aa70674b76..2c96ae7e90 100644 --- a/core/app/(user)/communities/templateActions.ts +++ b/core/app/(user)/communities/templateActions.ts @@ -6,7 +6,7 @@ import { revalidatePath } from "next/cache" import { MemberRole } from "db/public" -import type { CommunityTemplate } from "~/lib/server/communityTemplate" +import type { CommunityTemplate, TemplateExportOptions } from "~/lib/server/communityTemplate" import { db } from "~/kysely/database" import { isUniqueConstraintError } from "~/kysely/errors" @@ -22,7 +22,13 @@ import { logger } from "logger" import { maybeWithTrx } from "~/lib/server/maybeWithTrx" export const exportCommunityTemplateAction = defineServerAction( - async function exportCommunityTemplateAction({ communityId }: { communityId: CommunitiesId }) { + async function exportCommunityTemplateAction({ + communityId, + options = {}, + }: { + communityId: CommunitiesId + options?: TemplateExportOptions + }) { const { user } = await getLoginData() if (!user) { @@ -40,7 +46,7 @@ export const exportCommunityTemplateAction = defineServerAction( } try { - const template = await exportTemplate(communityId) + const template = await exportTemplate(communityId, options) return { template: JSON.stringify(template, null, 2) } } catch (error) { logger.error({ msg: "Failed to export template", err: error }) @@ -158,7 +164,10 @@ function transformTemplateToSeedInput(template: CommunityTemplate, _currentUserI const pubs = template.pubs ?? [] const apiTokens = template.apiTokens ?? {} + const hasUsers = Object.keys(users).length > 0 + // transform users - add passwords if not provided + // if no users are specified, we skip user/membership creation entirely const transformedUsers: Record = {} for (const [slug, user] of Object.entries(users)) { transformedUsers[slug] = { @@ -168,18 +177,8 @@ function transformTemplateToSeedInput(template: CommunityTemplate, _currentUserI } } - // // ensure there's at least one admin user - // if (Object.keys(transformedUsers).length === 0) { - // transformedUsers["template-admin"] = { - // email: `template-admin-${crypto.randomUUID()}@temp.local`, - // firstName: "Template", - // lastName: "Admin", - // role: MemberRole.admin, - // password: `temp-${crypto.randomUUID()}`, - // } - // } - // transform stages with automations + // skip stage members if no users defined in template const transformedStages: Record = {} for (const [name, stage] of Object.entries(stages)) { const transformedAutomations: Record = {} @@ -200,18 +199,21 @@ function transformTemplateToSeedInput(template: CommunityTemplate, _currentUserI } transformedStages[name] = { - members: stage.members ?? {}, + // only include members if users are defined in the template + ...(hasUsers && stage.members ? { members: stage.members } : {}), ...(Object.keys(transformedAutomations).length > 0 ? { automations: transformedAutomations } : {}), } } - // transform forms + // transform forms - skip form members if no users defined const transformedForms: Record = {} for (const [name, form] of Object.entries(forms)) { transformedForms[name] = { ...form, + // only include members if users are defined in the template + ...(hasUsers && form.members ? { members: form.members } : {}), elements: form.elements.map((el) => { if (el.type === "pubfield") { return { @@ -224,10 +226,12 @@ function transformTemplateToSeedInput(template: CommunityTemplate, _currentUserI } } - // transform pubs + // transform pubs - skip pub members if no users defined const transformedPubs = pubs.map((pub) => ({ ...pub, values: pub.values ?? {}, + // only include members if users are defined in the template + ...(hasUsers && pub.members ? { members: pub.members } : {}), })) // cast to any since template types are intentionally looser than seed types diff --git a/core/lib/server/communityClone/export.ts b/core/lib/server/communityClone/export.ts new file mode 100644 index 0000000000..ade52e0da7 --- /dev/null +++ b/core/lib/server/communityClone/export.ts @@ -0,0 +1,273 @@ +import type { CommunitiesId } from "db/public" + +import type { + CloneExportOptions, + CommunityClone, + CloneActionConfigDefault, + CloneActionInstance, + CloneApiAccessPermission, + CloneApiAccessToken, + CloneAutomation, + CloneAutomationCondition, + CloneAutomationConditionBlock, + CloneAutomationTrigger, + CloneForm, + CloneFormElement, + CloneFormElementToPubType, + CloneMoveConstraint, + ClonePub, + ClonePubField, + ClonePubFieldToPubType, + ClonePubsInStages, + ClonePubType, + ClonePubValue, + CloneStage, +} from "./types" + +import { db } from "~/kysely/database" + +export const exportCommunityClone = async ( + communityId: CommunitiesId, + _options: CloneExportOptions = {} +): Promise => { + // fetch community + const community = await db + .selectFrom("communities") + .select(["id", "name", "slug", "avatar"]) + .where("id", "=", communityId) + .executeTakeFirstOrThrow() + + // fetch pub fields + const pubFields = await db + .selectFrom("pub_fields") + .select(["id", "name", "slug", "schemaName", "isRelation"]) + .where("communityId", "=", communityId) + .execute() + + // fetch pub types + const pubTypes = await db + .selectFrom("pub_types") + .select(["id", "name", "description"]) + .where("communityId", "=", communityId) + .execute() + + // fetch pub field to pub type mappings + const pubFieldToPubType = await db + .selectFrom("_PubFieldToPubType") + .innerJoin("pub_fields", "pub_fields.id", "_PubFieldToPubType.A") + .select([ + "_PubFieldToPubType.A", + "_PubFieldToPubType.B", + "_PubFieldToPubType.isTitle", + "_PubFieldToPubType.rank", + ]) + .where("pub_fields.communityId", "=", communityId) + .execute() + + // fetch stages + const stages = await db + .selectFrom("stages") + .select(["id", "name", "order"]) + .where("communityId", "=", communityId) + .orderBy("order", "asc") + .execute() + + const stageIds = stages.map((s) => s.id) + + // fetch move constraints (composite key, no id) + const moveConstraints = + stageIds.length > 0 + ? await db + .selectFrom("move_constraint") + .select(["stageId", "destinationId"]) + .where("stageId", "in", stageIds) + .execute() + : [] + + // fetch forms + const forms = await db + .selectFrom("forms") + .select(["id", "name", "slug", "access", "isArchived", "isDefault", "pubTypeId"]) + .where("communityId", "=", communityId) + .execute() + + const formIds = forms.map((f) => f.id) + + // fetch form elements + const formElements = + formIds.length > 0 + ? await db + .selectFrom("form_elements") + .select([ + "id", + "formId", + "fieldId", + "type", + "component", + "config", + "content", + "label", + "element", + "rank", + "required", + "stageId", + ]) + .where("formId", "in", formIds) + .execute() + : [] + + const formElementIds = formElements.map((fe) => fe.id) + + // fetch form element to pub type mappings + const formElementToPubType = + formElementIds.length > 0 + ? await db + .selectFrom("_FormElementToPubType") + .select(["A", "B"]) + .where("A", "in", formElementIds) + .execute() + : [] + + // fetch automations + const automations = await db + .selectFrom("automations") + .select(["id", "name", "stageId", "icon", "conditionEvaluationTiming", "resolver"]) + .where("communityId", "=", communityId) + .execute() + + const automationIds = automations.map((a) => a.id) + + // fetch automation triggers + const automationTriggers = + automationIds.length > 0 + ? await db + .selectFrom("automation_triggers") + .select(["id", "automationId", "event", "config", "sourceAutomationId"]) + .where("automationId", "in", automationIds) + .execute() + : [] + + // fetch action instances + const actionInstances = + automationIds.length > 0 + ? await db + .selectFrom("action_instances") + .select(["id", "automationId", "action", "config"]) + .where("automationId", "in", automationIds) + .orderBy("createdAt", "asc") + .execute() + : [] + + // fetch automation condition blocks + const automationConditionBlocks = + automationIds.length > 0 + ? await db + .selectFrom("automation_condition_blocks") + .select(["id", "automationId", "automationConditionBlockId", "type", "rank"]) + .where("automationId", "in", automationIds) + .execute() + : [] + + const conditionBlockIds = automationConditionBlocks.map((cb) => cb.id) + + // fetch automation conditions + const automationConditions = + conditionBlockIds.length > 0 + ? await db + .selectFrom("automation_conditions") + .select(["id", "automationConditionBlockId", "type", "expression", "rank"]) + .where("automationConditionBlockId", "in", conditionBlockIds) + .execute() + : [] + + // fetch pubs + const pubs = await db + .selectFrom("pubs") + .select(["id", "pubTypeId"]) + .where("communityId", "=", communityId) + .execute() + + const pubIds = pubs.map((p) => p.id) + + // fetch pub values + const pubValues = + pubIds.length > 0 + ? await db + .selectFrom("pub_values") + .select(["id", "pubId", "fieldId", "value", "relatedPubId"]) + .where("pubId", "in", pubIds) + .execute() + : [] + + // fetch pubs in stages + const pubsInStages = + pubIds.length > 0 + ? await db + .selectFrom("PubsInStages") + .select(["pubId", "stageId"]) + .where("pubId", "in", pubIds) + .execute() + : [] + + // fetch api access tokens + const apiAccessTokens = await db + .selectFrom("api_access_tokens") + .select(["id", "name", "description", "expiration"]) + .where("communityId", "=", communityId) + .execute() + + const tokenIds = apiAccessTokens.map((t) => t.id) + + // fetch api access permissions + const apiAccessPermissions = + tokenIds.length > 0 + ? await db + .selectFrom("api_access_permissions") + .select(["id", "apiAccessTokenId", "scope", "accessType", "constraints"]) + .where("apiAccessTokenId", "in", tokenIds) + .execute() + : [] + + // fetch action config defaults + const actionConfigDefaults = await db + .selectFrom("action_config_defaults") + .select(["action", "config"]) + .where("communityId", "=", communityId) + .execute() + + return { + version: "1.0", + exportedAt: new Date().toISOString(), + sourceCommunity: { + id: community.id, + name: community.name, + slug: community.slug, + }, + data: { + community: { + name: community.name, + slug: community.slug, + avatar: community.avatar, + }, + pubFields: pubFields as ClonePubField[], + pubTypes: pubTypes as ClonePubType[], + pubFieldToPubType: pubFieldToPubType as ClonePubFieldToPubType[], + stages: stages as CloneStage[], + moveConstraints: moveConstraints as CloneMoveConstraint[], + forms: forms as CloneForm[], + formElements: formElements as CloneFormElement[], + formElementToPubType: formElementToPubType as CloneFormElementToPubType[], + automations: automations as CloneAutomation[], + automationTriggers: automationTriggers as CloneAutomationTrigger[], + actionInstances: actionInstances as CloneActionInstance[], + automationConditionBlocks: automationConditionBlocks as CloneAutomationConditionBlock[], + automationConditions: automationConditions as CloneAutomationCondition[], + pubs: pubs as ClonePub[], + pubValues: pubValues as ClonePubValue[], + pubsInStages: pubsInStages as ClonePubsInStages[], + apiAccessTokens: apiAccessTokens as CloneApiAccessToken[], + apiAccessPermissions: apiAccessPermissions as CloneApiAccessPermission[], + actionConfigDefaults: actionConfigDefaults as CloneActionConfigDefault[], + }, + } +} diff --git a/core/lib/server/communityClone/import.ts b/core/lib/server/communityClone/import.ts new file mode 100644 index 0000000000..44bdb4585a --- /dev/null +++ b/core/lib/server/communityClone/import.ts @@ -0,0 +1,467 @@ +import type { + ActionInstancesId, + ApiAccessPermissionsId, + ApiAccessTokensId, + AutomationConditionBlocksId, + AutomationConditionsId, + AutomationsId, + AutomationTriggersId, + CommunitiesId, + FormElementsId, + FormsId, + PubFieldsId, + PubsId, + PubTypesId, + PubValuesId, + StagesId, + UsersId, +} from "db/public" + +import type { CommunityClone, IdMapping } from "./types" +import { createEmptyIdMapping } from "./types" + +import { db } from "~/kysely/database" +import { createLastModifiedBy } from "~/lib/lastModifiedBy" +import { generateToken } from "~/lib/server/token" + +export type CloneImportOptions = { + // new slug for the community (required to avoid conflict) + newSlug: string + // optional new name + newName?: string + // user id to use for api token issuedById + issuedById: UsersId +} + +export const importCommunityClone = async ( + clone: CommunityClone, + options: CloneImportOptions, + trx = db +): Promise<{ communityId: CommunitiesId; mapping: IdMapping }> => { + const mapping = createEmptyIdMapping() + const { data } = clone + const { newSlug, newName, issuedById } = options + + // generate new community id + const newCommunityId = crypto.randomUUID() as CommunitiesId + mapping.communities.set(clone.sourceCommunity.id, newCommunityId) + + // create community + await trx + .insertInto("communities") + .values({ + id: newCommunityId, + name: newName ?? data.community.name, + slug: newSlug, + avatar: data.community.avatar, + }) + .execute() + + // create pub fields with new ids + for (const field of data.pubFields) { + const newId = crypto.randomUUID() as PubFieldsId + mapping.pubFields.set(field.id, newId) + + // update slug to use new community slug + const slugParts = field.slug.split(":") + const fieldSlug = slugParts.length > 1 ? slugParts[1] : field.slug + const newFieldSlug = `${newSlug}:${fieldSlug}` + + await trx + .insertInto("pub_fields") + .values({ + id: newId, + name: field.name, + slug: newFieldSlug, + communityId: newCommunityId, + schemaName: field.schemaName as any, + isRelation: field.isRelation ?? false, + }) + .execute() + } + + // create pub types with new ids + for (const pubType of data.pubTypes) { + const newId = crypto.randomUUID() as PubTypesId + mapping.pubTypes.set(pubType.id, newId) + + await trx + .insertInto("pub_types") + .values({ + id: newId, + name: pubType.name, + description: pubType.description, + communityId: newCommunityId, + }) + .execute() + } + + // create pub field to pub type mappings + for (const mappingEntry of data.pubFieldToPubType) { + const newFieldId = mapping.pubFields.get(mappingEntry.A) + const newPubTypeId = mapping.pubTypes.get(mappingEntry.B) + + if (!newFieldId || !newPubTypeId) continue + + await trx + .insertInto("_PubFieldToPubType") + .values({ + A: newFieldId, + B: newPubTypeId, + isTitle: mappingEntry.isTitle, + rank: mappingEntry.rank, + }) + .execute() + } + + // create stages with new ids + for (const stage of data.stages) { + const newId = crypto.randomUUID() as StagesId + mapping.stages.set(stage.id, newId) + + await trx + .insertInto("stages") + .values({ + id: newId, + name: stage.name, + order: stage.order, + communityId: newCommunityId, + }) + .execute() + } + + // create move constraints (composite key, no id) + for (const constraint of data.moveConstraints) { + const newStageId = mapping.stages.get(constraint.stageId) + const newDestId = mapping.stages.get(constraint.destinationId) + + if (!newStageId || !newDestId) continue + + await trx + .insertInto("move_constraint") + .values({ + stageId: newStageId, + destinationId: newDestId, + }) + .execute() + } + + // create forms with new ids + for (const form of data.forms) { + const newId = crypto.randomUUID() as FormsId + const newPubTypeId = mapping.pubTypes.get(form.pubTypeId) + + if (!newPubTypeId) continue + + mapping.forms.set(form.id, newId) + + await trx + .insertInto("forms") + .values({ + id: newId, + name: form.name, + slug: form.slug, + access: form.access as any, + isArchived: form.isArchived, + isDefault: form.isDefault, + pubTypeId: newPubTypeId, + communityId: newCommunityId, + }) + .execute() + } + + // create form elements with new ids + for (const element of data.formElements) { + const newId = crypto.randomUUID() as FormElementsId + const newFormId = mapping.forms.get(element.formId) + const newFieldId = element.fieldId ? mapping.pubFields.get(element.fieldId) : null + const newStageId = element.stageId ? mapping.stages.get(element.stageId) : null + + if (!newFormId) continue + + mapping.formElements.set(element.id, newId) + + await trx + .insertInto("form_elements") + .values({ + id: newId, + formId: newFormId, + fieldId: newFieldId, + type: element.type as any, + component: element.component as any, + config: element.config as any, + content: element.content, + label: element.label, + element: element.element as any, + rank: element.rank, + required: element.required, + stageId: newStageId, + }) + .execute() + } + + // create form element to pub type mappings + for (const mappingEntry of data.formElementToPubType) { + const newElementId = mapping.formElements.get(mappingEntry.A) + const newPubTypeId = mapping.pubTypes.get(mappingEntry.B) + + if (!newElementId || !newPubTypeId) continue + + await trx + .insertInto("_FormElementToPubType") + .values({ + A: newElementId, + B: newPubTypeId, + }) + .execute() + } + + // create automations with new ids + for (const automation of data.automations) { + const newId = crypto.randomUUID() as AutomationsId + const newStageId = automation.stageId ? mapping.stages.get(automation.stageId) : null + + mapping.automations.set(automation.id, newId) + + await trx + .insertInto("automations") + .values({ + id: newId, + name: automation.name, + stageId: newStageId, + icon: automation.icon as any, + conditionEvaluationTiming: automation.conditionEvaluationTiming as any, + resolver: automation.resolver, + communityId: newCommunityId, + }) + .execute() + } + + // create automation triggers with new ids + for (const trigger of data.automationTriggers) { + const newId = crypto.randomUUID() as AutomationTriggersId + const newAutomationId = mapping.automations.get(trigger.automationId) + const newSourceAutomationId = trigger.sourceAutomationId + ? mapping.automations.get(trigger.sourceAutomationId) + : null + + if (!newAutomationId) continue + + mapping.automationTriggers.set(trigger.id, newId) + + await trx + .insertInto("automation_triggers") + .values({ + id: newId, + automationId: newAutomationId, + event: trigger.event as any, + config: trigger.config as any, + sourceAutomationId: newSourceAutomationId, + }) + .execute() + } + + // create action instances with new ids + for (const action of data.actionInstances) { + const newId = crypto.randomUUID() as ActionInstancesId + const newAutomationId = mapping.automations.get(action.automationId) + + if (!newAutomationId) continue + + mapping.actionInstances.set(action.id, newId) + + await trx + .insertInto("action_instances") + .values({ + id: newId, + automationId: newAutomationId, + action: action.action, + config: action.config as any, + }) + .execute() + } + + // create automation condition blocks with new ids + // first pass: create all blocks without parent references + for (const block of data.automationConditionBlocks) { + const newId = crypto.randomUUID() as AutomationConditionBlocksId + const newAutomationId = block.automationId + ? mapping.automations.get(block.automationId) + : null + + mapping.automationConditionBlocks.set(block.id, newId) + + await trx + .insertInto("automation_condition_blocks") + .values({ + id: newId, + automationId: newAutomationId as any, + automationConditionBlockId: null, + type: block.type as any, + rank: block.rank, + }) + .execute() + } + + // second pass: update parent references + for (const block of data.automationConditionBlocks) { + if (!block.automationConditionBlockId) continue + + const newId = mapping.automationConditionBlocks.get(block.id) + const newParentId = mapping.automationConditionBlocks.get(block.automationConditionBlockId) + + if (!newId || !newParentId) continue + + await trx + .updateTable("automation_condition_blocks") + .set({ automationConditionBlockId: newParentId }) + .where("id", "=", newId) + .execute() + } + + // create automation conditions with new ids + for (const condition of data.automationConditions) { + const newId = crypto.randomUUID() as AutomationConditionsId + const newBlockId = mapping.automationConditionBlocks.get(condition.automationConditionBlockId) + + if (!newBlockId) continue + + mapping.automationConditions.set(condition.id, newId) + + await trx + .insertInto("automation_conditions") + .values({ + id: newId, + automationConditionBlockId: newBlockId, + type: condition.type as any, + expression: condition.expression ?? "", + rank: condition.rank, + }) + .execute() + } + + // create pubs with new ids + for (const pub of data.pubs) { + const newId = crypto.randomUUID() as PubsId + const newPubTypeId = mapping.pubTypes.get(pub.pubTypeId) + + if (!newPubTypeId) continue + + mapping.pubs.set(pub.id, newId) + + await trx + .insertInto("pubs") + .values({ + id: newId, + pubTypeId: newPubTypeId, + communityId: newCommunityId, + }) + .execute() + } + + // create pub values with new ids + const lastModifiedBy = createLastModifiedBy("system") + for (const value of data.pubValues) { + const newId = crypto.randomUUID() as PubValuesId + const newPubId = mapping.pubs.get(value.pubId) + const newFieldId = mapping.pubFields.get(value.fieldId) + const newRelatedPubId = value.relatedPubId + ? mapping.pubs.get(value.relatedPubId) ?? null + : null + + if (!newPubId || !newFieldId) continue + + mapping.pubValues.set(value.id, newId) + + await trx + .insertInto("pub_values") + .values({ + id: newId, + pubId: newPubId, + fieldId: newFieldId, + value: value.value as any, + relatedPubId: newRelatedPubId as any, + lastModifiedBy, + }) + .execute() + } + + // create pubs in stages + for (const pubInStage of data.pubsInStages) { + const newPubId = mapping.pubs.get(pubInStage.pubId) + const newStageId = mapping.stages.get(pubInStage.stageId) + + if (!newPubId || !newStageId) continue + + await trx + .insertInto("PubsInStages") + .values({ + pubId: newPubId, + stageId: newStageId, + }) + .execute() + } + + // create api access tokens with new ids + for (const token of data.apiAccessTokens) { + const newId = crypto.randomUUID() as ApiAccessTokensId + mapping.apiAccessTokens.set(token.id, newId) + + // generate new token value + const newTokenValue = generateToken() + + // handle expiration date (may be string after JSON parsing) + const expiration = token.expiration + ? new Date(token.expiration) + : null + + await trx + .insertInto("api_access_tokens") + .values({ + id: newId, + name: token.name, + token: newTokenValue, + description: token.description, + expiration: expiration as any, + issuedAt: new Date(), + issuedById: issuedById, + communityId: newCommunityId, + }) + .execute() + } + + // create api access permissions with new ids + for (const permission of data.apiAccessPermissions) { + const newId = crypto.randomUUID() as ApiAccessPermissionsId + const newTokenId = mapping.apiAccessTokens.get(permission.apiAccessTokenId) + + if (!newTokenId) continue + + mapping.apiAccessPermissions.set(permission.id, newId) + + await trx + .insertInto("api_access_permissions") + .values({ + id: newId, + apiAccessTokenId: newTokenId, + scope: permission.scope, + accessType: permission.accessType, + constraints: permission.constraints as any, + }) + .execute() + } + + // create action config defaults + for (const configDefault of data.actionConfigDefaults) { + await trx + .insertInto("action_config_defaults") + .values({ + action: configDefault.action, + config: configDefault.config as any, + communityId: newCommunityId, + }) + .execute() + } + + return { communityId: newCommunityId, mapping } +} diff --git a/core/lib/server/communityClone/index.ts b/core/lib/server/communityClone/index.ts new file mode 100644 index 0000000000..fc8653e214 --- /dev/null +++ b/core/lib/server/communityClone/index.ts @@ -0,0 +1,3 @@ +export * from "./types" +export * from "./export" +export * from "./import" diff --git a/core/lib/server/communityClone/types.ts b/core/lib/server/communityClone/types.ts new file mode 100644 index 0000000000..1c38190ee4 --- /dev/null +++ b/core/lib/server/communityClone/types.ts @@ -0,0 +1,274 @@ +import type { + Action, + ActionInstancesId, + ApiAccessPermissionsId, + ApiAccessScope, + ApiAccessTokensId, + ApiAccessType, + AutomationConditionBlocksId, + AutomationConditionsId, + AutomationsId, + AutomationTriggersId, + CommunitiesId, + FormElementsId, + FormsId, + PubFieldsId, + PubsId, + PubTypesId, + PubValuesId, + StagesId, +} from "db/public" + +// id mapping structure for remapping entity ids during clone import +export type IdMapping = { + communities: Map + pubFields: Map + pubTypes: Map + stages: Map + pubs: Map + pubValues: Map + forms: Map + formElements: Map + // move constraints use composite key, not id + automations: Map + automationTriggers: Map + actionInstances: Map + automationConditionBlocks: Map + automationConditions: Map + apiAccessTokens: Map + apiAccessPermissions: Map +} + +// helper to create an empty id mapping +export const createEmptyIdMapping = (): IdMapping => ({ + communities: new Map(), + pubFields: new Map(), + pubTypes: new Map(), + stages: new Map(), + pubs: new Map(), + pubValues: new Map(), + forms: new Map(), + formElements: new Map(), + automations: new Map(), + automationTriggers: new Map(), + actionInstances: new Map(), + automationConditionBlocks: new Map(), + automationConditions: new Map(), + apiAccessTokens: new Map(), + apiAccessPermissions: new Map(), +}) + +// pub field data for clone +export type ClonePubField = { + id: PubFieldsId + name: string + slug: string + schemaName: string + isRelation: boolean | null +} + +// pub type data for clone +export type ClonePubType = { + id: PubTypesId + name: string + description: string | null +} + +// pub field to pub type mapping +export type ClonePubFieldToPubType = { + A: PubFieldsId + B: PubTypesId + isTitle: boolean + rank: string +} + +// stage data for clone +export type CloneStage = { + id: StagesId + name: string + order: string +} + +// move constraint data for clone (composite key) +export type CloneMoveConstraint = { + stageId: StagesId + destinationId: StagesId +} + +// form data for clone +export type CloneForm = { + id: FormsId + name: string + slug: string + access: string | null + isArchived: boolean + isDefault: boolean + pubTypeId: PubTypesId +} + +// form element data for clone +export type CloneFormElement = { + id: FormElementsId + formId: FormsId + fieldId: PubFieldsId | null + type: string + component: string | null + config: unknown + content: string | null + label: string | null + element: string | null + rank: string + required: boolean | null + stageId: StagesId | null +} + +// form element to pub type mapping +export type CloneFormElementToPubType = { + A: FormElementsId + B: PubTypesId +} + +// automation data for clone +export type CloneAutomation = { + id: AutomationsId + name: string + stageId: StagesId | null + icon: unknown + conditionEvaluationTiming: string | null + resolver: string | null +} + +// automation trigger data for clone +export type CloneAutomationTrigger = { + id: AutomationTriggersId + automationId: AutomationsId + event: string + config: unknown + sourceAutomationId: AutomationsId | null +} + +// action instance data for clone +export type CloneActionInstance = { + id: ActionInstancesId + automationId: AutomationsId + action: Action + config: unknown +} + +// automation condition block data for clone +export type CloneAutomationConditionBlock = { + id: AutomationConditionBlocksId + automationId: AutomationsId | null + automationConditionBlockId: AutomationConditionBlocksId | null + type: string + rank: string +} + +// automation condition data for clone +export type CloneAutomationCondition = { + id: AutomationConditionsId + automationConditionBlockId: AutomationConditionBlocksId + type: string + expression: string | null + rank: string +} + +// pub data for clone (no parentId, relationships are through pub_values) +export type ClonePub = { + id: PubsId + pubTypeId: PubTypesId +} + +// pub value data for clone +export type ClonePubValue = { + id: PubValuesId + pubId: PubsId + fieldId: PubFieldsId + value: unknown + relatedPubId: PubsId | null +} + +// pubs in stages data for clone +export type ClonePubsInStages = { + pubId: PubsId + stageId: StagesId +} + +// api access token data for clone +export type CloneApiAccessToken = { + id: ApiAccessTokensId + name: string + description: string | null + expiration: Date | null +} + +// api access permission data for clone +export type CloneApiAccessPermission = { + id: ApiAccessPermissionsId + apiAccessTokenId: ApiAccessTokensId + scope: ApiAccessScope + accessType: ApiAccessType + constraints: unknown +} + +// action config default data for clone +export type CloneActionConfigDefault = { + action: Action + config: unknown +} + +// export options for community clone +export type CloneExportOptions = { + // automation logs are not exported due to complexity +} + +// the main community clone format +export type CommunityClone = { + version: "1.0" + exportedAt: string + sourceCommunity: { + id: CommunitiesId + name: string + slug: string + } + + data: { + // community info + community: { + name: string + slug: string + avatar: string | null + } + + // schema + pubFields: ClonePubField[] + pubTypes: ClonePubType[] + pubFieldToPubType: ClonePubFieldToPubType[] + + // stages + stages: CloneStage[] + moveConstraints: CloneMoveConstraint[] + + // forms + forms: CloneForm[] + formElements: CloneFormElement[] + formElementToPubType: CloneFormElementToPubType[] + + // automations + automations: CloneAutomation[] + automationTriggers: CloneAutomationTrigger[] + actionInstances: CloneActionInstance[] + automationConditionBlocks: CloneAutomationConditionBlock[] + automationConditions: CloneAutomationCondition[] + + // pubs + pubs: ClonePub[] + pubValues: ClonePubValue[] + pubsInStages: ClonePubsInStages[] + + // config + apiAccessTokens: CloneApiAccessToken[] + apiAccessPermissions: CloneApiAccessPermission[] + actionConfigDefaults: CloneActionConfigDefault[] + } +} diff --git a/core/lib/server/communityTemplate/export.ts b/core/lib/server/communityTemplate/export.ts index 0d860f0559..7b218874be 100644 --- a/core/lib/server/communityTemplate/export.ts +++ b/core/lib/server/communityTemplate/export.ts @@ -1,4 +1,4 @@ -import type { CommunitiesId } from "db/public" +import type { CommunitiesId, MemberRole, PubsId } from "db/public" import type { IconConfig } from "ui/dynamic-icon" import { jsonArrayFrom } from "kysely/helpers/postgres" @@ -7,10 +7,14 @@ import { AutomationConditionBlockType, ElementType } from "db/public" import type { CommunityTemplate, + TemplateActionConfigDefault, + TemplateApiToken, TemplateAutomation, TemplateConditionItem, + TemplateExportOptions, TemplateForm, TemplateFormElement, + TemplatePub, TemplatePubField, TemplateStage, } from "./types" @@ -39,8 +43,11 @@ const transformConditionItems = (items: ConditionBlockItem[]): TemplateCondition } export const exportCommunityTemplate = async ( - communityId: CommunitiesId + communityId: CommunitiesId, + options: TemplateExportOptions = {} ): Promise => { + const { includePubs = false, includeApiTokens = false, includeActionConfigDefaults = false } = + options // fetch community const community = await db .selectFrom("communities") @@ -88,20 +95,12 @@ export const exportCommunityTemplate = async ( } } - // fetch stages with automations + // fetch stages (no members - memberships are skipped in templates) const stages = await db .selectFrom("stages") .select(["stages.id", "stages.name"]) - .select((eb) => - jsonArrayFrom( - eb - .selectFrom("stage_memberships") - .innerJoin("users", "users.id", "stage_memberships.userId") - .select(["users.slug", "stage_memberships.role"]) - .whereRef("stage_memberships.stageId", "=", "stages.id") - ).as("members") - ) .where("communityId", "=", communityId) + .orderBy("stages.order", "asc") .execute() // fetch automations @@ -186,7 +185,7 @@ export const exportCommunityTemplate = async ( } } - // build stages template + // build stages template (no members - memberships are skipped in templates) const stagesTemplate: Record = {} for (const stage of stages) { const stageAutomations = automations.filter((a) => a.stageId === stage.id) @@ -225,13 +224,7 @@ export const exportCommunityTemplate = async ( } } - const membersTemplate: Record = {} - for (const member of stage.members) { - membersTemplate[member.slug] = member.role - } - stagesTemplate[stage.name] = { - ...(Object.keys(membersTemplate).length > 0 ? { members: membersTemplate as any } : {}), ...(Object.keys(automationsTemplate).length > 0 ? { automations: automationsTemplate } : {}), @@ -372,8 +365,161 @@ export const exportCommunityTemplate = async ( template.forms = formsTemplate } - // note: we intentionally do not export users (security) or pubs (can be very large) - // users can be added manually to the template if needed + // optionally export pubs + if (includePubs) { + const pubsTemplate = await exportPubs(communityId, pubTypes, stages) + if (pubsTemplate.length > 0) { + template.pubs = pubsTemplate + } + } + + // optionally export api tokens + if (includeApiTokens) { + const apiTokensTemplate = await exportApiTokens(communityId) + if (Object.keys(apiTokensTemplate).length > 0) { + template.apiTokens = apiTokensTemplate + } + } + + // optionally export action config defaults + if (includeActionConfigDefaults) { + const actionConfigDefaultsTemplate = await exportActionConfigDefaults(communityId) + if (actionConfigDefaultsTemplate.length > 0) { + template.actionConfigDefaults = actionConfigDefaultsTemplate + } + } return template } + +// helper to export pubs +const exportPubs = async ( + communityId: CommunitiesId, + pubTypes: Array<{ id: string; name: string }>, + stages: Array<{ id: string; name: string }> +): Promise => { + // build lookup maps + const pubTypeIdToName = new Map(pubTypes.map((pt) => [pt.id, pt.name])) + const stageIdToName = new Map(stages.map((s) => [s.id, s.name])) + + // fetch all pubs with their values and stage info + const pubs = await db + .selectFrom("pubs") + .select(["pubs.id", "pubs.pubTypeId"]) + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom("pub_values") + .innerJoin("pub_fields", "pub_fields.id", "pub_values.fieldId") + .select([ + "pub_fields.name as fieldName", + "pub_values.value", + "pub_values.relatedPubId", + ]) + .whereRef("pub_values.pubId", "=", "pubs.id") + ).as("values"), + jsonArrayFrom( + eb + .selectFrom("PubsInStages") + .select(["stageId"]) + .whereRef("PubsInStages.pubId", "=", "pubs.id") + ).as("stages"), + ]) + .where("pubs.communityId", "=", communityId) + .execute() + + // build a map of pub id to pub for resolving related pubs + const pubIdToIndex = new Map() + pubs.forEach((pub, idx) => { + pubIdToIndex.set(pub.id, idx) + }) + + const result: TemplatePub[] = [] + for (const pub of pubs) { + const pubTypeName = pubTypeIdToName.get(pub.pubTypeId) + if (!pubTypeName) continue + + const stageId = pub.stages[0]?.stageId + const stageName = stageId ? stageIdToName.get(stageId) : undefined + + // build values + const values: Record = {} + for (const val of pub.values) { + if (val.relatedPubId) { + // relation value + if (!values[val.fieldName]) { + values[val.fieldName] = [] + } + ;(values[val.fieldName] as Array<{ value: unknown; relatedPubId: string }>).push({ + value: val.value, + relatedPubId: val.relatedPubId, + }) + } else { + values[val.fieldName] = val.value + } + } + + result.push({ + id: pub.id, + pubType: pubTypeName, + values, + ...(stageName ? { stage: stageName } : {}), + }) + } + + return result +} + +// helper to export api tokens +const exportApiTokens = async ( + communityId: CommunitiesId +): Promise> => { + const tokens = await db + .selectFrom("api_access_tokens") + .select(["name", "description"]) + .select((eb) => + jsonArrayFrom( + eb + .selectFrom("api_access_permissions") + .select(["scope", "accessType", "constraints"]) + .whereRef("api_access_permissions.apiAccessTokenId", "=", "api_access_tokens.id") + ).as("permissions") + ) + .where("communityId", "=", communityId) + .execute() + + const result: Record = {} + for (const token of tokens) { + // build permissions object + const permissions: Record = {} + for (const perm of token.permissions) { + if (!permissions[perm.scope]) { + permissions[perm.scope] = {} + } + ;(permissions[perm.scope] as Record)[perm.accessType] = perm.constraints + } + + result[token.name] = { + ...(token.description ? { description: token.description } : {}), + permissions: Object.keys(permissions).length > 0 ? permissions : true, + } + } + + return result +} + +// helper to export action config defaults +const exportActionConfigDefaults = async ( + communityId: CommunitiesId +): Promise => { + const defaults = await db + .selectFrom("action_config_defaults") + .select(["action", "config"]) + .where("communityId", "=", communityId) + .execute() + + return defaults.map((d) => ({ + action: d.action, + config: (d.config ?? {}) as Record, + })) +} diff --git a/core/lib/server/communityTemplate/schema.ts b/core/lib/server/communityTemplate/schema.ts index 182057672d..6331eb844d 100644 --- a/core/lib/server/communityTemplate/schema.ts +++ b/core/lib/server/communityTemplate/schema.ts @@ -519,6 +519,23 @@ export const createCommunityTemplateSchema = (): JSONSchema => { additionalProperties: false, } + const actionConfigDefault: JSONSchema = { + type: "object", + properties: { + action: { + enum: [...actions], + description: "The action this config applies to", + }, + config: { + type: "object", + additionalProperties: true, + description: "Default configuration for the action", + }, + }, + required: ["action", "config"], + additionalProperties: false, + } + const community: JSONSchema = { type: "object", properties: { @@ -604,6 +621,11 @@ export const createCommunityTemplateSchema = (): JSONSchema => { additionalProperties: apiToken, description: "API tokens for programmatic access. Keys are token names.", }, + actionConfigDefaults: { + type: "array", + items: actionConfigDefault, + description: "Default configurations for actions across the community.", + }, }, required: ["community"], additionalProperties: false, diff --git a/core/lib/server/communityTemplate/types.ts b/core/lib/server/communityTemplate/types.ts index 8705166076..d13e186456 100644 --- a/core/lib/server/communityTemplate/types.ts +++ b/core/lib/server/communityTemplate/types.ts @@ -154,6 +154,12 @@ export type TemplateApiToken = { permissions?: Record | true } +// action config default definition +export type TemplateActionConfigDefault = { + action: Action + config: Record +} + // the main community template type export type CommunityTemplate = { community: { @@ -163,12 +169,21 @@ export type CommunityTemplate = { } pubFields?: Record pubTypes?: Record> + // users are optional - if not provided, memberships are skipped users?: Record stages?: Record stageConnections?: TemplateStageConnections pubs?: TemplatePub[] forms?: Record apiTokens?: Record + actionConfigDefaults?: TemplateActionConfigDefault[] +} + +// options for exporting a community template +export type TemplateExportOptions = { + includePubs?: boolean + includeApiTokens?: boolean + includeActionConfigDefaults?: boolean } // minimal template for starting from scratch @@ -179,7 +194,7 @@ export const MINIMAL_TEMPLATE: CommunityTemplate = { }, } -// example template with common structure +// example template with common structure (no users - memberships skipped) export const EXAMPLE_TEMPLATE: CommunityTemplate = { community: { name: "Example Community", @@ -195,20 +210,8 @@ export const EXAMPLE_TEMPLATE: CommunityTemplate = { Content: { isTitle: false }, }, }, - users: { - admin: { - email: "admin@example.com", - firstName: "Admin", - lastName: "User", - role: "admin" as MemberRole, - }, - }, stages: { - Draft: { - members: { - admin: "admin" as MemberRole, - }, - }, + Draft: {}, Published: {}, }, stageConnections: { diff --git a/core/lib/server/member.ts b/core/lib/server/member.ts index d61f4c4d8f..40bd2d2e29 100644 --- a/core/lib/server/member.ts +++ b/core/lib/server/member.ts @@ -244,7 +244,10 @@ export const insertCommunityMembershipsOverrideRole = ( export const insertStageMemberships = ( membership: NewStageMemberships & { userId: UsersId; forms: FormsId[] }, trx = db -) => autoRevalidate(trx.insertInto("stage_memberships").values(getMembershipRows(membership))) +) => + autoRevalidate( + trx.insertInto("stage_memberships").values(getMembershipRows(membership)).returningAll() + ) export const deleteStageMemberships = ( params: { communityId: CommunitiesId; userId: UsersId }, diff --git a/core/lib/server/stages.ts b/core/lib/server/stages.ts index 58527a46af..0a592a6390 100644 --- a/core/lib/server/stages.ts +++ b/core/lib/server/stages.ts @@ -28,11 +28,11 @@ import { autoCache } from "./cache/autoCache" import { autoRevalidate } from "./cache/autoRevalidate" import { maybeWithTrx } from "./maybeWithTrx" -export const createStage = (props: NewStages) => - autoRevalidate(db.insertInto("stages").values(props)) +export const createStage = (props: NewStages, trx = db) => + autoRevalidate(trx.insertInto("stages").values(props).returningAll()) -export const updateStage = (stageId: StagesId, props: StagesUpdate) => - autoRevalidate(db.updateTable("stages").set(props).where("id", "=", stageId)) +export const updateStage = (stageId: StagesId, props: StagesUpdate, trx = db) => + autoRevalidate(trx.updateTable("stages").set(props).where("id", "=", stageId)) export const removeStages = (stageIds: StagesId[]) => autoRevalidate( @@ -47,8 +47,8 @@ export const removeStages = (stageIds: StagesId[]) => .where("stageId", "in", (eb) => eb.selectFrom("deleted_stages").select("id")) ) -export const createMoveConstraint = (props: NewMoveConstraint) => - autoRevalidate(db.insertInto("move_constraint").values(props)) +export const createMoveConstraint = (props: NewMoveConstraint, trx = db) => + autoRevalidate(trx.insertInto("move_constraint").values(props).returningAll()) /** * You should use `executeTakeFirst` here diff --git a/core/prisma/seed/seedCommunity.ts b/core/prisma/seed/seedCommunity.ts index 853a2e1bff..bc5198e55b 100644 --- a/core/prisma/seed/seedCommunity.ts +++ b/core/prisma/seed/seedCommunity.ts @@ -76,7 +76,10 @@ import { } from "~/lib/server/apiAccessTokens" import { insertForm } from "~/lib/server/form" import { InviteService } from "~/lib/server/invites/InviteService" +import { insertCommunityMemberships, insertStageMemberships } from "~/lib/server/member" +import { createMoveConstraint, createStage } from "~/lib/server/stages" import { generateToken } from "~/lib/server/token" +import { addUser } from "~/lib/server/user" import { slugifyString } from "~/lib/string" export type PubFieldsInitializer = Record< @@ -124,9 +127,22 @@ export type UsersInitializer = Record< isVerified?: boolean } | { + /** reference an existing user by id */ id: UsersId existing: true - role: MemberRole + role?: MemberRole | null + } + | { + /** reference an existing user by slug */ + slug: string + existing: true + role?: MemberRole | null + } + | { + /** reference an existing user by email */ + email: string + existing: true + role?: MemberRole | null } > @@ -982,16 +998,23 @@ export async function seedCommunity< ]) ) as PubTypesByName + // separate new users from existing user references const newUsers = Object.entries(props.users ?? {}).filter( - (user): user is [string, (typeof user)[1] & { existing: false }] => !user[1].existing + (user): user is [string, (typeof user)[1] & { existing?: false }] => !user[1].existing ) + + const existingUserRefs = Object.entries(props.users ?? {}).filter( + (user): user is [string, (typeof user)[1] & { existing: true }] => !!user[1].existing + ) + + // create new users using addUser const newUserValues = await Promise.all( newUsers.map(async ([slug, userInfo]) => ({ id: userInfo.id ?? (crypto.randomUUID() as UsersId), slug: options?.randomSlug === false ? (userInfo.slug ?? slug) - : `${userInfo.slug ?? slug}-${randomSlugSuffix}`, + : `${userInfo.slug ?? slug}${randomSlugSuffix}`, email: userInfo.email ?? faker.internet.email(), firstName: userInfo.firstName ?? faker.person.firstName(), lastName: userInfo.lastName ?? faker.person.lastName(), @@ -999,47 +1022,71 @@ export async function seedCommunity< passwordHash: await createPasswordHash(userInfo.password ?? faker.internet.password()), isSuperAdmin: userInfo.isSuperAdmin ?? false, isVerified: userInfo.isVerified !== false, - // the key of the user initializer })) ) - console.log("newUserValues", newUserValues) - const createdUsers = newUserValues.length - ? await trx.insertInto("users").values(newUserValues).returningAll().execute() - : [] + const createdUsers: Users[] = [] + for (const userValue of newUserValues) { + const user = await addUser(userValue, trx).executeTakeFirstOrThrow() + // addUser returns SafeUser without passwordHash, but we need the full Users type + createdUsers.push({ ...user, passwordHash: userValue.passwordHash }) + } + + // resolve existing user references by id, slug, or email + const resolvedExistingUsers: Array<[string, Users & { role?: MemberRole | null }]> = [] + for (const [key, userRef] of existingUserRefs) { + let existingUser: Users | undefined + + if ("id" in userRef && userRef.id) { + existingUser = await trx + .selectFrom("users") + .selectAll() + .where("id", "=", userRef.id) + .executeTakeFirst() + } else if ("slug" in userRef && userRef.slug) { + existingUser = await trx + .selectFrom("users") + .selectAll() + .where("slug", "=", userRef.slug) + .executeTakeFirst() + } else if ("email" in userRef && userRef.email) { + existingUser = await trx + .selectFrom("users") + .selectAll() + .where("email", "=", userRef.email) + .executeTakeFirst() + } - // we use the index of the userinitializers as the slug for the users, even though - // it could be something else. it just makes finding it back easier + if (!existingUser) { + throw new Error( + `Could not find existing user referenced by ${JSON.stringify(userRef)}` + ) + } + + resolvedExistingUsers.push([key, { ...existingUser, role: userRef.role }]) + } + + // combine new and existing users const usersBySlug = Object.fromEntries([ ...newUsers.map(([slug], idx) => [slug, { ...newUsers[idx][1], ...createdUsers[idx] }]), - ...Object.entries(props.users ?? {}) - .filter( - (user): user is [string, (typeof user)[1] & { existing: true }] => - !!user[1].existing - ) - .map(([slug, userInfo]) => [slug, userInfo]), + ...resolvedExistingUsers, ]) as UsersBySlug + // create community memberships for users with roles const possibleMembers = Object.entries(usersBySlug) .filter(([, userInfo]) => !!userInfo.role) - .flatMap(([, userWithRole]) => { - return [ - { - id: userWithRole.existing ? undefined : userWithRole.id, - userId: userWithRole.id, - communityId, - role: userWithRole.role!, - } satisfies NewCommunityMemberships, - ] - }) + .map(([, userWithRole]) => ({ + userId: userWithRole.id, + communityId, + role: userWithRole.role!, + forms: [] as FormsId[], + })) - const createdMembers = possibleMembers?.length - ? await trx - .insertInto("community_memberships") - .values(possibleMembers) - .returningAll() - .execute() - : [] + const createdMembers: CommunityMemberships[] = [] + for (const memberData of possibleMembers) { + const members = await insertCommunityMemberships(memberData, trx).execute() + createdMembers.push(...members) + } const usersWithMemberShips = Object.fromEntries( Object.entries(usersBySlug) @@ -1055,20 +1102,21 @@ export async function seedCommunity< const stageList = Object.entries(props.stages ?? {}) - const createdStages = stageList.length - ? await trx - .insertInto("stages") - .values( - stageList.map(([stageName, stageInfo], idx) => ({ - id: stageInfo.id, - communityId, - name: stageName, - order: `${(idx + 10).toString(36)}${(idx + 10).toString(36)}`, - })) - ) - .returningAll() - .execute() - : [] + // create stages using createStage + const createdStages: Stages[] = [] + for (let idx = 0; idx < stageList.length; idx++) { + const [stageName, stageInfo] = stageList[idx] + const stage = await createStage( + { + id: stageInfo.id, + communityId, + name: stageName, + order: `${(idx + 10).toString(36)}${(idx + 10).toString(36)}`, + }, + trx + ).executeTakeFirstOrThrow() + createdStages.push(stage) + } const consolidatedStages = createdStages.map((stage, idx) => ({ ...stageList[idx][1], @@ -1076,6 +1124,7 @@ export async function seedCommunity< })) // + // create stage memberships using insertStageMemberships const stageMembers = consolidatedStages .flatMap((stage) => { if (!stage.members) return [] @@ -1087,85 +1136,79 @@ export async function seedCommunity< })) }) .filter( - (stageMember) => stageMember.user.member !== undefined && stageMember.role !== undefined + (stageMember) => + stageMember.user?.member !== undefined && stageMember.role !== undefined ) - const stageMemberships = - stageMembers.length > 0 - ? await trx - .insertInto("stage_memberships") - .values(() => - stageMembers.map((stageMember) => ({ - role: stageMember.role!, - stageId: stageMember.stage.id, - userId: stageMember.user.id, - })) - ) - .returningAll() - .execute() - : [] - - console.log("consolidatedStages", consolidatedStages) - console.log("stageConnectionsList", props.stageConnections) - const stageConnectionsList = props.stageConnections - ? await trx - .insertInto("move_constraint") - .values( - Object.entries(props.stageConnections).flatMap(([stage, destinations]) => { - if (!destinations) { - return [] - } + const stageMemberships = [] + for (const stageMember of stageMembers) { + const result = await insertStageMemberships( + { + role: stageMember.role!, + stageId: stageMember.stage.id, + userId: stageMember.user.id, + forms: [] as FormsId[], + }, + trx + ).execute() + stageMemberships.push(...result) + } - const currentStageId = consolidatedStages.find( - (consolidatedStage) => consolidatedStage.name === stage - )?.id + // create stage connections using createMoveConstraint + const stageConnectionsList = [] + if (props.stageConnections) { + for (const [stage, destinations] of Object.entries(props.stageConnections)) { + if (!destinations) continue - if (!currentStageId) { - throw new Error( - `Something went wrong during the creation of stage connections. Stage ${stage} not found in the output of the created stages.` - ) - } + const currentStageId = consolidatedStages.find( + (consolidatedStage) => consolidatedStage.name === stage + )?.id - const { to, from } = destinations + if (!currentStageId) { + throw new Error( + `Something went wrong during the creation of stage connections. Stage ${stage} not found in the output of the created stages.` + ) + } - const tos = - to?.map((dest) => { - const toStage = consolidatedStages.find( - (stage) => stage.name === dest - ) - if (!toStage) { - throw new Error( - `Something went wrong during the creation of stage connections. Stage ${String(dest)} not found in the output of the created stages.` - ) - } - return { - stageId: currentStageId, - destinationId: toStage.id, - } - }) ?? [] + const { to, from } = destinations - const froms = - from?.map((dest) => { - const fromStage = consolidatedStages.find( - (stage) => stage.name === dest - ) - if (!fromStage) { - throw new Error( - `Something went wrong during the creation of stage connections. Stage ${String(dest)} not found in the output of the created stages.` - ) - } - return { - stageId: fromStage.id, - destinationId: currentStageId, - } - }) ?? [] + // create "to" connections + for (const dest of to ?? []) { + const toStage = consolidatedStages.find((s) => s.name === dest) + if (!toStage) { + throw new Error( + `Something went wrong during the creation of stage connections. Stage ${String(dest)} not found in the output of the created stages.` + ) + } + const constraint = await createMoveConstraint( + { + stageId: currentStageId, + destinationId: toStage.id, + }, + trx + ).executeTakeFirstOrThrow() + stageConnectionsList.push(constraint) + } - return [...tos, ...froms] - }) - ) - .returningAll() - .execute() - : [] + // create "from" connections + for (const dest of from ?? []) { + const fromStage = consolidatedStages.find((s) => s.name === dest) + if (!fromStage) { + throw new Error( + `Something went wrong during the creation of stage connections. Stage ${String(dest)} not found in the output of the created stages.` + ) + } + const constraint = await createMoveConstraint( + { + stageId: fromStage.id, + destinationId: currentStageId, + }, + trx + ).executeTakeFirstOrThrow() + stageConnectionsList.push(constraint) + } + } + } const createPubRecursiveInput = props.pubs ? makePubInitializerMatchCreatePubRecursiveInput({ From 9fb0d9705771b5a7cee67fa50982bd1e12ae32c6 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 2 Feb 2026 16:08:10 +0100 Subject: [PATCH 3/5] feat: add way to skip cache --- core/lib/server/cache/autoCache.ts | 22 ++++++++-- core/lib/server/cache/autoRevalidate.ts | 14 ++++++- core/lib/server/cache/skipCacheStore.ts | 54 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 core/lib/server/cache/skipCacheStore.ts diff --git a/core/lib/server/cache/autoCache.ts b/core/lib/server/cache/autoCache.ts index b6bad2699d..ac69fa9773 100644 --- a/core/lib/server/cache/autoCache.ts +++ b/core/lib/server/cache/autoCache.ts @@ -10,6 +10,7 @@ import { createCacheTag, createCommunityCacheTags } from "./cacheTags" import { getCommunitySlug } from "./getCommunitySlug" import { memoize } from "./memoize" import { cachedFindTables, directAutoOutput } from "./sharedAuto" +import { shouldSkipCache as shouldSkipCacheStore } from "./skipCacheStore" import { getTablesWithLinkedTables } from "./specialTables" import { getTransactionStore, setTransactionStore } from "./transactionStorage" @@ -60,10 +61,25 @@ const executeWithCache = < options?: AutoCacheOptions ) => { const executeFn = cache(async (...args: Parameters) => { - const communitySlug = options?.communitySlug ?? (await getCommunitySlug()) - const compiledQuery = qb.compile() + const willSkipCacheStore = shouldSkipCacheStore("store") + const willSkipCacheFn = options?.skipCacheFn?.() + + const willSkipCache = willSkipCacheStore || willSkipCacheFn + + if (willSkipCache) { + logger.debug( + willSkipCacheStore + ? `Skipping cache for query ${compiledQuery.sql} because of skipCacheStore` + : `Skipping cache for query ${compiledQuery.sql} because of skipCacheFn` + ) + + return qb[method](...args) as ReturnType + } + + const communitySlug = options?.communitySlug ?? (await getCommunitySlug()) + const tables = await cachedFindTables(compiledQuery, "select") const allTables = getTablesWithLinkedTables(tables) @@ -88,7 +104,7 @@ const executeWithCache = < asOne ) - if (shouldSkipCache || options?.skipCacheFn?.()) { + if (shouldSkipCache) { if (env.CACHE_LOG) { logger.debug(`AUTOCACHE: Skipping cache for query: ${asOne}`) } diff --git a/core/lib/server/cache/autoRevalidate.ts b/core/lib/server/cache/autoRevalidate.ts index bfcd8cf961..0b139a0820 100644 --- a/core/lib/server/cache/autoRevalidate.ts +++ b/core/lib/server/cache/autoRevalidate.ts @@ -9,6 +9,7 @@ import { env } from "~/lib/env/env" import { getCommunitySlug } from "./getCommunitySlug" import { revalidateTagsForCommunity } from "./revalidate" import { cachedFindTables, directAutoOutput } from "./sharedAuto" +import { shouldSkipCache as shouldSkipCacheStore } from "./skipCacheStore" import { setTransactionStore } from "./transactionStorage" const executeWithRevalidate = < @@ -20,12 +21,21 @@ const executeWithRevalidate = < options?: AutoRevalidateOptions ) => { const executeFn = async (...args: Parameters) => { + const compiledQuery = qb.compile() + + const willSkipCacheStore = shouldSkipCacheStore("invalidate") + + if (willSkipCacheStore) { + logger.debug( + `Skipping revalidation for query ${compiledQuery.sql} because of skipCacheStore` + ) + return qb[method](...args) as ReturnType + } + const communitySlug = options?.communitySlug ?? (await getCommunitySlug()) const communitySlugs = Array.isArray(communitySlug) ? communitySlug : [communitySlug] - const compiledQuery = qb.compile() - const tables = await cachedFindTables(compiledQuery, "mutation") // necessary assertion here due to diff --git a/core/lib/server/cache/skipCacheStore.ts b/core/lib/server/cache/skipCacheStore.ts new file mode 100644 index 0000000000..1d690270b9 --- /dev/null +++ b/core/lib/server/cache/skipCacheStore.ts @@ -0,0 +1,54 @@ +import { AsyncLocalStorage } from "node:async_hooks" + +import { logger } from "logger" + +const SKIP_CACHE_OPTIONS = ["store", "invalidate", "both"] as const +export type SkipCacheOptions = (typeof SKIP_CACHE_OPTIONS)[number] + +// tags +export const skipCacheStore = new AsyncLocalStorage<{ + /** + * Whether to store the result in the cache or invalidate it + */ + shouldSkipCache: "store" | "invalidate" | "both" | undefined +}>() + +export const setSkipCacheStore = ({ shouldSkipCache }: { shouldSkipCache: SkipCacheOptions }) => { + const store = skipCacheStore.getStore() + + if (!store) { + logger.debug("no skip cache store found") + return + } + + store.shouldSkipCache = shouldSkipCache + + return store +} + +/** + * whether or not to skip the cache + */ +export const shouldSkipCache = (skipCacheOptions: SkipCacheOptions) => { + const store = skipCacheStore.getStore() + + if (!store) { + return false + } + + if (store.shouldSkipCache === "both") { + return true + } + + return store.shouldSkipCache === skipCacheOptions +} + +/** + * wrap a function with this to skip storing and/or invalidating the cache + * useful when outside of community contexts and you don't want to cache results + */ +export const withUncached = (fn: () => Promise, skipCacheOptions?: SkipCacheOptions) => { + return skipCacheStore.run({ shouldSkipCache: skipCacheOptions ?? "invalidate" }, async () => { + return fn() + }) +} From daa9f00518b91353f35d2842427a848442f7839f Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 2 Feb 2026 16:45:44 +0100 Subject: [PATCH 4/5] fix: able to import --- .../(user)/communities/AddCommunityDialog.tsx | 31 ++--- .../(user)/communities/AddCommunityForm.tsx | 2 +- .../communities/CloneCommunityButton.tsx | 49 +++++--- .../app/(user)/communities/CommunityTable.tsx | 12 +- .../communities/ExportTemplateButton.tsx | 27 ++-- .../app/(user)/communities/TemplateEditor.tsx | 13 +- .../communities/getCommunityTableColumns.tsx | 10 +- core/app/(user)/communities/page.tsx | 8 +- .../app/(user)/communities/templateActions.ts | 49 ++++---- core/app/test/page.tsx | 118 ++++++++++++++++++ 10 files changed, 218 insertions(+), 101 deletions(-) create mode 100644 core/app/test/page.tsx diff --git a/core/app/(user)/communities/AddCommunityDialog.tsx b/core/app/(user)/communities/AddCommunityDialog.tsx index d5944691c2..53511e6ffc 100644 --- a/core/app/(user)/communities/AddCommunityDialog.tsx +++ b/core/app/(user)/communities/AddCommunityDialog.tsx @@ -3,23 +3,17 @@ import React from "react" import { Button } from "ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, - DialogTrigger, -} from "ui/dialog" -import { CurlyBraces, Loader2, ListPlus } from "ui/icon" +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "ui/dialog" +import { CurlyBraces, ListPlus, Loader2 } from "ui/icon" import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs" import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip" import { toast } from "ui/use-toast" -import { didSucceed, useServerAction } from "~/lib/serverActions" import { EXAMPLE_TEMPLATE } from "~/lib/server/communityTemplate/types" +import { didSucceed, useServerAction } from "~/lib/serverActions" import { AddCommunityForm } from "./AddCommunityForm" -import { createCommunityFromTemplateAction } from "./templateActions" import { TemplateEditor, useTemplateEditor } from "./TemplateEditor" +import { createCommunityFromTemplateAction } from "./templateActions" type AddCommunityProps = { initialTemplate?: string @@ -66,10 +60,10 @@ export const AddCommunity = ({ initialTemplate }: AddCommunityProps) => { Basic - - - From Template - + + + From Template + @@ -77,10 +71,7 @@ export const AddCommunity = ({ initialTemplate }: AddCommunityProps) => { - + @@ -139,10 +130,10 @@ const TemplateTabContent = ({ setOpen, initialTemplate }: TemplateTabContentProp />
- - -
-
@@ -186,8 +192,9 @@ export const CloneCommunityButton = ({ onChange={(e) => setNewSlug(e.target.value)} placeholder="my-community-clone" /> -

- Must be unique. Use lowercase letters, numbers, and hyphens only. +

+ Must be unique. Use lowercase letters, numbers, and hyphens + only.

@@ -203,7 +210,9 @@ export const CloneCommunityButton = ({ {!cloneData && (
- +
- -
diff --git a/core/app/(user)/communities/CommunityTable.tsx b/core/app/(user)/communities/CommunityTable.tsx index 9553076f93..64c03004fb 100644 --- a/core/app/(user)/communities/CommunityTable.tsx +++ b/core/app/(user)/communities/CommunityTable.tsx @@ -3,7 +3,6 @@ import type { TableCommunity } from "./getCommunityTableColumns" import * as React from "react" -import { useRouter } from "next/navigation" import { DataTable } from "~/app/components/DataTable/DataTable" import { getCommunityTableColumns } from "./getCommunityTableColumns" @@ -14,19 +13,10 @@ type CommunityTableProps = { } export const CommunityTable = ({ communities, onCreateCopy }: CommunityTableProps) => { - const router = useRouter() - const communityTableColumns = React.useMemo( () => getCommunityTableColumns({ onCreateCopy }), [onCreateCopy] ) - return ( - router.push(`/c/${row.original.slug}/stages`)} - /> - ) + return } diff --git a/core/app/(user)/communities/ExportTemplateButton.tsx b/core/app/(user)/communities/ExportTemplateButton.tsx index 3eafba638a..e3d2562ede 100644 --- a/core/app/(user)/communities/ExportTemplateButton.tsx +++ b/core/app/(user)/communities/ExportTemplateButton.tsx @@ -5,19 +5,14 @@ import type { CommunitiesId } from "db/public" import * as React from "react" import { Button } from "ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, -} from "ui/dialog" +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "ui/dialog" import { DropdownMenuItem } from "ui/dropdown-menu" import { Clipboard, CurlyBraces, Download, Loader2 } from "ui/icon" import { toast } from "ui/use-toast" import { didSucceed, useServerAction } from "~/lib/serverActions" -import { exportCommunityTemplateAction } from "./templateActions" import { TemplateEditor } from "./TemplateEditor" +import { exportCommunityTemplateAction } from "./templateActions" type ExportTemplateButtonProps = { communityId: string @@ -91,7 +86,7 @@ export const ExportTemplateButton = ({ { e.preventDefault() - handleExport() + void handleExport() }} className="gap-2" > @@ -124,21 +119,25 @@ export const ExportTemplateButton = ({
- +
- {onCreateCopy && ( - )} diff --git a/core/app/(user)/communities/TemplateEditor.tsx b/core/app/(user)/communities/TemplateEditor.tsx index 5e974b0d93..8f71534175 100644 --- a/core/app/(user)/communities/TemplateEditor.tsx +++ b/core/app/(user)/communities/TemplateEditor.tsx @@ -4,11 +4,10 @@ import type { ValidationResult } from "ui/monaco" import * as React from "react" -import { cn } from "utils" - import { AlertCircle, CheckCircle, Clipboard, CurlyBraces } from "ui/icon" import { JsonEditor } from "ui/monaco" import { toast } from "ui/use-toast" +import { cn } from "utils" import { communityTemplateSchema } from "~/lib/server/communityTemplate/schema" import { validateCommunityTemplate } from "~/lib/server/communityTemplate/validate" @@ -62,10 +61,7 @@ export const TemplateEditor = ({ } } - const allErrors = [ - ...validationResult.errors.map((e) => e.message), - ...crossRefErrors, - ] + const allErrors = [...validationResult.errors.map((e) => e.message), ...crossRefErrors] const isValid = validationResult.valid && crossRefErrors.length === 0 && value.trim().length > 0 return ( @@ -86,7 +82,7 @@ export const TemplateEditor = ({ +
+
+ ) +} From 90f323151e6cf32ea00eb5caf9e4eb00fad08df7 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 2 Apr 2026 12:24:41 +0200 Subject: [PATCH 5/5] feat: new blueprint idea --- core/actions/_lib/zodTypes.ts | 28 + core/actions/createPub/action.ts | 5 +- core/actions/email/action.tsx | 4 +- core/actions/move/action.ts | 4 + .../(user)/communities/AddCommunityDialog.tsx | 94 +-- .../communities/BlueprintImportWizard.tsx | 373 +++++++++++ .../communities/ExportTemplateButton.tsx | 131 ++-- .../(user)/communities/blueprintActions.ts | 226 +++++++ core/app/api/dev/write-seed/route.ts | 53 ++ .../[communitySlug]/stages/manage/actions.ts | 21 + .../automationsTab/StagePanelAutomation.tsx | 35 +- .../automationsTab/StagePanelAutomations.tsx | 6 + core/lib/server/blueprint/configRewriter.ts | 322 ++++++++++ core/lib/server/blueprint/dag.ts | 72 +++ core/lib/server/blueprint/export.ts | 605 ++++++++++++++++++ core/lib/server/blueprint/import.ts | 149 +++++ core/lib/server/blueprint/index.ts | 7 + core/lib/server/blueprint/toSeed.ts | 265 ++++++++ core/lib/server/blueprint/types.ts | 221 +++++++ core/lib/server/blueprint/validate.ts | 192 ++++++ core/lib/server/cache/autoCache.ts | 11 +- core/lib/server/cache/autoRevalidate.ts | 11 +- core/prisma/seed/seedCommunity.ts | 35 +- core/prisma/seeds/coar-notify.ts | 56 +- 24 files changed, 2748 insertions(+), 178 deletions(-) create mode 100644 core/app/(user)/communities/BlueprintImportWizard.tsx create mode 100644 core/app/(user)/communities/blueprintActions.ts create mode 100644 core/app/api/dev/write-seed/route.ts create mode 100644 core/lib/server/blueprint/configRewriter.ts create mode 100644 core/lib/server/blueprint/dag.ts create mode 100644 core/lib/server/blueprint/export.ts create mode 100644 core/lib/server/blueprint/import.ts create mode 100644 core/lib/server/blueprint/index.ts create mode 100644 core/lib/server/blueprint/toSeed.ts create mode 100644 core/lib/server/blueprint/types.ts create mode 100644 core/lib/server/blueprint/validate.ts diff --git a/core/actions/_lib/zodTypes.ts b/core/actions/_lib/zodTypes.ts index c40f9ec57a..697f1c3d12 100644 --- a/core/actions/_lib/zodTypes.ts +++ b/core/actions/_lib/zodTypes.ts @@ -50,6 +50,24 @@ class Stage extends z.ZodString { }) } +class Member extends z.ZodString { + static create = () => + new Member({ + typeName: "Member" as z.ZodFirstPartyTypeKind.ZodString, + checks: [], + coerce: false, + }) +} + +class FormSlugType extends z.ZodString { + static create = () => + new FormSlugType({ + typeName: "FormSlug" as z.ZodFirstPartyTypeKind.ZodString, + checks: [], + coerce: false, + }) +} + // @ts-expect-error FIXME: 'ZodObject<{ pubField: ZodString; responseField: ZodString; }, UnknownKeysParam, ZodTypeAny, { pubField: string; responseField: string; }, { pubField: string; responseField: string; }>' is assignable to the constraint of type 'El', but 'El' could be instantiated with a different subtype of constraint 'ZodTypeAny' blahblahblah class OutputMap extends z.ZodArray< z.ZodObject<{ pubField: z.ZodString; responseField: z.ZodString }> @@ -69,4 +87,14 @@ export const markdown = Markdown.create export const stringWithTokens = StringWithTokens.create export const fieldName = FieldName.create export const stage = Stage.create +export const member = Member.create +export const formSlug = FormSlugType.create export const outputMap = OutputMap.create + +// custom typeName constants used for detecting reference fields during config rewriting +export const REFERENCE_TYPE_NAMES = { + Stage: "Stage", + Member: "Member", + FormSlug: "FormSlug", + FieldName: "FieldName", +} as const diff --git a/core/actions/createPub/action.ts b/core/actions/createPub/action.ts index d4246c9273..1c3c16cd07 100644 --- a/core/actions/createPub/action.ts +++ b/core/actions/createPub/action.ts @@ -3,6 +3,7 @@ import z from "zod" import { Action } from "db/public" +import { formSlug, stage } from "../_lib/zodTypes" import { defineAction } from "../types" /** @@ -36,8 +37,8 @@ export const action = defineAction({ description: "Create a new pub", config: { schema: z.object({ - stage: z.string().uuid(), - formSlug: z.string(), + stage: stage().describe("The stage the new pub will be created in"), + formSlug: formSlug().describe("The form slug that determines the pub type"), pubValues: z.record(z.unknown()), relationConfig: relationConfigSchema.describe( "Optional configuration for relating the new pub to an existing pub" diff --git a/core/actions/email/action.tsx b/core/actions/email/action.tsx index 3f5c88f090..d5d0f7f7a1 100644 --- a/core/actions/email/action.tsx +++ b/core/actions/email/action.tsx @@ -7,7 +7,7 @@ import { RenderWithPubToken, renderWithPubTokens, } from "~/lib/server/render/pub/renderWithPubTokens" -import { markdown, stringWithTokens } from "../_lib/zodTypes" +import { markdown, member, stringWithTokens } from "../_lib/zodTypes" import { defineAction } from "../types" const emptyStringToUndefined = (arg: unknown) => { @@ -34,7 +34,7 @@ const schema = z.object({ "The email address of the recipient(s). Either this or 'Recipient Member' must be set." ), recipientMember: z - .preprocess(emptyStringToUndefined, z.string().uuid().optional()) + .preprocess(emptyStringToUndefined, member().optional()) .optional() .describe( "Someone who is a member of the community. Either this or 'Recipient Email' must be set." diff --git a/core/actions/move/action.ts b/core/actions/move/action.ts index c3d2ad13eb..a735311402 100644 --- a/core/actions/move/action.ts +++ b/core/actions/move/action.ts @@ -18,3 +18,7 @@ export const action = defineAction({ description: "Move a pub into a different stage", icon: MoveHorizontal, }) + +// is this field a weak ref to foreignkey? +// is this field sensitive/should not be copied? +// diff --git a/core/app/(user)/communities/AddCommunityDialog.tsx b/core/app/(user)/communities/AddCommunityDialog.tsx index 53511e6ffc..f1faf3371e 100644 --- a/core/app/(user)/communities/AddCommunityDialog.tsx +++ b/core/app/(user)/communities/AddCommunityDialog.tsx @@ -4,16 +4,12 @@ import React from "react" import { Button } from "ui/button" import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "ui/dialog" -import { CurlyBraces, ListPlus, Loader2 } from "ui/icon" +import { CurlyBraces, ListPlus } from "ui/icon" import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs" import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip" -import { toast } from "ui/use-toast" -import { EXAMPLE_TEMPLATE } from "~/lib/server/communityTemplate/types" -import { didSucceed, useServerAction } from "~/lib/serverActions" import { AddCommunityForm } from "./AddCommunityForm" -import { TemplateEditor, useTemplateEditor } from "./TemplateEditor" -import { createCommunityFromTemplateAction } from "./templateActions" +import { BlueprintImportWizard } from "./BlueprintImportWizard" type AddCommunityProps = { initialTemplate?: string @@ -23,17 +19,15 @@ export const AddCommunity = ({ initialTemplate }: AddCommunityProps) => { const [open, setOpen] = React.useState(false) const [activeTab, setActiveTab] = React.useState("basic") - // reset tab when dialog closes React.useEffect(() => { if (!open) { setActiveTab("basic") } }, [open]) - // if initial template is provided, open in template mode React.useEffect(() => { if (initialTemplate) { - setActiveTab("template") + setActiveTab("blueprint") setOpen(true) } }, [initialTemplate]) @@ -54,15 +48,15 @@ export const AddCommunity = ({ initialTemplate }: AddCommunityProps) => { Create Community - Create a new community from scratch or use a template. + Create a new community from scratch or import from a blueprint. Basic - + - From Template + From Blueprint @@ -70,80 +64,14 @@ export const AddCommunity = ({ initialTemplate }: AddCommunityProps) => { - - + + setOpen(false)} + initialBlueprint={initialTemplate} + /> ) } - -type TemplateTabContentProps = { - setOpen: (open: boolean) => void - initialTemplate?: string -} - -const TemplateTabContent = ({ setOpen, initialTemplate }: TemplateTabContentProps) => { - const { value, setValue, isValid } = useTemplateEditor( - initialTemplate ?? JSON.stringify(EXAMPLE_TEMPLATE, null, 2) - ) - const [isSubmitting, setIsSubmitting] = React.useState(false) - const runCreateFromTemplate = useServerAction(createCommunityFromTemplateAction) - - const handleSubmit = async () => { - if (!isValid) return - - setIsSubmitting(true) - try { - const result = await runCreateFromTemplate({ templateJson: value }) - if (didSucceed(result)) { - toast.success("Community created successfully") - setOpen(false) - } - } finally { - setIsSubmitting(false) - } - } - - const loadExample = () => { - setValue(JSON.stringify(EXAMPLE_TEMPLATE, null, 2)) - } - - return ( -
-
-

- Paste or edit a community template JSON below. -

- -
- - - -
- - -
-
- ) -} diff --git a/core/app/(user)/communities/BlueprintImportWizard.tsx b/core/app/(user)/communities/BlueprintImportWizard.tsx new file mode 100644 index 0000000000..6acc19c104 --- /dev/null +++ b/core/app/(user)/communities/BlueprintImportWizard.tsx @@ -0,0 +1,373 @@ +"use client" + +import * as React from "react" + +import { Button } from "ui/button" +import { AlertCircle, ArrowLeft, ArrowRight, CheckCircle, Download, Loader2 } from "ui/icon" +import { Input } from "ui/input" +import { Label } from "ui/label" +import { JsonEditor } from "ui/monaco" +import { toast } from "ui/use-toast" +import { cn } from "utils" + +import { didSucceed, useServerAction } from "~/lib/serverActions" +import { analyzeBlueprintAction, importBlueprintAction } from "./blueprintActions" + +type BlueprintImportWizardProps = { + onComplete: () => void + initialBlueprint?: string +} + +type AnalysisSummary = { + communityName: string + communitySlug: string + pubFieldCount: number + pubTypeCount: number + stageCount: number + formCount: number + pubCount: number + apiTokenCount: number + userSlots: Array<{ name: string; role: string | null; description: string }> +} + +type WizardStep = "paste" | "review" | "create" + +export const BlueprintImportWizard = ({ + onComplete, + initialBlueprint, +}: BlueprintImportWizardProps) => { + const [step, setStep] = React.useState(initialBlueprint ? "review" : "paste") + const [blueprintJson, setBlueprintJson] = React.useState(initialBlueprint ?? "") + const [summary, setSummary] = React.useState(null) + const [slugOverride, setSlugOverride] = React.useState("") + const [nameOverride, setNameOverride] = React.useState("") + const [isAnalyzing, setIsAnalyzing] = React.useState(false) + const [isImporting, setIsImporting] = React.useState(false) + const [importWarnings, setImportWarnings] = React.useState([]) + + const runAnalyze = useServerAction(analyzeBlueprintAction) + const runImport = useServerAction(importBlueprintAction) + + const handleAnalyze = React.useCallback(async () => { + setIsAnalyzing(true) + const result = await runAnalyze({ blueprintJson }) + setIsAnalyzing(false) + + if (!didSucceed(result) || !result.summary) return + + setSummary(result.summary) + setSlugOverride(result.summary.communitySlug) + setNameOverride(result.summary.communityName) + setStep("review") + }, [blueprintJson, runAnalyze]) + + const handleImport = async () => { + setIsImporting(true) + const result = await runImport({ + blueprintJson, + slugOverride: slugOverride || undefined, + nameOverride: nameOverride || undefined, + }) + setIsImporting(false) + + if (!didSucceed(result) || !result.communitySlug) return + + if (result.warnings?.length) { + setImportWarnings(result.warnings) + } + toast.success("Community created successfully") + setStep("create") + setTimeout(() => { + window.location.href = `/c/${result.communitySlug}/stages` + }, 1500) + } + + React.useEffect(() => { + if (initialBlueprint && !summary) { + void handleAnalyze() + } + }, [initialBlueprint, handleAnalyze, summary]) + + return ( +
+ + + {step === "paste" && ( + + )} + + {step === "review" && summary && ( + setStep("paste")} + onImport={handleImport} + isImporting={isImporting} + /> + )} + + {step === "create" && } +
+ ) +} + +const StepIndicator = ({ current }: { current: WizardStep }) => { + const steps: Array<{ key: WizardStep; label: string }> = [ + { key: "paste", label: "Upload" }, + { key: "review", label: "Review" }, + { key: "create", label: "Create" }, + ] + + const currentIdx = steps.findIndex((s) => s.key === current) + + return ( +
+ {steps.map((s, i) => ( + +
+ + {i < currentIdx ? : i + 1} + + {s.label} +
+ {i < steps.length - 1 &&
} + + ))} +
+ ) +} + +type PasteStepProps = { + value: string + onChange: (v: string) => void + onNext: () => void + isAnalyzing: boolean +} + +const PasteStep = ({ value, onChange, onNext, isAnalyzing }: PasteStepProps) => { + const isValidJson = React.useMemo(() => { + try { + JSON.parse(value) + return true + } catch { + return false + } + }, [value]) + + return ( + <> +

+ Paste a community blueprint JSON below, or upload a file. +

+ +
+ +
+ +
+ + +
+ + +
+ + ) +} + +type ReviewStepProps = { + summary: AnalysisSummary + slugOverride: string + nameOverride: string + onSlugChange: (v: string) => void + onNameChange: (v: string) => void + onBack: () => void + onImport: () => void + isImporting: boolean +} + +const ReviewStep = ({ + summary, + slugOverride, + nameOverride, + onSlugChange, + onNameChange, + onBack, + onImport, + isImporting, +}: ReviewStepProps) => ( + <> +
+
+ + onNameChange(e.target.value)} + /> +
+ +
+ + onSlugChange(e.target.value)} + /> +

+ Must be unique. Lowercase letters, numbers, and hyphens only. +

+
+ +
+

What will be created

+
+ + + + + + +
+
+ + {summary.userSlots.length > 0 && ( +
+
+ +
+

+ User references found +

+

+ This blueprint references {summary.userSlots.length} user + {summary.userSlots.length !== 1 ? "s" : ""}. User-specific + configurations (like email recipients) will need to be reconfigured + after import. +

+
    + {summary.userSlots.map((slot) => ( +
  • + {slot.name} + {slot.description && ( + + {" "} + ({slot.description}) + + )} +
  • + ))} +
+
+
+
+ )} +
+ +
+ + +
+ +) + +const SummaryRow = ({ label, value }: { label: string; value: number }) => ( + <> + {label} + {value} + +) + +type CreateStepProps = { + warnings: string[] + onComplete: () => void +} + +const CreateStep = ({ warnings, onComplete: _onComplete }: CreateStepProps) => ( +
+ +

Community Created

+

+ Redirecting to the new community... +

+ {warnings.length > 0 && ( +
+

+ Import warnings +

+
    + {warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+
+ )} +
+) diff --git a/core/app/(user)/communities/ExportTemplateButton.tsx b/core/app/(user)/communities/ExportTemplateButton.tsx index e3d2562ede..d106a4ab50 100644 --- a/core/app/(user)/communities/ExportTemplateButton.tsx +++ b/core/app/(user)/communities/ExportTemplateButton.tsx @@ -7,12 +7,12 @@ import * as React from "react" import { Button } from "ui/button" import { Dialog, DialogContent, DialogDescription, DialogTitle } from "ui/dialog" import { DropdownMenuItem } from "ui/dropdown-menu" -import { Clipboard, CurlyBraces, Download, Loader2 } from "ui/icon" +import { AlertCircle, Clipboard, CurlyBraces, Download, Loader2 } from "ui/icon" +import { JsonEditor } from "ui/monaco" import { toast } from "ui/use-toast" import { didSucceed, useServerAction } from "~/lib/serverActions" -import { TemplateEditor } from "./TemplateEditor" -import { exportCommunityTemplateAction } from "./templateActions" +import { exportBlueprintAction, exportBlueprintAsSeedAction } from "./blueprintActions" type ExportTemplateButtonProps = { communityId: string @@ -26,17 +26,21 @@ export const ExportTemplateButton = ({ onCreateCopy, }: ExportTemplateButtonProps) => { const [open, setOpen] = React.useState(false) - const [template, setTemplate] = React.useState("") + const [blueprint, setBlueprint] = React.useState("") + const [exportWarnings, setExportWarnings] = React.useState([]) const [isLoading, setIsLoading] = React.useState(false) - const runExport = useServerAction(exportCommunityTemplateAction) + const [isCopyingSeed, setIsCopyingSeed] = React.useState(false) + const runExport = useServerAction(exportBlueprintAction) + const runExportSeed = useServerAction(exportBlueprintAsSeedAction) const handleExport = async () => { setIsLoading(true) setOpen(true) const result = await runExport({ communityId: communityId as CommunitiesId }) - if (didSucceed(result) && result.template) { - setTemplate(result.template) + if (didSucceed(result) && result.blueprint) { + setBlueprint(result.blueprint) + setExportWarnings(result.warnings ?? []) } else { setOpen(false) } @@ -45,39 +49,37 @@ export const ExportTemplateButton = ({ const handleCopy = async () => { try { - await navigator.clipboard.writeText(template) - toast.success("Template copied to clipboard") + await navigator.clipboard.writeText(blueprint) + toast.success("Blueprint copied to clipboard") } catch { toast.error("Failed to copy to clipboard") } } const handleDownload = () => { - const blob = new Blob([template], { type: "application/json" }) + const blob = new Blob([blueprint], { type: "application/json" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url - a.download = `${communityName.toLowerCase().replace(/\s+/g, "-")}-template.json` + a.download = `${communityName.toLowerCase().replace(/\s+/g, "-")}-blueprint.json` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) - toast.success("Template downloaded") + toast.success("Blueprint downloaded") } const handleCreateCopy = () => { - if (onCreateCopy) { - // modify the template to have a new slug - try { - const parsed = JSON.parse(template) - parsed.community.slug = `${parsed.community.slug}-copy` - parsed.community.name = `${parsed.community.name} (Copy)` - onCreateCopy(JSON.stringify(parsed, null, 2)) - setOpen(false) - } catch { - onCreateCopy(template) - setOpen(false) - } + if (!onCreateCopy) return + try { + const parsed = JSON.parse(blueprint) + parsed.community.slug = `${parsed.community.slug}-copy` + parsed.community.name = `${parsed.community.name} (Copy)` + onCreateCopy(JSON.stringify(parsed, null, 2)) + setOpen(false) + } catch { + onCreateCopy(blueprint) + setOpen(false) } } @@ -91,15 +93,15 @@ export const ExportTemplateButton = ({ className="gap-2" > - Export Template + Export Blueprint - Export Community Template + Export Community Blueprint - This template contains the structure of "{communityName}" and can be used to - create a new community with the same configuration. + This blueprint contains the full structure of "{communityName}" and can be + used to recreate the community on any PubPub instance. {isLoading ? ( @@ -108,14 +110,37 @@ export const ExportTemplateButton = ({
) : ( <> - + {exportWarnings.length > 0 && ( +
+ +
+ + {exportWarnings.length} warning + {exportWarnings.length !== 1 ? "s" : ""} + +
    + {exportWarnings.slice(0, 5).map((w, i) => ( +
  • {w}
  • + ))} + {exportWarnings.length > 5 && ( +
  • ...and {exportWarnings.length - 5} more
  • + )} +
+
+
+ )} + +
+ +
@@ -127,6 +152,40 @@ export const ExportTemplateButton = ({ Download +