Compare commits

..

No commits in common. "d120619245232356caf22b32db6798ab09c34d03" and "e027ae93572e9a6f05e3604bc3fbacb1ab4348d4" have entirely different histories.

63 changed files with 360 additions and 1434 deletions

View file

@ -30,8 +30,8 @@ The native Docmost system already provides the complete mention notification pip
R3.7 adds:
1. `MentionDetectorService` — pure service that walks Tiptap JSON and extracts user mentions (no DB). Used by the emitter and independently testable.
2. `NotificationEmitterService` — listens to `acadenice.page.content.updated` (REST API save path, not collab) and queues `PAGE_MENTION_NOTIFICATION`. Bridges the gap between the collab-only native detection and pages saved via REST (templates instantiate, import, etc.).
3. `AcadeniceNotificationsController` — facade over native `NotificationService`, prefix `/api/v1/notifications`.
4. `NotificationPreferencesController` — GET/PUT `/api/v1/notification-preferences` (reads/writes native `users.settings.notifications`).
3. `AcadeniceNotificationsController` — facade over native `NotificationService`, prefix `/api/acadenice/notifications`.
4. `NotificationPreferencesController` — GET/PUT `/api/acadenice/notification-preferences` (reads/writes native `users.settings.notifications`).
5. Frontend `/notifications` page — full inbox using native `NotificationItem` component.
6. Frontend `/settings/notifications` preferences page — dedicated toggles via Acadenice API.
@ -47,8 +47,8 @@ R3.7 adds:
| `apps/server/src/core/acadenice/notifications/services/mention-detector.service.ts` | Tiptap mention walker (pure) |
| `apps/server/src/core/acadenice/notifications/services/notification-emitter.service.ts` | Event listener -> PAGE_MENTION_NOTIFICATION queue |
| `apps/server/src/core/acadenice/notifications/services/notification-preferences.service.ts` | Read/write users.settings.notifications |
| `apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts` | REST facade /api/v1/notifications |
| `apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts` | REST /api/v1/notification-preferences |
| `apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts` | REST facade /api/acadenice/notifications |
| `apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts` | REST /api/acadenice/notification-preferences |
| `apps/server/src/core/acadenice/notifications/spec/mention-detector.service.spec.ts` | 18 unit tests |
| `apps/server/src/core/acadenice/notifications/spec/notifications.controller.spec.ts` | 10 unit tests |
| `apps/server/src/core/acadenice/notifications/spec/notification-preferences.spec.ts` | 4 unit tests |
@ -77,13 +77,13 @@ R3.7 adds:
### Endpoints
```
GET /api/v1/notifications paginated list
GET /api/v1/notifications/unread-count unread badge count (polled 30s)
POST /api/v1/notifications/read-all mark all read
POST /api/v1/notifications/mark-read bulk mark read
POST /api/v1/notifications/:id/read single mark read
GET /api/v1/notification-preferences get prefs
PUT /api/v1/notification-preferences update prefs
GET /api/acadenice/notifications paginated list
GET /api/acadenice/notifications/unread-count unread badge count (polled 30s)
POST /api/acadenice/notifications/read-all mark all read
POST /api/acadenice/notifications/mark-read bulk mark read
POST /api/acadenice/notifications/:id/read single mark read
GET /api/acadenice/notification-preferences get prefs
PUT /api/acadenice/notification-preferences update prefs
```
### Tests
@ -1409,44 +1409,3 @@ Aucune. Toutes les dependances sont deja presentes dans le monorepo Docmost :
- Migration idempotente : la contrainte UNIQUE est appliquee via `ALTER TABLE ... ADD CONSTRAINT ... IF NOT EXISTS` avec un catch sur l'erreur si deja existante (certaines versions de Kysely ne supportent pas `ifNotExists` sur les contraintes)
- Audit log creation/modification/suppression de commandes
- data-testid sur les elements cles pour les tests e2e Playwright
---
## Patch R5.3 — OpenAPI Swagger documentation auto-generee
**Date** : 2026-05-08
**Commit** : `21ce2a9`
**Scope** : `apps/server/src/main.ts`, tous les controllers `apps/server/src/core/acadenice/*/controllers/*.controller.ts`, `docs/api-docs.md`
### Packages installes
- `@nestjs/swagger@^11.4.2` — SwaggerModule + DocumentBuilder + decorators
- `nestjs-zod@^5.3.0``cleanupOpenApiDoc()` pour post-processing du doc OpenAPI generee (compatible Zod v4)
### Architecture
- `main.ts` : SwaggerModule bootstrappe apres app.enableCors(). Active uniquement si `NODE_ENV !== 'production'` OU `SWAGGER_ENABLED=true`. Expose `/api/docs` (UI) et `/api/docs-json` (OpenAPI JSON).
- 16 controllers decores : `@ApiTags`, `@ApiBearerAuth`, `@ApiOperation`, `@ApiResponse` (tous status codes), `@ApiParam`, `@ApiQuery`, `@ApiBody` sur chaque methode.
- Tags : templates, sync-blocks, audit-log, api-keys, clipper, graph, rbac, comments, notifications, security, backlinks, slash-commands.
- `cleanupOpenApiDoc()` de nestjs-zod applique le post-processing standard avant `SwaggerModule.setup()`.
### Tests
`src/core/acadenice/swagger.spec.ts` — 8 tests :
1. DocumentBuilder produit un objet OpenAPI 3 valide
2. Les 12 tags attendus sont tous declares
3. BearerAuth + CookieAuth security schemes configures
4. @ApiTags present sur RowCommentsController
5. @ApiTags present sur SyncBlocksController
6. @ApiTags present sur AcadenicePermissionsController
7. @ApiTags present sur BacklinksController
8. @ApiTags present sur AcadeniceOidcStatusController
### Acces
```
GET /api/docs # Swagger UI (dev/staging)
GET /api/docs-json # OpenAPI 3 JSON spec
```
Voir `docs/api-docs.md` pour generation de SDK client.

View file

@ -1,118 +0,0 @@
# 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

@ -6,17 +6,17 @@ import {
} from "../types/api-key.types";
export async function listAcadeniceApiKeys(): Promise<AcadeniceApiKey[]> {
const resp = await api.get("/v1/api-keys");
const resp = await api.get("/acadenice/api-keys");
return resp.data;
}
export async function createAcadeniceApiKey(
data: CreateAcadeniceApiKeyRequest,
): Promise<CreateAcadeniceApiKeyResponse> {
const resp = await api.post("/v1/api-keys", data);
const resp = await api.post("/acadenice/api-keys", data);
return resp.data;
}
export async function revokeAcadeniceApiKey(id: string): Promise<void> {
await api.delete(`/v1/api-keys/${id}`);
await api.delete(`/acadenice/api-keys/${id}`);
}

View file

