diff --git a/lib/api/index.js b/lib/api/index.js index 027206a..7a2172d 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -15,6 +15,7 @@ import { router as sourceDocsRouter, resourcesScopedRouter as sourceDocsByResour import { router as agentsRouter, tokensRouter as agentTokensRouter } from './routes/agents.js'; import { router as conversationsRouter } from './routes/conversations.js'; import { conversationsScopedRouter as messagesByConvRouter } from './routes/messages.js'; +import { router as tagsRouter, entityScopedRouter as tagsByEntityRouter } from './routes/tags.js'; export function mountApi(app) { const api = Router(); @@ -37,6 +38,8 @@ export function mountApi(app) { api.use('/agent-tokens', agentTokensRouter); api.use('/conversations', conversationsRouter); api.use('/conversations/:conversation_id/messages', messagesByConvRouter); + api.use('/tags', tagsRouter); + api.use('/:entity_type/:entity_id/tags', tagsByEntityRouter); api.use((_req, _res, next) => next(new NotFoundError('route not found'))); diff --git a/lib/api/routes/tags.js b/lib/api/routes/tags.js new file mode 100644 index 0000000..4179f71 --- /dev/null +++ b/lib/api/routes/tags.js @@ -0,0 +1,64 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import * as repo from '../../db/repos/tags.js'; +import { validate } from '../validate.js'; +import { requireWrite } from '../cap.js'; +import { asyncWrap } from '../errors.js'; + +const ENTITY_TYPES = ['space','project','task','page','ref','resource','source_doc','conversation']; + +const upsertSchema = z.object({ + name: z.string().min(1).max(64), + description: z.string().nullable().optional(), + color: z.string().nullable().optional() +}); + +const attachSchema = z.object({ tag_id: z.string().uuid() }); + +const entityParams = z.object({ + entity_type: z.enum(ENTITY_TYPES), + entity_id: z.string().uuid() +}); + +const entityTagParams = entityParams.extend({ tag_id: z.string().uuid() }); + +export const router = Router(); +export const entityScopedRouter = Router({ mergeParams: true }); + +router.get('/', asyncWrap(async (_req, res) => { res.json(await repo.list()); })); + +router.post('/', + requireWrite('tag'), + validate({ body: upsertSchema }), + asyncWrap(async (req, res) => { + const row = await repo.upsert(req.body.name, { + description: req.body.description, color: req.body.color + }); + res.status(201).json(row); + }) +); + +entityScopedRouter.get('/', + validate({ params: entityParams }), + asyncWrap(async (req, res) => { + res.json(await repo.listForEntity(req.params.entity_type, req.params.entity_id)); + }) +); + +entityScopedRouter.post('/', + requireWrite('tag'), + validate({ params: entityParams, body: attachSchema }), + asyncWrap(async (req, res) => { + await repo.attach(req.params.entity_type, req.params.entity_id, req.body.tag_id); + res.status(204).end(); + }) +); + +entityScopedRouter.delete('/:tag_id', + requireWrite('tag'), + validate({ params: entityTagParams }), + asyncWrap(async (req, res) => { + await repo.detach(req.params.entity_type, req.params.entity_id, req.params.tag_id); + res.status(204).end(); + }) +); diff --git a/tests/api/tags.test.js b/tests/api/tags.test.js new file mode 100644 index 0000000..7ffc989 --- /dev/null +++ b/tests/api/tags.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import request from 'supertest'; +import { setup } from './helpers.js'; +import * as spacesRepo from '../../lib/db/repos/spaces.js'; + +let app, ownerHeaders, space; +const owner = { kind: 'user', id: null }; + +beforeAll(async () => { ({ app, ownerHeaders } = await setup()); }); +beforeEach(async () => { + space = await spacesRepo.create({ slug: `s-${Date.now()}-${Math.random().toString(36).slice(2,5)}`, name: 'S' }, owner); +}); + +describe('tags routes', () => { + it('POST upserts (idempotent)', async () => { + const a = await request(app).post('/api/tags').set(ownerHeaders).send({ name: 'urgent' }); + const b = await request(app).post('/api/tags').set(ownerHeaders).send({ name: 'urgent', color: '#f00' }); + expect(a.body.id).toBe(b.body.id); + expect(b.body.color).toBe('#f00'); + }); + + it('attach + list-for-entity + detach', async () => { + const page = await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'pg', title: 'PG' }); + const tag = await request(app).post('/api/tags').set(ownerHeaders).send({ name: 'wip' }); + const attach = await request(app) + .post(`/api/page/${page.body.id}/tags`).set(ownerHeaders) + .send({ tag_id: tag.body.id }); + expect(attach.status).toBe(204); + const list = await request(app).get(`/api/page/${page.body.id}/tags`).set(ownerHeaders); + expect(list.body.length).toBe(1); + expect(list.body[0].name).toBe('wip'); + const det = await request(app) + .delete(`/api/page/${page.body.id}/tags/${tag.body.id}`).set(ownerHeaders); + expect(det.status).toBe(204); + const after = await request(app).get(`/api/page/${page.body.id}/tags`).set(ownerHeaders); + expect(after.body.length).toBe(0); + }); + + it('attach is idempotent', async () => { + const page = await request(app).post(`/api/spaces/${space.id}/pages`).set(ownerHeaders) + .send({ slug: 'pg2', title: 'PG2' }); + const tag = await request(app).post('/api/tags').set(ownerHeaders).send({ name: 'x' }); + await request(app).post(`/api/page/${page.body.id}/tags`).set(ownerHeaders).send({ tag_id: tag.body.id }); + const dup = await request(app).post(`/api/page/${page.body.id}/tags`).set(ownerHeaders) + .send({ tag_id: tag.body.id }); + expect(dup.status).toBe(204); + }); + + it('unknown entity_type → 400', async () => { + const res = await request(app) + .get(`/api/widget/00000000-0000-0000-0000-000000000000/tags`).set(ownerHeaders); + expect(res.status).toBe(400); + }); +});