refactor(acadedoc): audit REST conventions + fix pre-existing tests — R5.2

BREAKING CHANGES (row-comments routes):
- POST /v1/row-comments/list     → GET  /v1/row-comments (query params)
- POST /v1/row-comments/create   → POST /v1/row-comments (201 Created)
- POST /v1/row-comments/update   → PATCH /v1/row-comments/:id
- POST /v1/row-comments/resolve  → PATCH /v1/row-comments/:id/resolve
- POST /v1/row-comments/delete   → DELETE /v1/row-comments/:id (204)
- POST /v1/row-comments/count    → GET /v1/row-comments/count (query params)
- UpdateRowCommentDto/ResolveRowCommentDto: removed commentId field (now path param)

REST patches (non-breaking):
- POST /v1/sync-blocks: added explicit @HttpCode(201)
- POST /v1/slash-commands: added explicit @HttpCode(201)
- POST /v1/templates: added explicit @HttpCode(201)
- POST /v1/templates/:id/instantiate: added explicit @HttpCode(201)

Pre-existing test fixes:
- clipper-client.test.ts: jest.mock/jest.fn → vi.mock/vi.fn (Vitest compat)
- templates-client.ts + clipper-client.ts + slash-commands-client.ts
  + sync-blocks-client.ts: removed double-unwrap .data.data → .data

Tests: 366/366 client vitest pass, 448/453 server jest pass (5 pre-existing infra failures)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-08 15:33:54 +02:00
parent 9dd283ced6
commit a39c158748
15 changed files with 466 additions and 160 deletions

View file

@ -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 |

View file

