feat: add custom widget flow workbench
This commit is contained in:
1 parent
c32a5317ca
commit
a6bdbbb4ea
259 files changed
+23875
-1293
No files matched your search
@@ -1,19 +1,15 @@
|
||||
import { contentProcedures } from "./content-procedures";
|
||||
import { nativeProcedures } from "./native-procedures";
|
||||
import { workshopUpdateProcedures } from "./workshop-update-procedures";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import {
|
||||
customWidgetDefinitionSchema,
|
||||
customWidgetUpdateSchema,
|
||||
normalizeCustomWidgetAuthoringUpdate,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
|
||||
import { creationProcedures } from "./creation-procedures";
|
||||
import { managementQueryProcedures } from "./management-queries";
|
||||
import { metadataProcedures } from "./metadata-procedures";
|
||||
@@ -27,160 +23,24 @@ import {
|
||||
} from "./stored-definition";
|
||||
import { templateProcedures } from "./template-procedures";
|
||||
import { transferProcedures } from "./transfer-procedures";
|
||||
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
|
||||
import { secretProcedures } from "./secret-procedures";
|
||||
import { workshopProcedures } from "./workshop-procedures";
|
||||
import { updateCustomWidgetProcedure } from "./definition-update-procedure";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
export const customWidgetRouter = createTRPCRouter({
|
||||
...metadataProcedures,
|
||||
...nativeProcedures,
|
||||
...contentProcedures,
|
||||
...managementQueryProcedures,
|
||||
...creationProcedures,
|
||||
|
||||
update: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update one Custom JSX widget. Prefer templateLines for multiline JSX changes. Returns a client-navigable edit link.",
|
||||
},
|
||||
})
|
||||
.input(customWidgetUpdateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
const current = parseStoredCustomWidgetDefinition(existing);
|
||||
const { id, secrets, ...authoringChanges } = input;
|
||||
const definition = parseCustomWidgetAuthoringInput(() => {
|
||||
const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
|
||||
return customWidgetDefinitionSchema.parse({ ...current, ...changes });
|
||||
});
|
||||
if (secrets) assertSecretSources(definition.sources, secrets);
|
||||
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
|
||||
const secretRows = secrets?.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
const sourceIds = Object.keys(definition.sources);
|
||||
const changedSecretBindings = new Set(
|
||||
Object.entries(definition.sources).flatMap(([sourceId, source]) => {
|
||||
const previous = current.sources[sourceId];
|
||||
return previous && !hasSameSecretBinding(previous, source) ? [sourceId] : [];
|
||||
}),
|
||||
);
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(schema.customWidgetDefinitions.id, id));
|
||||
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
|
||||
),
|
||||
);
|
||||
|
||||
for (const sourceId of changedSecretBindings) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
eq(schema.customWidgetSecrets.sourceId, sourceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
||||
const where = and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
eq(schema.customWidgetSecrets.sourceId, sourceId),
|
||||
);
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(schema.customWidgetSecrets.kind, kinds)) : where);
|
||||
}
|
||||
|
||||
for (const secret of secretRows ?? []) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secret);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, id))
|
||||
.run();
|
||||
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
|
||||
.run();
|
||||
|
||||
for (const sourceId of changedSecretBindings) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId)))
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
||||
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const secret of secretRows ?? []) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, id),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
logger.info("Updated custom widget definition", { id });
|
||||
return { id, managementPath: `/manage/custom-widgets/edit/${id}` };
|
||||
}),
|
||||
update: updateCustomWidgetProcedure,
|
||||
|
||||
...secretProcedures,
|
||||
...workshopProcedures,
|
||||
...workshopUpdateProcedures,
|
||||
|
||||
toggleEnabled: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
Reference in new issue
Block a user