diff --git a/server/api/admin/wiki/[id].patch.js b/server/api/admin/wiki/[id].patch.js new file mode 100644 index 0000000..1ad9a08 --- /dev/null +++ b/server/api/admin/wiki/[id].patch.js @@ -0,0 +1,28 @@ +import * as z from 'zod' +import WikiArticle from '../../../models/wikiArticle.js' +import { connectDB } from '../../../utils/mongoose.js' + +const wikiTagsSchema = z.object({ + tags: z.array(z.string()) +}) + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + + const body = await validateBody(event, wikiTagsSchema) + const id = getRouterParam(event, 'id') + + await connectDB() + + const article = await WikiArticle.findByIdAndUpdate( + id, + { tags: body.tags }, + { new: true } + ) + + if (!article) { + throw createError({ statusCode: 404, statusMessage: 'Article not found' }) + } + + return article +}) diff --git a/server/api/admin/wiki/batch-tag.post.js b/server/api/admin/wiki/batch-tag.post.js new file mode 100644 index 0000000..be0ebef --- /dev/null +++ b/server/api/admin/wiki/batch-tag.post.js @@ -0,0 +1,55 @@ +import * as z from 'zod' +import WikiArticle from '../../../models/wikiArticle.js' +import { connectDB } from '../../../utils/mongoose.js' + +const batchTagSchema = z.object({ + articleIds: z.array(z.string()).optional(), + collection: z.string().optional(), + addTags: z.array(z.string()).optional(), + removeTags: z.array(z.string()).optional() +}) + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + + const body = await validateBody(event, batchTagSchema) + + if (!body.articleIds && !body.collection) { + throw createError({ + statusCode: 400, + statusMessage: 'Must provide either articleIds or collection' + }) + } + + if (!body.addTags?.length && !body.removeTags?.length) { + throw createError({ + statusCode: 400, + statusMessage: 'Must provide at least one of addTags or removeTags' + }) + } + + await connectDB() + + const filter = body.articleIds + ? { _id: { $in: body.articleIds } } + : { collection: body.collection } + + let modified = 0 + + if (body.addTags?.length) { + const result = await WikiArticle.updateMany(filter, { + $addToSet: { tags: { $each: body.addTags } } + }) + modified = result.modifiedCount || 0 + } + + if (body.removeTags?.length) { + const result = await WikiArticle.updateMany(filter, { + $pull: { tags: { $in: body.removeTags } } + }) + // Use the higher count if both operations ran + modified = Math.max(modified, result.modifiedCount || 0) + } + + return { modified } +}) diff --git a/server/api/admin/wiki/index.get.js b/server/api/admin/wiki/index.get.js new file mode 100644 index 0000000..b4baf5a --- /dev/null +++ b/server/api/admin/wiki/index.get.js @@ -0,0 +1,24 @@ +import WikiArticle from '../../../models/wikiArticle.js' +import { connectDB } from '../../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + await connectDB() + + const { collection, search } = getQuery(event) + + const filter = {} + if (collection) { + filter.collection = collection + } + if (search) { + filter.title = { $regex: search, $options: 'i' } + } + + const articles = await WikiArticle.find(filter) + .select('collection title tags url outlineId publishedAt') + .sort({ collection: 1, title: 1 }) + .lean() + + return articles +}) 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/tests/server/api/admin-wiki.test.js b/tests/server/api/admin-wiki.test.js new file mode 100644 index 0000000..bd70678 --- /dev/null +++ b/tests/server/api/admin-wiki.test.js @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../../../server/models/wikiArticle.js', () => ({ + default: { + find: vi.fn(), + findByIdAndUpdate: vi.fn(), + updateMany: vi.fn() + } +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) + +import WikiArticle from '../../../server/models/wikiArticle.js' +import indexHandler from '../../../server/api/admin/wiki/index.get.js' +import patchHandler from '../../../server/api/admin/wiki/[id].patch.js' +import batchTagHandler from '../../../server/api/admin/wiki/batch-tag.post.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// --- GET /api/admin/wiki --- + +describe('GET /api/admin/wiki', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + }) + + it('returns all articles with collection, title, tags', async () => { + const articles = [ + { _id: 'a1', collection: 'General', title: 'Alpha', tags: ['co-ops'], url: '/doc/alpha', outlineId: 'o1', publishedAt: new Date() }, + { _id: 'a2', collection: 'General', title: 'Beta', tags: [], url: '/doc/beta', outlineId: 'o2', publishedAt: new Date() } + ] + WikiArticle.find.mockReturnValue({ + select: () => ({ + sort: () => ({ + lean: () => Promise.resolve(articles) + }) + }) + }) + + const event = createMockEvent({ method: 'GET', path: '/api/admin/wiki' }) + const result = await indexHandler(event) + + expect(result).toEqual(articles) + expect(WikiArticle.find).toHaveBeenCalledWith({}) + }) + + it('filters by collection', async () => { + WikiArticle.find.mockReturnValue({ + select: () => ({ + sort: () => ({ + lean: () => Promise.resolve([]) + }) + }) + }) + + const event = createMockEvent({ + method: 'GET', + path: '/api/admin/wiki?collection=Guides' + }) + await indexHandler(event) + + expect(WikiArticle.find).toHaveBeenCalledWith({ collection: 'Guides' }) + }) + + it('searches by title (case-insensitive)', async () => { + WikiArticle.find.mockReturnValue({ + select: () => ({ + sort: () => ({ + lean: () => Promise.resolve([]) + }) + }) + }) + + const event = createMockEvent({ + method: 'GET', + path: '/api/admin/wiki?search=cooperative' + }) + await indexHandler(event) + + expect(WikiArticle.find).toHaveBeenCalledWith({ + title: { $regex: 'cooperative', $options: 'i' } + }) + }) + + it('requires admin auth (403)', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Admin access required' }) + ) + + const event = createMockEvent({ method: 'GET', path: '/api/admin/wiki' }) + await expect(indexHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) +}) + +// --- PATCH /api/admin/wiki/:id --- + +describe('PATCH /api/admin/wiki/:id', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + validateBody.mockImplementation(async (event) => readBody(event)) + }) + + it('sets tags on an article', async () => { + const updated = { + _id: 'a1', + title: 'Alpha', + tags: ['co-ops', 'governance'], + collection: 'General' + } + WikiArticle.findByIdAndUpdate.mockResolvedValue(updated) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/admin/wiki/a1', + body: { tags: ['co-ops', 'governance'] } + }) + // Simulate getRouterParam returning the id + event.context.params = { id: 'a1' } + + const result = await patchHandler(event) + + expect(result).toEqual(updated) + expect(WikiArticle.findByIdAndUpdate).toHaveBeenCalledWith( + 'a1', + { tags: ['co-ops', 'governance'] }, + { new: true } + ) + }) + + it('returns 404 for invalid article ID', async () => { + WikiArticle.findByIdAndUpdate.mockResolvedValue(null) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/admin/wiki/nonexistent', + body: { tags: ['test'] } + }) + event.context.params = { id: 'nonexistent' } + + await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('requires admin auth (403)', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Admin access required' }) + ) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/admin/wiki/a1', + body: { tags: ['test'] } + }) + event.context.params = { id: 'a1' } + + await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) +}) + +// --- POST /api/admin/wiki/batch-tag --- + +describe('POST /api/admin/wiki/batch-tag', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + validateBody.mockImplementation(async (event) => readBody(event)) + }) + + it('adds tags to multiple articles (add/merge semantics)', async () => { + WikiArticle.updateMany.mockResolvedValue({ modifiedCount: 3 }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/batch-tag', + body: { + articleIds: ['a1', 'a2', 'a3'], + addTags: ['governance', 'finance'] + } + }) + + const result = await batchTagHandler(event) + + expect(result).toEqual({ modified: 3 }) + expect(WikiArticle.updateMany).toHaveBeenCalledWith( + { _id: { $in: ['a1', 'a2', 'a3'] } }, + { $addToSet: { tags: { $each: ['governance', 'finance'] } } } + ) + }) + + it('removes tags from multiple articles', async () => { + WikiArticle.updateMany.mockResolvedValue({ modifiedCount: 2 }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/batch-tag', + body: { + collection: 'Guides', + removeTags: ['outdated'] + } + }) + + const result = await batchTagHandler(event) + + expect(result).toEqual({ modified: 2 }) + expect(WikiArticle.updateMany).toHaveBeenCalledWith( + { collection: 'Guides' }, + { $pull: { tags: { $in: ['outdated'] } } } + ) + }) + + it('does not duplicate existing tags on add', async () => { + // $addToSet guarantees no duplicates — verify the operator is used + WikiArticle.updateMany.mockResolvedValue({ modifiedCount: 1 }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/batch-tag', + body: { + articleIds: ['a1'], + addTags: ['co-ops'] + } + }) + + await batchTagHandler(event) + + // Confirm $addToSet is used, not $push + const call = WikiArticle.updateMany.mock.calls[0] + expect(call[1]).toHaveProperty('$addToSet') + expect(call[1]).not.toHaveProperty('$push') + }) + + it('requires admin auth (403)', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Admin access required' }) + ) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/batch-tag', + body: { + articleIds: ['a1'], + addTags: ['test'] + } + }) + + await expect(batchTagHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) +})