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:
parent
9dd283ced6
commit
a39c158748
15 changed files with 466 additions and 160 deletions
118
_byan-output/fast-app/formation-hub/R5.2-REST-AUDIT.md
Normal file
118
_byan-output/fast-app/formation-hub/R5.2-REST-AUDIT.md
Normal 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 |
|
||||
|
|
@ -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<typeof axios>;
|
||||
vi.mock('axios');
|
||||
const mockedAxios = axios as unknown as {
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
post: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ export interface CreateTokenResponse {
|
|||
|
||||
export const clipperClient = {
|
||||
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> {
|
||||
return axios
|
||||
.post<CreateTokenResponse>(`${BASE}/tokens`, payload)
|
||||
.then((r) => r.data.data);
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
revokeToken(tokenId: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn> };
|
||||
const mockApi = api as unknown as {
|
||||
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 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 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,20 +33,14 @@ export interface CreateRowCommentParams {
|
|||
export async function listRowComments(
|
||||
params: ListRowCommentsParams,
|
||||
): Promise<RowComment[]> {
|
||||
const res = await api.post<RowComment[]>(
|
||||
"/v1/row-comments/list",
|
||||
params,
|
||||
);
|
||||
const res = await api.get<RowComment[]>("/v1/row-comments", { params });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createRowComment(
|
||||
params: CreateRowCommentParams,
|
||||
): Promise<RowComment> {
|
||||
const res = await api.post<RowComment>(
|
||||
"/v1/row-comments/create",
|
||||
params,
|
||||
);
|
||||
const res = await api.post<RowComment>("/v1/row-comments", params);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
|
@ -54,8 +48,7 @@ export async function updateRowComment(
|
|||
commentId: string,
|
||||
content: string,
|
||||
): Promise<RowComment> {
|
||||
const res = await api.post<RowComment>("/v1/row-comments/update", {
|
||||
commentId,
|
||||
const res = await api.patch<RowComment>(`/v1/row-comments/${commentId}`, {
|
||||
content,
|
||||
});
|
||||
return res.data;
|
||||
|
|
@ -65,24 +58,23 @@ export async function resolveRowComment(
|
|||
commentId: string,
|
||||
resolved: boolean,
|
||||
): Promise<RowComment> {
|
||||
const res = await api.post<RowComment>("/v1/row-comments/resolve", {
|
||||
commentId,
|
||||
resolved,
|
||||
});
|
||||
const res = await api.patch<RowComment>(
|
||||
`/v1/row-comments/${commentId}/resolve`,
|
||||
{ resolved },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
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(
|
||||
tableId: string,
|
||||
rowId: string,
|
||||
): Promise<number> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,21 +39,21 @@ const BASE = '/api/v1/slash-commands';
|
|||
|
||||
export const slashCommandsClient = {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
return axios
|
||||
.patch<SlashCommandDto>(`${BASE}/${id}`, payload)
|
||||
.then((r) => r.data.data);
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
delete(id: string): Promise<void> {
|
||||
|
|
@ -63,6 +63,6 @@ export const slashCommandsClient = {
|
|||
toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> {
|
||||
return axios
|
||||
.patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled })
|
||||
.then((r) => r.data.data);
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,15 +21,15 @@ const BASE = '/api/v1/sync-blocks';
|
|||
|
||||
export const syncBlocksClient = {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
|
|
@ -37,6 +37,6 @@ export const syncBlocksClient = {
|
|||
},
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,19 +42,19 @@ export const templatesClient = {
|
|||
list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> {
|
||||
return axios
|
||||
.get<TemplateDto[]>(BASE, { params: opts })
|
||||
.then((r) => r.data.data);
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
|
|
@ -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<TemplateDto> {
|
||||
return axios.patch<TemplateDto>(`${BASE}/${id}/default`).then((r) => r.data.data);
|
||||
return axios.patch<TemplateDto>(`${BASE}/${id}/default`).then((r) => r.data);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}) {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<T>(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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<T>(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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue