diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts
index 47d8bee..5478cf5 100644
--- a/backend/db/schema/index.ts
+++ b/backend/db/schema/index.ts
@@ -71,4 +71,5 @@ export * from "./vendors"
export * from "./staff_time_events"
export * from "./serialtypes"
export * from "./serialexecutions"
-export * from "./public_links"
\ No newline at end of file
+export * from "./public_links"
+export * from "./wikipages"
\ No newline at end of file
diff --git a/backend/db/schema/wikipages.ts b/backend/db/schema/wikipages.ts
new file mode 100644
index 0000000..e3b89d3
--- /dev/null
+++ b/backend/db/schema/wikipages.ts
@@ -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
\ No newline at end of file
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 7bff700..46bbacd 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -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"})
diff --git a/backend/src/plugins/cors.ts b/backend/src/plugins/cors.ts
index 7404059..3428d5d 100644
--- a/backend/src/plugins/cors.ts
+++ b/backend/src/plugins/cors.ts
@@ -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
diff --git a/backend/src/routes/wiki.ts b/backend/src/routes/wiki.ts
new file mode 100644
index 0000000..4a466c3
--- /dev/null
+++ b/backend/src/routes/wiki.ts
@@ -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 }
+ })
+}
\ No newline at end of file
diff --git a/frontend/components/wiki/TreeItem.vue b/frontend/components/wiki/TreeItem.vue
new file mode 100644
index 0000000..1e8fe40
--- /dev/null
+++ b/frontend/components/wiki/TreeItem.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+
+
Leer
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/components/wiki/WikiEditor.vue b/frontend/components/wiki/WikiEditor.vue
new file mode 100644
index 0000000..6f41f13
--- /dev/null
+++ b/frontend/components/wiki/WikiEditor.vue
@@ -0,0 +1,61 @@
+
+