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 { 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');
});

View file

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

View file

@ -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 } },
);
});
});

View file

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

View file

@ -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);
},
};

View file

@ -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);
},
};

View file

@ -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);
},
};

View file

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

View file

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

View file

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

View file

@ -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 });
});
});

View file

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

View file

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

View file

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