@ -7,6 +7,6 @@ import {
export async function getAcadeniceAuditLogs(
params: AcadeniceAuditLogQuery = {},
): Promise<AcadeniceAuditLogPage> {
const resp = await api.get("/v1/audit-log", { params });
const resp = await api.get("/acadenice/audit-log", { params });
return resp.data;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import { useMemo } from "react";
* Reads the user's Acadenice permissions from the auth context.
*
* Why not a server call here:
* The permissions are already resolved by R2.3a (`GET /api/v1/permissions/me`)
* The permissions are already resolved by R2.3a (`GET /api/acadenice/permissions/me`)
* and cached in the application-level React Query cache. This hook reads from
* that cache or falls back to parsing the `acadenice_permissions` claim from any
* JS-readable cookie. It does NOT perform a new HTTP request callers that need

View file

@ -56,17 +56,16 @@ export function createBridgeClient(bridgeUrl: string): AxiosInstance {
timeout: 15_000,
});
// Vite auto-exposes VITE_* vars from apps/client/.env(.local) via
// import.meta.env. The monorepo-root .env is not picked up automatically
// for client-side consumption, so the dev token lives in apps/client/.env.local
// (gitignored).
const envToken: string | undefined = (
import.meta as unknown as { env?: { VITE_BRIDGE_TOKEN?: string } }
).env?.VITE_BRIDGE_TOKEN;
instance.interceptors.request.use((config) => {
// Priority: cookie token (prod) > VITE_BRIDGE_TOKEN env (dev fallback)
// Priority: cookie token (prod) > VITE_BRIDGE_TOKEN env (dev fallback).
// Vite's define block in vite.config.ts injects VITE_BRIDGE_TOKEN into
// process.env at build/dev time (not into import.meta.env, since the .env
// is loaded from the monorepo root, not from apps/client/).
const cookieToken = readTokenFromCookie();
const envToken =
typeof process !== "undefined"
? (process.env as unknown as { VITE_BRIDGE_TOKEN?: string })?.VITE_BRIDGE_TOKEN
: undefined;
const token = cookieToken || envToken;
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;

View file

@ -54,7 +54,7 @@ beforeEach(() => {
describe("fetchGraph", () => {
it("calls /acadenice/graph with no params when all optional", async () => {
await fetchGraph({});
expect(mockGet).toHaveBeenCalledWith("/v1/graph");
expect(mockGet).toHaveBeenCalledWith("/acadenice/graph");
});
it("appends depth query param", async () => {

View file

@ -67,7 +67,7 @@ export async function fetchGraph(
query.includeOrphans = String(params.includeOrphans);
const qs = new URLSearchParams(query).toString();
const url = qs ? `/v1/graph?${qs}` : "/v1/graph";
const url = qs ? `/acadenice/graph?${qs}` : "/acadenice/graph";
return api.get(url) as unknown as Promise<GraphResponse>;
}

View file

@ -29,11 +29,11 @@ describe("notificationsClient", () => {
vi.clearAllMocks();
});
it("list: calls GET /api/v1/notifications", async () => {
it("list: calls GET /api/acadenice/notifications", async () => {
mockApi.get.mockResolvedValue({ data: { items: [], meta: {} } });
const result = await notificationsClient.list();
expect(mockApi.get).toHaveBeenCalledWith(
"/api/v1/notifications",
"/api/acadenice/notifications",
expect.objectContaining({ params: {} }),
);
expect(result).toEqual({ items: [], meta: {} });
@ -43,16 +43,16 @@ describe("notificationsClient", () => {
mockApi.get.mockResolvedValue({ data: { items: [] } });
await notificationsClient.list({ tab: "direct" });
expect(mockApi.get).toHaveBeenCalledWith(
"/api/v1/notifications",
"/api/acadenice/notifications",
expect.objectContaining({ params: { tab: "direct" } }),
);
});
it("unreadCount: calls GET /api/v1/notifications/unread-count", async () => {
it("unreadCount: calls GET /api/acadenice/notifications/unread-count", async () => {
mockApi.get.mockResolvedValue({ data: { count: 3 } });
const result = await notificationsClient.unreadCount();
expect(mockApi.get).toHaveBeenCalledWith(
"/api/v1/notifications/unread-count",
"/api/acadenice/notifications/unread-count",
);
expect(result).toEqual({ count: 3 });
});
@ -61,7 +61,7 @@ describe("notificationsClient", () => {
mockApi.post.mockResolvedValue({});
await notificationsClient.markRead(["id-1", "id-2"]);
expect(mockApi.post).toHaveBeenCalledWith(
"/api/v1/notifications/mark-read",
"/api/acadenice/notifications/mark-read",
{ notificationIds: ["id-1", "id-2"] },
);
});
@ -70,7 +70,7 @@ describe("notificationsClient", () => {
mockApi.post.mockResolvedValue({});
await notificationsClient.markAllRead();
expect(mockApi.post).toHaveBeenCalledWith(
"/api/v1/notifications/read-all",
"/api/acadenice/notifications/read-all",
);
});
@ -78,11 +78,11 @@ describe("notificationsClient", () => {
mockApi.post.mockResolvedValue({});
await notificationsClient.markOne("notif-uuid");
expect(mockApi.post).toHaveBeenCalledWith(
"/api/v1/notifications/notif-uuid/read",
"/api/acadenice/notifications/notif-uuid/read",
);
});
it("getPreferences: calls GET /api/v1/notification-preferences", async () => {
it("getPreferences: calls GET /api/acadenice/notification-preferences", async () => {
const prefs = {
emailMentions: true,
emailReplies: false,
@ -94,12 +94,12 @@ describe("notificationsClient", () => {
mockApi.get.mockResolvedValue({ data: prefs });
const result = await notificationsClient.getPreferences();
expect(mockApi.get).toHaveBeenCalledWith(
"/api/v1/notification-preferences",
"/api/acadenice/notification-preferences",
);
expect(result).toEqual(prefs);
});
it("updatePreferences: calls PUT /api/v1/notification-preferences", async () => {
it("updatePreferences: calls PUT /api/acadenice/notification-preferences", async () => {
const payload = { emailMentions: false };
const updatedPrefs = {
emailMentions: false,
@ -112,7 +112,7 @@ describe("notificationsClient", () => {
mockApi.put.mockResolvedValue({ data: updatedPrefs });
const result = await notificationsClient.updatePreferences(payload);
expect(mockApi.put).toHaveBeenCalledWith(
"/api/v1/notification-preferences",
"/api/acadenice/notification-preferences",
payload,
);
expect(result).toEqual(updatedPrefs);

View file

@ -13,8 +13,8 @@ export interface NotificationPreferences {
export type UpdatePreferencesPayload = Partial<NotificationPreferences>;
const BASE = "/api/v1/notifications";
const PREFS_BASE = "/api/v1/notification-preferences";
const BASE = "/api/acadenice/notifications";
const PREFS_BASE = "/api/acadenice/notification-preferences";
/**
* HTTP client for Acadenice notification endpoints (R3.7).

View file

@ -2,6 +2,6 @@ import api from "@/lib/api-client";
import { OidcStatusResponse } from "../types/oidc-status.types";
export async function getOidcStatus(): Promise<OidcStatusResponse> {
const resp = await api.get("/v1/security/oidc-status");
const resp = await api.get("/acadenice/security/oidc-status");
return resp.data;
}

View file

@ -12,7 +12,7 @@ export const MY_PERMISSIONS_QUERY_KEY = [
/**
* Source-of-truth hook for the current user's Acadenice permissions.
*
* Backed by `GET /api/v1/permissions/me` (R2.3a) the backend resolves
* Backed by `GET /api/acadenice/permissions/me` (R2.3a) the backend resolves
* the effective union from the user's roles, with a Redis 60s cache server-side
* (R2.1). We keep a 60s React Query staleTime to mirror that TTL: refetching
* sooner only hits the same Redis value.

View file

@ -11,14 +11,14 @@ import {
/**
* REST client for the Acadenice RBAC API (R2.1 backend).
* Endpoints under /api/v1 relative to api.baseURL ("/api").
* Endpoints under /api/acadenice relative to api.baseURL ("/api").
*
* Note : Docmost's axios interceptor returns `response.data` directly, so the
* return value of `api.get(...)` is already the body payload.
*/
export async function getPermissionsCatalog(): Promise<IPermissionDescriptor[]> {
return api.get("/v1/permissions") as unknown as Promise<
return api.get("/acadenice/permissions") as unknown as Promise<
IPermissionDescriptor[]
>;
}
@ -29,17 +29,17 @@ export async function getPermissionsCatalog(): Promise<IPermissionDescriptor[]>
*/
export async function getMyPermissions(): Promise<IMyPermissionsResponse> {
return api.get(
"/v1/permissions/me",
"/acadenice/permissions/me",
) as unknown as Promise<IMyPermissionsResponse>;
}
export async function listRoles(): Promise<IRole[]> {
return api.get("/v1/roles") as unknown as Promise<IRole[]>;
return api.get("/acadenice/roles") as unknown as Promise<IRole[]>;
}
export async function getRole(roleId: string): Promise<IRoleWithPermissions> {
return api.get(
`/v1/roles/${roleId}`,
`/acadenice/roles/${roleId}`,
) as unknown as Promise<IRoleWithPermissions>;
}
@ -47,7 +47,7 @@ export async function createRole(
payload: ICreateRolePayload,
): Promise<IRoleWithPermissions> {
return api.post(
"/v1/roles",
"/acadenice/roles",
payload,
) as unknown as Promise<IRoleWithPermissions>;
}
@ -57,20 +57,20 @@ export async function updateRole(
payload: IUpdateRolePayload,
): Promise<IRole> {
return api.patch(
`/v1/roles/${roleId}`,
`/acadenice/roles/${roleId}`,
payload,
) as unknown as Promise<IRole>;
}
export async function deleteRole(roleId: string): Promise<void> {
await api.delete(`/v1/roles/${roleId}`);
await api.delete(`/acadenice/roles/${roleId}`);
}
export async function setRolePermissions(
roleId: string,
permissions: string[],
): Promise<IRoleWithPermissions> {
return api.put(`/v1/roles/${roleId}/permissions`, {
return api.put(`/acadenice/roles/${roleId}/permissions`, {
permissions,
}) as unknown as Promise<IRoleWithPermissions>;
}
@ -78,7 +78,7 @@ export async function setRolePermissions(
export async function listUserRoles(
userId: string,
): Promise<IUserRoleAssignment[]> {
return api.get(`/v1/users/${userId}/roles`) as unknown as Promise<
return api.get(`/acadenice/users/${userId}/roles`) as unknown as Promise<
IUserRoleAssignment[]
>;
}
@ -87,7 +87,7 @@ export async function assignRolesToUser(
userId: string,
roleIds: string[],
): Promise<{ ok: true }> {
return api.post(`/v1/users/${userId}/roles`, {
return api.post(`/acadenice/users/${userId}/roles`, {
roleIds,
}) as unknown as Promise<{ ok: true }>;
}
@ -96,5 +96,5 @@ export async function unassignRoleFromUser(
userId: string,
roleId: string,
): Promise<void> {
await api.delete(`/v1/users/${userId}/roles/${roleId}`);
await api.delete(`/acadenice/users/${userId}/roles/${roleId}`);
}

View file

@ -59,7 +59,7 @@ export interface IUpdateRolePayload {
}
/**
* Response of `GET /api/v1/permissions/me` (R2.3a).
* Response of `GET /api/acadenice/permissions/me` (R2.3a).
* `is_admin_wildcard` is a cheap boolean so the UI can branch without
* scanning the array.
*/

View file

@ -35,25 +35,25 @@ export interface CreateSlashCommandPayload {
export type UpdateSlashCommandPayload = Partial<CreateSlashCommandPayload>;
const BASE = '/api/v1/slash-commands';
const BASE = '/api/acadenice/slash-commands';
export const slashCommandsClient = {
list(): Promise<SlashCommandDto[]> {
return axios.get<SlashCommandDto[]>(BASE).then((r) => r.data);
return axios.get<SlashCommandDto[]>(BASE).then((r) => r.data.data);
},
get(id: string): Promise<SlashCommandDto> {
return axios.get<SlashCommandDto>(`${BASE}/${id}`).then((r) => r.data);
return axios.get<SlashCommandDto>(`${BASE}/${id}`).then((r) => r.data.data);
},
create(payload: CreateSlashCommandPayload): Promise<SlashCommandDto> {
return axios.post<SlashCommandDto>(BASE, payload).then((r) => r.data);
return axios.post<SlashCommandDto>(BASE, payload).then((r) => r.data.data);
},
update(id: string, payload: UpdateSlashCommandPayload): Promise<SlashCommandDto> {
return axios
.patch<SlashCommandDto>(`${BASE}/${id}`, payload)
.then((r) => r.data);
.then((r) => r.data.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);
.then((r) => r.data.data);
},
};

View file

@ -7,7 +7,7 @@ export const SYNC_BLOCK_QUERY_KEY = "sync-block";
* SSE hook for sync block realtime updates (R4.2).
*
* Listens to the NestJS EventEmitter2 via an SSE endpoint:
* GET /api/v1/sync-blocks/{masterId}/events
* GET /api/acadenice/sync-blocks/{masterId}/events
*
* When the server broadcasts a `sync-block.updated` event for this masterId,
* we invalidate the React Query cache entry so the NodeView re-fetches the
@ -36,7 +36,7 @@ export function useSyncBlockRealtime(masterId: string | undefined): void {
useEffect(() => {
if (!masterId) return;
const sseUrl = `/api/v1/sync-blocks/${encodeURIComponent(masterId)}/events`;
const sseUrl = `/api/acadenice/sync-blocks/${encodeURIComponent(masterId)}/events`;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;

View file

@ -17,19 +17,19 @@ export interface SyncBlockUsageDto {
workspaceId: string;
}
const BASE = '/api/v1/sync-blocks';
const BASE = '/api/acadenice/sync-blocks';
export const syncBlocksClient = {
create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> {
return axios.post<SyncBlockDto>(BASE, { content }).then((r) => r.data);
return axios.post<SyncBlockDto>(BASE, { content }).then((r) => r.data.data);
},
get(id: string): Promise<SyncBlockDto> {
return axios.get<SyncBlockDto>(`${BASE}/${id}`).then((r) => r.data);
return axios.get<SyncBlockDto>(`${BASE}/${id}`).then((r) => r.data.data);
},
update(id: string, content: Record<string, unknown>): Promise<SyncBlockDto> {
return axios.patch<SyncBlockDto>(`${BASE}/${id}`, { content }).then((r) => r.data);
return axios.patch<SyncBlockDto>(`${BASE}/${id}`, { content }).then((r) => r.data.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);
return axios.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`).then((r) => r.data.data);
},
};

View file

@ -33,11 +33,11 @@ const sampleTemplate = {
};
describe("templatesClient", () => {
it("list — GET /api/v1/templates", async () => {
it("list — GET /api/acadenice/templates", async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: [sampleTemplate] });
const client = await getClient();
const result = await client.list();
expect(axios.get).toHaveBeenCalledWith("/api/v1/templates", { params: {} });
expect(axios.get).toHaveBeenCalledWith("/api/acadenice/templates", { params: {} });
expect(result).toHaveLength(1);
});
@ -45,68 +45,68 @@ describe("templatesClient", () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
const client = await getClient();
await client.list({ category: "meeting" });
expect(axios.get).toHaveBeenCalledWith("/api/v1/templates", {
expect(axios.get).toHaveBeenCalledWith("/api/acadenice/templates", {
params: { category: "meeting" },
});
});
it("get — GET /api/v1/templates/:id", async () => {
it("get — GET /api/acadenice/templates/:id", async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: sampleTemplate });
const client = await getClient();
const result = await client.get("tmpl-1");
expect(axios.get).toHaveBeenCalledWith("/api/v1/templates/tmpl-1");
expect(axios.get).toHaveBeenCalledWith("/api/acadenice/templates/tmpl-1");
expect(result.id).toBe("tmpl-1");
});
it("create — POST /api/v1/templates", async () => {
it("create — POST /api/acadenice/templates", async () => {
vi.mocked(axios.post).mockResolvedValueOnce({ data: sampleTemplate });
const client = await getClient();
const result = await client.create({ name: "Meeting Note", category: "meeting" });
expect(axios.post).toHaveBeenCalledWith("/api/v1/templates", {
expect(axios.post).toHaveBeenCalledWith("/api/acadenice/templates", {
name: "Meeting Note",
category: "meeting",
});
expect(result.name).toBe("Meeting Note");
});
it("update — PATCH /api/v1/templates/:id", async () => {
it("update — PATCH /api/acadenice/templates/:id", async () => {
vi.mocked(axios.patch).mockResolvedValueOnce({
data: { ...sampleTemplate, name: "Updated" },
});
const client = await getClient();
const result = await client.update("tmpl-1", { name: "Updated" });
expect(axios.patch).toHaveBeenCalledWith("/api/v1/templates/tmpl-1", {
expect(axios.patch).toHaveBeenCalledWith("/api/acadenice/templates/tmpl-1", {
name: "Updated",
});
expect(result.name).toBe("Updated");
});
it("delete — DELETE /api/v1/templates/:id", async () => {
it("delete — DELETE /api/acadenice/templates/:id", async () => {
vi.mocked(axios.delete).mockResolvedValueOnce({});
const client = await getClient();
await expect(client.delete("tmpl-1")).resolves.toBeUndefined();
expect(axios.delete).toHaveBeenCalledWith("/api/v1/templates/tmpl-1");
expect(axios.delete).toHaveBeenCalledWith("/api/acadenice/templates/tmpl-1");
});
it("instantiate — POST /api/v1/templates/:id/instantiate", async () => {
it("instantiate — POST /api/acadenice/templates/:id/instantiate", async () => {
vi.mocked(axios.post).mockResolvedValueOnce({ data: { pageId: "p1", slugId: "slug1" } });
const client = await getClient();
const result = await client.instantiate("tmpl-1", { spaceId: "space-1" });
expect(axios.post).toHaveBeenCalledWith(
"/api/v1/templates/tmpl-1/instantiate",
"/api/acadenice/templates/tmpl-1/instantiate",
{ spaceId: "space-1" },
);
expect(result.pageId).toBe("p1");
expect(result.slugId).toBe("slug1");
});
it("setDefault — PATCH /api/v1/templates/:id/default", async () => {
it("setDefault — PATCH /api/acadenice/templates/:id/default", async () => {
vi.mocked(axios.patch).mockResolvedValueOnce({
data: { ...sampleTemplate, isWorkspaceDefault: true },
});
const client = await getClient();
const result = await client.setDefault("tmpl-1");
expect(axios.patch).toHaveBeenCalledWith("/api/v1/templates/tmpl-1/default");
expect(axios.patch).toHaveBeenCalledWith("/api/acadenice/templates/tmpl-1/default");
expect(result.isWorkspaceDefault).toBe(true);
});
@ -122,7 +122,7 @@ describe("templatesClient", () => {
const client = await getClient();
await client.instantiate("tmpl-1", { spaceId: "space-1", parentPageId: "parent-1" });
expect(axios.post).toHaveBeenCalledWith(
"/api/v1/templates/tmpl-1/instantiate",
"/api/acadenice/templates/tmpl-1/instantiate",
{ spaceId: "space-1", parentPageId: "parent-1" },
);
});

View file

@ -36,25 +36,25 @@ export interface InstantiatePayload {
name?: string;
}
const BASE = '/api/v1/templates';
const BASE = '/api/acadenice/templates';
export const templatesClient = {
list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> {
return axios
.get<TemplateDto[]>(BASE, { params: opts })
.then((r) => r.data);
.then((r) => r.data.data);
},
get(id: string): Promise<TemplateDto> {
return axios.get<TemplateDto>(`${BASE}/${id}`).then((r) => r.data);
return axios.get<TemplateDto>(`${BASE}/${id}`).then((r) => r.data.data);
},
create(payload: CreateTemplatePayload): Promise<TemplateDto> {
return axios.post<TemplateDto>(BASE, payload).then((r) => r.data);
return axios.post<TemplateDto>(BASE, payload).then((r) => r.data.data);
},
update(id: string, payload: UpdateTemplatePayload): Promise<TemplateDto> {
return axios.patch<TemplateDto>(`${BASE}/${id}`, payload).then((r) => r.data);
return axios.patch<TemplateDto>(`${BASE}/${id}`, payload).then((r) => r.data.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);
.then((r) => r.data.data);
},
setDefault(id: string): Promise<TemplateDto> {
return axios.patch<TemplateDto>(`${BASE}/${id}/default`).then((r) => r.data);
return axios.patch<TemplateDto>(`${BASE}/${id}/default`).then((r) => r.data.data);
},
};

View file

@ -7,15 +7,12 @@ import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useTranslation } from "react-i18next";
import React, { useEffect } from "react";
import React from "react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { Link } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { useDisclosure } from "@mantine/hooks";
// Acadenice R4.8 — /template slash command wires to TemplatePickerModal via DOM event
import TemplatePickerModal from "@/features/acadenice/templates/components/template-picker-modal";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedPageHeader = React.memo(PageHeader);
const MemoizedHistoryModal = React.memo(HistoryModal);
@ -46,17 +43,6 @@ export default function Page() {
function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
const { t } = useTranslation();
// Acadenice R4.8 — /template slash command opens TemplatePickerModal via DOM event.
// The slash command in menu-items.ts dispatches 'acadenice:open-template-picker'.
// We listen here (page level) so the modal has access to the current page/space context.
const [templatePickerOpened, { open: openTemplatePicker, close: closeTemplatePicker }] =
useDisclosure(false);
useEffect(() => {
const handler = () => openTemplatePicker();
document.addEventListener("acadenice:open-template-picker", handler);
return () => document.removeEventListener("acadenice:open-template-picker", handler);
}, [openTemplatePicker]);
const {
data: page,
@ -126,16 +112,6 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
canComment={canComment}
/>
<MemoizedHistoryModal pageId={page.id} />
{/* Acadenice R4.8 — template picker opened via /template slash command */}
{page.space?.id && (
<TemplatePickerModal
opened={templatePickerOpened}
onClose={closeTemplatePicker}
spaceId={page.space.id}
parentPageId={page.id}
/>
)}
</div>
)
);

View file

@ -42,7 +42,7 @@ export async function sendClip(
apiToken: string,
payload: ClipPayload,
): Promise<ClipResult> {
const url = `${apiUrl.replace(/\/$/, '')}/api/v1/clipper/import`;
const url = `${apiUrl.replace(/\/$/, '')}/api/acadenice/clipper/import`;
let response: Response;
try {

View file

@ -8,7 +8,7 @@
* Flow:
* 1. On open: load settings, query the active tab for page data.
* 2. User fills in form and clicks "Clip".
* 3. sendClip() hits POST /api/v1/clipper/import.
* 3. sendClip() hits POST /api/acadenice/clipper/import.
* 4. On success: show result link. On error: show typed error message.
*/

View file

@ -43,14 +43,14 @@ describe('sendClip', () => {
mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' });
await sendClip(BASE_URL, TOKEN, samplePayload);
const [url] = (global.fetch as any).mock.calls[0];
expect(url).toBe(`${BASE_URL}/api/v1/clipper/import`);
expect(url).toBe(`${BASE_URL}/api/acadenice/clipper/import`);
});
it('strips trailing slash from apiUrl', async () => {
mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' });
await sendClip(`${BASE_URL}/`, TOKEN, samplePayload);
const [url] = (global.fetch as any).mock.calls[0];
expect(url).toBe(`${BASE_URL}/api/v1/clipper/import`);
expect(url).toBe(`${BASE_URL}/api/acadenice/clipper/import`);
});
it('throws ApiError with statusCode on 401', async () => {

View file

@ -59,7 +59,6 @@
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.2",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.19",
@ -94,7 +93,6 @@
"nestjs-cls": "^6.2.0",
"nestjs-kysely": "^3.1.2",
"nestjs-pino": "^4.6.1",
"nestjs-zod": "^5.3.0",
"nodemailer": "^8.0.5",
"openid-client": "^6.8.2",
"otpauth": "^9.5.0",

View file

@ -6,9 +6,9 @@ import { AcadeniceApiKeyService } from './services/api-key.service';
* AcadeniceApiKeysModule personal access tokens (R4.5).
*
* Endpoints:
* GET /api/v1/api-keys
* POST /api/v1/api-keys
* DELETE /api/v1/api-keys/:id
* GET /api/acadenice/api-keys
* POST /api/acadenice/api-keys
* DELETE /api/acadenice/api-keys/:id
*
* Token format: acdk_<64 hex chars>
* Storage: bcrypt hash only plaintext returned once at creation.

View file

@ -11,14 +11,6 @@ import {
Body,
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';
@ -33,20 +25,15 @@ import { CreateApiKeySchema } from '../dto/api-key.dto';
/**
* AcadeniceApiKeyController personal access tokens (R4.5).
*
* GET /api/v1/api-keys List caller's tokens (no hashes)
* POST /api/v1/api-keys Create token returns plain once
* DELETE /api/v1/api-keys/:id Revoke
* GET /api/acadenice/api-keys List caller's tokens (no hashes)
* POST /api/acadenice/api-keys Create token returns plain once
* DELETE /api/acadenice/api-keys/:id Revoke
*/
@ApiTags('api-keys')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/api-keys')
@Controller('acadenice/api-keys')
export class AcadeniceApiKeyController {
constructor(private readonly apiKeyService: AcadeniceApiKeyService) {}
@ApiOperation({ summary: 'List personal API tokens', description: 'Returns all active API tokens for the authenticated user in this workspace. Token hashes are never returned.' })
@ApiResponse({ status: 200, description: 'Array of API key metadata (no secret hashes)' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Get()
async list(
@AuthUser() user: User,
@ -55,11 +42,6 @@ export class AcadeniceApiKeyController {
return this.apiKeyService.list(user.id, workspace.id);
}
@ApiOperation({ summary: 'Create API token', description: 'Generates a new personal API token. The plain token is returned once — store it securely.' })
@ApiBody({ schema: { type: 'object', required: ['label'], properties: { label: { type: 'string', maxLength: 120 }, durationDays: { type: 'integer', description: 'Expiry in days. Null = never expires.' } } }, description: 'Token creation payload' })
@ApiResponse({ status: 201, description: 'Returns { token (plain, shown once), keyInfo }' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Post()
@HttpCode(HttpStatus.CREATED)
async create(
@ -82,12 +64,6 @@ export class AcadeniceApiKeyController {
return { token: result.token, keyInfo: result.row };
}
@ApiOperation({ summary: 'Revoke API token', description: 'Permanently revokes a personal API token. The caller must own the token.' })
@ApiParam({ name: 'id', description: 'API token UUID', type: 'string' })
@ApiResponse({ status: 204, description: 'Token revoked' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Token does not belong to the caller' })
@ApiResponse({ status: 404, description: 'Token not found' })
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async revoke(

View file

@ -5,7 +5,7 @@ import { AcadeniceAuditLogService } from './services/audit-log.service';
/**
* AcadeniceAuditLogModule R4.5.
*
* Exposes GET /api/v1/audit-log (admin/owner only).
* Exposes GET /api/acadenice/audit-log (admin/owner only).
* Reads directly from the `audit` table via Kysely.
* No EE dependency.
*/

View file

@ -6,13 +6,6 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
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';
@ -27,28 +20,15 @@ import { AuditLogQuerySchema } from '../dto/audit-log-query.dto';
/**
* AcadeniceAuditLogController R4.5 read-only audit log.
*
* GET /api/v1/audit-log
* GET /api/acadenice/audit-log
* Auth : JWT (admin or owner only)
* Query : limit, offset, userId, action, since (ISO), until (ISO)
*/
@ApiTags('audit-log')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/audit-log')
@Controller('acadenice/audit-log')
export class AcadeniceAuditLogController {
constructor(private readonly auditLogService: AcadeniceAuditLogService) {}
@ApiOperation({ summary: 'List audit log entries', description: 'Returns paginated audit log entries for the workspace. Admin or Owner role required.' })
@ApiQuery({ name: 'limit', required: false, type: 'number', description: 'Max results per page (default 50)' })
@ApiQuery({ name: 'offset', required: false, type: 'number', description: 'Pagination offset' })
@ApiQuery({ name: 'userId', required: false, type: 'string', description: 'Filter by actor user UUID' })
@ApiQuery({ name: 'action', required: false, type: 'string', description: 'Filter by action type' })
@ApiQuery({ name: 'since', required: false, type: 'string', description: 'ISO 8601 lower bound (inclusive)' })
@ApiQuery({ name: 'until', required: false, type: 'string', description: 'ISO 8601 upper bound (inclusive)' })
@ApiResponse({ status: 200, description: 'Paginated audit log page' })
@ApiResponse({ status: 400, description: 'Invalid query params' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Admin or Owner role required' })
@Get()
async list(
@AuthUser() user: User,

View file

@ -12,7 +12,7 @@ import { PageContentUpdatedListener } from './events/page-content-updated.listen
* - BacklinkParserService : walks Tiptap JSON, extracts links
* - BacklinkIndexerService : delete-then-insert reindex per page
* - BacklinkService : permission-aware query API
* - BacklinksController : REST GET /api/v1/pages/:id/backlinks
* - BacklinksController : REST GET /api/acadenice/pages/:id/backlinks
* - PageContentUpdatedListener: reacts to collaboration saves
*
* Dependencies:

View file

@ -6,13 +6,6 @@ import {
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
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';
@ -22,17 +15,15 @@ import { BacklinkService, BacklinksResult } from '../services/backlink.service';
/**
* REST controller for the backlinks feature.
*
* Route: GET /api/v1/pages/:pageId/backlinks
* Route: GET /api/acadenice/pages/:pageId/backlinks
*
* Authentication: JWT (JwtAuthGuard). The user's read access to each source
* page is enforced inside BacklinkService (space_members / public space check).
* We do not require a special Acadenice permission here any authenticated
* workspace member can query backlinks for pages they can read.
*/
@ApiTags('backlinks')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/pages')
@Controller('acadenice/pages')
export class BacklinksController {
constructor(private readonly backlinkService: BacklinkService) {}
@ -42,11 +33,6 @@ export class BacklinksController {
* The response is filtered to pages the authenticated user can read.
* Returns an empty result (not 404) when no backlinks exist.
*/
@ApiOperation({ summary: 'Get backlinks for a page', description: 'Returns all pages that link to the given page, grouped by link type. Filtered to pages the caller can read.' })
@ApiParam({ name: 'pageId', description: 'Target page UUID', type: 'string' })
@ApiResponse({ status: 200, description: 'Backlinks result grouped by link type' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Page not found' })
@Get(':pageId/backlinks')
async getBacklinks(
@Param('pageId', ParseUUIDPipe) pageId: string,

View file

@ -13,15 +13,6 @@ import {
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiHeader,
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';
@ -58,16 +49,15 @@ const TOKEN_HEADER = 'x-clipper-token';
/**
* ClipperController Web Clipper REST surface (R4.3).
*
* POST /api/v1/clipper/import
* POST /api/acadenice/clipper/import
* Auth: X-Clipper-Token header (token validated against DB hash).
* Body: ImportClipDto (Zod).
*
* POST /api/v1/clipper/tokens (JWT auth)
* GET /api/v1/clipper/tokens (JWT auth)
* DELETE /api/v1/clipper/tokens/:id (JWT auth)
* POST /api/acadenice/clipper/tokens (JWT auth)
* GET /api/acadenice/clipper/tokens (JWT auth)
* DELETE /api/acadenice/clipper/tokens/:id (JWT auth)
*/
@ApiTags('clipper')
@Controller('v1/clipper')
@Controller('acadenice/clipper')
export class ClipperController {
constructor(
private readonly clipperService: ClipperService,
@ -78,12 +68,6 @@ export class ClipperController {
// Import endpoint — authenticated via X-Clipper-Token header
// ---------------------------------------------------------------------------
@ApiOperation({ summary: 'Import clipped content', description: 'Creates a page from web-clipped content. Authentication via X-Clipper-Token header (not JWT).' })
@ApiHeader({ name: 'x-clipper-token', required: true, description: 'Clipper token (obtained from POST /v1/clipper/tokens)' })
@ApiBody({ schema: { type: 'object', properties: { url: { type: 'string' }, title: { type: 'string' }, content: { type: 'string' }, target_workspace_id: { type: 'string', format: 'uuid' }, target_space_id: { type: 'string', format: 'uuid' } } }, description: 'Clip payload' })
@ApiResponse({ status: 201, description: 'Clip imported, returns page info' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Missing or invalid X-Clipper-Token' })
@Post('import')
@HttpCode(HttpStatus.CREATED)
async import(
@ -117,11 +101,6 @@ export class ClipperController {
// Token management — JWT auth (user manages their own tokens)
// ---------------------------------------------------------------------------
@ApiOperation({ summary: 'Create clipper token', description: 'Generates a token for the browser clipper extension. JWT auth required.' })
@ApiBearerAuth()
@ApiBody({ schema: { type: 'object', required: ['label'], properties: { label: { type: 'string' }, duration_days: { type: 'integer' } } }, description: 'Token creation payload' })
@ApiResponse({ status: 201, description: 'Returns { token (plain, shown once), tokenInfo }' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Post('tokens')
@HttpCode(HttpStatus.CREATED)
@ -150,10 +129,6 @@ export class ClipperController {
return { token: result.token, tokenInfo: result.row };
}
@ApiOperation({ summary: 'List clipper tokens', description: 'Returns all clipper tokens for the caller. Secret hashes never returned.' })
@ApiBearerAuth()
@ApiResponse({ status: 200, description: 'Array of token metadata' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('tokens')
async listTokens(
@ -163,13 +138,6 @@ export class ClipperController {
return this.tokenService.list(user.id, workspace.id);
}
@ApiOperation({ summary: 'Revoke clipper token', description: 'Permanently deletes a clipper token. Caller must own the token.' })
@ApiBearerAuth()
@ApiParam({ name: 'id', description: 'Clipper token UUID', type: 'string' })
@ApiResponse({ status: 204, description: 'Token revoked' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Token does not belong to caller' })
@ApiResponse({ status: 404, description: 'Token not found' })
@UseGuards(JwtAuthGuard)
@Delete('tokens/:id')
@HttpCode(HttpStatus.NO_CONTENT)

View file

@ -1,7 +1,7 @@
import { z } from 'zod';
/**
* Zod schema for POST /api/v1/clipper/import.
* Zod schema for POST /api/acadenice/clipper/import.
*
* html_selection is expected to arrive pre-sanitized (DOMPurify on the
* extension side). The server sanitizes again server-side via the existing
@ -22,7 +22,7 @@ export const ImportClipDto = z.object({
export type ImportClipDtoType = z.infer<typeof ImportClipDto>;
/**
* Zod schema for POST /api/v1/clipper/tokens.
* Zod schema for POST /api/acadenice/clipper/tokens.
* duration: days until expiry, or null for no expiry.
*/
export const CreateClipperTokenDto = z.object({

View file

@ -6,13 +6,6 @@ import {
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
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';
@ -30,23 +23,15 @@ import { ResolvePageCommentDto } from '../dto/comment.dto';
* requiring an open collab websocket.
*
* Endpoints:
* POST /api/v1/page-comments/resolve
* POST /api/acadenice/page-comments/resolve
*/
@ApiTags('comments')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/page-comments')
@Controller('acadenice/page-comments')
export class PageCommentsController {
constructor(
private readonly pageCommentResolveService: PageCommentResolveService,
) {}
@ApiOperation({ summary: 'Resolve or unresolve a page comment', description: 'Toggles the resolved state of a page comment thread.' })
@ApiBody({ schema: { type: 'object', required: ['commentId', 'resolved'], properties: { commentId: { type: 'string', format: 'uuid' }, resolved: { type: 'boolean' } } }, description: 'Resolve payload' })
@ApiResponse({ status: 200, description: 'Comment updated' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Comment not found' })
@HttpCode(HttpStatus.OK)
@Post('resolve')
async resolve(

View file

@ -1,28 +1,13 @@
import {
Body,
Controller,
Delete,
Get,
Post,
Body,
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';
@ -32,74 +17,42 @@ import {
CreateRowCommentDto,
UpdateRowCommentDto,
ResolveRowCommentDto,
DeleteRowCommentDto,
ListRowCommentsDto,
} from '../dto/comment.dto';
/**
* RowCommentsController threaded comments on Baserow rows (R3.8 / R5.2).
* RowCommentsController threaded comments on Baserow rows (R3.8).
*
* 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 (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
* Endpoints:
* POST /api/acadenice/row-comments/list list thread for (tableId, rowId)
* POST /api/acadenice/row-comments/create create root or reply
* POST /api/acadenice/row-comments/update edit own comment
* POST /api/acadenice/row-comments/resolve resolve/unresolve root thread
* POST /api/acadenice/row-comments/delete delete own (or moderate)
* POST /api/acadenice/row-comments/count count comments for a row
*/
@ApiTags('comments')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/row-comments')
@Controller('acadenice/row-comments')
export class RowCommentsController {
constructor(private readonly rowCommentService: RowCommentService) {}
@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()
@HttpCode(HttpStatus.OK)
@Post('list')
async list(
@Query() query: ListRowCommentsDto,
@Body() dto: ListRowCommentsDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.rowCommentService.list(workspace.id, query);
return this.rowCommentService.list(workspace.id, dto);
}
@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)
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreateRowCommentDto,
@AuthUser() user: User,
@ -108,63 +61,44 @@ export class RowCommentsController {
return this.rowCommentService.create(workspace.id, user.id, dto);
}
@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')
@HttpCode(HttpStatus.OK)
@Post('update')
async update(
@Param('id', ParseUUIDPipe) commentId: string,
@Body() dto: UpdateRowCommentDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.rowCommentService.update(
commentId,
dto.commentId,
workspace.id,
user.id,
dto,
);
}
@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')
@HttpCode(HttpStatus.OK)
@Post('resolve')
async resolve(
@Param('id', ParseUUIDPipe) commentId: string,
@Body() dto: ResolveRowCommentDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.rowCommentService.resolve(
commentId,
dto.commentId,
workspace.id,
user.id,
dto,
);
}
@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)
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(
@Param('id', ParseUUIDPipe) commentId: string,
@Body() dto: DeleteRowCommentDto,
@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 ?? [];
@ -172,10 +106,27 @@ export class RowCommentsController {
perms.includes('admin:*') || perms.includes('comments:moderate');
await this.rowCommentService.delete(
commentId,
dto.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,19 +32,21 @@ 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,6 +76,7 @@ describe('RowCommentService', () => {
await expect(
service.update('c-id', WORKSPACE, USER_B, {
commentId: 'c-id',
content: JSON.stringify({ type: 'doc' }),
}),
).rejects.toBeInstanceOf(ForbiddenException);
@ -100,6 +101,7 @@ describe('RowCommentService', () => {
await expect(
service.resolve('reply-id', WORKSPACE, USER_A, {
commentId: 'reply-id',
resolved: true,
}),
).rejects.toBeInstanceOf(BadRequestException);
@ -111,6 +113,7 @@ 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,23 +5,15 @@ import { RowCommentService } from '../services/row-comment.service';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
/**
* Unit tests for RowCommentsController (R3.8 / R5.2).
* Unit tests for RowCommentsController (R3.8).
*
* 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;
@ -33,7 +25,7 @@ function makeWorkspace() {
function makeComment(overrides: Record<string, unknown> = {}) {
return {
id: COMMENT_ID,
id: 'c-id',
workspaceId: WORKSPACE_ID,
tableId: TABLE_ID,
rowId: ROW_ID,
@ -71,15 +63,15 @@ describe('RowCommentsController', () => {
controller = module.get(RowCommentsController);
});
it('list delegates to service via query params', async () => {
it('list delegates to service', async () => {
service.list.mockResolvedValueOnce([makeComment()]);
const query = { tableId: TABLE_ID, rowId: ROW_ID };
const result = await controller.list(query, makeUser(), makeWorkspace());
expect(service.list).toHaveBeenCalledWith(WORKSPACE_ID, query);
const dto = { tableId: TABLE_ID, rowId: ROW_ID };
const result = await controller.list(dto, makeUser(), makeWorkspace());
expect(service.list).toHaveBeenCalledWith(WORKSPACE_ID, dto);
expect(result).toHaveLength(1);
});
it('create delegates to service and returns comment', async () => {
it('create delegates to service', async () => {
const comment = makeComment();
service.create.mockResolvedValueOnce(comment);
const dto = {
@ -92,41 +84,41 @@ describe('RowCommentsController', () => {
expect(result.tableId).toBe(TABLE_ID);
});
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 () => {
it('resolve delegates to service with dto', async () => {
const comment = makeComment({ isResolved: true });
service.resolve.mockResolvedValueOnce(comment);
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);
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);
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;
await controller.delete(COMMENT_ID, makeUser(), makeWorkspace(), req);
expect(service.delete).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, false);
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');
});
it('delete passes isModerator=true for admin user', async () => {
service.delete.mockResolvedValueOnce(undefined);
const req = { user: { acadenice_permissions: ['admin:*'] } } as any;
await controller.delete(COMMENT_ID, makeUser(), makeWorkspace(), req);
expect(service.delete).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, true);
await controller.delete({ commentId: 'c-id' }, makeUser(), makeWorkspace(), req);
expect(service.delete).toHaveBeenCalledWith('c-id', WORKSPACE_ID, USER_ID, true);
});
it('count returns count from service', async () => {
service.countByRow.mockResolvedValueOnce(3);
const result = await controller.count(TABLE_ID, ROW_ID, makeWorkspace());
const result = await controller.count(
{ tableId: TABLE_ID, rowId: ROW_ID },
makeWorkspace(),
);
expect(result).toEqual({ count: 3 });
});
});

View file

@ -5,13 +5,6 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
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';
@ -22,7 +15,7 @@ import { GraphQuerySchema, GraphResponse } from '../dto/graph.dto';
/**
* REST controller for the knowledge-graph endpoint (R3.5.1).
*
* Route: GET /api/v1/graph
* Route: GET /api/acadenice/graph
*
* Authentication: JWT (JwtAuthGuard). Permission filtering is applied inside
* GraphService using the same space_members / public visibility model as
@ -32,22 +25,11 @@ import { GraphQuerySchema, GraphResponse } from '../dto/graph.dto';
* workspaceId is always resolved from the JWT context (AuthWorkspace)
* the query param is accepted but ignored to prevent cross-workspace leaks.
*/
@ApiTags('graph')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/graph')
@Controller('acadenice/graph')
export class GraphController {
constructor(private readonly graphService: GraphService) {}
@ApiOperation({ summary: 'Get knowledge graph', description: 'Returns graph nodes and edges for the workspace. Filtered to pages the caller can read. parent_child edges always included.' })
@ApiQuery({ name: 'spaceId', required: false, type: 'string', description: 'Limit graph to a specific space' })
@ApiQuery({ name: 'pageId', required: false, type: 'string', description: 'Ego-centric graph centered on this page' })
@ApiQuery({ name: 'depth', required: false, type: 'number', description: 'Traversal depth (default 2)' })
@ApiQuery({ name: 'types', required: false, type: 'string', description: 'Comma-separated link types: wikilink, mention, embed, parent_child' })
@ApiQuery({ name: 'includeOrphans', required: false, type: 'boolean', description: 'Include pages with no links' })
@ApiResponse({ status: 200, description: 'Graph nodes and edges' })
@ApiResponse({ status: 400, description: 'Invalid query params' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Get()
async getGraph(
@Query() rawQuery: Record<string, string>,

View file

@ -8,7 +8,7 @@ import { GraphService } from './services/graph.service';
* Provides:
* - GraphService : builds { nodes, edges } from acadenice_backlink (R3.2)
* with BFS traversal, permission filtering, Redis cache
* - GraphController : REST GET /api/v1/graph
* - GraphController : REST GET /api/acadenice/graph
*
* Dependencies:
* - KyselyDB is global (AppModule).

View file

@ -424,24 +424,17 @@ export class GraphService {
pageIds: string[],
spaceId: string | undefined,
): Promise<PageMetaRow[]> {
// Filter out undefined / null / empty entries before binding — sql.lit
// throws "invalid immediate value undefined" otherwise. Edges with a
// missing source/target page id would surface here when finalPageIds is
// assembled from raw rows.
const cleanIds = pageIds.filter(
(id): id is string => typeof id === 'string' && id.length > 0,
);
if (cleanIds.length === 0) return [];
if (pageIds.length === 0) return [];
try {
const idList = sql.join(cleanIds.map((id) => sql.lit(id)));
const idList = sql.join(pageIds.map((id) => sql.lit(id)));
const spaceFilter = spaceId ? sql`AND sp.id = ${spaceId}` : sql``;
const rows = await sql<PageMetaRow>`
SELECT
p.id,
p.title,
p.slug_id AS slug,
p.slug,
p.space_id,
sp.name AS space_name,
p.icon
@ -486,7 +479,7 @@ export class GraphService {
SELECT
p.id,
p.title,
p.slug_id AS slug,
p.slug,
p.space_id,
sp.name AS space_name,
p.icon

View file

@ -8,13 +8,6 @@ import {
Put,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
import { User } from '@docmost/db/types/entity.types';
@ -34,28 +27,18 @@ import { ZodError } from 'zod';
* (the native Docmost JSONB column) so changes are immediately visible to
* the native notification email pipeline.
*/
@ApiTags('notifications')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/notification-preferences')
@Controller('acadenice/notification-preferences')
export class NotificationPreferencesController {
constructor(
private readonly prefsService: NotificationPreferencesService,
) {}
@ApiOperation({ summary: 'Get notification preferences', description: 'Returns the notification preferences for the authenticated user.' })
@ApiResponse({ status: 200, description: 'Notification preferences object' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Get()
async getPreferences(@AuthUser() user: User) {
return this.prefsService.getPreferences(user.id);
}
@ApiOperation({ summary: 'Update notification preferences', description: 'Replaces the full notification preferences for the authenticated user.' })
@ApiBody({ schema: { type: 'object', description: 'Notification preferences map (channel/event booleans)' }, description: 'Preferences payload' })
@ApiResponse({ status: 200, description: 'Updated preferences' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Put()
@HttpCode(HttpStatus.OK)
async updatePreferences(

View file

@ -11,15 +11,6 @@ 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 { AuthUser } from '../../../../common/decorators/auth-user.decorator';
import { User } from '@docmost/db/types/entity.types';
@ -54,29 +45,21 @@ function parseQuery<T>(schema: { parse: (v: unknown) => T }, raw: unknown): T {
* REST controller for Acadenice notification endpoints (R3.7).
*
* This controller is a thin facade over the native Docmost NotificationService.
* Endpoints are prefixed `/api/v1/notifications` to allow the Acadenice
* Endpoints are prefixed `/api/acadenice/notifications` to allow the Acadenice
* frontend to discover and poll them without conflicting with the native
* `/notifications` endpoints used by the upstream Docmost UI.
*
* All mutation endpoints use the same guards and service as the native path.
*/
@ApiTags('notifications')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/notifications')
@Controller('acadenice/notifications')
export class AcadeniceNotificationsController {
constructor(private readonly notificationService: NotificationService) {}
/**
* GET /api/v1/notifications
* GET /api/acadenice/notifications
* Paginated list of notifications for the authenticated user.
*/
@ApiOperation({ summary: 'List notifications', description: 'Returns paginated notifications for the authenticated user.' })
@ApiQuery({ name: 'limit', required: false, type: 'number', description: 'Page size' })
@ApiQuery({ name: 'cursor', required: false, type: 'string', description: 'Cursor for pagination' })
@ApiQuery({ name: 'tab', required: false, type: 'string', description: 'Filter tab (all, mentions, etc.)' })
@ApiResponse({ status: 200, description: 'Paginated notification list' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Get()
async list(
@AuthUser() user: User,
@ -99,22 +82,16 @@ export class AcadeniceNotificationsController {
}
/**
* GET /api/v1/notifications/unread-count
* GET /api/acadenice/notifications/unread-count
*/
@ApiOperation({ summary: 'Get unread notification count', description: 'Returns the count of unread notifications for the authenticated user.' })
@ApiResponse({ status: 200, description: '{ count: number }' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Get('unread-count')
async unreadCount(@AuthUser() user: User) {
return this.notificationService.getUnreadCount(user.id);
}
/**
* POST /api/v1/notifications/read-all
* POST /api/acadenice/notifications/read-all
*/
@ApiOperation({ summary: 'Mark all notifications as read', description: 'Marks all notifications for the authenticated user as read.' })
@ApiResponse({ status: 204, description: 'All notifications marked as read' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Post('read-all')
@HttpCode(HttpStatus.NO_CONTENT)
async readAll(@AuthUser() user: User): Promise<void> {
@ -122,13 +99,8 @@ export class AcadeniceNotificationsController {
}
/**
* POST /api/v1/notifications/mark-read
* POST /api/acadenice/notifications/mark-read
*/
@ApiOperation({ summary: 'Mark specific notifications as read', description: 'Marks an array of notification IDs as read.' })
@ApiBody({ schema: { type: 'object', required: ['notificationIds'], properties: { notificationIds: { type: 'array', items: { type: 'string', format: 'uuid' } } } }, description: 'IDs to mark as read' })
@ApiResponse({ status: 204, description: 'Notifications marked as read' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Post('mark-read')
@HttpCode(HttpStatus.NO_CONTENT)
async markRead(
@ -143,13 +115,8 @@ export class AcadeniceNotificationsController {
}
/**
* POST /api/v1/notifications/:id/read
* POST /api/acadenice/notifications/:id/read
*/
@ApiOperation({ summary: 'Mark a single notification as read', description: 'Marks the specified notification as read.' })
@ApiParam({ name: 'id', description: 'Notification UUID', type: 'string' })
@ApiResponse({ status: 204, description: 'Notification marked as read' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Notification not found' })
@Post(':id/read')
@HttpCode(HttpStatus.NO_CONTENT)
async markOne(

View file

@ -27,15 +27,15 @@ import { NotificationModule } from '../../notification/notification.module';
* Shared with native NotificationPref UI (same keys).
*
* 4. AcadeniceNotificationsController
* GET /api/v1/notifications (paginated)
* GET /api/v1/notifications/unread-count
* POST /api/v1/notifications/read-all
* POST /api/v1/notifications/mark-read
* POST /api/v1/notifications/:id/read
* GET /api/acadenice/notifications (paginated)
* GET /api/acadenice/notifications/unread-count
* POST /api/acadenice/notifications/read-all
* POST /api/acadenice/notifications/mark-read
* POST /api/acadenice/notifications/:id/read
*
* 5. NotificationPreferencesController
* GET /api/v1/notification-preferences
* PUT /api/v1/notification-preferences
* GET /api/acadenice/notification-preferences
* PUT /api/acadenice/notification-preferences
*
* Depends on:
* - NotificationModule (exports NotificationService also imports it)

View file

@ -5,12 +5,6 @@ import {
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
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';
@ -20,15 +14,11 @@ import { AcadeniceRoleService } from '../services/role.service';
const ADMIN_WILDCARD_KEY = 'admin:*';
@ApiTags('rbac')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/permissions')
@Controller('acadenice/permissions')
export class AcadenicePermissionsController {
constructor(private readonly roleService: AcadeniceRoleService) {}
@ApiOperation({ summary: 'List all permission keys', description: 'Returns the full permissions catalog — all valid permission strings organized by group.' })
@ApiResponse({ status: 200, description: 'Array of { key, group, description }' })
@HttpCode(HttpStatus.OK)
@Get()
list() {
@ -52,9 +42,6 @@ export class AcadenicePermissionsController {
* holds `admin:*`, the array is short-circuited to `["admin:*"]` and we
* surface the wildcard flag separately for cheap UI checks.
*/
@ApiOperation({ summary: 'Get my effective permissions', description: 'Returns the effective permission set for the authenticated user in the current workspace. This is UX scaffolding — backend guards enforce independently.' })
@ApiResponse({ status: 200, description: '{ userId, workspaceId, permissions, is_admin_wildcard }' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@HttpCode(HttpStatus.OK)
@Get('me')
async getMyPermissions(

View file

@ -12,14 +12,6 @@ import {
Put,
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';
@ -33,28 +25,17 @@ import {
import { AcadenicePermissionsGuard } from '../guards/permissions.guard';
import { RequirePermission } from '../guards/require-permission.decorator';
@ApiTags('rbac')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, AcadenicePermissionsGuard)
@Controller('v1/roles')
@Controller('acadenice/roles')
export class AcadeniceRolesController {
constructor(private readonly roleService: AcadeniceRoleService) {}
@ApiOperation({ summary: 'List roles', description: 'Returns all roles defined in the workspace.' })
@ApiResponse({ status: 200, description: 'Array of role objects' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@HttpCode(HttpStatus.OK)
@Get()
async list(@AuthWorkspace() workspace: Workspace) {
return this.roleService.listRoles(workspace.id);
}
@ApiOperation({ summary: 'Create role', description: 'Creates a new custom workspace role. Requires roles:manage permission.' })
@ApiBody({ schema: { type: 'object', required: ['name'], properties: { name: { type: 'string', maxLength: 120 }, description: { type: 'string', maxLength: 2000 }, permissions: { type: 'array', items: { type: 'string' } } } }, description: 'Role definition' })
@ApiResponse({ status: 201, description: 'Role created' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Missing roles:manage permission' })
@RequirePermission('roles:manage')
@HttpCode(HttpStatus.CREATED)
@Post()
@ -70,12 +51,6 @@ export class AcadeniceRolesController {
});
}
@ApiOperation({ summary: 'Get role by ID', description: 'Returns a single role with its permissions. Requires roles:manage.' })
@ApiParam({ name: 'id', description: 'Role UUID', type: 'string' })
@ApiResponse({ status: 200, description: 'Role detail with permissions' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Missing roles:manage permission' })
@ApiResponse({ status: 404, description: 'Role not found' })
@RequirePermission('roles:manage')
@HttpCode(HttpStatus.OK)
@Get(':id')
@ -86,13 +61,6 @@ export class AcadeniceRolesController {
return this.roleService.getRoleWithPermissions(id, workspace.id);
}
@ApiOperation({ summary: 'Update role metadata', description: 'Updates name/description of a custom role. Requires roles:manage.' })
@ApiParam({ name: 'id', description: 'Role UUID', type: 'string' })
@ApiBody({ schema: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' } } }, description: 'Partial role update' })
@ApiResponse({ status: 200, description: 'Updated role' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Missing roles:manage permission' })
@ApiResponse({ status: 404, description: 'Role not found' })
@RequirePermission('roles:manage')
@HttpCode(HttpStatus.OK)
@Patch(':id')
@ -107,12 +75,6 @@ export class AcadeniceRolesController {
});
}
@ApiOperation({ summary: 'Delete role', description: 'Deletes a custom role. System roles cannot be deleted. Requires roles:manage.' })
@ApiParam({ name: 'id', description: 'Role UUID', type: 'string' })
@ApiResponse({ status: 204, description: 'Role deleted' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Missing roles:manage or attempt to delete system role' })
@ApiResponse({ status: 404, description: 'Role not found' })
@RequirePermission('roles:manage')
@HttpCode(HttpStatus.NO_CONTENT)
@Delete(':id')
@ -123,11 +85,6 @@ export class AcadeniceRolesController {
await this.roleService.deleteRole(id, workspace.id);
}
@ApiOperation({ summary: 'List role permissions', description: 'Returns the permission strings assigned to a role. Requires roles:manage.' })
@ApiParam({ name: 'id', description: 'Role UUID', type: 'string' })
@ApiResponse({ status: 200, description: '{ roleId, permissions }' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Missing roles:manage permission' })
@RequirePermission('roles:manage')
@HttpCode(HttpStatus.OK)
@Get(':id/permissions')
@ -142,12 +99,6 @@ export class AcadeniceRolesController {
return { roleId: role.id, permissions: role.permissions };
}
@ApiOperation({ summary: 'Set role permissions', description: 'Replaces the full permission set of a role. Requires roles:manage.' })
@ApiParam({ name: 'id', description: 'Role UUID', type: 'string' })
@ApiBody({ schema: { type: 'object', required: ['permissions'], properties: { permissions: { type: 'array', items: { type: 'string' } } } }, description: 'Full permissions replacement' })
@ApiResponse({ status: 200, description: 'Updated permissions' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Missing roles:manage permission' })
@RequirePermission('roles:manage')
@HttpCode(HttpStatus.OK)
@Put(':id/permissions')

View file

@ -11,14 +11,6 @@ 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';
@ -41,18 +33,11 @@ import { permissionMatches } from '../permissions-catalog';
* The guard is intentionally hand-rolled here (no `AcadenicePermissionsGuard`)
* because the access logic depends on `userId` path param vs the actor.
*/
@ApiTags('rbac')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/users/:userId/roles')
@Controller('acadenice/users/:userId/roles')
export class AcadeniceUserRolesController {
constructor(private readonly roleService: AcadeniceRoleService) {}
@ApiOperation({ summary: 'List user roles', description: 'Returns the roles assigned to the specified user. Actor must be the user themselves OR have roles:manage.' })
@ApiParam({ name: 'userId', description: 'Target user UUID', type: 'string' })
@ApiResponse({ status: 200, description: 'Array of role assignments' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Missing roles:manage and not self' })
@HttpCode(HttpStatus.OK)
@Get()
async list(
@ -66,12 +51,6 @@ export class AcadeniceUserRolesController {
return this.roleService.listUserRoles(userId, workspace.id);
}
@ApiOperation({ summary: 'Assign roles to user', description: 'Assigns one or more roles to a user. Self-assignment forbidden. Requires roles:manage.' })
@ApiParam({ name: 'userId', description: 'Target user UUID', type: 'string' })
@ApiBody({ schema: { type: 'object', required: ['roleIds'], properties: { roleIds: { type: 'array', items: { type: 'string', format: 'uuid' }, minItems: 1 } } }, description: 'Role IDs to assign' })
@ApiResponse({ status: 200, description: '{ ok: true }' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Self-assignment or missing roles:manage' })
@HttpCode(HttpStatus.OK)
@Post()
async assign(
@ -95,12 +74,6 @@ export class AcadeniceUserRolesController {
return { ok: true };
}
@ApiOperation({ summary: 'Unassign role from user', description: 'Removes a role from a user. Self-unassignment forbidden. Requires roles:manage.' })
@ApiParam({ name: 'userId', description: 'Target user UUID', type: 'string' })
@ApiParam({ name: 'roleId', description: 'Role UUID to unassign', type: 'string' })
@ApiResponse({ status: 204, description: 'Role unassigned' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Self-unassignment or missing roles:manage' })
@HttpCode(HttpStatus.NO_CONTENT)
@Delete(':roleId')
async unassign(

View file

@ -4,12 +4,6 @@ import {
Get,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
import { User } from '@docmost/db/types/entity.types';
@ -28,21 +22,15 @@ export interface OidcStatusResponse {
/**
* AcadeniceOidcStatusController R4.5 read-only OIDC status for admins.
*
* GET /api/v1/security/oidc-status
* GET /api/acadenice/security/oidc-status
* Auth : JWT (admin or owner only)
* Returns OIDC configuration derived from env vars no secrets exposed.
*/
@ApiTags('security')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/security')
@Controller('acadenice/security')
export class AcadeniceOidcStatusController {
constructor(private readonly env: EnvironmentService) {}
@ApiOperation({ summary: 'Get OIDC configuration status', description: 'Returns OIDC/SSO provider configuration derived from env vars. No secrets exposed. Admin or Owner only.' })
@ApiResponse({ status: 200, description: 'OIDC status — { enabled, providerName, issuer, scopes, redirectUri, loginUrl }' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Admin or Owner role required' })
@Get('oidc-status')
oidcStatus(@AuthUser() user: User): OidcStatusResponse {
if (

View file

@ -4,7 +4,7 @@ import { AcadeniceOidcStatusController } from './controllers/oidc-status.control
/**
* AcadeniceSecurityModule R4.5.
*
* Exposes GET /api/v1/security/oidc-status (admin/owner only).
* Exposes GET /api/acadenice/security/oidc-status (admin/owner only).
* Returns OIDC configuration from environment never exposes client_secret.
*/
@Module({

View file

@ -12,14 +12,6 @@ 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';
@ -61,10 +53,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')
@Controller('acadenice/slash-commands')
export class SlashCommandsController {
constructor(private readonly slashCommandService: SlashCommandService) {}
@ -72,9 +62,6 @@ 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,
@ -87,12 +74,6 @@ 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')
@ -103,14 +84,7 @@ 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(
@ -122,13 +96,6 @@ 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')
@ -141,12 +108,6 @@ 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

@ -1,199 +0,0 @@
/**
* Swagger / OpenAPI document metadata tests (R5.3).
*
* These tests verify OpenAPI decorator metadata on the AcadeDoc controllers
* without booting a full NestJS application (avoids ESM module chain issues
* with prosemirror/collaboration utilities in the test environment).
*
* Strategy:
* - Import only the controller class (not its full DI tree)
* - Use `Reflect.getMetadata` to inspect @nestjs/swagger decorator metadata
* - Use `DocumentBuilder` standalone to verify tag/security configuration
*/
import 'reflect-metadata';
import { DocumentBuilder } from '@nestjs/swagger';
import { DECORATORS } from '@nestjs/swagger/dist/constants';
// ---------------------------------------------------------------------------
// Minimal stubs so controller imports do not pull the full DI graph
// ---------------------------------------------------------------------------
jest.mock(
'../../../../common/helpers/prosemirror/html/index',
() => ({}),
{ virtual: true },
);
jest.mock(
'../../../../collaboration/collaboration.util',
() => ({}),
{ virtual: true },
);
// Import only what we need to verify — just the controller classes for metadata
// We avoid importing services/entities to sidestep the prosemirror/collab chain.
// The Swagger decorator metadata is stored on the class constructor itself.
describe('Swagger OpenAPI configuration (R5.3)', () => {
// ---------------------------------------------------------------------------
// Test 1: DocumentBuilder produces a valid OpenAPI 3 spec skeleton
// ---------------------------------------------------------------------------
it('should build a valid OpenAPI 3 config object', () => {
const config = new DocumentBuilder()
.setTitle('AcadeDoc API')
.setDescription('API officielle AcadeDoc')
.setVersion('1.0')
.addBearerAuth()
.addCookieAuth('authToken')
.addTag('templates', 'Templates de pages')
.addTag('sync-blocks', 'Sync blocks (cross-page content)')
.addTag('audit-log', 'Audit log (read-only)')
.addTag('api-keys', 'Personal API tokens')
.addTag('clipper', 'Web clipper')
.addTag('graph', 'Knowledge graph')
.addTag('rbac', 'RBAC permissions/roles')
.addTag('comments', 'Page + row comments')
.addTag('notifications', 'Notifications + preferences')
.addTag('security', 'OIDC / SSO config')
.addTag('backlinks', 'Bidirectional backlinks')
.addTag('slash-commands', 'Custom slash commands')
.build();
expect(config.openapi).toBe('3.0.0');
expect(config.info.title).toBe('AcadeDoc API');
expect(config.info.version).toBe('1.0');
expect(config.tags).toBeDefined();
});
// ---------------------------------------------------------------------------
// Test 2: All expected tags are present
// ---------------------------------------------------------------------------
it('should declare all 12 expected resource tags', () => {
const config = new DocumentBuilder()
.addTag('templates')
.addTag('sync-blocks')
.addTag('audit-log')
.addTag('api-keys')
.addTag('clipper')
.addTag('graph')
.addTag('rbac')
.addTag('comments')
.addTag('notifications')
.addTag('security')
.addTag('backlinks')
.addTag('slash-commands')
.build();
const tagNames = (config.tags ?? []).map((t) => t.name);
const expected = [
'templates',
'sync-blocks',
'audit-log',
'api-keys',
'clipper',
'graph',
'rbac',
'comments',
'notifications',
'security',
'backlinks',
'slash-commands',
];
for (const tag of expected) {
expect(tagNames).toContain(tag);
}
});
// ---------------------------------------------------------------------------
// Test 3: BearerAuth security scheme is configured
// ---------------------------------------------------------------------------
it('should include BearerAuth and CookieAuth security schemes', () => {
const config = new DocumentBuilder()
.addBearerAuth()
.addCookieAuth('authToken')
.build();
const schemes = config.components?.securitySchemes ?? {};
const schemeValues = Object.values(schemes) as any[];
const hasBearerAuth = schemeValues.some(
(s) => s.type === 'http' && s.scheme === 'bearer',
);
const hasCookieAuth = schemeValues.some((s) => s.type === 'apiKey' && s.in === 'cookie');
expect(hasBearerAuth).toBe(true);
expect(hasCookieAuth).toBe(true);
});
// ---------------------------------------------------------------------------
// Test 4: @ApiTags metadata is resolvable on controller classes
// We load the file dynamically in this test only to isolate the import error.
// ---------------------------------------------------------------------------
it('should find @ApiTags on RowCommentsController', async () => {
// RowCommentsController has no service import issues
const { RowCommentsController } = await import(
'./comments/controllers/row-comments.controller'
);
const tags = Reflect.getMetadata(
DECORATORS.API_TAGS,
RowCommentsController,
);
expect(tags).toContain('comments');
});
// ---------------------------------------------------------------------------
// Test 5: @ApiTags metadata is resolvable on SyncBlocksController
// ---------------------------------------------------------------------------
it('should find @ApiTags on SyncBlocksController', async () => {
const { SyncBlocksController } = await import(
'./sync-blocks/controllers/sync-blocks.controller'
);
const tags = Reflect.getMetadata(
DECORATORS.API_TAGS,
SyncBlocksController,
);
expect(tags).toContain('sync-blocks');
});
// ---------------------------------------------------------------------------
// Test 6: @ApiTags metadata on AcadenicePermissionsController
// ---------------------------------------------------------------------------
it('should find @ApiTags on AcadenicePermissionsController', async () => {
const { AcadenicePermissionsController } = await import(
'./rbac/controllers/permissions.controller'
);
const tags = Reflect.getMetadata(
DECORATORS.API_TAGS,
AcadenicePermissionsController,
);
expect(tags).toContain('rbac');
});
// ---------------------------------------------------------------------------
// Test 7: @ApiTags on BacklinksController
// ---------------------------------------------------------------------------
it('should find @ApiTags on BacklinksController', async () => {
const { BacklinksController } = await import(
'./backlinks/controllers/backlinks.controller'
);
const tags = Reflect.getMetadata(
DECORATORS.API_TAGS,
BacklinksController,
);
expect(tags).toContain('backlinks');
});
// ---------------------------------------------------------------------------
// Test 8: @ApiTags on AcadeniceOidcStatusController
// ---------------------------------------------------------------------------
it('should find @ApiTags on AcadeniceOidcStatusController', async () => {
const { AcadeniceOidcStatusController } = await import(
'./security/controllers/oidc-status.controller'
);
const tags = Reflect.getMetadata(
DECORATORS.API_TAGS,
AcadeniceOidcStatusController,
);
expect(tags).toContain('security');
});
});

View file

@ -11,14 +11,6 @@ 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';
@ -39,25 +31,18 @@ import {
* automatically scoped to the authenticated workspace.
*
* Routes:
* POST /api/v1/sync-blocks create master block
* GET /api/v1/sync-blocks/:id read content
* PATCH /api/v1/sync-blocks/:id update content
* DELETE /api/v1/sync-blocks/:id delete master
* GET /api/v1/sync-blocks/:id/usages list referencing pages
* POST /api/acadenice/sync-blocks create master block
* GET /api/acadenice/sync-blocks/:id read content
* PATCH /api/acadenice/sync-blocks/:id update content
* DELETE /api/acadenice/sync-blocks/:id delete master
* GET /api/acadenice/sync-blocks/:id/usages list referencing pages
*/
@ApiTags('sync-blocks')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/sync-blocks')
@Controller('acadenice/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,
@ -66,11 +51,6 @@ 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,
@ -79,14 +59,6 @@ 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,
@ -97,12 +69,6 @@ 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(
@ -113,11 +79,6 @@ 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

@ -24,11 +24,11 @@ export class SyncBlockRepo {
): Promise<SyncBlockResponseDto> {
const result = await sql<{
id: string;
workspaceId: string;
workspace_id: string;
content: Record<string, unknown>;
createdBy: string;
createdAt: Date;
updatedAt: Date;
created_by: string;
created_at: Date;
updated_at: Date;
}>`
INSERT INTO acadenice_sync_block (workspace_id, content, created_by)
VALUES (${workspaceId}, ${JSON.stringify(content)}::jsonb, ${createdBy})
@ -44,11 +44,11 @@ export class SyncBlockRepo {
): Promise<SyncBlockResponseDto | null> {
const result = await sql<{
id: string;
workspaceId: string;
workspace_id: string;
content: Record<string, unknown>;
createdBy: string;
createdAt: Date;
updatedAt: Date;
created_by: string;
created_at: Date;
updated_at: Date;
}>`
SELECT id, workspace_id, content, created_by, created_at, updated_at
FROM acadenice_sync_block
@ -67,11 +67,11 @@ export class SyncBlockRepo {
): Promise<SyncBlockResponseDto | null> {
const result = await sql<{
id: string;
workspaceId: string;
workspace_id: string;
content: Record<string, unknown>;
createdBy: string;
createdAt: Date;
updatedAt: Date;
created_by: string;
created_at: Date;
updated_at: Date;
}>`
UPDATE acadenice_sync_block
SET content = ${JSON.stringify(content)}::jsonb, updated_at = NOW()
@ -101,7 +101,7 @@ export class SyncBlockRepo {
id: string,
workspaceId: string,
): Promise<Buffer | null> {
const result = await sql<{ yjsState: Buffer | null }>`
const result = await sql<{ yjs_state: Buffer | null }>`
SELECT yjs_state
FROM acadenice_sync_block
WHERE id = ${id}
@ -109,7 +109,7 @@ export class SyncBlockRepo {
`.execute(this.db);
if (result.rows.length === 0) return null;
return result.rows[0].yjsState ?? null;
return result.rows[0].yjs_state ?? null;
}
async delete(id: string, workspaceId: string): Promise<boolean> {
@ -137,15 +137,12 @@ export class SyncBlockRepo {
// Pages store their content as JSONB. We search for any object in the
// content tree where type='syncBlock' and attrs->>'masterId' = blockId.
// Using jsonb_path_exists for recursive search.
// CamelCasePlugin (in DatabaseModule) converts snake_case columns to
// camelCase at runtime, including SELECT aliases. So even though the SQL
// says "page_id" the row property is "pageId".
const result = await sql<{
pageId: string;
page_id: string;
title: string | null;
slugId: string;
spaceId: string;
workspaceId: string;
slug_id: string;
space_id: string;
workspace_id: string;
}>`
SELECT DISTINCT p.id AS page_id, p.title, p.slug_id, p.space_id, s.workspace_id
FROM pages p
@ -161,11 +158,11 @@ export class SyncBlockRepo {
`.execute(this.db);
return result.rows.map((r) => ({
pageId: r.pageId,
pageId: r.page_id,
pageTitle: r.title,
slugId: r.slugId,
spaceId: r.spaceId,
workspaceId: r.workspaceId,
slugId: r.slug_id,
spaceId: r.space_id,
workspaceId: r.workspace_id,
}));
}
@ -181,19 +178,19 @@ export class SyncBlockRepo {
private mapRow(row: {
id: string;
workspaceId: string;
workspace_id: string;
content: Record<string, unknown>;
createdBy: string;
createdAt: Date | string;
updatedAt: Date | string;
created_by: string;
created_at: Date;
updated_at: Date;
}): SyncBlockResponseDto {
return {
id: row.id,
workspaceId: row.workspaceId,
workspaceId: row.workspace_id,
content: row.content,
createdBy: row.createdBy,
createdAt: new Date(row.createdAt).toISOString(),
updatedAt: new Date(row.updatedAt).toISOString(),
createdBy: row.created_by,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
}

View file

@ -13,15 +13,6 @@ 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';
@ -65,30 +56,22 @@ function parseBody<T>(schema: { parse: (v: unknown) => T }, body: unknown): T {
* REST controller for workspace page templates (R3.6).
*
* Permission matrix:
* GET /v1/templates templates:read
* GET /v1/templates/:id templates:read
* POST /v1/templates templates:create
* PATCH /v1/templates/:id owner-or-manage (service enforces)
* DELETE /v1/templates/:id owner-or-manage (service enforces)
* POST /v1/templates/:id/instantiate templates:read
* PATCH /v1/templates/:id/default templates:manage
* GET /acadenice/templates templates:read
* GET /acadenice/templates/:id templates:read
* POST /acadenice/templates templates:create
* PATCH /acadenice/templates/:id owner-or-manage (service enforces)
* DELETE /acadenice/templates/:id owner-or-manage (service enforces)
* POST /acadenice/templates/:id/instantiate templates:read
* PATCH /acadenice/templates/:id/default templates:manage
*/
@ApiTags('templates')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/templates')
@Controller('acadenice/templates')
export class TemplatesController {
constructor(
private readonly templateService: TemplateService,
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')
@ -100,12 +83,6 @@ 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')
@ -116,14 +93,7 @@ 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(
@ -139,14 +109,6 @@ 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,
@ -160,12 +122,6 @@ 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(
@ -178,16 +134,7 @@ 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(
@ -200,12 +147,6 @@ 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')

View file

@ -35,13 +35,13 @@ const BUILT_IN_TEMPLATES: ReadonlyArray<BuiltInSpec> = [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Meeting Note' }] },
{ type: 'paragraph', content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Date: ' }, { type: 'text', text: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Attendees' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Agenda' }] },
{ type: 'orderedList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'orderedList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Notes' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Action Items' }] },
{ type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: false }, content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: false }, content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
],
},
},
@ -59,11 +59,11 @@ const BUILT_IN_TEMPLATES: ReadonlyArray<BuiltInSpec> = [
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Scope' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'What is in scope / out of scope?' }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Stakeholders' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Timeline' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Key milestones and deadlines.' }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Risks' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
],
},
},
@ -77,11 +77,11 @@ const BUILT_IN_TEMPLATES: ReadonlyArray<BuiltInSpec> = [
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Daily Standup' }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Yesterday' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Today' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Blockers' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
],
},
},
@ -95,11 +95,11 @@ const BUILT_IN_TEMPLATES: ReadonlyArray<BuiltInSpec> = [
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Weekly Review' }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Wins' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Challenges' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Next Week Priorities' }] },
{ type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: false }, content: [{ type: 'paragraph', content: [] }] }] },
{ type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: false }, content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] }] },
],
},
},

View file

@ -157,7 +157,7 @@ export class TemplateService {
${dto.icon ?? null},
${dto.coverUrl ?? null},
${dto.category ?? null},
${content as unknown as string}::jsonb,
${JSON.stringify(content)}::jsonb,
${dto.sourcePageId ?? null},
false,
${userId}
@ -227,7 +227,7 @@ export class TemplateService {
}
const contentParam = dto.content
? sql`${dto.content as unknown as string}::jsonb`
? sql`${JSON.stringify(dto.content)}::jsonb`
: sql`content`;
const result = await sql<TemplateDto>`
@ -331,7 +331,7 @@ export class TemplateService {
${workspaceId},
${userId},
${userId},
${content as unknown as string}::jsonb,
${JSON.stringify(content)}::jsonb,
${textContent ?? ''},
${ydoc},
${nextPosition},
@ -461,7 +461,7 @@ export class TemplateService {
${spec.description},
${spec.icon ?? null},
${spec.category},
${spec.content as unknown as string}::jsonb,
${JSON.stringify(spec.content)}::jsonb,
true,
${systemUserId}
)

View file

@ -15,7 +15,7 @@ import { Kysely, sql } from 'kysely';
*
* UNIQUE(workspace_id, keyword) prevents collision within the same workspace.
* Both systems and custom commands share the slash menu the runtime fetches
* custom ones via GET /api/v1/slash-commands and merges at menu-open time.
* custom ones via GET /api/acadenice/slash-commands and merges at menu-open time.
*
* Idempotent: ifNotExists on every CREATE so migration re-runs never fail.
*/

View file

@ -12,8 +12,6 @@ import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { cleanupOpenApiDoc } from 'nestjs-zod';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@ -115,38 +113,6 @@ async function bootstrap() {
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
app.enableShutdownHooks();
// Swagger UI — enabled in dev/staging by default; opt-in for production via env
const swaggerEnabled =
process.env.NODE_ENV !== 'production' ||
process.env.SWAGGER_ENABLED === 'true';
if (swaggerEnabled) {
const swaggerConfig = new DocumentBuilder()
.setTitle('AcadeDoc API')
.setDescription(
'API officielle AcadeDoc — endpoints v1 (acadenice fork extensions on Docmost upstream)',
)
.setVersion('1.0')
.addBearerAuth()
.addCookieAuth('authToken')
.addTag('templates', 'Templates de pages')
.addTag('sync-blocks', 'Sync blocks (cross-page content)')
.addTag('audit-log', 'Audit log (read-only)')
.addTag('api-keys', 'Personal API tokens')
.addTag('clipper', 'Web clipper')
.addTag('graph', 'Knowledge graph')
.addTag('rbac', 'RBAC permissions/roles')
.addTag('comments', 'Page + row comments')
.addTag('notifications', 'Notifications + preferences')
.addTag('security', 'OIDC / SSO config')
.addTag('backlinks', 'Bidirectional backlinks')
.addTag('slash-commands', 'Custom slash commands')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, cleanupOpenApiDoc(document));
}
const logger = new Logger('NestApplication');
process.on('unhandledRejection', (reason, promise) => {

View file

@ -1,107 +0,0 @@
# AcadeDoc API — Developer Documentation
## Swagger UI
Available at `/api/docs` when the server is running in dev or staging mode.
Production: disabled by default. Enable via `SWAGGER_ENABLED=true` env var.
```
http://localhost:3000/api/docs # Swagger UI (interactive)
http://localhost:3000/api/docs-json # OpenAPI 3 JSON spec
```
Access requires the workspace subdomain (or hostname middleware) to be set up correctly. The Swagger UI itself is public (no auth wall) but all endpoints inside it require a Bearer token.
## Authentication
All `/api/v1/*` endpoints require a Bearer JWT or `authToken` cookie from a successful login.
To authenticate in Swagger UI:
1. Open `/api/docs`
2. Click "Authorize" (top right)
3. Paste your JWT in the `BearerAuth` field
4. Click "Authorize" and close
To get a JWT via curl:
```bash
curl -X POST https://your-workspace.acadedoc.io/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "password": "..."}'
```
Use the `token` field from the response.
## Example: Bearer authenticated request
```bash
export TOKEN="eyJ..."
export BASE="https://your-workspace.acadedoc.io"
curl -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/templates"
```
## Exporting the OpenAPI spec
```bash
curl -s http://localhost:3000/api/docs-json -o openapi.json
```
## Generating a TypeScript SDK client
Using `openapi-generator-cli` (requires Java or Docker):
```bash
# Install the CLI globally
npm install -g @openapitools/openapi-generator-cli
# Generate TypeScript-Fetch client
openapi-generator-cli generate \
-i openapi.json \
-g typescript-fetch \
-o ./sdk/acadedoc-client \
--additional-properties=typescriptThreePlus=true,npmName=acadedoc-client,npmVersion=1.0.0
# Or using npx (no global install)
npx @openapitools/openapi-generator-cli generate \
-i http://localhost:3000/api/docs-json \
-g typescript-fetch \
-o ./sdk/acadedoc-client
```
Supported generators: `typescript-fetch`, `typescript-axios`, `typescript-node`.
## Generating a Python SDK
```bash
openapi-generator-cli generate \
-i openapi.json \
-g python \
-o ./sdk/acadedoc-python \
--additional-properties=packageName=acadedoc_client
```
## Tags reference
| Tag | Controller prefix | Description |
|-----|------------------|-------------|
| `templates` | `/api/v1/templates` | Page templates CRUD + instantiate |
| `sync-blocks` | `/api/v1/sync-blocks` | Cross-page shared content blocks |
| `audit-log` | `/api/v1/audit-log` | Read-only workspace audit trail (admin) |
| `api-keys` | `/api/v1/api-keys` | Personal access tokens |
| `clipper` | `/api/v1/clipper` | Web clipper import + token management |
| `graph` | `/api/v1/graph` | Knowledge graph (nodes + edges) |
| `rbac` | `/api/v1/permissions`, `/api/v1/roles`, `/api/v1/users/:id/roles` | Role-based access control |
| `comments` | `/api/v1/page-comments`, `/api/v1/row-comments` | Page and Baserow row comments |
| `notifications` | `/api/v1/notifications`, `/api/v1/notification-preferences` | Notifications + preferences |
| `security` | `/api/v1/security` | OIDC/SSO configuration status |
| `backlinks` | `/api/v1/pages/:pageId/backlinks` | Bidirectional page backlinks |
| `slash-commands` | `/api/v1/slash-commands` | Custom editor slash commands |
## Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `SWAGGER_ENABLED` | unset | Set to `true` to enable Swagger UI in production |
| `NODE_ENV` | `development` | Swagger auto-enabled when not `production` |

76
pnpm-lock.yaml generated
View file

@ -624,9 +624,6 @@ importers:
'@nestjs/schedule':
specifier: ^6.1.3
version: 6.1.3(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)
'@nestjs/swagger':
specifier: ^11.4.2
version: 11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
'@nestjs/terminus':
specifier: ^11.1.1
version: 11.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -729,9 +726,6 @@ importers:
nestjs-pino:
specifier: ^4.6.1
version: 4.6.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2)
nestjs-zod:
specifier: ^5.3.0
version: 5.3.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6)
nodemailer:
specifier: ^8.0.5
version: 8.0.5
@ -3013,9 +3007,6 @@ packages:
'@mermaid-js/parser@1.0.1':
resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==}
'@microsoft/tsdoc@0.16.0':
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
@ -3215,23 +3206,6 @@ packages:
peerDependencies:
typescript: '>=4.8.2'
'@nestjs/swagger@11.4.2':
resolution: {integrity: sha512-aBihEogDMj/bLEcaqhkvyX/ZVWUw/bmnhKzR0zwUoyGJikvZyaq7rOPYl/H7Lxkkr3c90SJxyuv1AX2UT1WKlw==}
peerDependencies:
'@fastify/static': ^8.0.0 || ^9.0.0
'@nestjs/common': ^11.0.1
'@nestjs/core': ^11.0.1
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
'@fastify/static':
optional: true
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/terminus@11.1.1':
resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==}
peerDependencies:
@ -4477,9 +4451,6 @@ packages:
cpu: [x64]
os: [win32]
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
@ -8943,17 +8914,6 @@ packages:
pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
rxjs: ^7.1.0
nestjs-zod@5.3.0:
resolution: {integrity: sha512-QY6imXm9heMOpWigjFHgMWPvc1ZQHeNQ7pdogo9Q5xj5F8HpqZ972vKlVdkaTyzYlOXJP/yVy3wlF1EjubDQPg==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0
rxjs: ^7.0.0
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
'@nestjs/swagger':
optional: true
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@ -10380,9 +10340,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
swagger-ui-dist@5.32.4:
resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==}
symbol-observable@4.0.0:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'}
@ -13884,8 +13841,6 @@ snapshots:
dependencies:
langium: 4.2.1
'@microsoft/tsdoc@0.16.0': {}
'@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.13(hono@4.12.14)
@ -14122,22 +14077,6 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/swagger@11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
js-yaml: 4.1.1
lodash: 4.18.1
path-to-regexp: 8.4.0
reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.4
optionalDependencies:
'@fastify/static': 9.1.3
class-transformer: 0.5.1
class-validator: 0.15.1
'@nestjs/terminus@11.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -15326,8 +15265,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.3':
optional: true
'@scarf/scarf@1.4.0': {}
'@selderee/plugin-htmlparser2@0.11.0':
dependencies:
domhandler: 5.0.3
@ -20539,15 +20476,6 @@ snapshots:
pino-http: 11.0.0
rxjs: 7.8.2
nestjs-zod@5.3.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6):
dependencies:
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1
rxjs: 7.8.2
zod: 4.3.6
optionalDependencies:
'@nestjs/swagger': 11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
node-abort-controller@3.1.1: {}
node-addon-api@8.5.0: {}
@ -22312,10 +22240,6 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
swagger-ui-dist@5.32.4:
dependencies:
'@scarf/scarf': 1.4.0
symbol-observable@4.0.0: {}
symbol-tree@3.2.4: {}