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 @@ + + + \ 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 @@ + + + + + \ No newline at end of file diff --git a/frontend/composables/useWikiTree.ts b/frontend/composables/useWikiTree.ts new file mode 100644 index 0000000..c7cddc2 --- /dev/null +++ b/frontend/composables/useWikiTree.ts @@ -0,0 +1,83 @@ +// composables/useWikiTree.ts +export interface WikiPageItem { + id: string + parentId: string | null + title: string + isFolder: boolean + sortOrder: number + entityType?: string | null + // children wird im Frontend berechnet + children?: WikiPageItem[] +} + +export const useWikiTree = () => { + const { $api } = useNuxtApp() + + // Globaler State (bleibt beim Navigieren erhalten) + const items = useState('wiki-items', () => []) + const isLoading = useState('wiki-loading', () => false) + + // --- Computed: Flat List zu Baum --- + const tree = computed(() => { + const rawItems = items.value + if (!rawItems || rawItems.length === 0) return [] + + const roots: WikiPageItem[] = [] + const lookup: Record = {} + + // 1. Kopieren & Lookup füllen + rawItems.forEach(item => { + lookup[item.id] = { ...item, children: [] } + }) + + // 2. Verknüpfen + rawItems.forEach(item => { + const node = lookup[item.id] + if (item.parentId && lookup[item.parentId]) { + lookup[item.parentId].children?.push(node) + } else { + roots.push(node) + } + }) + + // 3. Sortieren (Ordner zuerst, dann SortOrder, dann Alphabet) + const sortNodes = (nodes: WikiPageItem[]) => { + nodes.sort((a, b) => { + if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1 + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder + return a.title.localeCompare(b.title) + }) + nodes.forEach(node => { + if (node.children?.length) sortNodes(node.children) + }) + } + + sortNodes(roots) + return roots + }) + + // --- Actions --- + + /** + * Lädt den Baum. + * params kann { entityType: 'customer', entityId: 100 } enthalten. + */ + const loadTree = async (params: { entityType?: string, entityId?: number, entityUuid?: string } = {}) => { + isLoading.value = true + try { + // Wichtig: Bei GET nutzen wir 'query' für Parameter + const data = await $api('/api/wiki/tree', { + method: 'GET', + query: params + }) + items.value = data + } catch (e) { + // Fehler werden vom Plugin (Toast) behandelt, hier nur Log + console.error('Wiki Tree Load Error', e) + } finally { + isLoading.value = false + } + } + + return { tree, items, isLoading, loadTree } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 5ea1bbd..4d2d65b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,10 +46,10 @@ "@popperjs/core": "^2.11.8", "@sentry/browser": "^9.11.0", "@sentry/integrations": "^7.114.0", - "@tiptap/extension-underline": "^2.1.15", - "@tiptap/pm": "^2.1.15", - "@tiptap/starter-kit": "^2.1.15", - "@tiptap/vue-3": "^2.1.15", + "@tiptap/extension-placeholder": "^3.17.1", + "@tiptap/pm": "^3.17.1", + "@tiptap/starter-kit": "^3.17.1", + "@tiptap/vue-3": "^3.17.1", "@vicons/ionicons5": "^0.12.0", "@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-pdf-viewer/viewer": "^3.0.1", diff --git a/frontend/pages/wiki/[id].vue b/frontend/pages/wiki/[id].vue new file mode 100644 index 0000000..d0a5dff --- /dev/null +++ b/frontend/pages/wiki/[id].vue @@ -0,0 +1,126 @@ + + + + + \ No newline at end of file diff --git a/frontend/pages/wiki/index.vue b/frontend/pages/wiki/index.vue new file mode 100644 index 0000000..87ff205 --- /dev/null +++ b/frontend/pages/wiki/index.vue @@ -0,0 +1,84 @@ + + + + + \ No newline at end of file diff --git a/frontend/stores/wiki.ts b/frontend/stores/wiki.ts new file mode 100644 index 0000000..c23ae0a --- /dev/null +++ b/frontend/stores/wiki.ts @@ -0,0 +1,53 @@ +// stores/wiki.ts +import { defineStore } from 'pinia' + +export const useWikiStore = defineStore('wiki', () => { + const flatItems = ref([]) + const isLoading = ref(false) + + // Computed: Wandelt flatItems automatisch in Baum um, wenn sich Daten ändern + const tree = computed(() => buildTree(flatItems.value)) + + // Action: Baum laden + async function fetchTree(params: { entityType?: string, entityId?: number, entityUuid?: string } = {}) { + isLoading.value = true + try { + const { data } = await useFetch('/api/wiki/tree', { + query: params + }) + if (data.value) { + flatItems.value = data.value + } + } catch (e) { + console.error('Wiki tree fetch error', e) + } finally { + isLoading.value = false + } + } + + // Action: Eintrag verschieben (Drag & Drop im UI) + async function moveItem(itemId: string, newParentId: string | null, newSortOrder: number) { + // 1. Optimistic Update im State (damit es sich sofort schnell anfühlt) + const itemIndex = flatItems.value.findIndex(i => i.id === itemId) + if (itemIndex > -1) { + flatItems.value[itemIndex].parentId = newParentId + flatItems.value[itemIndex].sortOrder = newSortOrder + } + + // 2. API Call im Hintergrund + await $fetch(`/api/wiki/${itemId}`, { + method: 'PATCH', + body: { parentId: newParentId, sortOrder: newSortOrder } + }) + + // Fallback: Wenn Error, müsste man hier den alten State wiederherstellen + } + + return { + flatItems, + tree, + isLoading, + fetchTree, + moveItem + } +}) \ No newline at end of file