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 f792711e2f..f1faf3371e 100644 --- a/core/app/(user)/communities/AddCommunityDialog.tsx +++ b/core/app/(user)/communities/AddCommunityDialog.tsx @@ -3,18 +3,39 @@ 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, ListPlus } from "ui/icon" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs" import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip" import { AddCommunityForm } from "./AddCommunityForm" +import { BlueprintImportWizard } from "./BlueprintImportWizard" -export const AddCommunity = () => { +type AddCommunityProps = { + initialTemplate?: string +} + +export const AddCommunity = ({ initialTemplate }: AddCommunityProps) => { const [open, setOpen] = React.useState(false) + const [activeTab, setActiveTab] = React.useState("basic") + + React.useEffect(() => { + if (!open) { + setActiveTab("basic") + } + }, [open]) + + React.useEffect(() => { + if (initialTemplate) { + setActiveTab("blueprint") + setOpen(true) + } + }, [initialTemplate]) + return ( - Create a new community + Create a new community ) diff --git a/core/app/(user)/communities/AddCommunityForm.tsx b/core/app/(user)/communities/AddCommunityForm.tsx index 07fcededbd..9505c1b5d4 100644 --- a/core/app/(user)/communities/AddCommunityForm.tsx +++ b/core/app/(user)/communities/AddCommunityForm.tsx @@ -110,7 +110,7 @@ export const AddCommunityForm = (props: Props) => { )} /> - + + + ) +} + +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/CloneCommunityButton.tsx b/core/app/(user)/communities/CloneCommunityButton.tsx new file mode 100644 index 0000000000..a5ddbafc5d --- /dev/null +++ b/core/app/(user)/communities/CloneCommunityButton.tsx @@ -0,0 +1,244 @@ +"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 { Clipboard, Download, Layers, Loader2 } from "ui/icon" +import { Input } from "ui/input" +import { Label } from "ui/label" +import { JsonEditor } from "ui/monaco" +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" + +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() + void 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/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..64c03004fb 100644 --- a/core/app/(user)/communities/CommunityTable.tsx +++ b/core/app/(user)/communities/CommunityTable.tsx @@ -2,20 +2,21 @@ import type { TableCommunity } from "./getCommunityTableColumns" -import { useRouter } from "next/navigation" +import * as React from "react" import { DataTable } from "~/app/components/DataTable/DataTable" import { getCommunityTableColumns } from "./getCommunityTableColumns" -export const CommunityTable = ({ communities }: { communities: TableCommunity[] }) => { - const communityTableColumns = getCommunityTableColumns() - const router = useRouter() - return ( - router.push(`/c/${row.original.slug}/stages`)} - /> +type CommunityTableProps = { + communities: TableCommunity[] + onCreateCopy?: (template: string) => void +} + +export const CommunityTable = ({ communities, onCreateCopy }: CommunityTableProps) => { + const communityTableColumns = React.useMemo( + () => getCommunityTableColumns({ onCreateCopy }), + [onCreateCopy] ) + + return } diff --git a/core/app/(user)/communities/ExportTemplateButton.tsx b/core/app/(user)/communities/ExportTemplateButton.tsx new file mode 100644 index 0000000000..d106a4ab50 --- /dev/null +++ b/core/app/(user)/communities/ExportTemplateButton.tsx @@ -0,0 +1,211 @@ +"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 { 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 { exportBlueprintAction, exportBlueprintAsSeedAction } from "./blueprintActions" + +type ExportTemplateButtonProps = { + communityId: string + communityName: string + onCreateCopy?: (template: string) => void +} + +export const ExportTemplateButton = ({ + communityId, + communityName, + onCreateCopy, +}: ExportTemplateButtonProps) => { + const [open, setOpen] = React.useState(false) + const [blueprint, setBlueprint] = React.useState("") + const [exportWarnings, setExportWarnings] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + 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.blueprint) { + setBlueprint(result.blueprint) + setExportWarnings(result.warnings ?? []) + } else { + setOpen(false) + } + setIsLoading(false) + } + + const handleCopy = async () => { + try { + 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([blueprint], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${communityName.toLowerCase().replace(/\s+/g, "-")}-blueprint.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast.success("Blueprint downloaded") + } + + const handleCreateCopy = () => { + 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) + } + } + + return ( + <> + { + e.preventDefault() + void handleExport() + }} + className="gap-2" + > + + Export Blueprint + + + + + Export Community Blueprint + + This blueprint contains the full structure of "{communityName}" and can be + used to recreate the community on any PubPub instance. + + + {isLoading ? ( +
+ +
+ ) : ( + <> + {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
  • + )} +
