From 905b5155e278d84df85ef371e8965ca522999653 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:33:06 +0100 Subject: [PATCH] feat(wiki): add Outline utility and wiki sync API --- .env.example | 5 +- nuxt.config.ts | 1 + server/api/admin/wiki/sync.post.js | 84 ++++++ server/models/wikiArticle.js | 20 ++ server/utils/outline.js | 67 +++++ tests/server/api/admin-auth-guards.test.js | 8 +- tests/server/api/wiki-sync.test.js | 283 +++++++++++++++++++++ 7 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 server/api/admin/wiki/sync.post.js create mode 100644 server/models/wikiArticle.js create mode 100644 server/utils/outline.js create mode 100644 tests/server/api/wiki-sync.test.js diff --git a/.env.example b/.env.example index d59ecce..0754ce5 100644 --- a/.env.example +++ b/.env.example @@ -27,4 +27,7 @@ BASE_URL=http://localhost:3000 # OIDC Provider (for Outline Wiki SSO) OIDC_CLIENT_ID=outline-wiki OIDC_CLIENT_SECRET= -OIDC_COOKIE_SECRET= \ No newline at end of file +OIDC_COOKIE_SECRET= + +# Outline Wiki Integration +OUTLINE_API_KEY= \ No newline at end of file diff --git a/nuxt.config.ts b/nuxt.config.ts index 8d07012..c3843cf 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -87,6 +87,7 @@ export default defineNuxtConfig({ oidcClientId: process.env.OIDC_CLIENT_ID || "outline-wiki", oidcClientSecret: process.env.OIDC_CLIENT_SECRET || "", oidcCookieSecret: process.env.OIDC_COOKIE_SECRET || "", + outlineApiKey: process.env.OUTLINE_API_KEY || "", // Public keys (available on client-side) public: { diff --git a/server/api/admin/wiki/sync.post.js b/server/api/admin/wiki/sync.post.js new file mode 100644 index 0000000..c0b9dd2 --- /dev/null +++ b/server/api/admin/wiki/sync.post.js @@ -0,0 +1,84 @@ +import WikiArticle from '../../../models/wikiArticle.js' +import { connectDB } from '../../../utils/mongoose.js' +import { requireAdmin } from '../../../utils/auth.js' +import { fetchAllDocuments, extractSummary } from '../../../utils/outline.js' + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + + // Fetch ALL documents first — if this fails, no DB changes happen + let documents + try { + documents = await fetchAllDocuments() + } catch (err) { + console.error('[wiki-sync] Outline fetch failed:', err) + throw createError({ + statusCode: err.statusCode || 502, + statusMessage: err.statusMessage || 'Failed to fetch documents from Outline' + }) + } + + await connectDB() + + const fetchedOutlineIds = new Set(documents.map((doc) => doc.id)) + + // Get all existing articles for comparison + const existing = await WikiArticle.find({}, 'outlineId publishedAt') + const existingByOutlineId = new Map( + existing.map((a) => [a.outlineId, a]) + ) + + let created = 0 + let updated = 0 + let deleted = 0 + let errors = 0 + + // Upsert each fetched document + for (const doc of documents) { + try { + const articleData = { + title: doc.title, + collection: doc.collection?.name || null, + url: doc.url, + summary: extractSummary(doc.text), + publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : new Date(doc.createdAt), + permission: doc.permission || null, + lastSyncedAt: new Date(), + outlineUpdatedAt: doc.updatedAt ? new Date(doc.updatedAt) : null + } + + const result = await WikiArticle.findOneAndUpdate( + { outlineId: doc.id }, + { $set: articleData }, + { upsert: true, new: true, rawResult: true } + ) + + if (result.lastErrorObject?.updatedExisting) { + updated++ + } else { + created++ + } + } catch (err) { + console.error(`[wiki-sync] Error upserting doc ${doc.id}:`, err) + errors++ + } + } + + // Soft-delete articles no longer in Outline + for (const [outlineId, article] of existingByOutlineId) { + if (!fetchedOutlineIds.has(outlineId) && article.publishedAt !== null) { + try { + await WikiArticle.findOneAndUpdate( + { outlineId }, + { $set: { publishedAt: null, lastSyncedAt: new Date() } } + ) + deleted++ + } catch (err) { + console.error(`[wiki-sync] Error soft-deleting ${outlineId}:`, err) + errors++ + } + } + } + + return { created, updated, deleted, errors } +}) diff --git a/server/models/wikiArticle.js b/server/models/wikiArticle.js new file mode 100644 index 0000000..275ba7d --- /dev/null +++ b/server/models/wikiArticle.js @@ -0,0 +1,20 @@ +import mongoose from 'mongoose' + +const wikiArticleSchema = new mongoose.Schema( + { + outlineId: { type: String, unique: true, required: true }, + title: { type: String, required: true }, + collection: String, + url: { type: String, required: true }, + summary: String, + tags: [{ type: String }], + publishedAt: Date, + permission: String, + lastSyncedAt: Date, + outlineUpdatedAt: Date + }, + { timestamps: true } +) + +export default mongoose.models.WikiArticle || + mongoose.model('WikiArticle', wikiArticleSchema) diff --git a/server/utils/outline.js b/server/utils/outline.js new file mode 100644 index 0000000..fb85f34 --- /dev/null +++ b/server/utils/outline.js @@ -0,0 +1,67 @@ +const OUTLINE_API_BASE = 'https://wiki.ghostguild.org/api' + +/** + * Strip HTML tags and truncate to 200 characters at a word boundary. + * If the stripped text is <= 200 chars, returns it as-is. + */ +export function extractSummary(text) { + if (!text) return '' + + const stripped = text.replace(/<[^>]*>/g, '').trim() + + if (stripped.length <= 200) return stripped + + const truncated = stripped.slice(0, 200) + const lastSpace = truncated.lastIndexOf(' ') + + // If no space found at all, return the full 200 chars (single long word) + if (lastSpace <= 0) return truncated + + return truncated.slice(0, lastSpace) +} + +/** + * Fetch all documents from Outline wiki, paginating through all pages. + * Throws on any page failure — caller is responsible for abort logic. + */ +export async function fetchAllDocuments() { + const config = useRuntimeConfig() + const apiKey = config.outlineApiKey + + if (!apiKey) { + throw createError({ + statusCode: 500, + statusMessage: 'Outline API key not configured' + }) + } + + const documents = [] + let path = '/documents.list' + + while (path) { + const response = await fetch(`${OUTLINE_API_BASE}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify({ limit: 25 }) + }) + + if (!response.ok) { + const errorText = await response.text() + console.error(`[outline] POST ${path} ${response.status} ${errorText}`) + throw createError({ + statusCode: response.status, + statusMessage: 'Outline API error' + }) + } + + const data = await response.json() + documents.push(...(data.data || [])) + + path = data.pagination?.nextPath || null + } + + return documents +} diff --git a/tests/server/api/admin-auth-guards.test.js b/tests/server/api/admin-auth-guards.test.js index 0bcb6e7..1770dec 100644 --- a/tests/server/api/admin-auth-guards.test.js +++ b/tests/server/api/admin-auth-guards.test.js @@ -45,6 +45,9 @@ const adminRoutes = { 'alerts/dismiss.post.js', 'alerts/dismissed.get.js', 'alerts/restore.post.js' + ], + 'admin/wiki/': [ + 'wiki/sync.post.js' ] } @@ -72,7 +75,10 @@ const businessLogicPatterns = [ 'computeAllAlerts(', 'AdminAlertDismissal.findOneAndUpdate', 'AdminAlertDismissal.find', - 'AdminAlertDismissal.deleteMany' + 'AdminAlertDismissal.deleteMany', + 'WikiArticle.find', + 'WikiArticle.findOneAndUpdate', + 'fetchAllDocuments(' ] describe('Admin endpoint auth guards', () => { diff --git a/tests/server/api/wiki-sync.test.js b/tests/server/api/wiki-sync.test.js new file mode 100644 index 0000000..ce19882 --- /dev/null +++ b/tests/server/api/wiki-sync.test.js @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock dependencies before imports +vi.mock('../../../server/models/wikiArticle.js', () => ({ + default: { + find: vi.fn(), + findOneAndUpdate: vi.fn() + } +})) +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +vi.mock('../../../server/utils/auth.js', () => ({ + requireAdmin: vi.fn() +})) +vi.mock('../../../server/utils/outline.js', () => ({ + fetchAllDocuments: vi.fn(), + extractSummary: vi.fn((text) => text || '') +})) + +import WikiArticle from '../../../server/models/wikiArticle.js' +import { requireAdmin } from '../../../server/utils/auth.js' +import { fetchAllDocuments, extractSummary } from '../../../server/utils/outline.js' +import syncHandler from '../../../server/api/admin/wiki/sync.post.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// Also test extractSummary directly (unmocked) +import { extractSummary as realExtractSummary } from '../../../server/utils/outline.js' + +function makeOutlineDoc(overrides = {}) { + return { + id: 'doc-1', + title: 'Test Article', + url: '/doc/test-article', + text: 'Some article content', + publishedAt: '2026-01-15T00:00:00Z', + createdAt: '2026-01-10T00:00:00Z', + updatedAt: '2026-01-15T00:00:00Z', + permission: 'read', + collection: { name: 'General' }, + ...overrides + } +} + +describe('wiki sync endpoint', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + WikiArticle.find.mockResolvedValue([]) + }) + + it('requires admin auth (403)', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Admin access required' }) + ) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + await expect(syncHandler(event)).rejects.toMatchObject({ + statusCode: 403, + statusMessage: 'Admin access required' + }) + + expect(requireAdmin).toHaveBeenCalledWith(event) + expect(fetchAllDocuments).not.toHaveBeenCalled() + }) + + it('upserts new articles from Outline', async () => { + const docs = [ + makeOutlineDoc({ id: 'doc-1', title: 'First Article' }), + makeOutlineDoc({ id: 'doc-2', title: 'Second Article' }) + ] + fetchAllDocuments.mockResolvedValue(docs) + WikiArticle.find.mockResolvedValue([]) + WikiArticle.findOneAndUpdate.mockResolvedValue({ + lastErrorObject: { updatedExisting: false } + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result.created).toBe(2) + expect(result.updated).toBe(0) + expect(result.deleted).toBe(0) + expect(result.errors).toBe(0) + expect(WikiArticle.findOneAndUpdate).toHaveBeenCalledTimes(2) + expect(WikiArticle.findOneAndUpdate).toHaveBeenCalledWith( + { outlineId: 'doc-1' }, + expect.objectContaining({ + $set: expect.objectContaining({ title: 'First Article' }) + }), + { upsert: true, new: true, rawResult: true } + ) + }) + + it('updates changed titles and URLs', async () => { + const docs = [ + makeOutlineDoc({ id: 'doc-1', title: 'Updated Title', url: '/doc/new-url' }) + ] + fetchAllDocuments.mockResolvedValue(docs) + WikiArticle.find.mockResolvedValue([ + { outlineId: 'doc-1', publishedAt: new Date('2026-01-15') } + ]) + WikiArticle.findOneAndUpdate.mockResolvedValue({ + lastErrorObject: { updatedExisting: true } + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result.updated).toBe(1) + expect(result.created).toBe(0) + expect(WikiArticle.findOneAndUpdate).toHaveBeenCalledWith( + { outlineId: 'doc-1' }, + expect.objectContaining({ + $set: expect.objectContaining({ + title: 'Updated Title', + url: '/doc/new-url' + }) + }), + { upsert: true, new: true, rawResult: true } + ) + }) + + it('soft-deletes removed articles (publishedAt set to null)', async () => { + fetchAllDocuments.mockResolvedValue([ + makeOutlineDoc({ id: 'doc-1' }) + ]) + WikiArticle.find.mockResolvedValue([ + { outlineId: 'doc-1', publishedAt: new Date('2026-01-15') }, + { outlineId: 'doc-2', publishedAt: new Date('2026-01-10') } + ]) + WikiArticle.findOneAndUpdate.mockResolvedValue({ + lastErrorObject: { updatedExisting: true } + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result.deleted).toBe(1) + // The second call should be the soft-delete for doc-2 + const softDeleteCall = WikiArticle.findOneAndUpdate.mock.calls.find( + (call) => call[0].outlineId === 'doc-2' + ) + expect(softDeleteCall).toBeTruthy() + expect(softDeleteCall[1].$set.publishedAt).toBeNull() + }) + + it('restores re-published articles', async () => { + const docs = [ + makeOutlineDoc({ + id: 'doc-1', + publishedAt: '2026-02-01T00:00:00Z' + }) + ] + fetchAllDocuments.mockResolvedValue(docs) + // doc-1 was previously soft-deleted (publishedAt: null) + WikiArticle.find.mockResolvedValue([ + { outlineId: 'doc-1', publishedAt: null } + ]) + WikiArticle.findOneAndUpdate.mockResolvedValue({ + lastErrorObject: { updatedExisting: true } + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result.updated).toBe(1) + expect(result.deleted).toBe(0) + // The upsert should set publishedAt from the Outline document + const upsertCall = WikiArticle.findOneAndUpdate.mock.calls.find( + (call) => call[0].outlineId === 'doc-1' + ) + expect(upsertCall[1].$set.publishedAt).toEqual(new Date('2026-02-01T00:00:00Z')) + }) + + it('aborts on mid-pagination API error with no partial writes', async () => { + fetchAllDocuments.mockRejectedValue( + createError({ statusCode: 502, statusMessage: 'Outline API error' }) + ) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + await expect(syncHandler(event)).rejects.toMatchObject({ + statusCode: 502 + }) + + // No DB writes should have occurred + expect(WikiArticle.findOneAndUpdate).not.toHaveBeenCalled() + expect(WikiArticle.find).not.toHaveBeenCalled() + }) + + it('returns counts in response', async () => { + const docs = [ + makeOutlineDoc({ id: 'doc-1' }), + makeOutlineDoc({ id: 'doc-2' }), + makeOutlineDoc({ id: 'doc-3' }) + ] + fetchAllDocuments.mockResolvedValue(docs) + WikiArticle.find.mockResolvedValue([ + { outlineId: 'doc-1', publishedAt: new Date() }, + { outlineId: 'doc-4', publishedAt: new Date() } + ]) + // doc-1 exists (update), doc-2 and doc-3 are new (create), doc-4 is gone (delete) + WikiArticle.findOneAndUpdate + .mockResolvedValueOnce({ lastErrorObject: { updatedExisting: true } }) // doc-1 update + .mockResolvedValueOnce({ lastErrorObject: { updatedExisting: false } }) // doc-2 create + .mockResolvedValueOnce({ lastErrorObject: { updatedExisting: false } }) // doc-3 create + .mockResolvedValue({}) // doc-4 soft-delete + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result).toEqual({ + created: 2, + updated: 1, + deleted: 1, + errors: 0 + }) + }) +}) + +// Test extractSummary directly — reimport unmocked +describe('extractSummary', () => { + // We need the real implementation, so we dynamically import it + // to bypass the module mock above + let realExtract + + beforeEach(async () => { + const mod = await vi.importActual('../../../server/utils/outline.js') + realExtract = mod.extractSummary + }) + + it('returns empty string for falsy input', () => { + expect(realExtract('')).toBe('') + expect(realExtract(null)).toBe('') + expect(realExtract(undefined)).toBe('') + }) + + it('strips HTML tags', () => { + expect(realExtract('

Hello world

')).toBe('Hello world') + }) + + it('returns text as-is when <= 200 chars after stripping', () => { + const short = 'A short summary.' + expect(realExtract(short)).toBe(short) + }) + + it('truncates at word boundary without mid-word cuts', () => { + // Build a string that is longer than 200 chars + const words = 'word '.repeat(50).trim() // 249 chars: "word word word..." + const result = realExtract(words) + expect(result.length).toBeLessThanOrEqual(200) + // Should not end mid-word + expect(result).not.toMatch(/\S$\S/) + // Should end with a complete word + expect(result.endsWith('word')).toBe(true) + }) +})