@ -1,8 +1,13 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import axios from 'axios'; import axios from 'axios';
import { clipperClient } from '../services/clipper-client'; import { clipperClient } from '../services/clipper-client';
jest.mock('axios'); vi.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>; const mockedAxios = axios as unknown as {
get: ReturnType<typeof vi.fn>;
post: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
const sampleToken = { const sampleToken = {
id: 'tk-1', id: 'tk-1',
@ -15,11 +20,11 @@ const sampleToken = {
}; };
describe('clipperClient', () => { describe('clipperClient', () => {
afterEach(() => jest.resetAllMocks()); afterEach(() => vi.resetAllMocks());
describe('listTokens', () => { describe('listTokens', () => {
it('GETs /api/v1/clipper/tokens', async () => { 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(); const result = await clipperClient.listTokens();
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].id).toBe('tk-1'); expect(result[0].id).toBe('tk-1');
@ -30,7 +35,7 @@ describe('clipperClient', () => {
describe('createToken', () => { describe('createToken', () => {
it('POSTs and returns token + info', async () => { it('POSTs and returns token + info', async () => {
const response = { token: 'clip_abc123', tokenInfo: sampleToken }; 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 }); const result = await clipperClient.createToken({ label: 'My token', duration_days: 30 });
@ -45,7 +50,7 @@ describe('clipperClient', () => {
describe('revokeToken', () => { describe('revokeToken', () => {
it('DELETEs the token by id', async () => { it('DELETEs the token by id', async () => {
mockedAxios.delete = jest.fn().mockResolvedValue({}); mockedAxios.delete = vi.fn().mockResolvedValue({});
await clipperClient.revokeToken('tk-1'); await clipperClient.revokeToken('tk-1');
expect(mockedAxios.delete).toHaveBeenCalledWith('/api/v1/clipper/tokens/tk-1'); expect(mockedAxios.delete).toHaveBeenCalledWith('/api/v1/clipper/tokens/tk-1');
}); });

View file

@ -24,13 +24,13 @@ export interface CreateTokenResponse {
export const clipperClient = { export const clipperClient = {
listTokens(): Promise<ClipperTokenInfo[]> { listTokens(): Promise<ClipperTokenInfo[]> {
return axios.get<ClipperTokenInfo[]>(`${BASE}/tokens`).then((r) => r.data.data); return axios.get<ClipperTokenInfo[]>(`${BASE}/tokens`).then((r) => r.data);
}, },
createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> { createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> {
return axios return axios
.post<CreateTokenResponse>(`${BASE}/tokens`, payload) .post<CreateTokenResponse>(`${BASE}/tokens`, payload)
.then((r) => r.data.data); .then((r) => r.data);
}, },
revokeToken(tokenId: string): Promise<void> { revokeToken(tokenId: string): Promise<void> {

View file

@ -1,14 +1,18 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; 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. * api-client is fully mocked no network calls.
* Routes updated to REST conventions in R5.2.
*/ */
vi.mock("@/lib/api-client", () => ({ vi.mock("@/lib/api-client", () => ({
default: { default: {
get: vi.fn(),
post: vi.fn(), post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}, },
})); }));
@ -22,12 +26,16 @@ import {
countRowComments, countRowComments,
} from "../services/row-comments-client"; } from "../services/row-comments-client";
// Cast through unknown — the mock replaces AxiosInstance methods with vi.fn(). const mockApi = api as unknown as {
const mockApi = api as unknown as { post: ReturnType<typeof vi.fn> }; get: ReturnType<typeof vi.fn>;
post: ReturnType<typeof vi.fn>;
patch: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
const TABLE_ID = "table-1"; const TABLE_ID = "table-1";
const ROW_ID = "row-42"; const ROW_ID = "row-42";
const COMMENT_ID = "c-00000000"; const COMMENT_ID = "c-00000000-0000-0000-0000-000000000000";
function makeComment() { function makeComment() {
return { return {
@ -51,30 +59,30 @@ describe("row-comments-client", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("listRowComments posts to /v1/row-comments/list", async () => { it("listRowComments GETs /v1/row-comments with query params", async () => {
const comment = makeComment(); const comment = makeComment();
mockApi.post.mockResolvedValueOnce({ data: [comment] }); mockApi.get.mockResolvedValueOnce({ data: [comment] });
const result = await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID }); const result = await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID });
expect(mockApi.post).toHaveBeenCalledWith( expect(mockApi.get).toHaveBeenCalledWith(
"/v1/row-comments/list", "/v1/row-comments",
{ tableId: TABLE_ID, rowId: ROW_ID }, { params: { tableId: TABLE_ID, rowId: ROW_ID } },
); );
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].id).toBe(COMMENT_ID); expect(result[0].id).toBe(COMMENT_ID);
}); });
it("listRowComments forwards resolved filter", async () => { it("listRowComments forwards resolved filter as query param", async () => {
mockApi.post.mockResolvedValueOnce({ data: [] }); mockApi.get.mockResolvedValueOnce({ data: [] });
await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID, resolved: true }); await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID, resolved: true });
expect(mockApi.post).toHaveBeenCalledWith( expect(mockApi.get).toHaveBeenCalledWith(
"/v1/row-comments/list", "/v1/row-comments",
{ tableId: TABLE_ID, rowId: ROW_ID, resolved: true }, { 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(); const comment = makeComment();
mockApi.post.mockResolvedValueOnce({ data: comment }); mockApi.post.mockResolvedValueOnce({ data: comment });
@ -85,54 +93,48 @@ describe("row-comments-client", () => {
}; };
const result = await createRowComment(params); const result = await createRowComment(params);
expect(mockApi.post).toHaveBeenCalledWith( expect(mockApi.post).toHaveBeenCalledWith("/v1/row-comments", params);
"/v1/row-comments/create",
params,
);
expect(result.id).toBe(COMMENT_ID); 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(); const comment = makeComment();
mockApi.post.mockResolvedValueOnce({ data: comment }); mockApi.patch.mockResolvedValueOnce({ data: comment });
await updateRowComment(COMMENT_ID, JSON.stringify({ type: "doc" })); await updateRowComment(COMMENT_ID, JSON.stringify({ type: "doc" }));
expect(mockApi.post).toHaveBeenCalledWith( expect(mockApi.patch).toHaveBeenCalledWith(
"/v1/row-comments/update", `/v1/row-comments/${COMMENT_ID}`,
{ commentId: COMMENT_ID, content: JSON.stringify({ type: "doc" }) }, { 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 }; const comment = { ...makeComment(), isResolved: true };
mockApi.post.mockResolvedValueOnce({ data: comment }); mockApi.patch.mockResolvedValueOnce({ data: comment });
const result = await resolveRowComment(COMMENT_ID, true); const result = await resolveRowComment(COMMENT_ID, true);
expect(mockApi.post).toHaveBeenCalledWith( expect(mockApi.patch).toHaveBeenCalledWith(
"/v1/row-comments/resolve", `/v1/row-comments/${COMMENT_ID}/resolve`,
{ commentId: COMMENT_ID, resolved: true }, { resolved: true },
); );
expect(result.isResolved).toBe(true); expect(result.isResolved).toBe(true);
}); });
it("deleteRowComment posts to /v1/row-comments/delete", async () => { it("deleteRowComment DELETEs /v1/row-comments/:id", async () => {
mockApi.post.mockResolvedValueOnce({ data: undefined }); mockApi.delete.mockResolvedValueOnce({});
await deleteRowComment(COMMENT_ID); await deleteRowComment(COMMENT_ID);
expect(mockApi.post).toHaveBeenCalledWith( expect(mockApi.delete).toHaveBeenCalledWith(`/v1/row-comments/${COMMENT_ID}`);
"/v1/row-comments/delete",
{ commentId: COMMENT_ID },
);
}); });
it("countRowComments returns numeric count", async () => { it("countRowComments GETs /v1/row-comments/count with query params", async () => {
mockApi.post.mockResolvedValueOnce({ data: { count: 7 } }); mockApi.get.mockResolvedValueOnce({ data: { count: 7 } });
const count = await countRowComments(TABLE_ID, ROW_ID); const count = await countRowComments(TABLE_ID, ROW_ID);
expect(count).toBe(7); expect(count).toBe(7);
expect(mockApi.post).toHaveBeenCalledWith( expect(mockApi.get).toHaveBeenCalledWith(
"/v1/row-comments/count", "/v1/row-comments/count",
{ tableId: TABLE_ID, rowId: ROW_ID }, { params: { tableId: TABLE_ID, rowId: ROW_ID } },
); );
}); });
}); });

View file

@ -33,20 +33,14 @@ export interface CreateRowCommentParams {
export async function listRowComments( export async function listRowComments(
params: ListRowCommentsParams, params: ListRowCommentsParams,
): Promise<RowComment[]> { ): Promise<RowComment[]> {
const res = await api.post<RowComment[]>( const res = await api.get<RowComment[]>("/v1/row-comments", { params });
"/v1/row-comments/list",
params,
);
return res.data; return res.data;
} }
export async function createRowComment( export async function createRowComment(
params: CreateRowCommentParams, params: CreateRowCommentParams,
): Promise<RowComment> { ): Promise<RowComment> {
const res = await api.post<RowComment>( const res = await api.post<RowComment>("/v1/row-comments", params);
"/v1/row-comments/create",
params,
);
return res.data; return res.data;
} }
@ -54,8 +48,7 @@ export async function updateRowComment(
commentId: string, commentId: string,
content: string, content: string,
): Promise<RowComment> { ): Promise<RowComment> {
const res = await api.post<RowComment>("/v1/row-comments/update", { const res = await api.patch<RowComment>(`/v1/row-comments/${commentId}`, {
commentId,
content, content,
}); });
return res.data; return res.data;
@ -65,24 +58,23 @@ export async function resolveRowComment(
commentId: string, commentId: string,
resolved: boolean, resolved: boolean,
): Promise<RowComment> { ): Promise<RowComment> {
const res = await api.post<RowComment>("/v1/row-comments/resolve", { const res = await api.patch<RowComment>(
commentId, `/v1/row-comments/${commentId}/resolve`,
resolved, { resolved },
}); );
return res.data; return res.data;
} }
export async function deleteRowComment(commentId: string): Promise<void> { export async function deleteRowComment(commentId: string): Promise<void> {
await api.post("/v1/row-comments/delete", { commentId }); await api.delete(`/v1/row-comments/${commentId}`);
} }
export async function countRowComments( export async function countRowComments(
tableId: string, tableId: string,
rowId: string, rowId: string,
): Promise<number> { ): Promise<number> {
const res = await api.post<{ count: number }>( const res = await api.get<{ count: number }>("/v1/row-comments/count", {
"/v1/row-comments/count", params: { tableId, rowId },
{ tableId, rowId }, });
);
return res.data.count; return res.data.count;
} }

View file

@ -39,21 +39,21 @@ const BASE = '/api/v1/slash-commands';
export const slashCommandsClient = { export const slashCommandsClient = {
list(): Promise<SlashCommandDto[]> { list(): Promise<SlashCommandDto[]> {
return axios.get<SlashCommandDto[]>(BASE).then((r) => r.data.data); return axios.get<SlashCommandDto[]>(BASE).then((r) => r.data);
}, },
get(id: string): Promise<SlashCommandDto> { get(id: string): Promise<SlashCommandDto> {
return axios.get<SlashCommandDto>(`${BASE}/${id}`).then((r) => r.data.data); return axios.get<SlashCommandDto>(`${BASE}/${id}`).then((r) => r.data);
}, },
create(payload: CreateSlashCommandPayload): Promise<SlashCommandDto> { create(payload: CreateSlashCommandPayload): Promise<SlashCommandDto> {
return axios.post<SlashCommandDto>(BASE, payload).then((r) => r.data.data); return axios.post<SlashCommandDto>(BASE, payload).then((r) => r.data);
}, },
update(id: string, payload: UpdateSlashCommandPayload): Promise<SlashCommandDto> { update(id: string, payload: UpdateSlashCommandPayload): Promise<SlashCommandDto> {
return axios return axios
.patch<SlashCommandDto>(`${BASE}/${id}`, payload) .patch<SlashCommandDto>(`${BASE}/${id}`, payload)
.then((r) => r.data.data); .then((r) => r.data);
}, },
delete(id: string): Promise<void> { delete(id: string): Promise<void> {
@ -63,6 +63,6 @@ export const slashCommandsClient = {
toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> { toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> {
return axios return axios
.patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled }) .patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled })
.then((r) => r.data.data); .then((r) => r.data);
}, },
}; };

View file

@ -21,15 +21,15 @@ const BASE = '/api/v1/sync-blocks';
export const syncBlocksClient = { export const syncBlocksClient = {
create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> { create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> {
return axios.post<SyncBlockDto>(BASE, { content }).then((r) => r.data.data); return axios.post<SyncBlockDto>(BASE, { content }).then((r) => r.data);
}, },
get(id: string): Promise<SyncBlockDto> { get(id: string): Promise<SyncBlockDto> {
return axios.get<SyncBlockDto>(`${BASE}/${id}`).then((r) => r.data.data); return axios.get<SyncBlockDto>(`${BASE}/${id}`).then((r) => r.data);
}, },
update(id: string, content: Record<string, unknown>): Promise<SyncBlockDto> { update(id: string, content: Record<string, unknown>): Promise<SyncBlockDto> {
return axios.patch<SyncBlockDto>(`${BASE}/${id}`, { content }).then((r) => r.data.data); return axios.patch<SyncBlockDto>(`${BASE}/${id}`, { content }).then((r) => r.data);
}, },
delete(id: string): Promise<void> { delete(id: string): Promise<void> {
@ -37,6 +37,6 @@ export const syncBlocksClient = {
}, },
usages(id: string): Promise<SyncBlockUsageDto[]> { usages(id: string): Promise<SyncBlockUsageDto[]> {
return axios.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`).then((r) => r.data.data); return axios.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`).then((r) => r.data);
}, },
}; };

View file

@ -42,19 +42,19 @@ export const templatesClient = {
list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> { list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> {
return axios return axios
.get<TemplateDto[]>(BASE, { params: opts }) .get<TemplateDto[]>(BASE, { params: opts })
.then((r) => r.data.data); .then((r) => r.data);
}, },
get(id: string): Promise<TemplateDto> { get(id: string): Promise<TemplateDto> {
return axios.get<TemplateDto>(`${BASE}/${id}`).then((r) => r.data.data); return axios.get<TemplateDto>(`${BASE}/${id}`).then((r) => r.data);
}, },
create(payload: CreateTemplatePayload): Promise<TemplateDto> { create(payload: CreateTemplatePayload): Promise<TemplateDto> {
return axios.post<TemplateDto>(BASE, payload).then((r) => r.data.data); return axios.post<TemplateDto>(BASE, payload).then((r) => r.data);
}, },
update(id: string, payload: UpdateTemplatePayload): Promise<TemplateDto> { update(id: string, payload: UpdateTemplatePayload): Promise<TemplateDto> {
return axios.patch<TemplateDto>(`${BASE}/${id}`, payload).then((r) => r.data.data); return axios.patch<TemplateDto>(`${BASE}/${id}`, payload).then((r) => r.data);
}, },
delete(id: string): Promise<void> { delete(id: string): Promise<void> {
@ -67,10 +67,10 @@ export const templatesClient = {
): Promise<{ pageId: string; slugId: string }> { ): Promise<{ pageId: string; slugId: string }> {
return axios return axios
.post<{ pageId: string; slugId: string }>(`${BASE}/${id}/instantiate`, payload) .post<{ pageId: string; slugId: string }>(`${BASE}/${id}/instantiate`, payload)
.then((r) => r.data.data); .then((r) => r.data);
}, },
setDefault(id: string): Promise<TemplateDto> { setDefault(id: string): Promise<TemplateDto> {
return axios.patch<TemplateDto>(`${BASE}/${id}/default`).then((r) => r.data.data); return axios.patch<TemplateDto>(`${BASE}/${id}/default`).then((r) => r.data);
}, },
}; };

View file

@ -1,13 +1,28 @@
import { import {
Controller,
Post,
Body, Body,
Controller,
Delete,
Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards, UseGuards,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request } from 'express'; import { Request } from 'express';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
@ -17,42 +32,74 @@ import {
CreateRowCommentDto, CreateRowCommentDto,
UpdateRowCommentDto, UpdateRowCommentDto,
ResolveRowCommentDto, ResolveRowCommentDto,
DeleteRowCommentDto,
ListRowCommentsDto, ListRowCommentsDto,
} from '../dto/comment.dto'; } 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 * All routes are protected by JwtAuthGuard. Acadenice RBAC permission
* checks are enforced per-action directly in this controller using the * checks are enforced per-action directly in this controller using the
* user's `acadenice_permissions` JWT claim. * user's `acadenice_permissions` JWT claim.
* *
* Endpoints: * Endpoints (REST-compliant, R5.2):
* POST /api/v1/row-comments/list list thread for (tableId, rowId) * GET /api/v1/row-comments list thread for (tableId, rowId) via query params
* POST /api/v1/row-comments/create create root or reply * POST /api/v1/row-comments create root or reply 201 Created
* POST /api/v1/row-comments/update edit own comment * PATCH /api/v1/row-comments/:id edit own comment 200 OK
* POST /api/v1/row-comments/resolve resolve/unresolve root thread * PATCH /api/v1/row-comments/:id/resolve resolve/unresolve root thread 200 OK
* POST /api/v1/row-comments/delete delete own (or moderate) * DELETE /api/v1/row-comments/:id delete own (or moderate) 204 No Content
* POST /api/v1/row-comments/count count comments for a row * GET /api/v1/row-comments/count count comments for a row
*/ */
@ApiTags('comments')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('v1/row-comments') @Controller('v1/row-comments')
export class RowCommentsController { export class RowCommentsController {
constructor(private readonly rowCommentService: RowCommentService) {} constructor(private readonly rowCommentService: RowCommentService) {}
@HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'List row comments', description: 'Returns paginated comment thread for a Baserow row.' })
@Post('list') @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( async list(
@Body() dto: ListRowCommentsDto, @Query() query: ListRowCommentsDto,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
return this.rowCommentService.list(workspace.id, dto); return this.rowCommentService.list(workspace.id, query);
} }
@HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Count row comments', description: 'Returns comment count for a specific Baserow row.' })
@Post('create') @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( async create(
@Body() dto: CreateRowCommentDto, @Body() dto: CreateRowCommentDto,
@AuthUser() user: User, @AuthUser() user: User,
@ -61,44 +108,63 @@ export class RowCommentsController {
return this.rowCommentService.create(workspace.id, user.id, dto); return this.rowCommentService.create(workspace.id, user.id, dto);
} }
@HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Update row comment', description: 'Edits the content of an existing comment. Caller must own the comment.' })
@Post('update') @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( async update(
@Param('id', ParseUUIDPipe) commentId: string,
@Body() dto: UpdateRowCommentDto, @Body() dto: UpdateRowCommentDto,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
return this.rowCommentService.update( return this.rowCommentService.update(
dto.commentId, commentId,
workspace.id, workspace.id,
user.id, user.id,
dto, dto,
); );
} }
@HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Resolve row comment thread', description: 'Toggles resolved state of a comment thread.' })
@Post('resolve') @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( async resolve(
@Param('id', ParseUUIDPipe) commentId: string,
@Body() dto: ResolveRowCommentDto, @Body() dto: ResolveRowCommentDto,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
return this.rowCommentService.resolve( return this.rowCommentService.resolve(
dto.commentId, commentId,
workspace.id, workspace.id,
user.id, user.id,
dto, dto,
); );
} }
@HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Delete row comment', description: 'Deletes a comment. Moderators (admin:* or comments:moderate) can delete any comment.' })
@Post('delete') @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( async delete(
@Body() dto: DeleteRowCommentDto, @Param('id', ParseUUIDPipe) commentId: string,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Req() req: Request, @Req() req: Request,
) { ): Promise<void> {
// Moderators (admin:* or comments:moderate) can delete any comment // Moderators (admin:* or comments:moderate) can delete any comment
const perms: string[] = const perms: string[] =
(req as any)?.user?.acadenice_permissions ?? []; (req as any)?.user?.acadenice_permissions ?? [];
@ -106,27 +172,10 @@ export class RowCommentsController {
perms.includes('admin:*') || perms.includes('comments:moderate'); perms.includes('admin:*') || perms.includes('comments:moderate');
await this.rowCommentService.delete( await this.rowCommentService.delete(
dto.commentId, commentId,
workspace.id, workspace.id,
user.id, user.id,
isModerator, 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 };
} }
} }

View file

@ -32,21 +32,19 @@ export class CreateRowCommentDto {
} }
export class UpdateRowCommentDto { export class UpdateRowCommentDto {
@IsUUID()
commentId: string;
@IsJSON() @IsJSON()
content: string; content: string;
} }
export class ResolveRowCommentDto { export class ResolveRowCommentDto {
@IsUUID()
commentId: string;
@IsBoolean() @IsBoolean()
resolved: boolean; resolved: boolean;
} }
/**
* @deprecated No longer used delete is handled via path param.
* Kept for backwards-compat during transition.
*/
export class DeleteRowCommentDto { export class DeleteRowCommentDto {
@IsUUID() @IsUUID()
commentId: string; commentId: string;

View file

@ -76,7 +76,6 @@ describe('RowCommentService', () => {
await expect( await expect(
service.update('c-id', WORKSPACE, USER_B, { service.update('c-id', WORKSPACE, USER_B, {
commentId: 'c-id',
content: JSON.stringify({ type: 'doc' }), content: JSON.stringify({ type: 'doc' }),
}), }),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
@ -101,7 +100,6 @@ describe('RowCommentService', () => {
await expect( await expect(
service.resolve('reply-id', WORKSPACE, USER_A, { service.resolve('reply-id', WORKSPACE, USER_A, {
commentId: 'reply-id',
resolved: true, resolved: true,
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
@ -113,7 +111,6 @@ describe('RowCommentService', () => {
// sql`` will throw on bare {} db — catch it and verify we got past the guard // sql`` will throw on bare {} db — catch it and verify we got past the guard
await expect( await expect(
service.resolve(comment.id, WORKSPACE, USER_A, { service.resolve(comment.id, WORKSPACE, USER_A, {
commentId: comment.id,
resolved: true, resolved: true,
}), }),
).rejects.not.toBeInstanceOf(BadRequestException); ).rejects.not.toBeInstanceOf(BadRequestException);

View file

@ -5,15 +5,23 @@ import { RowCommentService } from '../services/row-comment.service';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; 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. * 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 WORKSPACE_ID = 'ws-0001-0000-0000-000000000000';
const USER_ID = 'user-0000-0000-0000-000000000000'; const USER_ID = 'user-0000-0000-0000-000000000000';
const TABLE_ID = 'table-1'; const TABLE_ID = 'table-1';
const ROW_ID = 'row-1'; const ROW_ID = 'row-1';
const COMMENT_ID = 'c-0000-0000-0000-000000000000';
function makeUser() { function makeUser() {
return { id: USER_ID, name: 'Alice' } as any; return { id: USER_ID, name: 'Alice' } as any;
@ -25,7 +33,7 @@ function makeWorkspace() {
function makeComment(overrides: Record<string, unknown> = {}) { function makeComment(overrides: Record<string, unknown> = {}) {
return { return {
id: 'c-id', id: COMMENT_ID,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
tableId: TABLE_ID, tableId: TABLE_ID,
rowId: ROW_ID, rowId: ROW_ID,
@ -63,15 +71,15 @@ describe('RowCommentsController', () => {
controller = module.get(RowCommentsController); controller = module.get(RowCommentsController);
}); });
it('list delegates to service', async () => { it('list delegates to service via query params', async () => {
service.list.mockResolvedValueOnce([makeComment()]); service.list.mockResolvedValueOnce([makeComment()]);
const dto = { tableId: TABLE_ID, rowId: ROW_ID }; const query = { tableId: TABLE_ID, rowId: ROW_ID };
const result = await controller.list(dto, makeUser(), makeWorkspace()); const result = await controller.list(query, makeUser(), makeWorkspace());
expect(service.list).toHaveBeenCalledWith(WORKSPACE_ID, dto); expect(service.list).toHaveBeenCalledWith(WORKSPACE_ID, query);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
}); });
it('create delegates to service', async () => { it('create delegates to service and returns comment', async () => {
const comment = makeComment(); const comment = makeComment();
service.create.mockResolvedValueOnce(comment); service.create.mockResolvedValueOnce(comment);
const dto = { const dto = {
@ -84,41 +92,41 @@ describe('RowCommentsController', () => {
expect(result.tableId).toBe(TABLE_ID); 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 }); const comment = makeComment({ isResolved: true });
service.resolve.mockResolvedValueOnce(comment); service.resolve.mockResolvedValueOnce(comment);
const dto = { commentId: 'c-id', resolved: true }; const dto = { resolved: true };
const result = await controller.resolve(dto, makeUser(), makeWorkspace()); const result = await controller.resolve(COMMENT_ID, dto, makeUser(), makeWorkspace());
expect(service.resolve).toHaveBeenCalledWith('c-id', WORKSPACE_ID, USER_ID, dto); expect(service.resolve).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, dto);
expect(result.isResolved).toBe(true); expect(result.isResolved).toBe(true);
}); });
it('delete passes isModerator=false for regular user', async () => { it('delete passes isModerator=false for regular user', async () => {
service.delete.mockResolvedValueOnce(undefined); service.delete.mockResolvedValueOnce(undefined);
const req = { user: { acadenice_permissions: ['comments:write'] } } as any; const req = { user: { acadenice_permissions: ['comments:write'] } } as any;
const result = await controller.delete( await controller.delete(COMMENT_ID, makeUser(), makeWorkspace(), req);
{ commentId: 'c-id' }, expect(service.delete).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, false);
makeUser(),
makeWorkspace(),
req,
);
expect(service.delete).toHaveBeenCalledWith('c-id', WORKSPACE_ID, USER_ID, false);
expect(result.message).toBe('Comment deleted');
}); });
it('delete passes isModerator=true for admin user', async () => { it('delete passes isModerator=true for admin user', async () => {
service.delete.mockResolvedValueOnce(undefined); service.delete.mockResolvedValueOnce(undefined);
const req = { user: { acadenice_permissions: ['admin:*'] } } as any; const req = { user: { acadenice_permissions: ['admin:*'] } } as any;
await controller.delete({ commentId: 'c-id' }, makeUser(), makeWorkspace(), req); await controller.delete(COMMENT_ID, makeUser(), makeWorkspace(), req);
expect(service.delete).toHaveBeenCalledWith('c-id', WORKSPACE_ID, USER_ID, true); expect(service.delete).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, true);
}); });
it('count returns count from service', async () => { it('count returns count from service', async () => {
service.countByRow.mockResolvedValueOnce(3); service.countByRow.mockResolvedValueOnce(3);
const result = await controller.count( const result = await controller.count(TABLE_ID, ROW_ID, makeWorkspace());
{ tableId: TABLE_ID, rowId: ROW_ID },
makeWorkspace(),
);
expect(result).toEqual({ count: 3 }); expect(result).toEqual({ count: 3 });
}); });
}); });

View file

@ -12,6 +12,14 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard'; import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard';
import { RequirePermission } from '../../rbac/guards/require-permission.decorator'; import { RequirePermission } from '../../rbac/guards/require-permission.decorator';
@ -53,6 +61,8 @@ function parseBody<T>(schema: { parse: (v: unknown) => T }, body: unknown): T {
* Requires permission `slash_commands:manage` (workspace Owner + Admin by * Requires permission `slash_commands:manage` (workspace Owner + Admin by
* default via the seed see permissions-catalog.ts). * default via the seed see permissions-catalog.ts).
*/ */
@ApiTags('slash-commands')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('v1/slash-commands') @Controller('v1/slash-commands')
export class SlashCommandsController { export class SlashCommandsController {
@ -62,6 +72,9 @@ export class SlashCommandsController {
* Returns all active custom slash commands for the current workspace. * Returns all active custom slash commands for the current workspace.
* Called by the editor runtime hook on every page open. * 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() @Get()
async list( async list(
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@ -74,6 +87,12 @@ export class SlashCommandsController {
* Requires slash_commands:manage to avoid leaking webhook URLs to * Requires slash_commands:manage to avoid leaking webhook URLs to
* non-admin members who only need the runtime menu items. * 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') @Get(':id')
@UseGuards(AcadenicePermissionsGuard) @UseGuards(AcadenicePermissionsGuard)
@RequirePermission('slash_commands:manage') @RequirePermission('slash_commands:manage')
@ -84,7 +103,14 @@ export class SlashCommandsController {
return this.slashCommandService.get(id, workspace.id); 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() @Post()
@HttpCode(HttpStatus.CREATED)
@UseGuards(AcadenicePermissionsGuard) @UseGuards(AcadenicePermissionsGuard)
@RequirePermission('slash_commands:manage') @RequirePermission('slash_commands:manage')
async create( async create(
@ -96,6 +122,13 @@ export class SlashCommandsController {
return this.slashCommandService.create(workspace.id, user.id, dto); 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') @Patch(':id')
@UseGuards(AcadenicePermissionsGuard) @UseGuards(AcadenicePermissionsGuard)
@RequirePermission('slash_commands:manage') @RequirePermission('slash_commands:manage')
@ -108,6 +141,12 @@ export class SlashCommandsController {
return this.slashCommandService.update(id, workspace.id, dto); 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') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AcadenicePermissionsGuard) @UseGuards(AcadenicePermissionsGuard)

View file

@ -11,6 +11,14 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
@ -37,12 +45,19 @@ import {
* DELETE /api/v1/sync-blocks/:id delete master * DELETE /api/v1/sync-blocks/:id delete master
* GET /api/v1/sync-blocks/:id/usages list referencing pages * GET /api/v1/sync-blocks/:id/usages list referencing pages
*/ */
@ApiTags('sync-blocks')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('v1/sync-blocks') @Controller('v1/sync-blocks')
export class SyncBlocksController { export class SyncBlocksController {
constructor(private readonly syncBlocksService: SyncBlocksService) {} 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() @Post()
@HttpCode(HttpStatus.CREATED)
async create( async create(
@Body() dto: CreateSyncBlockDto, @Body() dto: CreateSyncBlockDto,
@AuthUser() user: User, @AuthUser() user: User,
@ -51,6 +66,11 @@ export class SyncBlocksController {
return this.syncBlocksService.create(workspace.id, user.id, dto); 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') @Get(':id')
async findOne( async findOne(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@ -59,6 +79,14 @@ export class SyncBlocksController {
return this.syncBlocksService.findById(id, workspace.id); 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') @Patch(':id')
async update( async update(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@ -69,6 +97,12 @@ export class SyncBlocksController {
return this.syncBlocksService.update(id, workspace.id, user.id, dto); 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') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async remove( async remove(
@ -79,6 +113,11 @@ export class SyncBlocksController {
return this.syncBlocksService.delete(id, workspace.id, user.id); 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') @Get(':id/usages')
async usages( async usages(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,

View file

@ -13,6 +13,15 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard'; import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard';
import { RequirePermission } from '../../rbac/guards/require-permission.decorator'; import { RequirePermission } from '../../rbac/guards/require-permission.decorator';
@ -64,6 +73,8 @@ function parseBody<T>(schema: { parse: (v: unknown) => T }, body: unknown): T {
* POST /v1/templates/:id/instantiate templates:read * POST /v1/templates/:id/instantiate templates:read
* PATCH /v1/templates/:id/default templates:manage * PATCH /v1/templates/:id/default templates:manage
*/ */
@ApiTags('templates')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('v1/templates') @Controller('v1/templates')
export class TemplatesController { export class TemplatesController {
@ -72,6 +83,12 @@ export class TemplatesController {
private readonly roleService: AcadeniceRoleService, 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() @Get()
@UseGuards(AcadenicePermissionsGuard) @UseGuards(AcadenicePermissionsGuard)
@RequirePermission('templates:read') @RequirePermission('templates:read')
@ -83,6 +100,12 @@ export class TemplatesController {
return this.templateService.list(workspace.id, opts); 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') @Get(':id')
@UseGuards(AcadenicePermissionsGuard) @UseGuards(AcadenicePermissionsGuard)
@RequirePermission('templates:read') @RequirePermission('templates:read')
@ -93,7 +116,14 @@ export class TemplatesController {
return this.templateService.get(id, workspace.id); 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() @Post()
@HttpCode(HttpStatus.CREATED)
@UseGuards(AcadenicePermissionsGuard) @UseGuards(AcadenicePermissionsGuard)
@RequirePermission('templates:create') @RequirePermission('templates:create')
async create( async create(
@ -109,6 +139,14 @@ export class TemplatesController {
* Update a template. The service enforces owner-or-manage logic. * Update a template. The service enforces owner-or-manage logic.
* We derive canManage from the user's effective permissions here. * 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') @Patch(':id')
async update( async update(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@ -122,6 +160,12 @@ export class TemplatesController {
return this.templateService.update(id, workspace.id, user.id, dto, canManage); 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') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async remove( async remove(
@ -134,7 +178,16 @@ export class TemplatesController {
return this.templateService.delete(id, workspace.id, user.id, canManage); 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') @Post(':id/instantiate')
@HttpCode(HttpStatus.CREATED)
@UseGuards(AcadenicePermissionsGuard) @UseGuards(AcadenicePermissionsGuard)
@RequirePermission('templates:read') @RequirePermission('templates:read')
async instantiate( async instantiate(
@ -147,6 +200,12 @@ export class TemplatesController {
return this.templateService.instantiate(id, workspace.id, user.id, dto); 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') @Patch(':id/default')
@UseGuards(AcadenicePermissionsGuard) @UseGuards(AcadenicePermissionsGuard)
@RequirePermission('templates:manage') @RequirePermission('templates:manage')