Added Wiki
This commit is contained in:
@@ -71,4 +71,5 @@ export * from "./vendors"
|
||||
export * from "./staff_time_events"
|
||||
export * from "./serialtypes"
|
||||
export * from "./serialexecutions"
|
||||
export * from "./public_links"
|
||||
export * from "./public_links"
|
||||
export * from "./wikipages"
|
||||
99
backend/db/schema/wikipages.ts
Normal file
99
backend/db/schema/wikipages.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
jsonb,
|
||||
integer,
|
||||
index,
|
||||
uuid,
|
||||
AnyPgColumn
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { relations } from "drizzle-orm"
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const wikiPages = pgTable(
|
||||
"wiki_pages",
|
||||
{
|
||||
// ID des Wiki-Eintrags selbst (neu = UUID)
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.defaultRandom(),
|
||||
|
||||
tenantId: bigint("tenant_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
|
||||
parentId: uuid("parent_id")
|
||||
.references((): AnyPgColumn => wikiPages.id, { onDelete: "cascade" }),
|
||||
|
||||
title: text("title").notNull(),
|
||||
|
||||
content: jsonb("content"),
|
||||
|
||||
isFolder: boolean("is_folder").notNull().default(false),
|
||||
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
|
||||
// --- POLYMORPHE BEZIEHUNG (Split) ---
|
||||
|
||||
// Art der Entität (z.B. 'customer', 'invoice', 'iot_device')
|
||||
entityType: text("entity_type"),
|
||||
|
||||
// SPALTE 1: Für Legacy-Tabellen (BigInt)
|
||||
// Nutzung: Wenn entityType='customer', wird hier die ID 1050 gespeichert
|
||||
entityId: bigint("entity_id", { mode: "number" }),
|
||||
|
||||
// SPALTE 2: Für neue Tabellen (UUID)
|
||||
// Nutzung: Wenn entityType='iot_device', wird hier die UUID gespeichert
|
||||
entityUuid: uuid("entity_uuid"),
|
||||
|
||||
// ------------------------------------
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
|
||||
createdBy: uuid("created_by").references(() => authUsers.id),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
},
|
||||
(table) => ({
|
||||
tenantIdx: index("wiki_pages_tenant_idx").on(table.tenantId),
|
||||
parentIdx: index("wiki_pages_parent_idx").on(table.parentId),
|
||||
|
||||
// ZWEI separate Indexe für schnelle Lookups, je nachdem welche ID genutzt wird
|
||||
// Fall 1: Suche nach Notizen für Kunde 1050
|
||||
entityIntIdx: index("wiki_pages_entity_int_idx")
|
||||
.on(table.tenantId, table.entityType, table.entityId),
|
||||
|
||||
// Fall 2: Suche nach Notizen für IoT-Device 550e84...
|
||||
entityUuidIdx: index("wiki_pages_entity_uuid_idx")
|
||||
.on(table.tenantId, table.entityType, table.entityUuid),
|
||||
})
|
||||
)
|
||||
|
||||
export const wikiPagesRelations = relations(wikiPages, ({ one, many }) => ({
|
||||
tenant: one(tenants, {
|
||||
fields: [wikiPages.tenantId],
|
||||
references: [tenants.id],
|
||||
}),
|
||||
parent: one(wikiPages, {
|
||||
fields: [wikiPages.parentId],
|
||||
references: [wikiPages.id],
|
||||
relationName: "parent_child",
|
||||
}),
|
||||
children: many(wikiPages, {
|
||||
relationName: "parent_child",
|
||||
}),
|
||||
author: one(authUsers, {
|
||||
fields: [wikiPages.createdBy],
|
||||
references: [authUsers.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export type WikiPage = typeof wikiPages.$inferSelect
|
||||
export type NewWikiPage = typeof wikiPages.$inferInsert
|
||||
@@ -29,6 +29,7 @@ import staffTimeRoutes from "./routes/staff/time";
|
||||
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
||||
import userRoutes from "./routes/auth/user";
|
||||
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||
import wikiRoutes from "./routes/wiki";
|
||||
|
||||
//Public Links
|
||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||
@@ -144,6 +145,7 @@ async function main() {
|
||||
await subApp.register(userRoutes);
|
||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||
await subApp.register(resourceRoutes);
|
||||
await subApp.register(wikiRoutes);
|
||||
|
||||
},{prefix: "/api"})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export default fp(async (server: FastifyInstance) => {
|
||||
"https://app.fedeo.de", // dein Nuxt-Frontend
|
||||
"capacitor://localhost", // dein Nuxt-Frontend
|
||||
],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS","PATCH",
|
||||
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin","Depth", "Overwrite", "Destination", "Lock-Token", "If"],
|
||||
exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
|
||||
|
||||
203
backend/src/routes/wiki.ts
Normal file
203
backend/src/routes/wiki.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, eq, isNull, asc, desc } from "drizzle-orm"
|
||||
import { wikiPages, authUsers } from "../../db/schema/"
|
||||
|
||||
// Types für Request Body & Query (statt Zod)
|
||||
interface WikiTreeQuery {
|
||||
entityType?: string
|
||||
entityId?: number // Kommt als string oder number an, je nach parser
|
||||
entityUuid?: string
|
||||
}
|
||||
|
||||
interface WikiCreateBody {
|
||||
title: string
|
||||
parentId?: string // UUID
|
||||
isFolder?: boolean
|
||||
entityType?: string
|
||||
entityId?: number
|
||||
entityUuid?: string
|
||||
}
|
||||
|
||||
interface WikiUpdateBody {
|
||||
title?: string
|
||||
content?: any // Das Tiptap JSON Object
|
||||
parentId?: string | null
|
||||
sortOrder?: number
|
||||
isFolder?: boolean
|
||||
}
|
||||
|
||||
export default async function wikiRoutes (server: FastifyInstance) {
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 1. GET /wiki/tree
|
||||
// Lädt die Struktur für die Sidebar (OHNE Content -> Schnell)
|
||||
// ---------------------------------------------------------
|
||||
server.get<{ Querystring: WikiTreeQuery }>("/wiki/tree", async (req, reply) => {
|
||||
const user = req.user
|
||||
const { entityType, entityId, entityUuid } = req.query
|
||||
|
||||
// Basis-Filter: Tenant Sicherheit
|
||||
const filters = [eq(wikiPages.tenantId, user.tenant_id)]
|
||||
|
||||
// Logik: Laden wir "Allgemein" oder "Entitäts-Spezifisch"?
|
||||
if (entityType) {
|
||||
// Spezifisch (z.B. Kunde)
|
||||
filters.push(eq(wikiPages.entityType, entityType))
|
||||
|
||||
if (entityId) {
|
||||
filters.push(eq(wikiPages.entityId, Number(entityId)))
|
||||
} else if (entityUuid) {
|
||||
filters.push(eq(wikiPages.entityUuid, entityUuid))
|
||||
}
|
||||
} else {
|
||||
// Allgemein (alles wo entityType NULL ist)
|
||||
filters.push(isNull(wikiPages.entityType))
|
||||
}
|
||||
|
||||
// Performance: Nur Metadaten laden, kein riesiges JSON-Content
|
||||
const pages = await server.db
|
||||
.select({
|
||||
id: wikiPages.id,
|
||||
parentId: wikiPages.parentId,
|
||||
title: wikiPages.title,
|
||||
isFolder: wikiPages.isFolder,
|
||||
sortOrder: wikiPages.sortOrder,
|
||||
entityType: wikiPages.entityType,
|
||||
updatedAt: wikiPages.updatedAt,
|
||||
})
|
||||
.from(wikiPages)
|
||||
.where(and(...filters))
|
||||
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title))
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2. GET /wiki/:id
|
||||
// Lädt EINEN Eintrag komplett MIT Content (für den Editor)
|
||||
// ---------------------------------------------------------
|
||||
server.get<{ Params: { id: string } }>("/wiki/:id", async (req, reply) => {
|
||||
const user = req.user
|
||||
const { id } = req.params
|
||||
|
||||
// Drizzle Query API nutzen für Relation (optional Author laden)
|
||||
const page = await server.db.query.wikiPages.findFirst({
|
||||
where: and(
|
||||
eq(wikiPages.id, id),
|
||||
eq(wikiPages.tenantId, user.tenant_id)
|
||||
),
|
||||
with: {
|
||||
author: {
|
||||
columns: {
|
||||
id: true,
|
||||
// name: true, // Falls im authUsers schema vorhanden
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!page) {
|
||||
return reply.code(404).send({ error: "Page not found" })
|
||||
}
|
||||
|
||||
return page
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3. POST /wiki
|
||||
// Erstellt neuen Eintrag
|
||||
// ---------------------------------------------------------
|
||||
server.post<{ Body: WikiCreateBody }>("/wiki", async (req, reply) => {
|
||||
const user = req.user
|
||||
const body = req.body
|
||||
|
||||
// Simple Validierung
|
||||
if (!body.title) return reply.code(400).send({ error: "Title required" })
|
||||
|
||||
// Split Logik für Polymorphie
|
||||
const hasEntity = !!body.entityType
|
||||
|
||||
const [newPage] = await server.db
|
||||
.insert(wikiPages)
|
||||
.values({
|
||||
tenantId: user.tenant_id,
|
||||
title: body.title,
|
||||
parentId: body.parentId || null, // undefined abfangen
|
||||
isFolder: body.isFolder ?? false,
|
||||
|
||||
// Polymorphe Zuweisung
|
||||
entityType: hasEntity ? body.entityType : null,
|
||||
entityId: hasEntity && body.entityId ? body.entityId : null,
|
||||
entityUuid: hasEntity && body.entityUuid ? body.entityUuid : null,
|
||||
//@ts-ignore
|
||||
createdBy: user.id,
|
||||
//@ts-ignore
|
||||
updatedBy: user.id
|
||||
})
|
||||
.returning()
|
||||
|
||||
return newPage
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4. PATCH /wiki/:id
|
||||
// Universal-Update (Inhalt, Titel, Verschieben, Sortieren)
|
||||
// ---------------------------------------------------------
|
||||
server.patch<{ Params: { id: string }; Body: WikiUpdateBody }>(
|
||||
"/wiki/:id",
|
||||
async (req, reply) => {
|
||||
const user = req.user
|
||||
const { id } = req.params
|
||||
const body = req.body
|
||||
|
||||
// 1. Prüfen ob Eintrag existiert & Tenant gehört
|
||||
const existing = await server.db.query.wikiPages.findFirst({
|
||||
where: and(eq(wikiPages.id, id), eq(wikiPages.tenantId, user.tenant_id)),
|
||||
columns: { id: true }
|
||||
})
|
||||
|
||||
if(!existing) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
// 2. Update durchführen
|
||||
const [updatedPage] = await server.db
|
||||
.update(wikiPages)
|
||||
.set({
|
||||
title: body.title, // update title if defined
|
||||
content: body.content, // update content if defined
|
||||
parentId: body.parentId, // update parent (move) if defined
|
||||
sortOrder: body.sortOrder,
|
||||
isFolder: body.isFolder,
|
||||
updatedAt: new Date(),
|
||||
//@ts-ignore
|
||||
updatedBy: user.id
|
||||
})
|
||||
.where(eq(wikiPages.id, id))
|
||||
.returning()
|
||||
|
||||
return updatedPage
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 5. DELETE /wiki/:id
|
||||
// Löscht Eintrag (DB Cascade erledigt die Kinder)
|
||||
// ---------------------------------------------------------
|
||||
server.delete<{ Params: { id: string } }>("/wiki/:id", async (req, reply) => {
|
||||
const user = req.user
|
||||
const { id } = req.params
|
||||
|
||||
const result = await server.db
|
||||
.delete(wikiPages)
|
||||
.where(and(
|
||||
eq(wikiPages.id, id),
|
||||
eq(wikiPages.tenantId, user.tenant_id) // WICHTIG: Security
|
||||
))
|
||||
.returning({ id: wikiPages.id })
|
||||
|
||||
if (result.length === 0) {
|
||||
return reply.code(404).send({ error: "Not found or not authorized" })
|
||||
}
|
||||
|
||||
return { success: true, deletedId: result[0].id }
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user