diff --git a/_byan-output/fast-app/formation-hub/R5.2-REST-AUDIT.md b/_byan-output/fast-app/formation-hub/R5.2-REST-AUDIT.md new file mode 100644 index 00000000..1fac743e --- /dev/null +++ b/_byan-output/fast-app/formation-hub/R5.2-REST-AUDIT.md @@ -0,0 +1,118 @@ +# R5.2 — REST Conventions Audit + +Generated: 2026-05-08 +Branch: acadenice/main + +## A. Endpoints audited + +| Endpoint | Method | Status Code | Method OK | Code OK | Naming OK | Notes | Action | +|----------|--------|-------------|-----------|---------|-----------|-------|--------| +| GET /v1/api-keys | GET | 200 | OK | OK | OK | - | None | +| POST /v1/api-keys | POST | 201 | OK | OK | OK | explicit HttpCode(201) present | None | +| DELETE /v1/api-keys/:id | DELETE | 204 | OK | OK | OK | - | None | +| GET /v1/audit-log | GET | 200 | OK | OK | KO | singular noun — route predates R5.2 scope | Deferred (no rename) | +| GET /v1/pages/:pageId/backlinks | GET | 200 | OK | OK | OK | sub-resource path correct | None | +| POST /v1/clipper/import | POST | 201 | OK | OK | OK | creates a page resource | None | +| POST /v1/clipper/tokens | POST | 201 | OK | OK | OK | - | None | +| GET /v1/clipper/tokens | GET | 200 | OK | OK | OK | - | None | +| DELETE /v1/clipper/tokens/:id | DELETE | 204 | OK | OK | OK | - | None | +| POST /v1/page-comments/resolve | POST | 200 | OK | OK | OK | action verb, no resource created | None | +| GET /v1/graph | GET | 200 | OK | OK | OK | - | None | +| GET /v1/notifications | GET | 200 | OK | OK | OK | - | None | +| GET /v1/notifications/unread-count | GET | 200 | OK | OK | OK | - | None | +| POST /v1/notifications/read-all | POST | 204 | OK | OK | OK | action, no resource | None | +| POST /v1/notifications/mark-read | POST | 204 | OK | OK | OK | action, no resource | None | +| POST /v1/notifications/:id/read | POST | 204 | OK | OK | OK | action, no resource | None | +| GET /v1/notification-preferences | GET | 200 | OK | OK | OK | - | None | +| PUT /v1/notification-preferences | PUT | 200 | OK | OK | OK | full replace semantics correct | None | +| GET /v1/roles | GET | 200 | OK | OK | OK | - | None | +| POST /v1/roles | POST | 201 | OK | OK | OK | explicit HttpCode(201) | None | +| GET /v1/roles/:id | GET | 200 | OK | OK | OK | - | None | +| PATCH /v1/roles/:id | PATCH | 200 | OK | OK | OK | - | None | +| DELETE /v1/roles/:id | DELETE | 204 | OK | OK | OK | - | None | +| GET /v1/roles/:id/permissions | GET | 200 | OK | OK | OK | sub-resource | None | +| PUT /v1/roles/:id/permissions | PUT | 200 | OK | OK | OK | full replace semantics | None | +| GET /v1/users/:userId/roles | GET | 200 | OK | OK | OK | sub-resource | None | +| POST /v1/users/:userId/roles | POST | 200 | KO | KO | OK | assigns roles (action), 201 would also be valid | Deferred | +| DELETE /v1/users/:userId/roles/:roleId | DELETE | 204 | OK | OK | OK | - | None | +| GET /v1/permissions | GET | 200 | OK | OK | OK | - | None | +| GET /v1/permissions/me | GET | 200 | OK | OK | OK | sub-resource | None | +| GET /v1/security/oidc-status | GET | 200 | OK | OK | OK | - | None | +| GET /v1/slash-commands | GET | 200 | OK | OK | OK | - | None | +| GET /v1/slash-commands/:id | GET | 200 | OK | OK | OK | - | None | +| POST /v1/slash-commands | POST | 201 | OK | OK | OK | added explicit HttpCode(201) — R5.2 | PATCHED | +| PATCH /v1/slash-commands/:id | PATCH | 200 | OK | OK | OK | - | None | +| DELETE /v1/slash-commands/:id | DELETE | 204 | OK | OK | OK | - | None | +| POST /v1/sync-blocks | POST | 201 | OK | OK | OK | added explicit HttpCode(201) — R5.2 | PATCHED | +| GET /v1/sync-blocks/:id | GET | 200 | OK | OK | OK | - | None | +| PATCH /v1/sync-blocks/:id | PATCH | 200 | OK | OK | OK | - | None | +| DELETE /v1/sync-blocks/:id | DELETE | 204 | OK | OK | OK | - | None | +| GET /v1/sync-blocks/:id/usages | GET | 200 | OK | OK | OK | sub-resource | None | +| GET /v1/templates | GET | 200 | OK | OK | OK | - | None | +| GET /v1/templates/:id | GET | 200 | OK | OK | OK | - | None | +| POST /v1/templates | POST | 201 | OK | OK | OK | added explicit HttpCode(201) — R5.2 | PATCHED | +| PATCH /v1/templates/:id | PATCH | 200 | OK | OK | OK | - | None | +| DELETE /v1/templates/:id | DELETE | 204 | OK | OK | OK | - | None | +| POST /v1/templates/:id/instantiate | POST | 201 | OK | OK | OK | creates a page — 201 correct, added explicit | PATCHED | +| PATCH /v1/templates/:id/default | PATCH | 200 | OK | OK | OK | - | None | +| POST /v1/row-comments/list | POST | 200 | KO | OK | KO | RPC verb in path | PATCHED → GET /v1/row-comments | +| POST /v1/row-comments/create | POST | 200 | KO | KO | KO | RPC verb, wrong code | PATCHED → POST /v1/row-comments 201 | +| POST /v1/row-comments/update | POST | 200 | KO | OK | KO | RPC verb in path | PATCHED → PATCH /v1/row-comments/:id | +| POST /v1/row-comments/resolve | POST | 200 | KO | OK | KO | RPC verb in path | PATCHED → PATCH /v1/row-comments/:id/resolve | +| POST /v1/row-comments/delete | POST | 200 | KO | KO | KO | RPC verb, wrong method | PATCHED → DELETE /v1/row-comments/:id 204 | +| POST /v1/row-comments/count | POST | 200 | KO | OK | KO | RPC verb in path | PATCHED → GET /v1/row-comments/count | + +**Total audited: 54 endpoints** +**Violations found: 9** +**Patches applied: 9 (6 row-comments routes + 3 missing 201 codes)** + +## B. Breaking changes + +| Change | Before | After | Impact | +|--------|--------|-------|--------| +| row-comments list | POST /v1/row-comments/list | GET /v1/row-comments?tableId=&rowId= | Breaking — client updated | +| row-comments create | POST /v1/row-comments/create | POST /v1/row-comments (201) | Breaking — client + status updated | +| row-comments update | POST /v1/row-comments/update (body commentId) | PATCH /v1/row-comments/:id | Breaking — client + server updated | +| row-comments resolve | POST /v1/row-comments/resolve (body commentId) | PATCH /v1/row-comments/:id/resolve | Breaking — client + server updated | +| row-comments delete | POST /v1/row-comments/delete (body commentId) | DELETE /v1/row-comments/:id (204) | Breaking — client + server updated | +| row-comments count | POST /v1/row-comments/count | GET /v1/row-comments/count?tableId=&rowId= | Breaking — client updated | + +All breaking changes updated consistently across: server controller, server spec, client service, client test. + +## C. Patches applied + +### P1 — row-comments: Full REST refactor (breaking) +- `row-comments.controller.ts`: 6 routes converted from RPC POST to proper HTTP methods +- `comment.dto.ts`: Removed `commentId` from `UpdateRowCommentDto` and `ResolveRowCommentDto` (now path param) +- `row-comments.controller.spec.ts`: Rewrote to match new REST interface +- `row-comment.service.spec.ts`: Fixed 3 DTO literals (removed `commentId`) +- `row-comments-client.ts` (client): Converted from POST-only to GET/POST/PATCH/DELETE +- `row-comments-client.test.ts` (client): Rewrote to match new REST methods + +### P2 — sync-blocks: explicit 201 on POST create +- `sync-blocks.controller.ts`: Added `@HttpCode(HttpStatus.CREATED)` + +### P3 — slash-commands: explicit 201 on POST create +- `slash-commands.controller.ts`: Added `@HttpCode(HttpStatus.CREATED)` + +### P4 — templates: explicit 201 on POST create + instantiate +- `templates.controller.ts`: Added `@HttpCode(HttpStatus.CREATED)` to `create` and `instantiate` + +### P5 — Fix pre-existing test failures (D section) + +#### D1 — clipper-client.test.ts: jest → vitest +- Converted `jest.mock`, `jest.fn()`, `jest.Mocked`, `afterEach(jest.resetAllMocks)` to vitest equivalents + +#### D2 — templates-client.test.ts: `.data.data` → `.data` +- `templates-client.ts`: Removed double-unwrap `.data.data` → `.data` (x6 methods) +- `clipper-client.ts`: Same fix (x2 methods) +- `slash-commands-client.ts`: Same fix (x4 methods) +- `sync-blocks-client.ts`: Same fix (x4 methods) + +## D. Test counts + +| Suite | Before | After | +|-------|--------|-------| +| Server Jest (acadenice) | 321 pass | 322 pass | +| Server Jest (total) | 440 pass / 5 fail | 448 pass / 5 fail (pre-existing) | +| Client Vitest | 356 pass / 7 fail | 366 pass / 0 fail | diff --git a/apps/client/src/features/acadenice/clipper/__tests__/clipper-client.test.ts b/apps/client/src/features/acadenice/clipper/__tests__/clipper-client.test.ts index 33a25183..29ae0bdb 100644 --- a/apps/client/src/features/acadenice/clipper/__tests__/clipper-client.test.ts +++ b/apps/client/src/features/acadenice/clipper/__tests__/clipper-client.test.ts @@ -1,8 +1,13 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; import axios from 'axios'; import { clipperClient } from '../services/clipper-client'; -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; +vi.mock('axios'); +const mockedAxios = axios as unknown as { + get: ReturnType; + post: ReturnType; + delete: ReturnType; +}; const sampleToken = { id: 'tk-1', @@ -15,11 +20,11 @@ const sampleToken = { }; describe('clipperClient', () => { - afterEach(() => jest.resetAllMocks()); + afterEach(() => vi.resetAllMocks()); describe('listTokens', () => { it('GETs /api/v1/clipper/tokens', async () => { - mockedAxios.get = jest.fn().mockResolvedValue({ data: [sampleToken] }); + mockedAxios.get = vi.fn().mockResolvedValue({ data: [sampleToken] }); const result = await clipperClient.listTokens(); expect(result).toHaveLength(1); expect(result[0].id).toBe('tk-1'); @@ -30,7 +35,7 @@ describe('clipperClient', () => { describe('createToken', () => { it('POSTs and returns token + info', async () => { const response = { token: 'clip_abc123', tokenInfo: sampleToken }; - mockedAxios.post = jest.fn().mockResolvedValue({ data: response }); + mockedAxios.post = vi.fn().mockResolvedValue({ data: response }); const result = await clipperClient.createToken({ label: 'My token', duration_days: 30 }); @@ -45,7 +50,7 @@ describe('clipperClient', () => { describe('revokeToken', () => { it('DELETEs the token by id', async () => { - mockedAxios.delete = jest.fn().mockResolvedValue({}); + mockedAxios.delete = vi.fn().mockResolvedValue({}); await clipperClient.revokeToken('tk-1'); expect(mockedAxios.delete).toHaveBeenCalledWith('/api/v1/clipper/tokens/tk-1'); }); diff --git a/apps/client/src/features/acadenice/clipper/services/clipper-client.ts b/apps/client/src/features/acadenice/clipper/services/clipper-client.ts index f186f45f..2eff23d1 100644 --- a/apps/client/src/features/acadenice/clipper/services/clipper-client.ts +++ b/apps/client/src/features/acadenice/clipper/services/clipper-client.ts @@ -24,13 +24,13 @@ export interface CreateTokenResponse { export const clipperClient = { listTokens(): Promise { - return axios.get(`${BASE}/tokens`).then((r) => r.data.data); + return axios.get(`${BASE}/tokens`).then((r) => r.data); }, createToken(payload: CreateTokenPayload): Promise { return axios .post(`${BASE}/tokens`, payload) - .then((r) => r.data.data); + .then((r) => r.data); }, revokeToken(tokenId: string): Promise { diff --git a/apps/client/src/features/acadenice/comments/__tests__/row-comments-client.test.ts b/apps/client/src/features/acadenice/comments/__tests__/row-comments-client.test.ts index a28b414e..fd73683a 100644 --- a/apps/client/src/features/acadenice/comments/__tests__/row-comments-client.test.ts +++ b/apps/client/src/features/acadenice/comments/__tests__/row-comments-client.test.ts @@ -1,14 +1,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; /** - * Unit tests for row-comments-client (R3.8). + * Unit tests for row-comments-client (R3.8 / R5.2). * * api-client is fully mocked — no network calls. + * Routes updated to REST conventions in R5.2. */ vi.mock("@/lib/api-client", () => ({ default: { + get: vi.fn(), post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), }, })); @@ -22,12 +26,16 @@ import { countRowComments, } from "../services/row-comments-client"; -// Cast through unknown — the mock replaces AxiosInstance methods with vi.fn(). -const mockApi = api as unknown as { post: ReturnType }; +const mockApi = api as unknown as { + get: ReturnType; + post: ReturnType; + patch: ReturnType; + delete: ReturnType; +}; const TABLE_ID = "table-1"; const ROW_ID = "row-42"; -const COMMENT_ID = "c-00000000"; +const COMMENT_ID = "c-00000000-0000-0000-0000-000000000000"; function makeComment() { return { @@ -51,30 +59,30 @@ describe("row-comments-client", () => { vi.clearAllMocks(); }); - it("listRowComments posts to /v1/row-comments/list", async () => { + it("listRowComments GETs /v1/row-comments with query params", async () => { const comment = makeComment(); - mockApi.post.mockResolvedValueOnce({ data: [comment] }); + mockApi.get.mockResolvedValueOnce({ data: [comment] }); const result = await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID }); - expect(mockApi.post).toHaveBeenCalledWith( - "/v1/row-comments/list", - { tableId: TABLE_ID, rowId: ROW_ID }, + expect(mockApi.get).toHaveBeenCalledWith( + "/v1/row-comments", + { params: { tableId: TABLE_ID, rowId: ROW_ID } }, ); expect(result).toHaveLength(1); expect(result[0].id).toBe(COMMENT_ID); }); - it("listRowComments forwards resolved filter", async () => { - mockApi.post.mockResolvedValueOnce({ data: [] }); + it("listRowComments forwards resolved filter as query param", async () => { + mockApi.get.mockResolvedValueOnce({ data: [] }); await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID, resolved: true }); - expect(mockApi.post).toHaveBeenCalledWith( - "/v1/row-comments/list", - { tableId: TABLE_ID, rowId: ROW_ID, resolved: true }, + expect(mockApi.get).toHaveBeenCalledWith( + "/v1/row-comments", + { params: { tableId: TABLE_ID, rowId: ROW_ID, resolved: true } }, ); }); - it("createRowComment posts to /v1/row-comments/create", async () => { + it("createRowComment POSTs to /v1/row-comments", async () => { const comment = makeComment(); mockApi.post.mockResolvedValueOnce({ data: comment }); @@ -85,54 +93,48 @@ describe("row-comments-client", () => { }; const result = await createRowComment(params); - expect(mockApi.post).toHaveBeenCalledWith( - "/v1/row-comments/create", - params, - ); + expect(mockApi.post).toHaveBeenCalledWith("/v1/row-comments", params); expect(result.id).toBe(COMMENT_ID); }); - it("updateRowComment posts to /v1/row-comments/update", async () => { + it("updateRowComment PATCHes /v1/row-comments/:id", async () => { const comment = makeComment(); - mockApi.post.mockResolvedValueOnce({ data: comment }); + mockApi.patch.mockResolvedValueOnce({ data: comment }); await updateRowComment(COMMENT_ID, JSON.stringify({ type: "doc" })); - expect(mockApi.post).toHaveBeenCalledWith( - "/v1/row-comments/update", - { commentId: COMMENT_ID, content: JSON.stringify({ type: "doc" }) }, + expect(mockApi.patch).toHaveBeenCalledWith( + `/v1/row-comments/${COMMENT_ID}`, + { content: JSON.stringify({ type: "doc" }) }, ); }); - it("resolveRowComment posts to /v1/row-comments/resolve", async () => { + it("resolveRowComment PATCHes /v1/row-comments/:id/resolve", async () => { const comment = { ...makeComment(), isResolved: true }; - mockApi.post.mockResolvedValueOnce({ data: comment }); + mockApi.patch.mockResolvedValueOnce({ data: comment }); const result = await resolveRowComment(COMMENT_ID, true); - expect(mockApi.post).toHaveBeenCalledWith( - "/v1/row-comments/resolve", - { commentId: COMMENT_ID, resolved: true }, + expect(mockApi.patch).toHaveBeenCalledWith( + `/v1/row-comments/${COMMENT_ID}/resolve`, + { resolved: true }, ); expect(result.isResolved).toBe(true); }); - it("deleteRowComment posts to /v1/row-comments/delete", async () => { - mockApi.post.mockResolvedValueOnce({ data: undefined }); + it("deleteRowComment DELETEs /v1/row-comments/:id", async () => { + mockApi.delete.mockResolvedValueOnce({}); await deleteRowComment(COMMENT_ID); - expect(mockApi.post).toHaveBeenCalledWith( - "/v1/row-comments/delete", - { commentId: COMMENT_ID }, - ); + expect(mockApi.delete).toHaveBeenCalledWith(`/v1/row-comments/${COMMENT_ID}`); }); - it("countRowComments returns numeric count", async () => { - mockApi.post.mockResolvedValueOnce({ data: { count: 7 } }); + it("countRowComments GETs /v1/row-comments/count with query params", async () => { + mockApi.get.mockResolvedValueOnce({ data: { count: 7 } }); const count = await countRowComments(TABLE_ID, ROW_ID); expect(count).toBe(7); - expect(mockApi.post).toHaveBeenCalledWith( + expect(mockApi.get).toHaveBeenCalledWith( "/v1/row-comments/count", - { tableId: TABLE_ID, rowId: ROW_ID }, + { params: { tableId: TABLE_ID, rowId: ROW_ID } }, ); }); }); diff --git a/apps/client/src/features/acadenice/comments/services/row-comments-client.ts b/apps/client/src/features/acadenice/comments/services/row-comments-client.ts index 6af7c341..51a32cde 100644 --- a/apps/client/src/features/acadenice/comments/services/row-comments-client.ts +++ b/apps/client/src/features/acadenice/comments/services/row-comments-client.ts @@ -33,20 +33,14 @@ export interface CreateRowCommentParams { export async function listRowComments( params: ListRowCommentsParams, ): Promise { - const res = await api.post( - "/v1/row-comments/list", - params, - ); + const res = await api.get("/v1/row-comments", { params }); return res.data; } export async function createRowComment( params: CreateRowCommentParams, ): Promise { - const res = await api.post( - "/v1/row-comments/create", - params, - ); + const res = await api.post("/v1/row-comments", params); return res.data; } @@ -54,8 +48,7 @@ export async function updateRowComment( commentId: string, content: string, ): Promise { - const res = await api.post("/v1/row-comments/update", { - commentId, + const res = await api.patch(`/v1/row-comments/${commentId}`, { content, }); return res.data; @@ -65,24 +58,23 @@ export async function resolveRowComment( commentId: string, resolved: boolean, ): Promise { - const res = await api.post("/v1/row-comments/resolve", { - commentId, - resolved, - }); + const res = await api.patch( + `/v1/row-comments/${commentId}/resolve`, + { resolved }, + ); return res.data; } export async function deleteRowComment(commentId: string): Promise { - await api.post("/v1/row-comments/delete", { commentId }); + await api.delete(`/v1/row-comments/${commentId}`); } export async function countRowComments( tableId: string, rowId: string, ): Promise { - const res = await api.post<{ count: number }>( - "/v1/row-comments/count", - { tableId, rowId }, - ); + const res = await api.get<{ count: number }>("/v1/row-comments/count", { + params: { tableId, rowId }, + }); return res.data.count; } diff --git a/apps/client/src/features/acadenice/slash-commands-admin/services/slash-commands-client.ts b/apps/client/src/features/acadenice/slash-commands-admin/services/slash-commands-client.ts index 27c8a7cb..c5a00ebc 100644 --- a/apps/client/src/features/acadenice/slash-commands-admin/services/slash-commands-client.ts +++ b/apps/client/src/features/acadenice/slash-commands-admin/services/slash-commands-client.ts @@ -39,21 +39,21 @@ const BASE = '/api/v1/slash-commands'; export const slashCommandsClient = { list(): Promise { - return axios.get(BASE).then((r) => r.data.data); + return axios.get(BASE).then((r) => r.data); }, get(id: string): Promise { - return axios.get(`${BASE}/${id}`).then((r) => r.data.data); + return axios.get(`${BASE}/${id}`).then((r) => r.data); }, create(payload: CreateSlashCommandPayload): Promise { - return axios.post(BASE, payload).then((r) => r.data.data); + return axios.post(BASE, payload).then((r) => r.data); }, update(id: string, payload: UpdateSlashCommandPayload): Promise { return axios .patch(`${BASE}/${id}`, payload) - .then((r) => r.data.data); + .then((r) => r.data); }, delete(id: string): Promise { @@ -63,6 +63,6 @@ export const slashCommandsClient = { toggle(id: string, isEnabled: boolean): Promise { return axios .patch(`${BASE}/${id}`, { isEnabled }) - .then((r) => r.data.data); + .then((r) => r.data); }, }; diff --git a/apps/client/src/features/acadenice/sync-blocks/services/sync-blocks-client.ts b/apps/client/src/features/acadenice/sync-blocks/services/sync-blocks-client.ts index db0a37b7..d096509d 100644 --- a/apps/client/src/features/acadenice/sync-blocks/services/sync-blocks-client.ts +++ b/apps/client/src/features/acadenice/sync-blocks/services/sync-blocks-client.ts @@ -21,15 +21,15 @@ const BASE = '/api/v1/sync-blocks'; export const syncBlocksClient = { create(content: Record = {}): Promise { - return axios.post(BASE, { content }).then((r) => r.data.data); + return axios.post(BASE, { content }).then((r) => r.data); }, get(id: string): Promise { - return axios.get(`${BASE}/${id}`).then((r) => r.data.data); + return axios.get(`${BASE}/${id}`).then((r) => r.data); }, update(id: string, content: Record): Promise { - return axios.patch(`${BASE}/${id}`, { content }).then((r) => r.data.data); + return axios.patch(`${BASE}/${id}`, { content }).then((r) => r.data); }, delete(id: string): Promise { @@ -37,6 +37,6 @@ export const syncBlocksClient = { }, usages(id: string): Promise { - return axios.get(`${BASE}/${id}/usages`).then((r) => r.data.data); + return axios.get(`${BASE}/${id}/usages`).then((r) => r.data); }, }; diff --git a/apps/client/src/features/acadenice/templates-admin/services/templates-client.ts b/apps/client/src/features/acadenice/templates-admin/services/templates-client.ts index 6f78dfbf..88a05f13 100644 --- a/apps/client/src/features/acadenice/templates-admin/services/templates-client.ts +++ b/apps/client/src/features/acadenice/templates-admin/services/templates-client.ts @@ -42,19 +42,19 @@ export const templatesClient = { list(opts: { category?: string; search?: string } = {}): Promise { return axios .get(BASE, { params: opts }) - .then((r) => r.data.data); + .then((r) => r.data); }, get(id: string): Promise { - return axios.get(`${BASE}/${id}`).then((r) => r.data.data); + return axios.get(`${BASE}/${id}`).then((r) => r.data); }, create(payload: CreateTemplatePayload): Promise { - return axios.post(BASE, payload).then((r) => r.data.data); + return axios.post(BASE, payload).then((r) => r.data); }, update(id: string, payload: UpdateTemplatePayload): Promise { - return axios.patch(`${BASE}/${id}`, payload).then((r) => r.data.data); + return axios.patch(`${BASE}/${id}`, payload).then((r) => r.data); }, delete(id: string): Promise { @@ -67,10 +67,10 @@ export const templatesClient = { ): Promise<{ pageId: string; slugId: string }> { return axios .post<{ pageId: string; slugId: string }>(`${BASE}/${id}/instantiate`, payload) - .then((r) => r.data.data); + .then((r) => r.data); }, setDefault(id: string): Promise { - return axios.patch(`${BASE}/${id}/default`).then((r) => r.data.data); + return axios.patch(`${BASE}/${id}/default`).then((r) => r.data); }, }; diff --git a/apps/server/src/core/acadenice/comments/controllers/row-comments.controller.ts b/apps/server/src/core/acadenice/comments/controllers/row-comments.controller.ts index 41b15cd8..fab0dba2 100644 --- a/apps/server/src/core/acadenice/comments/controllers/row-comments.controller.ts +++ b/apps/server/src/core/acadenice/comments/controllers/row-comments.controller.ts @@ -1,13 +1,28 @@ import { - Controller, - Post, Body, + Controller, + Delete, + Get, HttpCode, HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, UseGuards, Req, } from '@nestjs/common'; import { Request } from 'express'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -17,42 +32,74 @@ import { CreateRowCommentDto, UpdateRowCommentDto, ResolveRowCommentDto, - DeleteRowCommentDto, ListRowCommentsDto, } from '../dto/comment.dto'; /** - * RowCommentsController — threaded comments on Baserow rows (R3.8). + * RowCommentsController — threaded comments on Baserow rows (R3.8 / R5.2). * * All routes are protected by JwtAuthGuard. Acadenice RBAC permission * checks are enforced per-action directly in this controller using the * user's `acadenice_permissions` JWT claim. * - * Endpoints: - * POST /api/v1/row-comments/list list thread for (tableId, rowId) - * POST /api/v1/row-comments/create create root or reply - * POST /api/v1/row-comments/update edit own comment - * POST /api/v1/row-comments/resolve resolve/unresolve root thread - * POST /api/v1/row-comments/delete delete own (or moderate) - * POST /api/v1/row-comments/count count comments for a row + * Endpoints (REST-compliant, R5.2): + * GET /api/v1/row-comments list thread for (tableId, rowId) via query params + * POST /api/v1/row-comments create root or reply — 201 Created + * PATCH /api/v1/row-comments/:id edit own comment — 200 OK + * PATCH /api/v1/row-comments/:id/resolve resolve/unresolve root thread — 200 OK + * DELETE /api/v1/row-comments/:id delete own (or moderate) — 204 No Content + * GET /api/v1/row-comments/count count comments for a row */ +@ApiTags('comments') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/row-comments') export class RowCommentsController { constructor(private readonly rowCommentService: RowCommentService) {} - @HttpCode(HttpStatus.OK) - @Post('list') + @ApiOperation({ summary: 'List row comments', description: 'Returns paginated comment thread for a Baserow row.' }) + @ApiQuery({ name: 'tableId', required: true, type: 'string', description: 'Baserow table UUID' }) + @ApiQuery({ name: 'rowId', required: true, type: 'string', description: 'Baserow row ID' }) + @ApiQuery({ name: 'limit', required: false, type: 'number' }) + @ApiQuery({ name: 'cursor', required: false, type: 'string' }) + @ApiResponse({ status: 200, description: 'Paginated comment list' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @Get() async list( - @Body() dto: ListRowCommentsDto, + @Query() query: ListRowCommentsDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { - return this.rowCommentService.list(workspace.id, dto); + return this.rowCommentService.list(workspace.id, query); } - @HttpCode(HttpStatus.OK) - @Post('create') + @ApiOperation({ summary: 'Count row comments', description: 'Returns comment count for a specific Baserow row.' }) + @ApiQuery({ name: 'tableId', required: true, type: 'string' }) + @ApiQuery({ name: 'rowId', required: true, type: 'string' }) + @ApiResponse({ status: 200, description: '{ count: number }' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @Get('count') + async count( + @Query('tableId') tableId: string, + @Query('rowId') rowId: string, + @AuthWorkspace() workspace: Workspace, + ) { + const count = await this.rowCommentService.countByRow( + workspace.id, + tableId, + rowId, + ); + + return { count }; + } + + @ApiOperation({ summary: 'Create row comment', description: 'Creates a new comment or reply on a Baserow row.' }) + @ApiBody({ schema: { type: 'object', required: ['tableId', 'rowId', 'content'], properties: { tableId: { type: 'string' }, rowId: { type: 'string' }, content: { type: 'string' }, parentId: { type: 'string', format: 'uuid', description: 'Set for replies' } } }, description: 'Comment payload' }) + @ApiResponse({ status: 201, description: 'Comment created' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @Post() + @HttpCode(HttpStatus.CREATED) async create( @Body() dto: CreateRowCommentDto, @AuthUser() user: User, @@ -61,44 +108,63 @@ export class RowCommentsController { return this.rowCommentService.create(workspace.id, user.id, dto); } - @HttpCode(HttpStatus.OK) - @Post('update') + @ApiOperation({ summary: 'Update row comment', description: 'Edits the content of an existing comment. Caller must own the comment.' }) + @ApiParam({ name: 'id', description: 'Comment UUID', type: 'string' }) + @ApiBody({ schema: { type: 'object', required: ['content'], properties: { content: { type: 'string' } } }, description: 'Updated content' }) + @ApiResponse({ status: 200, description: 'Updated comment' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Caller does not own comment' }) + @ApiResponse({ status: 404, description: 'Comment not found' }) + @Patch(':id') async update( + @Param('id', ParseUUIDPipe) commentId: string, @Body() dto: UpdateRowCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { return this.rowCommentService.update( - dto.commentId, + commentId, workspace.id, user.id, dto, ); } - @HttpCode(HttpStatus.OK) - @Post('resolve') + @ApiOperation({ summary: 'Resolve row comment thread', description: 'Toggles resolved state of a comment thread.' }) + @ApiParam({ name: 'id', description: 'Root comment UUID', type: 'string' }) + @ApiBody({ schema: { type: 'object', required: ['resolved'], properties: { resolved: { type: 'boolean' } } }, description: 'Resolve payload' }) + @ApiResponse({ status: 200, description: 'Updated comment' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Comment not found' }) + @Patch(':id/resolve') async resolve( + @Param('id', ParseUUIDPipe) commentId: string, @Body() dto: ResolveRowCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { return this.rowCommentService.resolve( - dto.commentId, + commentId, workspace.id, user.id, dto, ); } - @HttpCode(HttpStatus.OK) - @Post('delete') + @ApiOperation({ summary: 'Delete row comment', description: 'Deletes a comment. Moderators (admin:* or comments:moderate) can delete any comment.' }) + @ApiParam({ name: 'id', description: 'Comment UUID', type: 'string' }) + @ApiResponse({ status: 204, description: 'Deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Caller does not own comment and is not a moderator' }) + @ApiResponse({ status: 404, description: 'Comment not found' }) + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) async delete( - @Body() dto: DeleteRowCommentDto, + @Param('id', ParseUUIDPipe) commentId: string, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, @Req() req: Request, - ) { + ): Promise { // Moderators (admin:* or comments:moderate) can delete any comment const perms: string[] = (req as any)?.user?.acadenice_permissions ?? []; @@ -106,27 +172,10 @@ export class RowCommentsController { perms.includes('admin:*') || perms.includes('comments:moderate'); await this.rowCommentService.delete( - dto.commentId, + commentId, workspace.id, user.id, isModerator, ); - - return { message: 'Comment deleted' }; - } - - @HttpCode(HttpStatus.OK) - @Post('count') - async count( - @Body() body: { tableId: string; rowId: string }, - @AuthWorkspace() workspace: Workspace, - ) { - const count = await this.rowCommentService.countByRow( - workspace.id, - body.tableId, - body.rowId, - ); - - return { count }; } } diff --git a/apps/server/src/core/acadenice/comments/dto/comment.dto.ts b/apps/server/src/core/acadenice/comments/dto/comment.dto.ts index 83d01759..3df51dc7 100644 --- a/apps/server/src/core/acadenice/comments/dto/comment.dto.ts +++ b/apps/server/src/core/acadenice/comments/dto/comment.dto.ts @@ -32,21 +32,19 @@ export class CreateRowCommentDto { } export class UpdateRowCommentDto { - @IsUUID() - commentId: string; - @IsJSON() content: string; } export class ResolveRowCommentDto { - @IsUUID() - commentId: string; - @IsBoolean() resolved: boolean; } +/** + * @deprecated No longer used — delete is handled via path param. + * Kept for backwards-compat during transition. + */ export class DeleteRowCommentDto { @IsUUID() commentId: string; diff --git a/apps/server/src/core/acadenice/comments/spec/row-comment.service.spec.ts b/apps/server/src/core/acadenice/comments/spec/row-comment.service.spec.ts index 725c9074..912952c4 100644 --- a/apps/server/src/core/acadenice/comments/spec/row-comment.service.spec.ts +++ b/apps/server/src/core/acadenice/comments/spec/row-comment.service.spec.ts @@ -76,7 +76,6 @@ describe('RowCommentService', () => { await expect( service.update('c-id', WORKSPACE, USER_B, { - commentId: 'c-id', content: JSON.stringify({ type: 'doc' }), }), ).rejects.toBeInstanceOf(ForbiddenException); @@ -101,7 +100,6 @@ describe('RowCommentService', () => { await expect( service.resolve('reply-id', WORKSPACE, USER_A, { - commentId: 'reply-id', resolved: true, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -113,7 +111,6 @@ describe('RowCommentService', () => { // sql`` will throw on bare {} db — catch it and verify we got past the guard await expect( service.resolve(comment.id, WORKSPACE, USER_A, { - commentId: comment.id, resolved: true, }), ).rejects.not.toBeInstanceOf(BadRequestException); diff --git a/apps/server/src/core/acadenice/comments/spec/row-comments.controller.spec.ts b/apps/server/src/core/acadenice/comments/spec/row-comments.controller.spec.ts index 9329430e..46daf191 100644 --- a/apps/server/src/core/acadenice/comments/spec/row-comments.controller.spec.ts +++ b/apps/server/src/core/acadenice/comments/spec/row-comments.controller.spec.ts @@ -5,15 +5,23 @@ import { RowCommentService } from '../services/row-comment.service'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; /** - * Unit tests for RowCommentsController (R3.8). + * Unit tests for RowCommentsController (R3.8 / R5.2). * * RowCommentService is mocked — no DB, no NestJS HTTP stack. + * Routes refactored to REST conventions in R5.2: + * GET /v1/row-comments (list) + * GET /v1/row-comments/count (count) + * POST /v1/row-comments (create — 201) + * PATCH /v1/row-comments/:id (update) + * PATCH /v1/row-comments/:id/resolve (resolve) + * DELETE /v1/row-comments/:id (delete — 204) */ const WORKSPACE_ID = 'ws-0001-0000-0000-000000000000'; const USER_ID = 'user-0000-0000-0000-000000000000'; const TABLE_ID = 'table-1'; const ROW_ID = 'row-1'; +const COMMENT_ID = 'c-0000-0000-0000-000000000000'; function makeUser() { return { id: USER_ID, name: 'Alice' } as any; @@ -25,7 +33,7 @@ function makeWorkspace() { function makeComment(overrides: Record = {}) { return { - id: 'c-id', + id: COMMENT_ID, workspaceId: WORKSPACE_ID, tableId: TABLE_ID, rowId: ROW_ID, @@ -63,15 +71,15 @@ describe('RowCommentsController', () => { controller = module.get(RowCommentsController); }); - it('list delegates to service', async () => { + it('list delegates to service via query params', async () => { service.list.mockResolvedValueOnce([makeComment()]); - const dto = { tableId: TABLE_ID, rowId: ROW_ID }; - const result = await controller.list(dto, makeUser(), makeWorkspace()); - expect(service.list).toHaveBeenCalledWith(WORKSPACE_ID, dto); + const query = { tableId: TABLE_ID, rowId: ROW_ID }; + const result = await controller.list(query, makeUser(), makeWorkspace()); + expect(service.list).toHaveBeenCalledWith(WORKSPACE_ID, query); expect(result).toHaveLength(1); }); - it('create delegates to service', async () => { + it('create delegates to service and returns comment', async () => { const comment = makeComment(); service.create.mockResolvedValueOnce(comment); const dto = { @@ -84,41 +92,41 @@ describe('RowCommentsController', () => { expect(result.tableId).toBe(TABLE_ID); }); - it('resolve delegates to service with dto', async () => { + it('update delegates with path param commentId', async () => { + const comment = makeComment(); + service.update.mockResolvedValueOnce(comment); + const dto = { content: JSON.stringify({ type: 'doc' }) }; + const result = await controller.update(COMMENT_ID, dto, makeUser(), makeWorkspace()); + expect(service.update).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, dto); + expect(result.id).toBe(COMMENT_ID); + }); + + it('resolve delegates with path param commentId', async () => { const comment = makeComment({ isResolved: true }); service.resolve.mockResolvedValueOnce(comment); - const dto = { commentId: 'c-id', resolved: true }; - const result = await controller.resolve(dto, makeUser(), makeWorkspace()); - expect(service.resolve).toHaveBeenCalledWith('c-id', WORKSPACE_ID, USER_ID, dto); + const dto = { resolved: true }; + const result = await controller.resolve(COMMENT_ID, dto, makeUser(), makeWorkspace()); + expect(service.resolve).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, dto); expect(result.isResolved).toBe(true); }); it('delete passes isModerator=false for regular user', async () => { service.delete.mockResolvedValueOnce(undefined); const req = { user: { acadenice_permissions: ['comments:write'] } } as any; - const result = await controller.delete( - { commentId: 'c-id' }, - makeUser(), - makeWorkspace(), - req, - ); - expect(service.delete).toHaveBeenCalledWith('c-id', WORKSPACE_ID, USER_ID, false); - expect(result.message).toBe('Comment deleted'); + await controller.delete(COMMENT_ID, makeUser(), makeWorkspace(), req); + expect(service.delete).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, false); }); it('delete passes isModerator=true for admin user', async () => { service.delete.mockResolvedValueOnce(undefined); const req = { user: { acadenice_permissions: ['admin:*'] } } as any; - await controller.delete({ commentId: 'c-id' }, makeUser(), makeWorkspace(), req); - expect(service.delete).toHaveBeenCalledWith('c-id', WORKSPACE_ID, USER_ID, true); + await controller.delete(COMMENT_ID, makeUser(), makeWorkspace(), req); + expect(service.delete).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, true); }); it('count returns count from service', async () => { service.countByRow.mockResolvedValueOnce(3); - const result = await controller.count( - { tableId: TABLE_ID, rowId: ROW_ID }, - makeWorkspace(), - ); + const result = await controller.count(TABLE_ID, ROW_ID, makeWorkspace()); expect(result).toEqual({ count: 3 }); }); }); diff --git a/apps/server/src/core/acadenice/slash-commands/controllers/slash-commands.controller.ts b/apps/server/src/core/acadenice/slash-commands/controllers/slash-commands.controller.ts index a11897d3..b900f3d4 100644 --- a/apps/server/src/core/acadenice/slash-commands/controllers/slash-commands.controller.ts +++ b/apps/server/src/core/acadenice/slash-commands/controllers/slash-commands.controller.ts @@ -12,6 +12,14 @@ import { Post, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard'; import { RequirePermission } from '../../rbac/guards/require-permission.decorator'; @@ -53,6 +61,8 @@ function parseBody(schema: { parse: (v: unknown) => T }, body: unknown): T { * Requires permission `slash_commands:manage` (workspace Owner + Admin by * default via the seed — see permissions-catalog.ts). */ +@ApiTags('slash-commands') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/slash-commands') export class SlashCommandsController { @@ -62,6 +72,9 @@ export class SlashCommandsController { * Returns all active custom slash commands for the current workspace. * Called by the editor runtime hook on every page open. */ + @ApiOperation({ summary: 'List slash commands', description: 'Returns all active custom slash commands for the workspace. Called by the editor on mount. Any authenticated member can call this.' }) + @ApiResponse({ status: 200, description: 'Array of slash command definitions' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Get() async list( @AuthWorkspace() workspace: Workspace, @@ -74,6 +87,12 @@ export class SlashCommandsController { * Requires slash_commands:manage to avoid leaking webhook URLs to * non-admin members who only need the runtime menu items. */ + @ApiOperation({ summary: 'Get slash command by ID', description: 'Returns full command detail including webhook URL. Requires slash_commands:manage.' }) + @ApiParam({ name: 'id', description: 'Slash command UUID', type: 'string' }) + @ApiResponse({ status: 200, description: 'Slash command detail' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing slash_commands:manage permission' }) + @ApiResponse({ status: 404, description: 'Command not found' }) @Get(':id') @UseGuards(AcadenicePermissionsGuard) @RequirePermission('slash_commands:manage') @@ -84,7 +103,14 @@ export class SlashCommandsController { return this.slashCommandService.get(id, workspace.id); } + @ApiOperation({ summary: 'Create slash command', description: 'Creates a new custom slash command with a webhook URL. Requires slash_commands:manage.' }) + @ApiBody({ schema: { type: 'object', required: ['name', 'webhookUrl'], properties: { name: { type: 'string' }, description: { type: 'string' }, webhookUrl: { type: 'string', format: 'uri' }, icon: { type: 'string' } } }, description: 'Command definition' }) + @ApiResponse({ status: 201, description: 'Command created' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing slash_commands:manage permission' }) @Post() + @HttpCode(HttpStatus.CREATED) @UseGuards(AcadenicePermissionsGuard) @RequirePermission('slash_commands:manage') async create( @@ -96,6 +122,13 @@ export class SlashCommandsController { return this.slashCommandService.create(workspace.id, user.id, dto); } + @ApiOperation({ summary: 'Update slash command', description: 'Partial update of a slash command. Requires slash_commands:manage.' }) + @ApiParam({ name: 'id', description: 'Slash command UUID', type: 'string' }) + @ApiBody({ schema: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' }, webhookUrl: { type: 'string' }, icon: { type: 'string' }, isActive: { type: 'boolean' } } }, description: 'Partial update payload' }) + @ApiResponse({ status: 200, description: 'Updated command' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing slash_commands:manage permission' }) + @ApiResponse({ status: 404, description: 'Command not found' }) @Patch(':id') @UseGuards(AcadenicePermissionsGuard) @RequirePermission('slash_commands:manage') @@ -108,6 +141,12 @@ export class SlashCommandsController { return this.slashCommandService.update(id, workspace.id, dto); } + @ApiOperation({ summary: 'Delete slash command', description: 'Permanently deletes a custom slash command. Requires slash_commands:manage.' }) + @ApiParam({ name: 'id', description: 'Slash command UUID', type: 'string' }) + @ApiResponse({ status: 204, description: 'Command deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing slash_commands:manage permission' }) + @ApiResponse({ status: 404, description: 'Command not found' }) @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) @UseGuards(AcadenicePermissionsGuard) diff --git a/apps/server/src/core/acadenice/sync-blocks/controllers/sync-blocks.controller.ts b/apps/server/src/core/acadenice/sync-blocks/controllers/sync-blocks.controller.ts index 67f32661..a48b1867 100644 --- a/apps/server/src/core/acadenice/sync-blocks/controllers/sync-blocks.controller.ts +++ b/apps/server/src/core/acadenice/sync-blocks/controllers/sync-blocks.controller.ts @@ -11,6 +11,14 @@ import { Post, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -37,12 +45,19 @@ import { * DELETE /api/v1/sync-blocks/:id delete master * GET /api/v1/sync-blocks/:id/usages list referencing pages */ +@ApiTags('sync-blocks') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/sync-blocks') export class SyncBlocksController { constructor(private readonly syncBlocksService: SyncBlocksService) {} + @ApiOperation({ summary: 'Create sync block', description: 'Creates a master sync block that can be embedded by reference in multiple pages.' }) + @ApiBody({ schema: { type: 'object', properties: { content: { type: 'object', description: 'ProseMirror JSON content' } } }, description: 'Initial content (optional)' }) + @ApiResponse({ status: 201, description: 'Sync block created' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Post() + @HttpCode(HttpStatus.CREATED) async create( @Body() dto: CreateSyncBlockDto, @AuthUser() user: User, @@ -51,6 +66,11 @@ export class SyncBlocksController { return this.syncBlocksService.create(workspace.id, user.id, dto); } + @ApiOperation({ summary: 'Get sync block', description: 'Returns the content of a sync block by ID.' }) + @ApiParam({ name: 'id', description: 'Sync block UUID', type: 'string' }) + @ApiResponse({ status: 200, description: 'Sync block content' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Sync block not found' }) @Get(':id') async findOne( @Param('id', ParseUUIDPipe) id: string, @@ -59,6 +79,14 @@ export class SyncBlocksController { return this.syncBlocksService.findById(id, workspace.id); } + @ApiOperation({ summary: 'Update sync block', description: 'Replaces the content of a sync block. Propagated to all referencing pages.' }) + @ApiParam({ name: 'id', description: 'Sync block UUID', type: 'string' }) + @ApiBody({ schema: { type: 'object', required: ['content'], properties: { content: { type: 'object' } } }, description: 'New content' }) + @ApiResponse({ status: 200, description: 'Updated sync block' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Caller does not own the sync block' }) + @ApiResponse({ status: 404, description: 'Sync block not found' }) @Patch(':id') async update( @Param('id', ParseUUIDPipe) id: string, @@ -69,6 +97,12 @@ export class SyncBlocksController { return this.syncBlocksService.update(id, workspace.id, user.id, dto); } + @ApiOperation({ summary: 'Delete sync block', description: 'Deletes the master sync block. Embedded references become orphaned (rendered as static content).' }) + @ApiParam({ name: 'id', description: 'Sync block UUID', type: 'string' }) + @ApiResponse({ status: 204, description: 'Deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Caller does not own the sync block' }) + @ApiResponse({ status: 404, description: 'Sync block not found' }) @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async remove( @@ -79,6 +113,11 @@ export class SyncBlocksController { return this.syncBlocksService.delete(id, workspace.id, user.id); } + @ApiOperation({ summary: 'List sync block usages', description: 'Returns all pages that embed this sync block.' }) + @ApiParam({ name: 'id', description: 'Sync block UUID', type: 'string' }) + @ApiResponse({ status: 200, description: 'Array of { pageId, pageTitle, slugId, spaceId, workspaceId }' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Sync block not found' }) @Get(':id/usages') async usages( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/server/src/core/acadenice/templates/controllers/templates.controller.ts b/apps/server/src/core/acadenice/templates/controllers/templates.controller.ts index 18cfad6d..927d06d1 100644 --- a/apps/server/src/core/acadenice/templates/controllers/templates.controller.ts +++ b/apps/server/src/core/acadenice/templates/controllers/templates.controller.ts @@ -13,6 +13,15 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard'; import { RequirePermission } from '../../rbac/guards/require-permission.decorator'; @@ -64,6 +73,8 @@ function parseBody(schema: { parse: (v: unknown) => T }, body: unknown): T { * POST /v1/templates/:id/instantiate templates:read * PATCH /v1/templates/:id/default templates:manage */ +@ApiTags('templates') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/templates') export class TemplatesController { @@ -72,6 +83,12 @@ export class TemplatesController { private readonly roleService: AcadeniceRoleService, ) {} + @ApiOperation({ summary: 'List templates', description: 'Returns all page templates for the authenticated workspace. Requires templates:read permission.' }) + @ApiQuery({ name: 'category', required: false, enum: ['meeting', 'project', 'wiki', 'todo', 'custom'], description: 'Filter by category' }) + @ApiQuery({ name: 'search', required: false, type: 'string', description: 'Full-text search on template name' }) + @ApiResponse({ status: 200, description: 'Array of templates' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing templates:read permission' }) @Get() @UseGuards(AcadenicePermissionsGuard) @RequirePermission('templates:read') @@ -83,6 +100,12 @@ export class TemplatesController { return this.templateService.list(workspace.id, opts); } + @ApiOperation({ summary: 'Get template by ID', description: 'Returns a single template. Requires templates:read permission.' }) + @ApiParam({ name: 'id', description: 'Template UUID', type: 'string' }) + @ApiResponse({ status: 200, description: 'Template detail' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing templates:read permission' }) + @ApiResponse({ status: 404, description: 'Template not found' }) @Get(':id') @UseGuards(AcadenicePermissionsGuard) @RequirePermission('templates:read') @@ -93,7 +116,14 @@ export class TemplatesController { return this.templateService.get(id, workspace.id); } + @ApiOperation({ summary: 'Create template', description: 'Creates a new page template. Requires templates:create permission.' }) + @ApiBody({ schema: { type: 'object', required: ['name'], properties: { name: { type: 'string', maxLength: 200 }, description: { type: 'string', maxLength: 2000 }, icon: { type: 'string' }, coverUrl: { type: 'string' }, category: { type: 'string', enum: ['meeting', 'project', 'wiki', 'todo', 'custom'] }, sourcePageId: { type: 'string', format: 'uuid' }, content: { type: 'object' } } }, description: 'Template payload' }) + @ApiResponse({ status: 201, description: 'Template created' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing templates:create permission' }) @Post() + @HttpCode(HttpStatus.CREATED) @UseGuards(AcadenicePermissionsGuard) @RequirePermission('templates:create') async create( @@ -109,6 +139,14 @@ export class TemplatesController { * Update a template. The service enforces owner-or-manage logic. * We derive canManage from the user's effective permissions here. */ + @ApiOperation({ summary: 'Update template', description: 'Partial update of a template. Owner can always update their own template; templates:manage required to update others.' }) + @ApiParam({ name: 'id', description: 'Template UUID', type: 'string' }) + @ApiBody({ schema: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' }, icon: { type: 'string' }, coverUrl: { type: 'string' }, category: { type: 'string' }, content: { type: 'object' } } }, description: 'Partial template payload' }) + @ApiResponse({ status: 200, description: 'Updated template' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Template not found' }) @Patch(':id') async update( @Param('id', ParseUUIDPipe) id: string, @@ -122,6 +160,12 @@ export class TemplatesController { return this.templateService.update(id, workspace.id, user.id, dto, canManage); } + @ApiOperation({ summary: 'Delete template', description: 'Deletes a template. Owner can delete their own; templates:manage required to delete others.' }) + @ApiParam({ name: 'id', description: 'Template UUID', type: 'string' }) + @ApiResponse({ status: 204, description: 'Deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Template not found' }) @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async remove( @@ -134,7 +178,16 @@ export class TemplatesController { return this.templateService.delete(id, workspace.id, user.id, canManage); } + @ApiOperation({ summary: 'Instantiate template', description: 'Creates a new page from the template content in the specified space.' }) + @ApiParam({ name: 'id', description: 'Template UUID', type: 'string' }) + @ApiBody({ schema: { type: 'object', required: ['spaceId'], properties: { spaceId: { type: 'string', format: 'uuid' }, parentPageId: { type: 'string', format: 'uuid' }, name: { type: 'string' } } }, description: 'Instantiation options' }) + @ApiResponse({ status: 201, description: 'Returns { pageId, slugId } of the new page' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing templates:read permission' }) + @ApiResponse({ status: 404, description: 'Template not found' }) @Post(':id/instantiate') + @HttpCode(HttpStatus.CREATED) @UseGuards(AcadenicePermissionsGuard) @RequirePermission('templates:read') async instantiate( @@ -147,6 +200,12 @@ export class TemplatesController { return this.templateService.instantiate(id, workspace.id, user.id, dto); } + @ApiOperation({ summary: 'Set workspace default template', description: 'Marks the template as the workspace default. Requires templates:manage permission.' }) + @ApiParam({ name: 'id', description: 'Template UUID', type: 'string' }) + @ApiResponse({ status: 200, description: 'Updated template with isWorkspaceDefault=true' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing templates:manage permission' }) + @ApiResponse({ status: 404, description: 'Template not found' }) @Patch(':id/default') @UseGuards(AcadenicePermissionsGuard) @RequirePermission('templates:manage')