+
+
+ )} + +
+ +
+ +
+
+ + + +
+
+ + {onCreateCopy && ( + + )} +
+
+ + )} +
+
+ + ) +} diff --git a/core/app/(user)/communities/TemplateEditor.tsx b/core/app/(user)/communities/TemplateEditor.tsx new file mode 100644 index 0000000000..8f71534175 --- /dev/null +++ b/core/app/(user)/communities/TemplateEditor.tsx @@ -0,0 +1,164 @@ +"use client" + +import type { ValidationResult } from "ui/monaco" + +import * as React from "react" + +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" + +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/blueprintActions.ts b/core/app/(user)/communities/blueprintActions.ts new file mode 100644 index 0000000000..6b32f70e1e --- /dev/null +++ b/core/app/(user)/communities/blueprintActions.ts @@ -0,0 +1,226 @@ +"use server" + +import type { CommunitiesId } from "db/public" + +import { revalidatePath } from "next/cache" + +import { MemberRole } from "db/public" +import { logger } from "logger" + +import { db } from "~/kysely/database" +import { isUniqueConstraintError } from "~/kysely/errors" +import { getLoginData } from "~/lib/authentication/loginData" +import { exportBlueprint } from "~/lib/server/blueprint/export" +import { importBlueprint } from "~/lib/server/blueprint/import" +import type { Blueprint, BlueprintExportOptions } from "~/lib/server/blueprint/types" +import { BLUEPRINT_VERSION } from "~/lib/server/blueprint/types" +import { defineServerAction } from "~/lib/server/defineServerAction" +import { maybeWithTrx } from "~/lib/server/maybeWithTrx" + +export const exportBlueprintAction = defineServerAction( + async function exportBlueprintAction({ + communityId, + options = {}, + }: { + communityId: CommunitiesId + options?: BlueprintExportOptions + }) { + const { user } = await getLoginData() + + if (!user) { + return { title: "Failed to export blueprint", error: "Not logged in" } + } + + if (!user.isSuperAdmin) { + return { title: "Failed to export blueprint", error: "User is not a super admin" } + } + + try { + const { blueprint, warnings } = await exportBlueprint(communityId, options) + return { + blueprint: JSON.stringify(blueprint, null, 2), + warnings: warnings.map((w) => `${w.path}: ${w.message}`), + } + } catch (error) { + logger.error({ msg: "Failed to export blueprint", err: error }) + return { + title: "Failed to export blueprint", + error: "An unexpected error occurred while exporting", + cause: error, + } + } + } +) + +export const importBlueprintAction = defineServerAction( + async function importBlueprintAction({ + blueprintJson, + slugOverride, + nameOverride, + }: { + blueprintJson: string + slugOverride?: string + nameOverride?: string + }) { + const { user } = await getLoginData() + + if (!user) { + return { title: "Failed to import blueprint", error: "Not logged in" } + } + + if (!user.isSuperAdmin) { + return { title: "Failed to import blueprint", error: "User is not a super admin" } + } + + let blueprint: Blueprint + try { + blueprint = JSON.parse(blueprintJson) + } catch (error) { + logger.error("Failed to parse blueprint JSON", { error }) + return { title: "Failed to import blueprint", error: "Invalid JSON", cause: error } + } + + if (blueprint.version !== BLUEPRINT_VERSION) { + return { + title: "Failed to import blueprint", + error: `Unsupported blueprint version: ${blueprint.version}`, + } + } + + try { + const result = await maybeWithTrx(db, async (trx) => { + const { communityId, communitySlug, warnings } = await importBlueprint( + blueprint, + { + overrides: { + slug: slugOverride, + name: nameOverride, + }, + }, + trx + ) + + // add current user as admin + await trx + .insertInto("community_memberships") + .values({ + userId: user.id, + communityId, + role: MemberRole.admin, + }) + .execute() + + return { communitySlug, warnings } + }) + + revalidatePath("/") + return { + communitySlug: result.communitySlug, + warnings: result.warnings.map((w) => `${w.path}: ${w.message}`), + } + } catch (error) { + logger.error({ msg: "Failed to import blueprint", err: error }) + if (isUniqueConstraintError(error) && error.constraint === "communities_slug_key") { + return { + title: "Failed to import blueprint", + error: "A community with that slug already exists", + cause: error, + } + } + return { + title: "Failed to import blueprint", + error: "An unexpected error occurred while importing", + cause: error, + } + } + } +) + +export const exportBlueprintAsSeedAction = defineServerAction( + async function exportBlueprintAsSeedAction({ + communityId, + }: { + communityId: CommunitiesId + }) { + const { user } = await getLoginData() + + if (!user) { + return { title: "Failed to export seed", error: "Not logged in" } + } + + if (!user.isSuperAdmin) { + return { title: "Failed to export seed", error: "User is not a super admin" } + } + + try { + const { exportBlueprint: exportBp } = await import( + "~/lib/server/blueprint/export" + ) + const { blueprintToSeedTs } = await import("~/lib/server/blueprint/toSeed") + + const { blueprint, warnings } = await exportBp(communityId, { + includePubs: true, + includeApiTokens: true, + includeActionConfigDefaults: true, + }) + const seedTs = blueprintToSeedTs(blueprint) + return { + seedTs, + warnings: warnings.map((w) => `${w.path}: ${w.message}`), + } + } catch (error) { + logger.error({ msg: "Failed to export as seed", err: error }) + return { + title: "Failed to export as seed", + error: "An unexpected error occurred", + cause: error, + } + } + } +) + +/** + * analyze a blueprint without importing it, returning a summary of what + * would be created and any warnings/holes that need filling. + */ +export const analyzeBlueprintAction = defineServerAction( + async function analyzeBlueprintAction({ blueprintJson }: { blueprintJson: string }) { + const { user } = await getLoginData() + + if (!user) { + return { title: "Failed to analyze blueprint", error: "Not logged in" } + } + + let blueprint: Blueprint + try { + blueprint = JSON.parse(blueprintJson) + } catch { + return { title: "Failed to analyze blueprint", error: "Invalid JSON" } + } + + if (blueprint.version !== BLUEPRINT_VERSION) { + return { + title: "Failed to analyze blueprint", + error: `Unsupported blueprint version: ${blueprint.version}`, + } + } + + return { + summary: { + communityName: blueprint.community.name, + communitySlug: blueprint.community.slug, + pubFieldCount: Object.keys(blueprint.pubFields ?? {}).length, + pubTypeCount: Object.keys(blueprint.pubTypes ?? {}).length, + stageCount: Object.keys(blueprint.stages ?? {}).length, + formCount: Object.keys(blueprint.forms ?? {}).length, + pubCount: Object.keys(blueprint.pubs ?? {}).length, + apiTokenCount: Object.keys(blueprint.apiTokens ?? {}).length, + userSlots: Object.entries(blueprint.userSlots ?? {}).map(([name, slot]) => ({ + name, + role: slot.role ?? null, + description: slot.description ?? "", + })), + }, + } + } +) 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 f2c8d75b1a..27788966a6 100644 --- a/core/app/(user)/communities/getCommunityTableColumns.tsx +++ b/core/app/(user)/communities/getCommunityTableColumns.tsx @@ -15,8 +15,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "ui/dropdown-menu" -import { MoreVertical } from "ui/icon" +import { ExternalLink, MoreVertical } from "ui/icon" +import { CloneCommunityButton } from "./CloneCommunityButton" +import { ExportTemplateButton } from "./ExportTemplateButton" import { RemoveCommunityButton } from "./RemoveCommunityButton" export type TableCommunity = { @@ -27,7 +29,11 @@ export type TableCommunity = { created: Date } -export const getCommunityTableColumns = () => +type GetCommunityTableColumnsOptions = { + onCreateCopy?: (template: string) => void +} + +export const getCommunityTableColumns = (options?: GetCommunityTableColumnsOptions) => [ { id: "select", @@ -73,7 +79,13 @@ export const getCommunityTableColumns = () => header: ({ column }) => , accessorKey: "slug", cell: ({ row }) => ( - {row.original.slug} + + {row.original.slug} + ), }, { @@ -94,7 +106,18 @@ export const getCommunityTableColumns = () => - Menu + Actions + + +
diff --git a/core/app/(user)/communities/page.tsx b/core/app/(user)/communities/page.tsx index aa145a5238..3496d64398 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 { Calendar, Layers } 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,94 @@ 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 ? ( + + ) : ( +
+
+ + Communities +
+ +
+ )} +
) } + +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..4297408a8f --- /dev/null +++ b/core/app/(user)/communities/templateActions.ts @@ -0,0 +1,250 @@ +"use server" + +import type { CommunitiesId } from "db/public" +import type { CommunityTemplate, TemplateExportOptions } from "~/lib/server/communityTemplate" + +import { revalidatePath } from "next/cache" + +import { MemberRole } from "db/public" +import { logger } from "logger" + +import { db } from "~/kysely/database" +import { isUniqueConstraintError } from "~/kysely/errors" +import { getLoginData } from "~/lib/authentication/loginData" +import { withUncached } from "~/lib/server/cache/skipCacheStore" +import { + communityTemplateSchema, + exportCommunityTemplate as exportTemplate, + validateCommunityTemplateQuick, +} from "~/lib/server/communityTemplate" +import { defineServerAction } from "~/lib/server/defineServerAction" +import { maybeWithTrx } from "~/lib/server/maybeWithTrx" +import { seedCommunity } from "~/prisma/seed/seedCommunity" + +export const exportCommunityTemplateAction = defineServerAction( + async function exportCommunityTemplateAction({ + communityId, + options = {}, + }: { + communityId: CommunitiesId + options?: TemplateExportOptions + }) { + 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, options) + 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 withUncached( + async () => 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 ?? {} + + 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] = { + ...user, + // generate a random password if not provided + 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 = {} + 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] = { + // 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 - 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 { + ...el, + config: el.config ?? {}, + } + } + return el + }), + } + } + + // 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 + 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/api/dev/write-seed/route.ts b/core/app/api/dev/write-seed/route.ts new file mode 100644 index 0000000000..b8754d1de7 --- /dev/null +++ b/core/app/api/dev/write-seed/route.ts @@ -0,0 +1,53 @@ +import type { CommunitiesId } from "db/public" + +import { writeFile } from "fs/promises" +import { join } from "path" +import { NextResponse } from "next/server" + +import { exportBlueprint } from "~/lib/server/blueprint/export" +import { blueprintToSeedTs } from "~/lib/server/blueprint/toSeed" + +/** + * dev-only endpoint that exports a community as a blueprint and writes it + * as a TypeScript seed file to core/prisma/seeds/.ts. + * + * POST /api/dev/write-seed + * body: { communityId: string } + */ +export async function POST(request: Request) { + // eslint-disable-next-line no-restricted-properties + if (process.env.NODE_ENV !== "development") { + return NextResponse.json( + { error: "this endpoint is only available in development mode" }, + { status: 403 } + ) + } + + const body = (await request.json()) as { communityId?: string } + + if (!body.communityId) { + return NextResponse.json({ error: "communityId is required" }, { status: 400 }) + } + + const communityId = body.communityId as CommunitiesId + + const { blueprint, warnings } = await exportBlueprint(communityId, { + includePubs: true, + includeApiTokens: true, + includeActionConfigDefaults: true, + }) + + const tsContent = blueprintToSeedTs(blueprint) + + const seedsDir = join(process.cwd(), "prisma", "seeds") + const filename = `${blueprint.community.slug}.ts` + const filepath = join(seedsDir, filename) + + await writeFile(filepath, tsContent, "utf-8") + + return NextResponse.json({ + written: filepath, + slug: blueprint.community.slug, + warnings: warnings.map((w) => `${w.path}: ${w.message}`), + }) +} 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 && ( + + )} { + {props.validationIssues && props.validationIssues.length > 0 && ( + + + i.severity === "error") + ? "text-destructive" + : "text-amber-500" + )} + /> + + +

Config issues:

+
    + {props.validationIssues.map((issue, idx) => ( +
  • + {issue.field}: {issue.message} +
  • + ))} +
+
+
+ )}
{triggerIcons.map((icon) => ( diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/automationsTab/StagePanelAutomations.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/automationsTab/StagePanelAutomations.tsx index 9497bd54f1..c632c6c01a 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/automationsTab/StagePanelAutomations.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/automationsTab/StagePanelAutomations.tsx @@ -1,5 +1,6 @@ import type { CommunitiesId, UsersId } from "db/public" import type { FullAutomation } from "db/types" +import type { AutomationValidationResult } from "~/lib/server/blueprint/validate" import type { CommunityStage } from "~/lib/server/stages" import { Card, CardContent, CardTitle } from "ui/card" @@ -16,9 +17,13 @@ type Props = { stage: CommunityStage communityId: CommunitiesId automations: FullAutomation[] + validationResults?: AutomationValidationResult[] } export const StagePanelAutomations = (props: Props) => { + const getIssuesForAutomation = (automationId: string) => + props.validationResults?.find((r) => r.automationId === automationId)?.issues + return ( @@ -37,6 +42,7 @@ export const StagePanelAutomations = (props: Props) => { communityId={props.stage.communityId as CommunitiesId} automation={automation} key={automation.id} + validationIssues={getIssuesForAutomation(automation.id)} /> ))} diff --git a/core/app/test/page.tsx b/core/app/test/page.tsx new file mode 100644 index 0000000000..2c6635effb --- /dev/null +++ b/core/app/test/page.tsx @@ -0,0 +1,118 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" + +import { Button } from "ui/button" + +export default function TestPage() { + const [currentlyHighlighted, setCurrentlyHighlighted] = useState("1") + + // const [startHighlighting, setStartHighlighting] = useState(false) + const [mode, _setMode] = useState<"move-through" | "single" | "focus">("move-through") + + const highlightingInterval = useRef(null) + + useEffect(() => { + // if (startHighlighting) { + highlightingInterval.current = setInterval(() => { + setCurrentlyHighlighted((prev) => (Number(prev) < 10 ? String(Number(prev) + 1) : "1")) + }, 300) as unknown as number + // return + // } + + return () => { + if (highlightingInterval.current) { + clearInterval(highlightingInterval.current) + } + } + }, []) + + const focusStyle = useMemo(() => { + switch (mode) { + case "move-through": + return ` + #s${currentlyHighlighted} { + background: #ffff0040; + } + ` + case "single": + return ` + #s${currentlyHighlighted} { + & > span.text { + background: #ffff0040; + } + } + ` + case "focus": + return ` + #fragment { + background: #ffff0040; + } + #s${currentlyHighlighted} { + & > span.text { + text-decoration: underline; + } + } + ` + default: + return ` + #fragment { + background: #ffff0040; + } + ` + } + }, [mode, currentlyHighlighted]) + + return ( +
+ +

+ + + + + + + + + + This + is + + a + + sentence + + with + + a + + few + + words + + in + + it. + +

+
+ +
+
+ ) +} diff --git a/core/lib/server/blueprint/configRewriter.ts b/core/lib/server/blueprint/configRewriter.ts new file mode 100644 index 0000000000..a272b29811 --- /dev/null +++ b/core/lib/server/blueprint/configRewriter.ts @@ -0,0 +1,322 @@ +import type { Action } from "db/public" +import type z from "zod" + +import { REFERENCE_TYPE_NAMES } from "~/actions/_lib/zodTypes" +import { actions } from "~/actions/api" +import type { BlueprintWarning } from "./types" + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +// maps from entity id to symbolic name +export type EntityLookup = { + stages: Map + forms: Map + members: Map + fields: Map +} + +export const createEmptyEntityLookup = (): EntityLookup => ({ + stages: new Map(), + forms: new Map(), + members: new Map(), + fields: new Map(), +}) + +const TYPE_TO_LOOKUP_KEY: Record = { + [REFERENCE_TYPE_NAMES.Stage]: "stages", + [REFERENCE_TYPE_NAMES.FormSlug]: "forms", + [REFERENCE_TYPE_NAMES.Member]: "members", + [REFERENCE_TYPE_NAMES.FieldName]: "fields", +} + +// unwrap optional, nullable, default, effects, preprocess wrappers to get the base type +const unwrapSchema = (schema: z.ZodTypeAny): z.ZodTypeAny => { + const def = schema._def as Record + const typeName = def.typeName as string | undefined + + if ( + typeName === "ZodOptional" || + typeName === "ZodNullable" || + typeName === "ZodDefault" + ) { + return unwrapSchema(def.innerType as z.ZodTypeAny) + } + + if (typeName === "ZodEffects") { + return unwrapSchema(def.schema as z.ZodTypeAny) + } + + return schema +} + +const isReferenceType = (typeName: string): boolean => + typeName in TYPE_TO_LOOKUP_KEY + +// find all reference fields in a zod object schema +export const findReferenceFields = ( + schema: z.ZodObject +): Array<{ path: string[]; typeName: string; lookupKey: keyof EntityLookup }> => { + const results: Array<{ path: string[]; typeName: string; lookupKey: keyof EntityLookup }> = [] + walkSchemaForReferences(schema, [], results) + return results +} + +const walkSchemaForReferences = ( + schema: z.ZodTypeAny, + path: string[], + results: Array<{ path: string[]; typeName: string; lookupKey: keyof EntityLookup }> +): void => { + const base = unwrapSchema(schema) + const def = base._def as Record + const typeName = (def.typeName as string) ?? "" + + if (isReferenceType(typeName)) { + results.push({ + path: [...path], + typeName, + lookupKey: TYPE_TO_LOOKUP_KEY[typeName], + }) + return + } + + if (typeName === "ZodObject") { + const shape = (base as z.ZodObject).shape + for (const [key, value] of Object.entries(shape)) { + walkSchemaForReferences(value as z.ZodTypeAny, [...path, key], results) + } + return + } + + if (typeName === "ZodArray") { + walkSchemaForReferences(def.type as z.ZodTypeAny, [...path, "[]"], results) + return + } + + // for ZodRecord, walk the value schema + if (typeName === "ZodRecord") { + walkSchemaForReferences(def.valueType as z.ZodTypeAny, [...path, "{}"], results) + return + } +} + +// get a nested value from an object using a path +const getNestedValue = (obj: Record, path: string[]): unknown => { + let current: unknown = obj + for (const key of path) { + if (current == null || typeof current !== "object") return undefined + if (key === "[]" || key === "{}") return current + current = (current as Record)[key] + } + return current +} + +// set a nested value in an object using a path, returning a new object +const setNestedValue = ( + obj: Record, + path: string[], + value: unknown +): Record => { + if (path.length === 0) return obj + + const result = { ...obj } + const key = path[0] + + if (key === "[]" || key === "{}") return result + + if (path.length === 1) { + result[key] = value + return result + } + + const nested = result[key] + if (nested != null && typeof nested === "object" && !Array.isArray(nested)) { + result[key] = setNestedValue( + nested as Record, + path.slice(1), + value + ) + } + + return result +} + +// rewrite values in a config for a single reference field, handling arrays/records +const rewriteFieldValue = ( + config: Record, + path: string[], + lookup: Map, + warnings: BlueprintWarning[], + direction: "toNames" | "toIds", + configPath: string +): Record => { + const arrayIdx = path.indexOf("[]") + const recordIdx = path.indexOf("{}") + + // simple scalar path with no array/record nesting + if (arrayIdx === -1 && recordIdx === -1) { + const currentValue = getNestedValue(config, path) + if (typeof currentValue !== "string" || !currentValue) return config + + const resolved = lookup.get(currentValue) + if (resolved) { + return setNestedValue(config, path, resolved) + } + + if (direction === "toNames" && UUID_PATTERN.test(currentValue)) { + warnings.push({ + path: `${configPath}.${path.join(".")}`, + message: `unresolved UUID reference: ${currentValue}`, + }) + } + return config + } + + // handle array nesting: rewrite each element + if (arrayIdx !== -1) { + const parentPath = path.slice(0, arrayIdx) + const childPath = path.slice(arrayIdx + 1) + const arr = getNestedValue(config, parentPath) + if (!Array.isArray(arr)) return config + + const rewritten = arr.map((item) => { + if (typeof item !== "object" || item == null) return item + const result = rewriteFieldValue( + item as Record, + childPath, + lookup, + warnings, + direction, + configPath + ) + return result + }) + return setNestedValue(config, parentPath, rewritten) + } + + return config +} + +/** + * rewrite action config references from UUIDs to symbolic names. + * used during blueprint export. + */ +export const rewriteConfigToNames = ( + actionName: Action, + config: Record, + lookup: EntityLookup +): { config: Record; warnings: BlueprintWarning[] } => { + const actionDef = actions[actionName] + if (!actionDef) return { config, warnings: [] } + + const schema = actionDef.config.schema + const refs = findReferenceFields(schema) + const warnings: BlueprintWarning[] = [] + + let result = { ...config } + for (const ref of refs) { + const refLookup = lookup[ref.lookupKey] + result = rewriteFieldValue( + result, + ref.path, + refLookup, + warnings, + "toNames", + `${actionName}.config` + ) + } + + // scan remaining string values for unresolved UUIDs + scanForUnresolvedUuids(result, [], warnings, `${actionName}.config`, lookup) + + return { config: result, warnings } +} + +/** + * rewrite action config references from symbolic names to UUIDs. + * used during blueprint import / seeding. + */ +export const rewriteConfigToIds = ( + actionName: Action, + config: Record, + lookup: EntityLookup +): { config: Record; warnings: BlueprintWarning[] } => { + const actionDef = actions[actionName] + if (!actionDef) return { config, warnings: [] } + + // build reverse lookup (name -> id) + const reverseLookup: EntityLookup = { + stages: invertMap(lookup.stages), + forms: invertMap(lookup.forms), + members: invertMap(lookup.members), + fields: invertMap(lookup.fields), + } + + const schema = actionDef.config.schema + const refs = findReferenceFields(schema) + const warnings: BlueprintWarning[] = [] + + let result = { ...config } + for (const ref of refs) { + const refLookup = reverseLookup[ref.lookupKey] + result = rewriteFieldValue( + result, + ref.path, + refLookup, + warnings, + "toIds", + `${actionName}.config` + ) + } + + return { config: result, warnings } +} + +const invertMap = (map: Map): Map => { + const inverted = new Map() + for (const [k, v] of map) { + inverted.set(v, k) + } + return inverted +} + +// walk all string values in a config object and warn about UUIDs that weren't +// rewritten by a known reference field +const scanForUnresolvedUuids = ( + obj: unknown, + path: string[], + warnings: BlueprintWarning[], + configPath: string, + lookup: EntityLookup +): void => { + if (typeof obj === "string") { + if (!UUID_PATTERN.test(obj)) return + + // check if this uuid is known in any lookup + const isKnown = + lookup.stages.has(obj) || + lookup.forms.has(obj) || + lookup.members.has(obj) || + lookup.fields.has(obj) + + if (!isKnown) { + warnings.push({ + path: `${configPath}.${path.join(".")}`, + message: `possible unresolved UUID: ${obj}. this value may not be portable.`, + }) + } + return + } + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + scanForUnresolvedUuids(obj[i], [...path, String(i)], warnings, configPath, lookup) + } + return + } + + if (typeof obj === "object" && obj != null) { + for (const [key, value] of Object.entries(obj)) { + scanForUnresolvedUuids(value, [...path, key], warnings, configPath, lookup) + } + } +} diff --git a/core/lib/server/blueprint/dag.ts b/core/lib/server/blueprint/dag.ts new file mode 100644 index 0000000000..901d77c6f9 --- /dev/null +++ b/core/lib/server/blueprint/dag.ts @@ -0,0 +1,72 @@ +/** + * generic topological sort over a directed acyclic graph. + * + * @param edges - map from node id to the ids of nodes it depends on + * @returns node ids in an order where all dependencies come before dependents + * @throws if the graph contains a cycle + */ +export const topoSort = (edges: Record): string[] => { + const visited = new Set() + const stack = new Set() + const result: string[] = [] + + const visit = (node: string, path: string[]): void => { + if (stack.has(node)) { + const cycleStart = path.indexOf(node) + const cycle = [...path.slice(cycleStart), node] + throw new Error( + `cycle detected: ${cycle.join(" -> ")}` + ) + } + + if (visited.has(node)) return + + stack.add(node) + for (const dep of edges[node] ?? []) { + if (dep in edges) { + visit(dep, [...path, node]) + } + } + stack.delete(node) + + visited.add(node) + result.push(node) + } + + for (const node of Object.keys(edges)) { + if (!visited.has(node)) { + visit(node, []) + } + } + + return result +} + +/** + * build a dependency graph for pubs based on their relation references. + * returns an edges map suitable for `topoSort`. + * + * a pub depends on any pub it references via `ref` in its `relatedPubs`. + */ +export const buildPubDependencyGraph = ( + pubs: Record> }> +): Record => { + const edges: Record = {} + + for (const pubKey of Object.keys(pubs)) { + const deps: string[] = [] + const pub = pubs[pubKey] + if (pub.relatedPubs) { + for (const relations of Object.values(pub.relatedPubs)) { + for (const rel of relations) { + if (rel.ref && rel.ref in pubs) { + deps.push(rel.ref) + } + } + } + } + edges[pubKey] = deps + } + + return edges +} diff --git a/core/lib/server/blueprint/export.ts b/core/lib/server/blueprint/export.ts new file mode 100644 index 0000000000..048bf036c1 --- /dev/null +++ b/core/lib/server/blueprint/export.ts @@ -0,0 +1,605 @@ +import type { Action, CommunitiesId } from "db/public" +import type { IconConfig } from "ui/dynamic-icon" + +import { jsonArrayFrom } from "kysely/helpers/postgres" + +import { type AutomationConditionBlockType, ElementType } from "db/public" + +import type { + Blueprint, + BlueprintActionConfigDefault, + BlueprintApiToken, + BlueprintAutomation, + BlueprintConditionItem, + BlueprintExportOptions, + BlueprintForm, + BlueprintFormElement, + BlueprintPub, + BlueprintPubField, + BlueprintStage, + BlueprintUserSlot, + BlueprintWarning, +} from "./types" + +import { db } from "~/kysely/database" +import { slugifyString } from "~/lib/string" +import { createEmptyEntityLookup, rewriteConfigToNames } from "./configRewriter" +import { BLUEPRINT_VERSION } from "./types" + +type ConditionBlockItem = + | { kind: "condition"; type: "jsonata"; expression: string } + | { kind: "block"; type: AutomationConditionBlockType; items: ConditionBlockItem[] } + +const transformConditionItems = (items: ConditionBlockItem[]): BlueprintConditionItem[] => + 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 exportBlueprint = async ( + communityId: CommunitiesId, + options: BlueprintExportOptions = {} +): Promise<{ blueprint: Blueprint; warnings: BlueprintWarning[] }> => { + const { + includePubs = false, + includeApiTokens = false, + includeActionConfigDefaults = false, + } = options + + const allWarnings: BlueprintWarning[] = [] + + const community = await db + .selectFrom("communities") + .select(["name", "slug", "avatar"]) + .where("id", "=", communityId) + .executeTakeFirstOrThrow() + + // pub fields + const pubFields = await db + .selectFrom("pub_fields") + .select(["id", "name", "schemaName", "isRelation", "slug"]) + .where("communityId", "=", communityId) + .execute() + + const pubFieldsBlueprint: Record = {} + for (const field of pubFields) { + if (!field.schemaName) continue + pubFieldsBlueprint[field.name] = { + schemaName: field.schemaName, + ...(field.isRelation ? { relation: true as const } : {}), + } + } + + // pub types + 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 pubTypesBlueprint: Record> = {} + for (const pubType of pubTypes) { + pubTypesBlueprint[pubType.name] = {} + for (const field of pubType.fields) { + pubTypesBlueprint[pubType.name][field.name] = { isTitle: field.isTitle } + } + } + + // stages + const stages = await db + .selectFrom("stages") + .select(["stages.id", "stages.name"]) + .where("communityId", "=", communityId) + .orderBy("stages.order", "asc") + .execute() + + // build entity lookup for config rewriting + const lookup = createEmptyEntityLookup() + for (const stage of stages) { + lookup.stages.set(stage.id, stage.name) + } + for (const field of pubFields) { + lookup.fields.set(field.id, field.name) + } + + // members lookup: fetch community members + const members = await db + .selectFrom("community_memberships") + .innerJoin("users", "users.id", "community_memberships.userId") + .select([ + "users.id", + "users.slug", + "users.firstName", + "users.lastName", + "users.email", + "community_memberships.role", + ]) + .where("community_memberships.communityId", "=", communityId) + .execute() + + for (const member of members) { + lookup.members.set(member.id, member.slug) + } + + // 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() + + const automationIdToName = new Map() + for (const automation of automations) { + automationIdToName.set(automation.id, automation.name) + } + + // condition items + const conditionBlockIds = automations + .flatMap((a) => a.conditionBlocks) + .map((cb) => cb.id) + + const conditionItemsMap = new Map() + if (conditionBlockIds.length > 0) { + const conditions = await db + .selectFrom("automation_conditions") + .select(["automationConditionBlockId", "type", "expression"]) + .where("automationConditionBlockId", "in", conditionBlockIds) + .execute() + + 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) + } + } + + // forms (needed for lookup before building stages) + const forms = await db + .selectFrom("forms") + .innerJoin("pub_types", "pub_types.id", "forms.pubTypeId") + .select([ + "forms.id", + "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() + + for (const form of forms) { + lookup.forms.set(form.id, form.slug) + } + + // user slots extracted from action configs + const userSlots: Record = {} + + // build stages blueprint + const stagesBlueprint: Record = {} + for (const stage of stages) { + const stageAutomations = automations.filter((a) => a.stageId === stage.id) + + const automationsBlueprint: Record = {} + for (const automation of stageAutomations) { + const conditionBlock = automation.conditionBlocks[0] + let condition: BlueprintAutomation["condition"] = undefined + + if (conditionBlock) { + const items = conditionItemsMap.get(conditionBlock.id) ?? [] + condition = { + type: conditionBlock.type, + items: transformConditionItems(items), + } + } + + // rewrite action configs from IDs to symbolic names + const rewrittenActions = automation.actions.map((a) => { + const rawConfig = (a.config ?? {}) as Record + const { config: rewritten, warnings } = rewriteConfigToNames( + a.action as Action, + rawConfig, + lookup + ) + allWarnings.push(...warnings) + + return { + action: a.action as Action, + config: rewritten, + } + }) + + automationsBlueprint[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: rewrittenActions, + } + } + + stagesBlueprint[stage.name] = { + ...(Object.keys(automationsBlueprint).length > 0 + ? { automations: automationsBlueprint } + : {}), + } + } + + // 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 stageConnectionsBlueprint: Record = {} + for (const constraint of moveConstraints) { + if (!stageConnectionsBlueprint[constraint.sourceName]) { + stageConnectionsBlueprint[constraint.sourceName] = {} + } + if (!stageConnectionsBlueprint[constraint.sourceName].to) { + stageConnectionsBlueprint[constraint.sourceName].to = [] + } + stageConnectionsBlueprint[constraint.sourceName].to!.push(constraint.destName) + } + + // forms blueprint + const formsBlueprint: Record = {} + for (const form of forms) { + const elements: BlueprintFormElement[] = 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 ?? "", + } + } + 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, + } + }) + + formsBlueprint[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, + } + } + + // extract user slots from members who are referenced in action configs + for (const member of members) { + const slug = member.slug + // only include as a slot if referenced somewhere + const isReferenced = allWarnings.some( + (w) => w.message.includes(member.id) + ) + if (isReferenced) { + userSlots[slug] = { + role: member.role, + description: `${member.firstName} ${member.lastName} (${member.email})`, + } + } + } + + // build blueprint + const blueprint: Blueprint = { + version: BLUEPRINT_VERSION, + community: { + name: community.name, + slug: community.slug, + ...(community.avatar ? { avatar: community.avatar } : {}), + }, + } + + if (Object.keys(pubFieldsBlueprint).length > 0) { + blueprint.pubFields = pubFieldsBlueprint + } + + if (Object.keys(pubTypesBlueprint).length > 0) { + blueprint.pubTypes = pubTypesBlueprint + } + + if (Object.keys(stagesBlueprint).length > 0) { + blueprint.stages = stagesBlueprint + } + + if (Object.keys(stageConnectionsBlueprint).length > 0) { + blueprint.stageConnections = stageConnectionsBlueprint + } + + if (Object.keys(formsBlueprint).length > 0) { + blueprint.forms = formsBlueprint + } + + if (Object.keys(userSlots).length > 0) { + blueprint.userSlots = userSlots + } + + if (includePubs) { + const pubsBlueprint = await exportPubs(communityId, pubTypes, stages, pubFields) + if (Object.keys(pubsBlueprint).length > 0) { + blueprint.pubs = pubsBlueprint + } + } + + if (includeApiTokens) { + const apiTokensBlueprint = await exportApiTokens(communityId) + if (Object.keys(apiTokensBlueprint).length > 0) { + blueprint.apiTokens = apiTokensBlueprint + } + } + + if (includeActionConfigDefaults) { + const actionConfigDefaultsBlueprint = await exportActionConfigDefaults(communityId) + if (actionConfigDefaultsBlueprint.length > 0) { + blueprint.actionConfigDefaults = actionConfigDefaultsBlueprint + } + } + + return { blueprint, warnings: allWarnings } +} + +const exportPubs = async ( + communityId: CommunitiesId, + pubTypes: Array<{ id: string; name: string }>, + stages: Array<{ id: string; name: string }>, + _pubFields: Array<{ id: string; name: string; slug: string }> +): Promise> => { + const pubTypeIdToName = new Map(pubTypes.map((pt) => [pt.id, pt.name])) + const stageIdToName = new Map(stages.map((s) => [s.id, s.name])) + + 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 pub id -> key mapping + const pubIdToKey = new Map() + for (let i = 0; i < pubs.length; i++) { + const pub = pubs[i] + const pubTypeName = pubTypeIdToName.get(pub.pubTypeId) + const titleValue = pub.values.find((v) => v.fieldName === "Title") + const keyBase = titleValue?.value + ? slugifyString(String(titleValue.value)).slice(0, 40) + : `${(pubTypeName ?? "pub").toLowerCase()}-${i}` + // ensure uniqueness + let key = keyBase + let suffix = 2 + while ([...pubIdToKey.values()].includes(key)) { + key = `${keyBase}-${suffix++}` + } + pubIdToKey.set(pub.id, key) + } + + const result: Record = {} + for (const pub of pubs) { + const pubTypeName = pubTypeIdToName.get(pub.pubTypeId) + if (!pubTypeName) continue + + const key = pubIdToKey.get(pub.id)! + const stageId = pub.stages[0]?.stageId + const stageName = stageId ? stageIdToName.get(stageId) : undefined + + const values: Record = {} + const relatedPubs: Record> = {} + + for (const val of pub.values) { + if (val.relatedPubId) { + if (!relatedPubs[val.fieldName]) { + relatedPubs[val.fieldName] = [] + } + const refKey = pubIdToKey.get(val.relatedPubId) + relatedPubs[val.fieldName].push({ + value: val.value, + ref: refKey ?? val.relatedPubId, + }) + } else { + values[val.fieldName] = val.value + } + } + + result[key] = { + pubType: pubTypeName, + values, + ...(stageName ? { stage: stageName } : {}), + ...(Object.keys(relatedPubs).length > 0 ? { relatedPubs } : {}), + } + } + + return result +} + +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) { + 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 +} + +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/blueprint/import.ts b/core/lib/server/blueprint/import.ts new file mode 100644 index 0000000000..be2888f2fa --- /dev/null +++ b/core/lib/server/blueprint/import.ts @@ -0,0 +1,149 @@ +import type { CommunitiesId } from "db/public" + +import type { Blueprint, BlueprintImportOptions, BlueprintWarning } from "./types" + +import { db } from "~/kysely/database" +import { seedCommunity } from "~/prisma/seed/seedCommunity" +import { BLUEPRINT_VERSION } from "./types" + +/** + * import a blueprint, creating a new community with all its configuration. + * + * user slots can be mapped to real user IDs, skipped, or left unresolved. + * the import uses seedCommunity under the hood, which handles config rewriting + * (symbolic names -> real IDs) automatically. + * + * future: when importing through the UI, this function could also create + * unverified users for each slot and send invitation emails. for now, user + * slots that are not mapped are simply dropped from memberships and configs. + */ +export const importBlueprint = async ( + blueprint: Blueprint, + options: BlueprintImportOptions = {}, + trx = db +): Promise<{ communityId: CommunitiesId; communitySlug: string; warnings: BlueprintWarning[] }> => { + const warnings: BlueprintWarning[] = [] + + if (blueprint.version !== BLUEPRINT_VERSION) { + throw new Error( + `unsupported blueprint version: ${blueprint.version}, expected ${BLUEPRINT_VERSION}` + ) + } + + const communitySlug = options.overrides?.slug ?? blueprint.community.slug + const communityName = options.overrides?.name ?? blueprint.community.name + + const pubFields = blueprint.pubFields ?? {} + const pubTypes = blueprint.pubTypes ?? {} + const stages = blueprint.stages ?? {} + const stageConnections = blueprint.stageConnections ?? {} + const forms = blueprint.forms ?? {} + const apiTokens = blueprint.apiTokens ?? {} + + // transform stages: strip user slot member references + // user mapping is applied here if provided + 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] = { + ...(Object.keys(transformedAutomations).length > 0 + ? { automations: transformedAutomations } + : {}), + } + } + + // transform forms: strip member references + 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 from Record to array format + // seedCommunity expects an array of pubs + const pubEntries = Object.entries(blueprint.pubs ?? {}) + const transformedPubs = pubEntries.map(([_key, pub]) => ({ + pubType: pub.pubType, + values: pub.values ?? {}, + ...(pub.stage ? { stage: pub.stage } : {}), + ...(pub.relatedPubs ? { relatedPubs: transformRelatedPubs(pub.relatedPubs, blueprint.pubs ?? {}) } : {}), + })) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const seedInput: any = { + community: { + name: communityName, + slug: communitySlug, + ...(blueprint.community.avatar ? { avatar: blueprint.community.avatar } : {}), + }, + pubFields, + pubTypes, + stages: transformedStages, + stageConnections, + forms: transformedForms, + pubs: transformedPubs, + apiTokens, + } + + const result = await seedCommunity(seedInput, { randomSlug: false }, trx) + + return { + communityId: result.community.id, + communitySlug: result.community.slug, + warnings, + } +} + +// convert blueprint relatedPubs (which use `ref` keys) to inline format +// for seedCommunity compatibility. referenced pubs are looked up from the +// full pubs record. +const transformRelatedPubs = ( + relatedPubs: Record>, + allPubs: Record }> +): Record> => { + const result: Record> = {} + + for (const [fieldName, relations] of Object.entries(relatedPubs)) { + result[fieldName] = relations.map((rel) => { + if (rel.pub) { + return { value: rel.value, pub: rel.pub } + } + if (rel.ref && allPubs[rel.ref]) { + const referencedPub = allPubs[rel.ref] + return { + value: rel.value, + pub: { + pubType: referencedPub.pubType, + values: referencedPub.values, + }, + } + } + return { value: rel.value, pub: { pubType: "", values: {} } } + }) + } + + return result +} diff --git a/core/lib/server/blueprint/index.ts b/core/lib/server/blueprint/index.ts new file mode 100644 index 0000000000..1373099fa7 --- /dev/null +++ b/core/lib/server/blueprint/index.ts @@ -0,0 +1,7 @@ +export * from "./types" +export * from "./configRewriter" +export * from "./dag" +export * from "./export" +export * from "./import" +export * from "./validate" +export * from "./toSeed" diff --git a/core/lib/server/blueprint/toSeed.ts b/core/lib/server/blueprint/toSeed.ts new file mode 100644 index 0000000000..4687dd0361 --- /dev/null +++ b/core/lib/server/blueprint/toSeed.ts @@ -0,0 +1,265 @@ +import type { Blueprint } from "./types" + +// maps of string enum values to their import paths +const ENUM_IMPORTS: Record = { + String: { module: "db/public", enum: "CoreSchemaType" }, + Boolean: { module: "db/public", enum: "CoreSchemaType" }, + DateTime: { module: "db/public", enum: "CoreSchemaType" }, + Email: { module: "db/public", enum: "CoreSchemaType" }, + FileUpload: { module: "db/public", enum: "CoreSchemaType" }, + Integer: { module: "db/public", enum: "CoreSchemaType" }, + MemberId: { module: "db/public", enum: "CoreSchemaType" }, + Null: { module: "db/public", enum: "CoreSchemaType" }, + Number: { module: "db/public", enum: "CoreSchemaType" }, + NumericArray: { module: "db/public", enum: "CoreSchemaType" }, + RichText: { module: "db/public", enum: "CoreSchemaType" }, + StringArray: { module: "db/public", enum: "CoreSchemaType" }, + URL: { module: "db/public", enum: "CoreSchemaType" }, + Vector3: { module: "db/public", enum: "CoreSchemaType" }, + Color: { module: "db/public", enum: "CoreSchemaType" }, + + admin: { module: "db/public", enum: "MemberRole" }, + editor: { module: "db/public", enum: "MemberRole" }, + contributor: { module: "db/public", enum: "MemberRole" }, + + pubEnteredStage: { module: "db/public", enum: "AutomationEvent" }, + pubLeftStage: { module: "db/public", enum: "AutomationEvent" }, + manual: { module: "db/public", enum: "AutomationEvent" }, + webhook: { module: "db/public", enum: "AutomationEvent" }, + schedule: { module: "db/public", enum: "AutomationEvent" }, + pubInStageDuration: { module: "db/public", enum: "AutomationEvent" }, + + pubfield: { module: "db/public", enum: "ElementType" }, + structural: { module: "db/public", enum: "ElementType" }, + button: { module: "db/public", enum: "ElementType" }, +} + +// known action names mapped to their enum member +const ACTION_NAMES = new Set([ + "move", + "email", + "createPub", + "http", + "log", + "pushToV6", + "googleDriveImport", + "dataCiteDeposit", + "buildSite", + "pdf", + "archive", +]) + +/** + * generate a TypeScript seed file from a blueprint. + * + * the generated file can be dropped into `core/prisma/seeds/` and will call + * `seedCommunity` with the blueprint data, using proper TS enum imports for + * readability. + */ +export const blueprintToSeedTs = (blueprint: Blueprint): string => { + const usedEnums = new Set() + const usedActions = new Set() + + // pre-scan to collect which enums are needed + collectEnums(blueprint, usedEnums, usedActions) + + const lines: string[] = [] + + // imports + lines.push(`import type { CommunitiesId } from "db/public"`) + lines.push("") + + const dbPublicImports = new Set() + for (const value of usedEnums) { + const info = ENUM_IMPORTS[value] + if (info) { + dbPublicImports.add(info.enum) + } + } + if (usedActions.size > 0) { + dbPublicImports.add("Action") + } + + if (dbPublicImports.size > 0) { + const sorted = [...dbPublicImports].sort() + lines.push(`import { ${sorted.join(", ")} } from "db/public"`) + lines.push("") + } + + lines.push(`import { seedCommunity } from "../seed/seedCommunity"`) + lines.push("") + + // function + const funcName = `seed${toPascalCase(blueprint.community.slug)}` + lines.push(`export async function ${funcName}(communityId?: CommunitiesId) {`) + lines.push(`\treturn seedCommunity(`) + lines.push(`\t\t{`) + + // community + lines.push(`\t\t\tcommunity: {`) + lines.push(`\t\t\t\tid: communityId,`) + lines.push(`\t\t\t\tname: ${JSON.stringify(blueprint.community.name)},`) + lines.push(`\t\t\t\tslug: ${JSON.stringify(blueprint.community.slug)},`) + if (blueprint.community.avatar) { + lines.push(`\t\t\t\tavatar: ${JSON.stringify(blueprint.community.avatar)},`) + } + lines.push(`\t\t\t},`) + + // pub fields + if (blueprint.pubFields && Object.keys(blueprint.pubFields).length > 0) { + lines.push(`\t\t\tpubFields: {`) + for (const [name, field] of Object.entries(blueprint.pubFields)) { + const schemaValue = enumRef(field.schemaName, "CoreSchemaType") + const relationPart = field.relation ? ", relation: true" : "" + lines.push(`\t\t\t\t${safeKey(name)}: { schemaName: ${schemaValue}${relationPart} },`) + } + lines.push(`\t\t\t},`) + } + + // pub types + if (blueprint.pubTypes && Object.keys(blueprint.pubTypes).length > 0) { + lines.push(`\t\t\tpubTypes: {`) + for (const [name, fields] of Object.entries(blueprint.pubTypes)) { + lines.push(`\t\t\t\t${safeKey(name)}: {`) + for (const [fieldName, meta] of Object.entries(fields)) { + lines.push( + `\t\t\t\t\t${safeKey(fieldName)}: { isTitle: ${meta.isTitle} },` + ) + } + lines.push(`\t\t\t\t},`) + } + lines.push(`\t\t\t},`) + } + + // stages + if (blueprint.stages && Object.keys(blueprint.stages).length > 0) { + lines.push(`\t\t\tstages: {`) + for (const [name, stage] of Object.entries(blueprint.stages)) { + lines.push(`\t\t\t\t${safeKey(name)}: {`) + if (stage.automations && Object.keys(stage.automations).length > 0) { + lines.push(`\t\t\t\t\tautomations: {`) + for (const [autoName, automation] of Object.entries(stage.automations)) { + lines.push(`\t\t\t\t\t\t${safeKey(autoName)}: ${serializeValue(automation, 6, usedActions)},`) + } + lines.push(`\t\t\t\t\t},`) + } + lines.push(`\t\t\t\t},`) + } + lines.push(`\t\t\t},`) + } + + // stage connections + if ( + blueprint.stageConnections && + Object.keys(blueprint.stageConnections).length > 0 + ) { + lines.push( + `\t\t\tstageConnections: ${serializeValue(blueprint.stageConnections, 3, usedActions)},` + ) + } + + // forms + if (blueprint.forms && Object.keys(blueprint.forms).length > 0) { + lines.push(`\t\t\tforms: ${serializeValue(blueprint.forms, 3, usedActions)},`) + } + + lines.push(`\t\t},`) + lines.push(`\t\t{`) + lines.push(`\t\t\trandomSlug: false,`) + lines.push(`\t\t}`) + lines.push(`\t)`) + lines.push(`}`) + lines.push("") + + return lines.join("\n") +} + +const toPascalCase = (s: string): string => + s + .split(/[-_\s]+/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join("") + +const safeKey = (key: string): string => + /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key) + +const enumRef = (value: string, enumName: string): string => { + const info = ENUM_IMPORTS[value] + if (info && info.enum === enumName) { + return `${enumName}.${value}` + } + return JSON.stringify(value) +} + +const serializeValue = ( + value: unknown, + depth: number, + usedActions: Set +): string => { + const indent = "\t".repeat(depth) + const childIndent = "\t".repeat(depth + 1) + + if (value === null || value === undefined) return "undefined" + if (typeof value === "boolean" || typeof value === "number") return String(value) + if (typeof value === "string") { + // check for known enum values + const info = ENUM_IMPORTS[value] + if (info) return `${info.enum}.${value}` + + // check for action names + if (ACTION_NAMES.has(value) && usedActions.has(value)) { + return `Action.${value}` + } + + return JSON.stringify(value) + } + + if (Array.isArray(value)) { + if (value.length === 0) return "[]" + const items = value.map( + (item) => `${childIndent}${serializeValue(item, depth + 1, usedActions)},` + ) + return `[\n${items.join("\n")}\n${indent}]` + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record) + if (entries.length === 0) return "{}" + const items = entries.map(([k, v]) => { + // special handling for the "action" field to use Action enum + if (k === "action" && typeof v === "string" && ACTION_NAMES.has(v)) { + usedActions.add(v) + return `${childIndent}${safeKey(k)}: Action.${v},` + } + return `${childIndent}${safeKey(k)}: ${serializeValue(v, depth + 1, usedActions)},` + }) + return `{\n${items.join("\n")}\n${indent}}` + } + + return JSON.stringify(value) +} + +const collectEnums = ( + obj: unknown, + usedEnums: Set, + usedActions: Set +): void => { + if (typeof obj === "string") { + if (ENUM_IMPORTS[obj]) usedEnums.add(obj) + if (ACTION_NAMES.has(obj)) usedActions.add(obj) + return + } + + if (Array.isArray(obj)) { + for (const item of obj) { + collectEnums(item, usedEnums, usedActions) + } + return + } + + if (typeof obj === "object" && obj !== null) { + for (const value of Object.values(obj)) { + collectEnums(value, usedEnums, usedActions) + } + } +} diff --git a/core/lib/server/blueprint/types.ts b/core/lib/server/blueprint/types.ts new file mode 100644 index 0000000000..afc7a35a2c --- /dev/null +++ b/core/lib/server/blueprint/types.ts @@ -0,0 +1,221 @@ +import type { + Action, + AutomationConditionBlockType, + AutomationEvent, + ConditionEvaluationTiming, + CoreSchemaType, + ElementType, + FormAccessType, + InputComponent, + MemberRole, + StructuralFormElement, +} from "db/public" +import type { IconConfig } from "ui/dynamic-icon" + +export const BLUEPRINT_VERSION = "1" as const + +// ============================================================================ +// condition / automation primitives +// ============================================================================ + +export type BlueprintConditionItem = + | { kind: "condition"; type: "jsonata"; expression: string } + | { kind: "block"; type: AutomationConditionBlockType; items: BlueprintConditionItem[] } + +export type BlueprintAutomationTrigger = { + event: AutomationEvent + config: Record + sourceAutomation?: string +} + +export type BlueprintAutomationAction = { + action: Action + name?: string + config: Record +} + +export type BlueprintAutomation = { + icon?: IconConfig + sourceAutomation?: string + timing?: ConditionEvaluationTiming + condition?: { + type: AutomationConditionBlockType + items: BlueprintConditionItem[] + } + resolver?: string + triggers: BlueprintAutomationTrigger[] + actions: BlueprintAutomationAction[] +} + +// ============================================================================ +// pub fields / pub types +// ============================================================================ + +export type BlueprintPubField = { + schemaName: CoreSchemaType + relation?: true +} + +export type BlueprintPubTypeField = { + isTitle: boolean +} + +// ============================================================================ +// stages +// ============================================================================ + +export type BlueprintStage = { + members?: Record + automations?: Record +} + +export type BlueprintStageConnections = Record< + string, + { to?: string[]; from?: string[] } +> + +// ============================================================================ +// user slots +// +// users are not stored in blueprints. instead, we record "slots" that describe +// where a user reference existed. during import the user decides how to fill +// each slot (map to existing user, create new, or skip). +// ============================================================================ + +export type BlueprintUserSlot = { + role?: MemberRole | null + description?: string +} + +// ============================================================================ +// forms +// ============================================================================ + +export type BlueprintFormElementPubField = { + type: typeof ElementType.pubfield + field: string + component: InputComponent | null + config: Record + relatedPubTypes?: string[] +} + +export type BlueprintFormElementStructural = { + type: typeof ElementType.structural + element: StructuralFormElement + content: string +} + +export type BlueprintFormElementButton = { + type: typeof ElementType.button + label: string + content: string + stage: string +} + +export type BlueprintFormElement = + | BlueprintFormElementPubField + | BlueprintFormElementStructural + | BlueprintFormElementButton + +export type BlueprintForm = { + access?: FormAccessType + isArchived?: boolean + slug?: string + pubType: string + members?: string[] + isDefault?: boolean + elements: BlueprintFormElement[] +} + +// ============================================================================ +// pubs +// +// pubs are keyed by a stable symbolic name so that relations between pubs can +// be expressed as references to those keys instead of UUIDs. +// ============================================================================ + +export type BlueprintRelatedPub = { + value?: unknown + // either an inline pub definition or a reference to another pub key + pub?: BlueprintPub + ref?: string +} + +export type BlueprintPub = { + pubType: string + values: Record + stage?: string + members?: Record + relatedPubs?: Record +} + +// ============================================================================ +// api tokens +// ============================================================================ + +export type BlueprintApiToken = { + description?: string + permissions?: Record | true +} + +// ============================================================================ +// action config defaults +// ============================================================================ + +export type BlueprintActionConfigDefault = { + action: Action + config: Record +} + +// ============================================================================ +// top-level blueprint +// ============================================================================ + +export type Blueprint = { + version: typeof BLUEPRINT_VERSION + community: { + name: string + slug: string + avatar?: string + } + pubFields?: Record + pubTypes?: Record> + stages?: Record + stageConnections?: BlueprintStageConnections + forms?: Record + pubs?: Record + apiTokens?: Record + actionConfigDefaults?: BlueprintActionConfigDefault[] + userSlots?: Record +} + +// ============================================================================ +// export options +// ============================================================================ + +export type BlueprintExportOptions = { + includePubs?: boolean + includeApiTokens?: boolean + includeActionConfigDefaults?: boolean +} + +// ============================================================================ +// import options +// ============================================================================ + +export type BlueprintImportOptions = { + userMapping?: Record + overrides?: { + slug?: string + name?: string + } +} + +// ============================================================================ +// warning produced during export or import +// ============================================================================ + +export type BlueprintWarning = { + path: string + message: string +} diff --git a/core/lib/server/blueprint/validate.ts b/core/lib/server/blueprint/validate.ts new file mode 100644 index 0000000000..1e6c2099be --- /dev/null +++ b/core/lib/server/blueprint/validate.ts @@ -0,0 +1,192 @@ +import type { Action, CommunitiesId } from "db/public" + +import { db } from "~/kysely/database" +import { findReferenceFields } from "./configRewriter" +import { actions } from "~/actions/api" + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export type AutomationValidationResult = { + automationId: string + automationName: string + stageId: string + stageName: string + issues: AutomationValidationIssue[] +} + +export type AutomationValidationIssue = { + actionName: string + field: string + message: string + severity: "error" | "warning" +} + +/** + * validate all action configurations in a community, checking that referenced + * entities (stages, forms, members) actually exist. + */ +export const validateCommunityActionConfigs = async ( + communityId: CommunitiesId +): Promise => { + const [stages, forms, members, automationData] = await Promise.all([ + db + .selectFrom("stages") + .select(["id", "name"]) + .where("communityId", "=", communityId) + .execute(), + db + .selectFrom("forms") + .select(["id", "slug"]) + .where("communityId", "=", communityId) + .execute(), + db + .selectFrom("community_memberships") + .select(["userId"]) + .where("communityId", "=", communityId) + .execute(), + db + .selectFrom("automations") + .innerJoin("stages", "stages.id", "automations.stageId") + .innerJoin("action_instances", "action_instances.automationId", "automations.id") + .select([ + "automations.id as automationId", + "automations.name as automationName", + "automations.stageId", + "stages.name as stageName", + "action_instances.action", + "action_instances.config", + ]) + .where("stages.communityId", "=", communityId) + .execute(), + ]) + + const stageIds = new Set(stages.map((s) => s.id)) + const formSlugs = new Set(forms.map((f) => f.slug)) + const memberIds = new Set(members.map((m) => m.userId)) + + const resultsMap = new Map() + + for (const row of automationData) { + const config = (row.config ?? {}) as Record + const actionDef = actions[row.action as Action] + if (!actionDef) continue + + const schema = actionDef.config.schema + const refs = findReferenceFields(schema) + const issues: AutomationValidationIssue[] = [] + + for (const ref of refs) { + const value = getValueAtPath(config, ref.path) + if (!value || typeof value !== "string") continue + + if (ref.lookupKey === "stages" && !stageIds.has(value)) { + issues.push({ + actionName: row.action, + field: ref.path.join("."), + message: `references non-existent stage: ${value}`, + severity: "error", + }) + } + + if (ref.lookupKey === "forms" && !formSlugs.has(value)) { + issues.push({ + actionName: row.action, + field: ref.path.join("."), + message: `references non-existent form: ${value}`, + severity: "error", + }) + } + + if (ref.lookupKey === "members" && !memberIds.has(value)) { + issues.push({ + actionName: row.action, + field: ref.path.join("."), + message: `references non-existent member: ${value}`, + severity: "warning", + }) + } + } + + // scan for any UUID-like strings in non-annotated fields + scanConfigForOrphanedUuids(config, [], issues, row.action, stageIds, formSlugs, memberIds) + + if (issues.length === 0) continue + + const existing = resultsMap.get(row.automationId) + if (existing) { + existing.issues.push(...issues) + } else { + resultsMap.set(row.automationId, { + automationId: row.automationId, + automationName: row.automationName, + stageId: row.stageId, + stageName: row.stageName, + issues, + }) + } + } + + return [...resultsMap.values()] +} + +const getValueAtPath = (obj: Record, path: string[]): unknown => { + let current: unknown = obj + for (const key of path) { + if (current == null || typeof current !== "object") return undefined + if (key === "[]" || key === "{}") return undefined + current = (current as Record)[key] + } + return current +} + +const scanConfigForOrphanedUuids = ( + obj: unknown, + path: string[], + issues: AutomationValidationIssue[], + actionName: string, + stageIds: Set, + formSlugs: Set, + memberIds: Set +): void => { + if (typeof obj === "string") { + if (!UUID_PATTERN.test(obj)) return + if (stageIds.has(obj) || formSlugs.has(obj) || memberIds.has(obj)) return + + issues.push({ + actionName, + field: path.join("."), + message: `contains UUID that does not match any known entity: ${obj}`, + severity: "warning", + }) + return + } + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + scanConfigForOrphanedUuids( + obj[i], + [...path, String(i)], + issues, + actionName, + stageIds, + formSlugs, + memberIds + ) + } + return + } + + if (typeof obj === "object" && obj != null) { + for (const [key, value] of Object.entries(obj)) { + scanConfigForOrphanedUuids( + value, + [...path, key], + issues, + actionName, + stageIds, + formSlugs, + memberIds + ) + } + } +} diff --git a/core/lib/server/cache/autoCache.ts b/core/lib/server/cache/autoCache.ts index b6bad2699d..c09875700c 100644 --- a/core/lib/server/cache/autoCache.ts +++ b/core/lib/server/cache/autoCache.ts @@ -4,12 +4,14 @@ import type { AutoCacheOptions, DirectAutoOutput, ExecuteFn, SQB } from "./types import { cache } from "react" import { logger } from "logger" +import { tryCatch } from "utils/try-catch" import { env } from "~/lib/env/env" 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 +62,33 @@ 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 [error, communitySlug] = options?.communitySlug + ? await tryCatch(getCommunitySlug()) + : [null, options?.communitySlug!] + + if (error) { + logger.error(`Error getting community slug: ${error.message}`) + logger.error(compiledQuery.sql) + throw error + } + const tables = await cachedFindTables(compiledQuery, "select") const allTables = getTablesWithLinkedTables(tables) @@ -88,7 +113,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..68faaa125f 100644 --- a/core/lib/server/cache/autoRevalidate.ts +++ b/core/lib/server/cache/autoRevalidate.ts @@ -4,11 +4,13 @@ import type { AutoRevalidateOptions, DirectAutoOutput, ExecuteFn, QB } from "./t import { revalidatePath, revalidateTag } from "next/cache" import { logger } from "logger" +import { tryCatch } from "utils/try-catch" 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,11 +22,28 @@ const executeWithRevalidate = < options?: AutoRevalidateOptions ) => { const executeFn = async (...args: Parameters) => { - const communitySlug = options?.communitySlug ?? (await getCommunitySlug()) + const compiledQuery = qb.compile() - const communitySlugs = Array.isArray(communitySlug) ? communitySlug : [communitySlug] + const willSkipCacheStore = shouldSkipCacheStore("invalidate") - const compiledQuery = qb.compile() + if (willSkipCacheStore) { + logger.debug( + `Skipping revalidation for query ${compiledQuery.sql} because of skipCacheStore` + ) + return qb[method](...args) as ReturnType + } + + const [error, communitySlug] = options?.communitySlug + ? await tryCatch(getCommunitySlug()) + : [null, options?.communitySlug!] + + if (error) { + logger.error(`Error getting community slug: ${error.message}`) + logger.error(compiledQuery.sql) + throw error + } + + const communitySlugs = Array.isArray(communitySlug) ? communitySlug : [communitySlug] const tables = await cachedFindTables(compiledQuery, "mutation") 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() + }) +} 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 new file mode 100644 index 0000000000..7b218874be --- /dev/null +++ b/core/lib/server/communityTemplate/export.ts @@ -0,0 +1,525 @@ +import type { CommunitiesId, MemberRole, PubsId } from "db/public" +import type { IconConfig } from "ui/dynamic-icon" + +import { jsonArrayFrom } from "kysely/helpers/postgres" + +import { AutomationConditionBlockType, ElementType } from "db/public" + +import type { + CommunityTemplate, + TemplateActionConfigDefault, + TemplateApiToken, + TemplateAutomation, + TemplateConditionItem, + TemplateExportOptions, + TemplateForm, + TemplateFormElement, + TemplatePub, + TemplatePubField, + TemplateStage, +} from "./types" + +import { db } from "~/kysely/database" + +type ConditionBlockItem = + | { kind: "condition"; type: "jsonata"; expression: string } + | { kind: "block"; type: AutomationConditionBlockType; items: ConditionBlockItem[] } + +const transformConditionItems = (items: ConditionBlockItem[]): TemplateConditionItem[] => { + 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, + options: TemplateExportOptions = {} +): Promise => { + const { includePubs = false, includeApiTokens = false, includeActionConfigDefaults = false } = + options + // 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 (no members - memberships are skipped in templates) + const stages = await db + .selectFrom("stages") + .select(["stages.id", "stages.name"]) + .where("communityId", "=", communityId) + .orderBy("stages.order", "asc") + .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 (no members - memberships are skipped in templates) + 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, + })), + } + } + + stagesTemplate[stage.name] = { + ...(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 + } + + // 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/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..6331eb844d --- /dev/null +++ b/core/lib/server/communityTemplate/schema.ts @@ -0,0 +1,636 @@ +// 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 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: { + 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.", + }, + actionConfigDefaults: { + type: "array", + items: actionConfigDefault, + description: "Default configurations for actions across the community.", + }, + }, + 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..d13e186456 --- /dev/null +++ b/core/lib/server/communityTemplate/types.ts @@ -0,0 +1,222 @@ +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 +} + +// action config default definition +export type TemplateActionConfigDefault = { + action: Action + config: Record +} + +// the main community template type +export type CommunityTemplate = { + community: { + name: string + slug: string + avatar?: string + } + 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 +export const MINIMAL_TEMPLATE: CommunityTemplate = { + community: { + name: "New Community", + slug: "new-community", + }, +} + +// example template with common structure (no users - memberships skipped) +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 }, + }, + }, + stages: { + Draft: {}, + 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/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 28ba880171..ca14c06bec 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,29 +998,23 @@ 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() - ) + // 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 ) - const newUsers = Object.entries(props.users ?? {}).filter( - (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(), @@ -1012,46 +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 })) ) - 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() + } + + if (!existingUser) { + throw new Error( + `Could not find existing user referenced by ${JSON.stringify(userRef)}` + ) + } + + resolvedExistingUsers.push([key, { ...existingUser, role: userRef.role }]) + } - // 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 + // 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) @@ -1067,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], @@ -1088,6 +1124,7 @@ export async function seedCommunity< })) // + // create stage memberships using insertStageMemberships const stageMembers = consolidatedStages .flatMap((stage) => { if (!stage.members) return [] @@ -1099,83 +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() - : [] - - const stageConnectionsList = props.stageConnections - ? await db - .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({ @@ -1224,6 +1257,7 @@ export async function seedCommunity< })) ) + console.log("formList", formList) const createdForms = formList.length > 0 ? await trx @@ -1301,6 +1335,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) => { @@ -1352,6 +1410,24 @@ export async function seedCommunity< ) as unknown as FormsByName const { upsertAutomation } = await import("~/lib/server/automations") + const { rewriteConfigToIds, createEmptyEntityLookup } = await import( + "~/lib/server/blueprint/configRewriter" + ) + + // build entity lookup for rewriting symbolic names in action configs to IDs + const entityLookup = createEmptyEntityLookup() + for (const stage of createdStages) { + entityLookup.stages.set(stage.name, stage.id) + } + for (const form of createdForms) { + entityLookup.forms.set(form.slug, form.id) + } + for (const field of createdPubFields) { + entityLookup.fields.set(field.name, field.slug) + } + for (const [slug, user] of Object.entries(usersBySlug)) { + entityLookup.members.set(slug, (user as Users).id) + } const initialCreatedAutomations: Automations[] = [] for (const stage of consolidatedStages) { @@ -1365,10 +1441,19 @@ export async function seedCommunity< name: automationName, id: crypto.randomUUID() as AutomationsId, ...automation, - actions: automation.actions.map((action) => ({ - id: crypto.randomUUID() as ActionInstancesId, - ...action, - })), + actions: automation.actions.map((action) => { + // rewrite symbolic references (stage names, form slugs, etc.) to real IDs + const { config: rewrittenConfig } = rewriteConfigToIds( + action.action, + action.config as Record, + entityLookup + ) + return { + id: crypto.randomUUID() as ActionInstancesId, + ...action, + config: rewrittenConfig, + } + }), } } ) diff --git a/core/prisma/seeds/coar-notify.ts b/core/prisma/seeds/coar-notify.ts index f4f5204633..b941abf242 100644 --- a/core/prisma/seeds/coar-notify.ts +++ b/core/prisma/seeds/coar-notify.ts @@ -1,4 +1,4 @@ -import type { CommunitiesId, StagesId, UsersId } from "db/public" +import type { CommunitiesId, UsersId } from "db/public" import { Action, @@ -14,20 +14,6 @@ import { seedCommunity } from "../seed/seedCommunity" export async function seedCoarNotify(communityId?: CommunitiesId) { const adminId = "dddddddd-dddd-4ddd-dddd-dddddddddd01" as UsersId - const STAGE_IDS = { - // Incoming notification processing stages - Inbox: "dddddddd-dddd-4ddd-dddd-dddddddddd10" as StagesId, - Accepted: "dddddddd-dddd-4ddd-dddd-dddddddddd12" as StagesId, - Rejected: "dddddddd-dddd-4ddd-dddd-dddddddddd13" as StagesId, - // Review workflow stages (for Reviews created from Notifications) - ReviewInbox: "dddddddd-dddd-4ddd-dddd-dddddddddd15" as StagesId, - Reviewing: "dddddddd-dddd-4ddd-dddd-dddddddddd16" as StagesId, - Published: "dddddddd-dddd-4ddd-dddd-dddddddddd14" as StagesId, - // Outbound review request stages (for our Submissions) - Submissions: "dddddddd-dddd-4ddd-dddd-dddddddddd17" as StagesId, - AwaitingResponse: "dddddddd-dddd-4ddd-dddd-dddddddddd18" as StagesId, - } - const WEBHOOK_PATH = "coar-inbox" // Default remote inbox URL - can be changed in UI for testing @@ -102,7 +88,6 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { ], stages: { Inbox: { - id: STAGE_IDS.Inbox, automations: { "Process COAR Notification": { icon: { @@ -115,7 +100,6 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { config: { path: WEBHOOK_PATH }, }, ], - // Only process incoming Offer notifications (review requests from external services) condition: { type: AutomationConditionBlockType.AND, items: [ @@ -130,7 +114,7 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { { action: Action.createPub, config: { - stage: STAGE_IDS.Inbox, + stage: "Inbox", formSlug: "notification-default-editor", pubValues: { Title: "URL: {{ $.json.object.id }} - Type: {{ $join($.json.type, ', ') }}", @@ -141,7 +125,6 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { }, ], }, - // Manual action to accept an incoming review request "Accept Request": { icon: { name: "check", @@ -166,11 +149,10 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { actions: [ { action: Action.move, - config: { stage: STAGE_IDS.Accepted }, + config: { stage: "Accepted" }, }, ], }, - // Manual action to reject an incoming review request "Reject Request": { icon: { name: "x", @@ -195,14 +177,13 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { actions: [ { action: Action.move, - config: { stage: STAGE_IDS.Rejected }, + config: { stage: "Rejected" }, }, ], }, }, }, ReviewInbox: { - id: STAGE_IDS.ReviewInbox, automations: { "Start Review": { icon: { @@ -211,13 +192,12 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { }, triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], actions: [ - { action: Action.move, config: { stage: STAGE_IDS.Reviewing } }, + { action: Action.move, config: { stage: "Reviewing" } }, ], }, }, }, Reviewing: { - id: STAGE_IDS.Reviewing, automations: { "Finish Review": { icon: { @@ -226,16 +206,13 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { }, triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], actions: [ - { action: Action.move, config: { stage: STAGE_IDS.Published } }, + { action: Action.move, config: { stage: "Published" } }, ], }, }, }, - // Entry point for our own submissions that we want to request reviews for Submissions: { - id: STAGE_IDS.Submissions, automations: { - // Manual action to request a review from a remote repository "Request Review": { icon: { name: "send", @@ -260,17 +237,14 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { actions: [ { action: Action.move, - config: { stage: STAGE_IDS.AwaitingResponse }, + config: { stage: "AwaitingResponse" }, }, ], }, }, }, - // Waiting for response from external service after requesting a review AwaitingResponse: { - id: STAGE_IDS.AwaitingResponse, automations: { - // Send the Offer when a submission enters this stage "Send Review Offer": { icon: { name: "send", @@ -329,7 +303,6 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { }, ], }, - // Process incoming responses (Accept/Reject/Announce) from external services "Process Response": { icon: { name: "mail", @@ -341,7 +314,6 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { config: { path: WEBHOOK_PATH }, }, ], - // Only process Accept, Reject, or Announce responses (not Offer) condition: { type: AutomationConditionBlockType.AND, items: [ @@ -353,8 +325,6 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { }, ], }, - // Resolver finds the Submission pub that matches the inReplyTo field - // The inReplyTo will be like "urn:uuid:" from our sent Offer resolver: `{{ $replace($.json.inReplyTo, "http://localhost:3000/c/coar-notify/pub/", "") }} = $.pub.id`, actions: [ { @@ -368,7 +338,6 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { }, }, Published: { - id: STAGE_IDS.Published, automations: { "Announce Review": { icon: { @@ -485,7 +454,6 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut subpath: "reviews", pages: [ { - // Individual review page - one per Review pub slug: "$.pub.id", filter: '$.pub.pubType.name = "Review"', extension: "html", @@ -510,7 +478,6 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut ''`, }, { - // JSON manifest - companion metadata for each review slug: "$.pub.id", filter: '$.pub.pubType.name = "Review"', extension: "json", @@ -522,7 +489,6 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut })`, }, { - // Index page - lists all published reviews slug: '"/"', filter: '$.pub.pubType.name = "Review"', extension: "html", @@ -540,7 +506,6 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut }, }, Accepted: { - id: STAGE_IDS.Accepted, automations: { "Send Accept Acknowledgement": { icon: { @@ -597,7 +562,6 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut }, ], }, - // Create a Review pub after accepting the request "Create Review for Notification": { icon: { name: "plus-circle", @@ -623,7 +587,7 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut { action: Action.createPub, config: { - stage: STAGE_IDS.ReviewInbox, + stage: "ReviewInbox", formSlug: "review-default-editor", pubValues: { Title: "Review for: {{ $.pub.values.title }}", @@ -641,7 +605,6 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut }, }, Rejected: { - id: STAGE_IDS.Rejected, automations: { "Send Reject Acknowledgement": { icon: { @@ -703,18 +666,15 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut }, }, stageConnections: { - // Incoming notification flow: process and either accept, reject, or create review Inbox: { to: ["Accepted", "Rejected"], }, - // Review workflow (for Reviews created from Notifications) ReviewInbox: { to: ["Reviewing"], }, Reviewing: { to: ["Published"], }, - // Outbound request flow: our submissions requesting external reviews Submissions: { to: ["AwaitingResponse"], },