CI / typecheck + build (turbo) (push) Canceled after 0s
- Migrate document fetching and CRUD to be PostgreSQL-authoritative - Remove static section enums and add dynamic listSections query - Support custom sections and metadata across API, MCP, and Web UI - Add /api/sections endpoint and update Header, Sidebar, and Forms - Remove obsolete phase planning docs and modernize README/AGENTS
159 lines
5.1 KiB
TypeScript
159 lines
5.1 KiB
TypeScript
import { z } from "zod";
|
|
import { publicProcedure, router, t } from "./trpc";
|
|
import {
|
|
getDocument,
|
|
getRelated,
|
|
hybridSearch,
|
|
keywordSearch,
|
|
listDocuments,
|
|
listSections,
|
|
semanticSearch,
|
|
listRevisions,
|
|
getRevision,
|
|
restoreRevision,
|
|
createDocument,
|
|
updateDocument,
|
|
deleteDocument,
|
|
} from "@mcpedia/core";
|
|
import { getQueue, INDEX_QUEUE } from "@mcpedia/queue";
|
|
import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client";
|
|
|
|
// restoreRevision and CRUD operations are state-changing actions.
|
|
// They must NOT be callable anonymously over the network —
|
|
// only an operator with the webhook secret (or authorized UI) may execute them.
|
|
const requireWriteAuth = t.middleware(({ ctx, next }) => {
|
|
if (!ctx.expectedSecret) {
|
|
throw new Error("WEBHOOK_SECRET is not configured; writes are disabled");
|
|
}
|
|
if (ctx.webhookSecret !== ctx.expectedSecret) {
|
|
throw new Error("unauthorized: missing or invalid x-webhook-secret");
|
|
}
|
|
return next();
|
|
});
|
|
|
|
export const appRouter = router({
|
|
search: publicProcedure
|
|
.input(z.object({ q: z.string(), limit: z.number().int().min(1).max(50).default(20) }))
|
|
.query(({ input }) => keywordSearch(input.q, input.limit)),
|
|
|
|
semanticSearch: publicProcedure
|
|
.input(z.object({ q: z.string(), limit: z.number().int().min(1).max(50).default(10) }))
|
|
.query(async ({ input }) => semanticSearch(input.q, input.limit)),
|
|
|
|
hybridSearch: publicProcedure
|
|
.input(z.object({ q: z.string(), limit: z.number().int().min(1).max(50).default(10) }))
|
|
.query(async ({ input }) => hybridSearch(input.q, input.limit)),
|
|
|
|
getDocument: publicProcedure
|
|
.input(z.object({ slug: z.string() }))
|
|
.query(async ({ input }) => getDocument(input.slug)),
|
|
|
|
listDocuments: publicProcedure
|
|
.input(z.object({ section: z.string().optional(), status: z.string().optional() }).optional())
|
|
.query(async ({ input }) => listDocuments(input ?? {})),
|
|
|
|
sections: publicProcedure.query(async () => listSections()),
|
|
|
|
related: publicProcedure
|
|
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(20).default(5) }))
|
|
.query(async ({ input }) => getRelated(input.slug, input.limit)),
|
|
|
|
// --- Revisions ---
|
|
revisions: publicProcedure
|
|
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) }))
|
|
.query(async ({ input }) => listRevisions(input.slug, input.limit)),
|
|
|
|
getRevision: publicProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.query(async ({ input }) => getRevision(input.id)),
|
|
|
|
restoreRevision: publicProcedure
|
|
.use(requireWriteAuth)
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ input }) => restoreRevision(input.id)),
|
|
|
|
// --- CRUD (gated by x-webhook-secret / admin auth) ---
|
|
createDocument: publicProcedure
|
|
.use(requireWriteAuth)
|
|
.input(
|
|
z.object({
|
|
slug: z.string().min(1),
|
|
title: z.string().min(1),
|
|
section: z.string().min(1),
|
|
body: z.string(),
|
|
type: z.string().optional(),
|
|
status: z.enum(["published", "draft"]).optional(),
|
|
author: z.string().optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
extraFields: z.record(z.string(), z.unknown()).optional(),
|
|
}),
|
|
)
|
|
.mutation(async ({ input }) => createDocument(input)),
|
|
|
|
updateDocument: publicProcedure
|
|
.use(requireWriteAuth)
|
|
.input(
|
|
z.object({
|
|
slug: z.string().min(1),
|
|
title: z.string().min(1).optional(),
|
|
body: z.string().optional(),
|
|
section: z.string().min(1).optional(),
|
|
type: z.string().optional(),
|
|
status: z.enum(["published", "draft"]).optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
author: z.string().optional(),
|
|
extraFields: z.record(z.string(), z.unknown()).optional(),
|
|
}),
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
const { slug, ...rest } = input;
|
|
return updateDocument(slug, rest);
|
|
}),
|
|
|
|
deleteDocument: publicProcedure
|
|
.use(requireWriteAuth)
|
|
.input(z.object({ slug: z.string().min(1) }))
|
|
.mutation(async ({ input }) => deleteDocument(input.slug)),
|
|
|
|
// --- Async job status ---
|
|
jobStatus: publicProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.query(async ({ input }) => {
|
|
const queue = getQueue();
|
|
const job = await queue.getJob(input.id);
|
|
if (!job) return { exists: false };
|
|
const state = await job.getState();
|
|
const failedReason = job.failedReason;
|
|
const returnvalue = job.returnvalue;
|
|
const progress = job.progress;
|
|
return {
|
|
exists: true,
|
|
id: job.id,
|
|
name: job.name,
|
|
state,
|
|
progress,
|
|
failedReason,
|
|
returnvalue,
|
|
attemptsMade: job.attemptsMade,
|
|
};
|
|
}),
|
|
|
|
queueStatus: publicProcedure.query(async () => {
|
|
const queue = getQueue();
|
|
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
|
queue.getWaitingCount(),
|
|
queue.getActiveCount(),
|
|
queue.getCompletedCount(),
|
|
queue.getFailedCount(),
|
|
queue.getDelayedCount(),
|
|
]);
|
|
return {
|
|
queue: INDEX_QUEUE,
|
|
prefix: BULLMQ_PREFIX,
|
|
counts: { waiting, active, completed, failed, delayed },
|
|
};
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|