Compare commits
No commits in common. "d120619245232356caf22b32db6798ab09c34d03" and "e027ae93572e9a6f05e3604bc3fbacb1ab4348d4" have entirely different histories.
d120619245
...
e027ae9357
63 changed files with 360 additions and 1434 deletions
|
|
@ -30,8 +30,8 @@ The native Docmost system already provides the complete mention notification pip
|
||||||
R3.7 adds:
|
R3.7 adds:
|
||||||
1. `MentionDetectorService` — pure service that walks Tiptap JSON and extracts user mentions (no DB). Used by the emitter and independently testable.
|
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.).
|
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`.
|
3. `AcadeniceNotificationsController` — facade over native `NotificationService`, prefix `/api/acadenice/notifications`.
|
||||||
4. `NotificationPreferencesController` — GET/PUT `/api/v1/notification-preferences` (reads/writes native `users.settings.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.
|
5. Frontend `/notifications` page — full inbox using native `NotificationItem` component.
|
||||||
6. Frontend `/settings/notifications` preferences page — dedicated toggles via Acadenice API.
|
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/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-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/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/notifications.controller.ts` | REST facade /api/acadenice/notifications |
|
||||||
| `apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts` | REST /api/v1/notification-preferences |
|
| `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/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/notifications.controller.spec.ts` | 10 unit tests |
|
||||||
| `apps/server/src/core/acadenice/notifications/spec/notification-preferences.spec.ts` | 4 unit tests |
|
| `apps/server/src/core/acadenice/notifications/spec/notification-preferences.spec.ts` | 4 unit tests |
|
||||||
|
|
@ -77,13 +77,13 @@ R3.7 adds:
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/notifications paginated list
|
GET /api/acadenice/notifications paginated list
|
||||||
GET /api/v1/notifications/unread-count unread badge count (polled 30s)
|
GET /api/acadenice/notifications/unread-count unread badge count (polled 30s)
|
||||||
POST /api/v1/notifications/read-all mark all read
|
POST /api/acadenice/notifications/read-all mark all read
|
||||||
POST /api/v1/notifications/mark-read bulk mark read
|
POST /api/acadenice/notifications/mark-read bulk mark read
|
||||||
POST /api/v1/notifications/:id/read single mark read
|
POST /api/acadenice/notifications/:id/read single mark read
|
||||||
GET /api/v1/notification-preferences get prefs
|
GET /api/acadenice/notification-preferences get prefs
|
||||||
PUT /api/v1/notification-preferences update prefs
|
PUT /api/acadenice/notification-preferences update prefs
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tests
|
### 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)
|
- 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
|
- Audit log creation/modification/suppression de commandes
|
||||||
- data-testid sur les elements cles pour les tests e2e Playwright
|
- 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.
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
||||||
|
|
@ -6,17 +6,17 @@ import {
|
||||||
} from "../types/api-key.types";
|
} from "../types/api-key.types";
|
||||||
|
|
||||||
export async function listAcadeniceApiKeys(): Promise<AcadeniceApiKey[]> {
|
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;
|
return resp.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAcadeniceApiKey(
|
export async function createAcadeniceApiKey(
|
||||||
data: CreateAcadeniceApiKeyRequest,
|
data: CreateAcadeniceApiKeyRequest,
|
||||||
): Promise<CreateAcadeniceApiKeyResponse> {
|
): Promise<CreateAcadeniceApiKeyResponse> {
|
||||||
const resp = await api.post("/v1/api-keys", data);
|
const resp = await api.post("/acadenice/api-keys", data);
|
||||||
return resp.data;
|
return resp.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeAcadeniceApiKey(id: string): Promise<void> {
|
export async function revokeAcadeniceApiKey(id: string): Promise<void> {
|
||||||
await api.delete(`/v1/api-keys/${id}`);
|
await api.delete(`/acadenice/api-keys/${id}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,6 @@ import {
|
||||||
export async function getAcadeniceAuditLogs(
|
export async function getAcadeniceAuditLogs(
|
||||||
params: AcadeniceAuditLogQuery = {},
|
params: AcadeniceAuditLogQuery = {},
|
||||||
): Promise<AcadeniceAuditLogPage> {
|
): Promise<AcadeniceAuditLogPage> {
|
||||||
const resp = await api.get("/v1/audit-log", { params });
|
const resp = await api.get("/acadenice/audit-log", { params });
|
||||||
return resp.data;
|
return resp.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { clipperClient } from '../services/clipper-client';
|
import { clipperClient } from '../services/clipper-client';
|
||||||
|
|
||||||
vi.mock('axios');
|
jest.mock('axios');
|
||||||
const mockedAxios = axios as unknown as {
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
get: ReturnType<typeof vi.fn>;
|
|
||||||
post: ReturnType<typeof vi.fn>;
|
|
||||||
delete: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sampleToken = {
|
const sampleToken = {
|
||||||
id: 'tk-1',
|
id: 'tk-1',
|
||||||
|
|
@ -20,29 +15,29 @@ const sampleToken = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('clipperClient', () => {
|
describe('clipperClient', () => {
|
||||||
afterEach(() => vi.resetAllMocks());
|
afterEach(() => jest.resetAllMocks());
|
||||||
|
|
||||||
describe('listTokens', () => {
|
describe('listTokens', () => {
|
||||||
it('GETs /api/v1/clipper/tokens', async () => {
|
it('GETs /api/acadenice/clipper/tokens', async () => {
|
||||||
mockedAxios.get = vi.fn().mockResolvedValue({ data: [sampleToken] });
|
mockedAxios.get = jest.fn().mockResolvedValue({ data: [sampleToken] });
|
||||||
const result = await clipperClient.listTokens();
|
const result = await clipperClient.listTokens();
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].id).toBe('tk-1');
|
expect(result[0].id).toBe('tk-1');
|
||||||
expect(mockedAxios.get).toHaveBeenCalledWith('/api/v1/clipper/tokens');
|
expect(mockedAxios.get).toHaveBeenCalledWith('/api/acadenice/clipper/tokens');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createToken', () => {
|
describe('createToken', () => {
|
||||||
it('POSTs and returns token + info', async () => {
|
it('POSTs and returns token + info', async () => {
|
||||||
const response = { token: 'clip_abc123', tokenInfo: sampleToken };
|
const response = { token: 'clip_abc123', tokenInfo: sampleToken };
|
||||||
mockedAxios.post = vi.fn().mockResolvedValue({ data: response });
|
mockedAxios.post = jest.fn().mockResolvedValue({ data: response });
|
||||||
|
|
||||||
const result = await clipperClient.createToken({ label: 'My token', duration_days: 30 });
|
const result = await clipperClient.createToken({ label: 'My token', duration_days: 30 });
|
||||||
|
|
||||||
expect(result.token).toBe('clip_abc123');
|
expect(result.token).toBe('clip_abc123');
|
||||||
expect(result.tokenInfo.label).toBe('My token');
|
expect(result.tokenInfo.label).toBe('My token');
|
||||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
'/api/v1/clipper/tokens',
|
'/api/acadenice/clipper/tokens',
|
||||||
{ label: 'My token', duration_days: 30 },
|
{ label: 'My token', duration_days: 30 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -50,9 +45,9 @@ describe('clipperClient', () => {
|
||||||
|
|
||||||
describe('revokeToken', () => {
|
describe('revokeToken', () => {
|
||||||
it('DELETEs the token by id', async () => {
|
it('DELETEs the token by id', async () => {
|
||||||
mockedAxios.delete = vi.fn().mockResolvedValue({});
|
mockedAxios.delete = jest.fn().mockResolvedValue({});
|
||||||
await clipperClient.revokeToken('tk-1');
|
await clipperClient.revokeToken('tk-1');
|
||||||
expect(mockedAxios.delete).toHaveBeenCalledWith('/api/v1/clipper/tokens/tk-1');
|
expect(mockedAxios.delete).toHaveBeenCalledWith('/api/acadenice/clipper/tokens/tk-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const BASE = '/api/v1/clipper';
|
const BASE = '/api/acadenice/clipper';
|
||||||
|
|
||||||
export interface ClipperTokenInfo {
|
export interface ClipperTokenInfo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -24,13 +24,13 @@ export interface CreateTokenResponse {
|
||||||
|
|
||||||
export const clipperClient = {
|
export const clipperClient = {
|
||||||
listTokens(): Promise<ClipperTokenInfo[]> {
|
listTokens(): Promise<ClipperTokenInfo[]> {
|
||||||
return axios.get<ClipperTokenInfo[]>(`${BASE}/tokens`).then((r) => r.data);
|
return axios.get<ClipperTokenInfo[]>(`${BASE}/tokens`).then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> {
|
createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> {
|
||||||
return axios
|
return axios
|
||||||
.post<CreateTokenResponse>(`${BASE}/tokens`, payload)
|
.post<CreateTokenResponse>(`${BASE}/tokens`, payload)
|
||||||
.then((r) => r.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
revokeToken(tokenId: string): Promise<void> {
|
revokeToken(tokenId: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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.
|
* api-client is fully mocked — no network calls.
|
||||||
* Routes updated to REST conventions in R5.2.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
vi.mock("@/lib/api-client", () => ({
|
vi.mock("@/lib/api-client", () => ({
|
||||||
default: {
|
default: {
|
||||||
get: vi.fn(),
|
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
patch: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -26,16 +22,12 @@ import {
|
||||||
countRowComments,
|
countRowComments,
|
||||||
} from "../services/row-comments-client";
|
} from "../services/row-comments-client";
|
||||||
|
|
||||||
const mockApi = api as unknown as {
|
// Cast through unknown — the mock replaces AxiosInstance methods with vi.fn().
|
||||||
get: ReturnType<typeof vi.fn>;
|
const mockApi = api as unknown as { post: ReturnType<typeof vi.fn> };
|
||||||
post: ReturnType<typeof vi.fn>;
|
|
||||||
patch: ReturnType<typeof vi.fn>;
|
|
||||||
delete: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TABLE_ID = "table-1";
|
const TABLE_ID = "table-1";
|
||||||
const ROW_ID = "row-42";
|
const ROW_ID = "row-42";
|
||||||
const COMMENT_ID = "c-00000000-0000-0000-0000-000000000000";
|
const COMMENT_ID = "c-00000000";
|
||||||
|
|
||||||
function makeComment() {
|
function makeComment() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -59,30 +51,30 @@ describe("row-comments-client", () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("listRowComments GETs /v1/row-comments with query params", async () => {
|
it("listRowComments posts to /acadenice/row-comments/list", async () => {
|
||||||
const comment = makeComment();
|
const comment = makeComment();
|
||||||
mockApi.get.mockResolvedValueOnce({ data: [comment] });
|
mockApi.post.mockResolvedValueOnce({ data: [comment] });
|
||||||
|
|
||||||
const result = await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID });
|
const result = await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID });
|
||||||
|
|
||||||
expect(mockApi.get).toHaveBeenCalledWith(
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
"/v1/row-comments",
|
"/acadenice/row-comments/list",
|
||||||
{ params: { tableId: TABLE_ID, rowId: ROW_ID } },
|
{ tableId: TABLE_ID, rowId: ROW_ID },
|
||||||
);
|
);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].id).toBe(COMMENT_ID);
|
expect(result[0].id).toBe(COMMENT_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("listRowComments forwards resolved filter as query param", async () => {
|
it("listRowComments forwards resolved filter", async () => {
|
||||||
mockApi.get.mockResolvedValueOnce({ data: [] });
|
mockApi.post.mockResolvedValueOnce({ data: [] });
|
||||||
await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID, resolved: true });
|
await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID, resolved: true });
|
||||||
expect(mockApi.get).toHaveBeenCalledWith(
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
"/v1/row-comments",
|
"/acadenice/row-comments/list",
|
||||||
{ params: { tableId: TABLE_ID, rowId: ROW_ID, resolved: true } },
|
{ 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();
|
const comment = makeComment();
|
||||||
mockApi.post.mockResolvedValueOnce({ data: comment });
|
mockApi.post.mockResolvedValueOnce({ data: comment });
|
||||||
|
|
||||||
|
|
@ -93,48 +85,54 @@ describe("row-comments-client", () => {
|
||||||
};
|
};
|
||||||
const result = await createRowComment(params);
|
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);
|
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();
|
const comment = makeComment();
|
||||||
mockApi.patch.mockResolvedValueOnce({ data: comment });
|
mockApi.post.mockResolvedValueOnce({ data: comment });
|
||||||
|
|
||||||
await updateRowComment(COMMENT_ID, JSON.stringify({ type: "doc" }));
|
await updateRowComment(COMMENT_ID, JSON.stringify({ type: "doc" }));
|
||||||
|
|
||||||
expect(mockApi.patch).toHaveBeenCalledWith(
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
`/v1/row-comments/${COMMENT_ID}`,
|
"/acadenice/row-comments/update",
|
||||||
{ content: JSON.stringify({ type: "doc" }) },
|
{ 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 };
|
const comment = { ...makeComment(), isResolved: true };
|
||||||
mockApi.patch.mockResolvedValueOnce({ data: comment });
|
mockApi.post.mockResolvedValueOnce({ data: comment });
|
||||||
|
|
||||||
const result = await resolveRowComment(COMMENT_ID, true);
|
const result = await resolveRowComment(COMMENT_ID, true);
|
||||||
|
|
||||||
expect(mockApi.patch).toHaveBeenCalledWith(
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
`/v1/row-comments/${COMMENT_ID}/resolve`,
|
"/acadenice/row-comments/resolve",
|
||||||
{ resolved: true },
|
{ commentId: COMMENT_ID, resolved: true },
|
||||||
);
|
);
|
||||||
expect(result.isResolved).toBe(true);
|
expect(result.isResolved).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deleteRowComment DELETEs /v1/row-comments/:id", async () => {
|
it("deleteRowComment posts to /acadenice/row-comments/delete", async () => {
|
||||||
mockApi.delete.mockResolvedValueOnce({});
|
mockApi.post.mockResolvedValueOnce({ data: undefined });
|
||||||
await deleteRowComment(COMMENT_ID);
|
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 () => {
|
it("countRowComments returns numeric count", async () => {
|
||||||
mockApi.get.mockResolvedValueOnce({ data: { count: 7 } });
|
mockApi.post.mockResolvedValueOnce({ data: { count: 7 } });
|
||||||
const count = await countRowComments(TABLE_ID, ROW_ID);
|
const count = await countRowComments(TABLE_ID, ROW_ID);
|
||||||
expect(count).toBe(7);
|
expect(count).toBe(7);
|
||||||
expect(mockApi.get).toHaveBeenCalledWith(
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
"/v1/row-comments/count",
|
"/acadenice/row-comments/count",
|
||||||
{ params: { tableId: TABLE_ID, rowId: ROW_ID } },
|
{ tableId: TABLE_ID, rowId: ROW_ID },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,20 @@ export interface CreateRowCommentParams {
|
||||||
export async function listRowComments(
|
export async function listRowComments(
|
||||||
params: ListRowCommentsParams,
|
params: ListRowCommentsParams,
|
||||||
): Promise<RowComment[]> {
|
): 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;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRowComment(
|
export async function createRowComment(
|
||||||
params: CreateRowCommentParams,
|
params: CreateRowCommentParams,
|
||||||
): Promise<RowComment> {
|
): Promise<RowComment> {
|
||||||
const res = await api.post<RowComment>("/v1/row-comments", params);
|
const res = await api.post<RowComment>(
|
||||||
|
"/acadenice/row-comments/create",
|
||||||
|
params,
|
||||||
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,7 +54,8 @@ export async function updateRowComment(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<RowComment> {
|
): Promise<RowComment> {
|
||||||
const res = await api.patch<RowComment>(`/v1/row-comments/${commentId}`, {
|
const res = await api.post<RowComment>("/acadenice/row-comments/update", {
|
||||||
|
commentId,
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|
@ -58,23 +65,24 @@ export async function resolveRowComment(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
resolved: boolean,
|
resolved: boolean,
|
||||||
): Promise<RowComment> {
|
): Promise<RowComment> {
|
||||||
const res = await api.patch<RowComment>(
|
const res = await api.post<RowComment>("/acadenice/row-comments/resolve", {
|
||||||
`/v1/row-comments/${commentId}/resolve`,
|
commentId,
|
||||||
{ resolved },
|
resolved,
|
||||||
);
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRowComment(commentId: string): Promise<void> {
|
export async function deleteRowComment(commentId: string): Promise<void> {
|
||||||
await api.delete(`/v1/row-comments/${commentId}`);
|
await api.post("/acadenice/row-comments/delete", { commentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countRowComments(
|
export async function countRowComments(
|
||||||
tableId: string,
|
tableId: string,
|
||||||
rowId: string,
|
rowId: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const res = await api.get<{ count: number }>("/v1/row-comments/count", {
|
const res = await api.post<{ count: number }>(
|
||||||
params: { tableId, rowId },
|
"/acadenice/row-comments/count",
|
||||||
});
|
{ tableId, rowId },
|
||||||
|
);
|
||||||
return res.data.count;
|
return res.data.count;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useMemo } from "react";
|
||||||
* Reads the user's Acadenice permissions from the auth context.
|
* Reads the user's Acadenice permissions from the auth context.
|
||||||
*
|
*
|
||||||
* Why not a server call here:
|
* 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
|
* 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
|
* 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
|
* JS-readable cookie. It does NOT perform a new HTTP request — callers that need
|
||||||
|
|
|
||||||
|
|
@ -56,17 +56,16 @@ export function createBridgeClient(bridgeUrl: string): AxiosInstance {
|
||||||
timeout: 15_000,
|
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) => {
|
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 cookieToken = readTokenFromCookie();
|
||||||
|
const envToken =
|
||||||
|
typeof process !== "undefined"
|
||||||
|
? (process.env as unknown as { VITE_BRIDGE_TOKEN?: string })?.VITE_BRIDGE_TOKEN
|
||||||
|
: undefined;
|
||||||
const token = cookieToken || envToken;
|
const token = cookieToken || envToken;
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers["Authorization"] = `Bearer ${token}`;
|
config.headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ beforeEach(() => {
|
||||||
describe("fetchGraph", () => {
|
describe("fetchGraph", () => {
|
||||||
it("calls /acadenice/graph with no params when all optional", async () => {
|
it("calls /acadenice/graph with no params when all optional", async () => {
|
||||||
await fetchGraph({});
|
await fetchGraph({});
|
||||||
expect(mockGet).toHaveBeenCalledWith("/v1/graph");
|
expect(mockGet).toHaveBeenCalledWith("/acadenice/graph");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appends depth query param", async () => {
|
it("appends depth query param", async () => {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export async function fetchGraph(
|
||||||
query.includeOrphans = String(params.includeOrphans);
|
query.includeOrphans = String(params.includeOrphans);
|
||||||
|
|
||||||
const qs = new URLSearchParams(query).toString();
|
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>;
|
return api.get(url) as unknown as Promise<GraphResponse>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,11 @@ describe("notificationsClient", () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("list: calls GET /api/v1/notifications", async () => {
|
it("list: calls GET /api/acadenice/notifications", async () => {
|
||||||
mockApi.get.mockResolvedValue({ data: { items: [], meta: {} } });
|
mockApi.get.mockResolvedValue({ data: { items: [], meta: {} } });
|
||||||
const result = await notificationsClient.list();
|
const result = await notificationsClient.list();
|
||||||
expect(mockApi.get).toHaveBeenCalledWith(
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
"/api/v1/notifications",
|
"/api/acadenice/notifications",
|
||||||
expect.objectContaining({ params: {} }),
|
expect.objectContaining({ params: {} }),
|
||||||
);
|
);
|
||||||
expect(result).toEqual({ items: [], meta: {} });
|
expect(result).toEqual({ items: [], meta: {} });
|
||||||
|
|
@ -43,16 +43,16 @@ describe("notificationsClient", () => {
|
||||||
mockApi.get.mockResolvedValue({ data: { items: [] } });
|
mockApi.get.mockResolvedValue({ data: { items: [] } });
|
||||||
await notificationsClient.list({ tab: "direct" });
|
await notificationsClient.list({ tab: "direct" });
|
||||||
expect(mockApi.get).toHaveBeenCalledWith(
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
"/api/v1/notifications",
|
"/api/acadenice/notifications",
|
||||||
expect.objectContaining({ params: { tab: "direct" } }),
|
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 } });
|
mockApi.get.mockResolvedValue({ data: { count: 3 } });
|
||||||
const result = await notificationsClient.unreadCount();
|
const result = await notificationsClient.unreadCount();
|
||||||
expect(mockApi.get).toHaveBeenCalledWith(
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
"/api/v1/notifications/unread-count",
|
"/api/acadenice/notifications/unread-count",
|
||||||
);
|
);
|
||||||
expect(result).toEqual({ count: 3 });
|
expect(result).toEqual({ count: 3 });
|
||||||
});
|
});
|
||||||
|
|
@ -61,7 +61,7 @@ describe("notificationsClient", () => {
|
||||||
mockApi.post.mockResolvedValue({});
|
mockApi.post.mockResolvedValue({});
|
||||||
await notificationsClient.markRead(["id-1", "id-2"]);
|
await notificationsClient.markRead(["id-1", "id-2"]);
|
||||||
expect(mockApi.post).toHaveBeenCalledWith(
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
"/api/v1/notifications/mark-read",
|
"/api/acadenice/notifications/mark-read",
|
||||||
{ notificationIds: ["id-1", "id-2"] },
|
{ notificationIds: ["id-1", "id-2"] },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -70,7 +70,7 @@ describe("notificationsClient", () => {
|
||||||
mockApi.post.mockResolvedValue({});
|
mockApi.post.mockResolvedValue({});
|
||||||
await notificationsClient.markAllRead();
|
await notificationsClient.markAllRead();
|
||||||
expect(mockApi.post).toHaveBeenCalledWith(
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
"/api/v1/notifications/read-all",
|
"/api/acadenice/notifications/read-all",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -78,11 +78,11 @@ describe("notificationsClient", () => {
|
||||||
mockApi.post.mockResolvedValue({});
|
mockApi.post.mockResolvedValue({});
|
||||||
await notificationsClient.markOne("notif-uuid");
|
await notificationsClient.markOne("notif-uuid");
|
||||||
expect(mockApi.post).toHaveBeenCalledWith(
|
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 = {
|
const prefs = {
|
||||||
emailMentions: true,
|
emailMentions: true,
|
||||||
emailReplies: false,
|
emailReplies: false,
|
||||||
|
|
@ -94,12 +94,12 @@ describe("notificationsClient", () => {
|
||||||
mockApi.get.mockResolvedValue({ data: prefs });
|
mockApi.get.mockResolvedValue({ data: prefs });
|
||||||
const result = await notificationsClient.getPreferences();
|
const result = await notificationsClient.getPreferences();
|
||||||
expect(mockApi.get).toHaveBeenCalledWith(
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
"/api/v1/notification-preferences",
|
"/api/acadenice/notification-preferences",
|
||||||
);
|
);
|
||||||
expect(result).toEqual(prefs);
|
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 payload = { emailMentions: false };
|
||||||
const updatedPrefs = {
|
const updatedPrefs = {
|
||||||
emailMentions: false,
|
emailMentions: false,
|
||||||
|
|
@ -112,7 +112,7 @@ describe("notificationsClient", () => {
|
||||||
mockApi.put.mockResolvedValue({ data: updatedPrefs });
|
mockApi.put.mockResolvedValue({ data: updatedPrefs });
|
||||||
const result = await notificationsClient.updatePreferences(payload);
|
const result = await notificationsClient.updatePreferences(payload);
|
||||||
expect(mockApi.put).toHaveBeenCalledWith(
|
expect(mockApi.put).toHaveBeenCalledWith(
|
||||||
"/api/v1/notification-preferences",
|
"/api/acadenice/notification-preferences",
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
expect(result).toEqual(updatedPrefs);
|
expect(result).toEqual(updatedPrefs);
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ export interface NotificationPreferences {
|
||||||
|
|
||||||
export type UpdatePreferencesPayload = Partial<NotificationPreferences>;
|
export type UpdatePreferencesPayload = Partial<NotificationPreferences>;
|
||||||
|
|
||||||
const BASE = "/api/v1/notifications";
|
const BASE = "/api/acadenice/notifications";
|
||||||
const PREFS_BASE = "/api/v1/notification-preferences";
|
const PREFS_BASE = "/api/acadenice/notification-preferences";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP client for Acadenice notification endpoints (R3.7).
|
* HTTP client for Acadenice notification endpoints (R3.7).
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@ import api from "@/lib/api-client";
|
||||||
import { OidcStatusResponse } from "../types/oidc-status.types";
|
import { OidcStatusResponse } from "../types/oidc-status.types";
|
||||||
|
|
||||||
export async function getOidcStatus(): Promise<OidcStatusResponse> {
|
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;
|
return resp.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export const MY_PERMISSIONS_QUERY_KEY = [
|
||||||
/**
|
/**
|
||||||
* Source-of-truth hook for the current user's Acadenice permissions.
|
* 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
|
* 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
|
* (R2.1). We keep a 60s React Query staleTime to mirror that TTL: refetching
|
||||||
* sooner only hits the same Redis value.
|
* sooner only hits the same Redis value.
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,14 @@ import {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST client for the Acadenice RBAC API (R2.1 backend).
|
* 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
|
* Note : Docmost's axios interceptor returns `response.data` directly, so the
|
||||||
* return value of `api.get(...)` is already the body payload.
|
* return value of `api.get(...)` is already the body payload.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function getPermissionsCatalog(): Promise<IPermissionDescriptor[]> {
|
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[]
|
IPermissionDescriptor[]
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
@ -29,17 +29,17 @@ export async function getPermissionsCatalog(): Promise<IPermissionDescriptor[]>
|
||||||
*/
|
*/
|
||||||
export async function getMyPermissions(): Promise<IMyPermissionsResponse> {
|
export async function getMyPermissions(): Promise<IMyPermissionsResponse> {
|
||||||
return api.get(
|
return api.get(
|
||||||
"/v1/permissions/me",
|
"/acadenice/permissions/me",
|
||||||
) as unknown as Promise<IMyPermissionsResponse>;
|
) as unknown as Promise<IMyPermissionsResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRoles(): Promise<IRole[]> {
|
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> {
|
export async function getRole(roleId: string): Promise<IRoleWithPermissions> {
|
||||||
return api.get(
|
return api.get(
|
||||||
`/v1/roles/${roleId}`,
|
`/acadenice/roles/${roleId}`,
|
||||||
) as unknown as Promise<IRoleWithPermissions>;
|
) as unknown as Promise<IRoleWithPermissions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ export async function createRole(
|
||||||
payload: ICreateRolePayload,
|
payload: ICreateRolePayload,
|
||||||
): Promise<IRoleWithPermissions> {
|
): Promise<IRoleWithPermissions> {
|
||||||
return api.post(
|
return api.post(
|
||||||
"/v1/roles",
|
"/acadenice/roles",
|
||||||
payload,
|
payload,
|
||||||
) as unknown as Promise<IRoleWithPermissions>;
|
) as unknown as Promise<IRoleWithPermissions>;
|
||||||
}
|
}
|
||||||
|
|
@ -57,20 +57,20 @@ export async function updateRole(
|
||||||
payload: IUpdateRolePayload,
|
payload: IUpdateRolePayload,
|
||||||
): Promise<IRole> {
|
): Promise<IRole> {
|
||||||
return api.patch(
|
return api.patch(
|
||||||
`/v1/roles/${roleId}`,
|
`/acadenice/roles/${roleId}`,
|
||||||
payload,
|
payload,
|
||||||
) as unknown as Promise<IRole>;
|
) as unknown as Promise<IRole>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(roleId: string): Promise<void> {
|
export async function deleteRole(roleId: string): Promise<void> {
|
||||||
await api.delete(`/v1/roles/${roleId}`);
|
await api.delete(`/acadenice/roles/${roleId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setRolePermissions(
|
export async function setRolePermissions(
|
||||||
roleId: string,
|
roleId: string,
|
||||||
permissions: string[],
|
permissions: string[],
|
||||||
): Promise<IRoleWithPermissions> {
|
): Promise<IRoleWithPermissions> {
|
||||||
return api.put(`/v1/roles/${roleId}/permissions`, {
|
return api.put(`/acadenice/roles/${roleId}/permissions`, {
|
||||||
permissions,
|
permissions,
|
||||||
}) as unknown as Promise<IRoleWithPermissions>;
|
}) as unknown as Promise<IRoleWithPermissions>;
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +78,7 @@ export async function setRolePermissions(
|
||||||
export async function listUserRoles(
|
export async function listUserRoles(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<IUserRoleAssignment[]> {
|
): 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[]
|
IUserRoleAssignment[]
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +87,7 @@ export async function assignRolesToUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
roleIds: string[],
|
roleIds: string[],
|
||||||
): Promise<{ ok: true }> {
|
): Promise<{ ok: true }> {
|
||||||
return api.post(`/v1/users/${userId}/roles`, {
|
return api.post(`/acadenice/users/${userId}/roles`, {
|
||||||
roleIds,
|
roleIds,
|
||||||
}) as unknown as Promise<{ ok: true }>;
|
}) as unknown as Promise<{ ok: true }>;
|
||||||
}
|
}
|
||||||
|
|
@ -96,5 +96,5 @@ export async function unassignRoleFromUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
roleId: string,
|
roleId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await api.delete(`/v1/users/${userId}/roles/${roleId}`);
|
await api.delete(`/acadenice/users/${userId}/roles/${roleId}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
* `is_admin_wildcard` is a cheap boolean so the UI can branch without
|
||||||
* scanning the array.
|
* scanning the array.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -35,25 +35,25 @@ export interface CreateSlashCommandPayload {
|
||||||
|
|
||||||
export type UpdateSlashCommandPayload = Partial<CreateSlashCommandPayload>;
|
export type UpdateSlashCommandPayload = Partial<CreateSlashCommandPayload>;
|
||||||
|
|
||||||
const BASE = '/api/v1/slash-commands';
|
const BASE = '/api/acadenice/slash-commands';
|
||||||
|
|
||||||
export const slashCommandsClient = {
|
export const slashCommandsClient = {
|
||||||
list(): Promise<SlashCommandDto[]> {
|
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> {
|
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> {
|
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> {
|
update(id: string, payload: UpdateSlashCommandPayload): Promise<SlashCommandDto> {
|
||||||
return axios
|
return axios
|
||||||
.patch<SlashCommandDto>(`${BASE}/${id}`, payload)
|
.patch<SlashCommandDto>(`${BASE}/${id}`, payload)
|
||||||
.then((r) => r.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
delete(id: string): Promise<void> {
|
delete(id: string): Promise<void> {
|
||||||
|
|
@ -63,6 +63,6 @@ export const slashCommandsClient = {
|
||||||
toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> {
|
toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> {
|
||||||
return axios
|
return axios
|
||||||
.patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled })
|
.patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled })
|
||||||
.then((r) => r.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export const SYNC_BLOCK_QUERY_KEY = "sync-block";
|
||||||
* SSE hook for sync block realtime updates (R4.2).
|
* SSE hook for sync block realtime updates (R4.2).
|
||||||
*
|
*
|
||||||
* Listens to the NestJS EventEmitter2 via an SSE endpoint:
|
* 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,
|
* 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
|
* 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(() => {
|
useEffect(() => {
|
||||||
if (!masterId) return;
|
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;
|
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,19 @@ export interface SyncBlockUsageDto {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE = '/api/v1/sync-blocks';
|
const BASE = '/api/acadenice/sync-blocks';
|
||||||
|
|
||||||
export const syncBlocksClient = {
|
export const syncBlocksClient = {
|
||||||
create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> {
|
create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> {
|
||||||
return axios.post<SyncBlockDto>(BASE, { content }).then((r) => r.data);
|
return axios.post<SyncBlockDto>(BASE, { content }).then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
get(id: string): Promise<SyncBlockDto> {
|
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> {
|
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> {
|
delete(id: string): Promise<void> {
|
||||||
|
|
@ -37,6 +37,6 @@ export const syncBlocksClient = {
|
||||||
},
|
},
|
||||||
|
|
||||||
usages(id: string): Promise<SyncBlockUsageDto[]> {
|
usages(id: string): Promise<SyncBlockUsageDto[]> {
|
||||||
return axios.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`).then((r) => r.data);
|
return axios.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`).then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,11 @@ const sampleTemplate = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("templatesClient", () => {
|
describe("templatesClient", () => {
|
||||||
it("list — GET /api/v1/templates", async () => {
|
it("list — GET /api/acadenice/templates", async () => {
|
||||||
vi.mocked(axios.get).mockResolvedValueOnce({ data: [sampleTemplate] });
|
vi.mocked(axios.get).mockResolvedValueOnce({ data: [sampleTemplate] });
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
const result = await client.list();
|
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);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -45,68 +45,68 @@ describe("templatesClient", () => {
|
||||||
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
|
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
await client.list({ category: "meeting" });
|
await client.list({ category: "meeting" });
|
||||||
expect(axios.get).toHaveBeenCalledWith("/api/v1/templates", {
|
expect(axios.get).toHaveBeenCalledWith("/api/acadenice/templates", {
|
||||||
params: { category: "meeting" },
|
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 });
|
vi.mocked(axios.get).mockResolvedValueOnce({ data: sampleTemplate });
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
const result = await client.get("tmpl-1");
|
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");
|
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 });
|
vi.mocked(axios.post).mockResolvedValueOnce({ data: sampleTemplate });
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
const result = await client.create({ name: "Meeting Note", category: "meeting" });
|
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",
|
name: "Meeting Note",
|
||||||
category: "meeting",
|
category: "meeting",
|
||||||
});
|
});
|
||||||
expect(result.name).toBe("Meeting Note");
|
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({
|
vi.mocked(axios.patch).mockResolvedValueOnce({
|
||||||
data: { ...sampleTemplate, name: "Updated" },
|
data: { ...sampleTemplate, name: "Updated" },
|
||||||
});
|
});
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
const result = await client.update("tmpl-1", { name: "Updated" });
|
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",
|
name: "Updated",
|
||||||
});
|
});
|
||||||
expect(result.name).toBe("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({});
|
vi.mocked(axios.delete).mockResolvedValueOnce({});
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
await expect(client.delete("tmpl-1")).resolves.toBeUndefined();
|
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" } });
|
vi.mocked(axios.post).mockResolvedValueOnce({ data: { pageId: "p1", slugId: "slug1" } });
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
const result = await client.instantiate("tmpl-1", { spaceId: "space-1" });
|
const result = await client.instantiate("tmpl-1", { spaceId: "space-1" });
|
||||||
expect(axios.post).toHaveBeenCalledWith(
|
expect(axios.post).toHaveBeenCalledWith(
|
||||||
"/api/v1/templates/tmpl-1/instantiate",
|
"/api/acadenice/templates/tmpl-1/instantiate",
|
||||||
{ spaceId: "space-1" },
|
{ spaceId: "space-1" },
|
||||||
);
|
);
|
||||||
expect(result.pageId).toBe("p1");
|
expect(result.pageId).toBe("p1");
|
||||||
expect(result.slugId).toBe("slug1");
|
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({
|
vi.mocked(axios.patch).mockResolvedValueOnce({
|
||||||
data: { ...sampleTemplate, isWorkspaceDefault: true },
|
data: { ...sampleTemplate, isWorkspaceDefault: true },
|
||||||
});
|
});
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
const result = await client.setDefault("tmpl-1");
|
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);
|
expect(result.isWorkspaceDefault).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -122,7 +122,7 @@ describe("templatesClient", () => {
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
await client.instantiate("tmpl-1", { spaceId: "space-1", parentPageId: "parent-1" });
|
await client.instantiate("tmpl-1", { spaceId: "space-1", parentPageId: "parent-1" });
|
||||||
expect(axios.post).toHaveBeenCalledWith(
|
expect(axios.post).toHaveBeenCalledWith(
|
||||||
"/api/v1/templates/tmpl-1/instantiate",
|
"/api/acadenice/templates/tmpl-1/instantiate",
|
||||||
{ spaceId: "space-1", parentPageId: "parent-1" },
|
{ spaceId: "space-1", parentPageId: "parent-1" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -36,25 +36,25 @@ export interface InstantiatePayload {
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE = '/api/v1/templates';
|
const BASE = '/api/acadenice/templates';
|
||||||
|
|
||||||
export const templatesClient = {
|
export const templatesClient = {
|
||||||
list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> {
|
list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> {
|
||||||
return axios
|
return axios
|
||||||
.get<TemplateDto[]>(BASE, { params: opts })
|
.get<TemplateDto[]>(BASE, { params: opts })
|
||||||
.then((r) => r.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
get(id: string): Promise<TemplateDto> {
|
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> {
|
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> {
|
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> {
|
delete(id: string): Promise<void> {
|
||||||
|
|
@ -67,10 +67,10 @@ export const templatesClient = {
|
||||||
): Promise<{ pageId: string; slugId: string }> {
|
): Promise<{ pageId: string; slugId: string }> {
|
||||||
return axios
|
return axios
|
||||||
.post<{ pageId: string; slugId: string }>(`${BASE}/${id}/instantiate`, payload)
|
.post<{ pageId: string; slugId: string }>(`${BASE}/${id}/instantiate`, payload)
|
||||||
.then((r) => r.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
setDefault(id: string): Promise<TemplateDto> {
|
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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,12 @@ import PageHeader from "@/features/page/components/header/page-header.tsx";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
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 MemoizedFullEditor = React.memo(FullEditor);
|
||||||
const MemoizedPageHeader = React.memo(PageHeader);
|
const MemoizedPageHeader = React.memo(PageHeader);
|
||||||
const MemoizedHistoryModal = React.memo(HistoryModal);
|
const MemoizedHistoryModal = React.memo(HistoryModal);
|
||||||
|
|
@ -46,17 +43,6 @@ export default function Page() {
|
||||||
|
|
||||||
function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||||
const { t } = useTranslation();
|
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 {
|
const {
|
||||||
data: page,
|
data: page,
|
||||||
|
|
@ -126,16 +112,6 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
/>
|
/>
|
||||||
<MemoizedHistoryModal pageId={page.id} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export async function sendClip(
|
||||||
apiToken: string,
|
apiToken: string,
|
||||||
payload: ClipPayload,
|
payload: ClipPayload,
|
||||||
): Promise<ClipResult> {
|
): Promise<ClipResult> {
|
||||||
const url = `${apiUrl.replace(/\/$/, '')}/api/v1/clipper/import`;
|
const url = `${apiUrl.replace(/\/$/, '')}/api/acadenice/clipper/import`;
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. On open: load settings, query the active tab for page data.
|
* 1. On open: load settings, query the active tab for page data.
|
||||||
* 2. User fills in form and clicks "Clip".
|
* 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.
|
* 4. On success: show result link. On error: show typed error message.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,14 @@ describe('sendClip', () => {
|
||||||
mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' });
|
mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' });
|
||||||
await sendClip(BASE_URL, TOKEN, samplePayload);
|
await sendClip(BASE_URL, TOKEN, samplePayload);
|
||||||
const [url] = (global.fetch as any).mock.calls[0];
|
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 () => {
|
it('strips trailing slash from apiUrl', async () => {
|
||||||
mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' });
|
mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' });
|
||||||
await sendClip(`${BASE_URL}/`, TOKEN, samplePayload);
|
await sendClip(`${BASE_URL}/`, TOKEN, samplePayload);
|
||||||
const [url] = (global.fetch as any).mock.calls[0];
|
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 () => {
|
it('throws ApiError with statusCode on 401', async () => {
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@
|
||||||
"@nestjs/platform-fastify": "^11.1.19",
|
"@nestjs/platform-fastify": "^11.1.19",
|
||||||
"@nestjs/platform-socket.io": "^11.1.19",
|
"@nestjs/platform-socket.io": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/swagger": "^11.4.2",
|
|
||||||
"@nestjs/terminus": "^11.1.1",
|
"@nestjs/terminus": "^11.1.1",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.19",
|
"@nestjs/websockets": "^11.1.19",
|
||||||
|
|
@ -94,7 +93,6 @@
|
||||||
"nestjs-cls": "^6.2.0",
|
"nestjs-cls": "^6.2.0",
|
||||||
"nestjs-kysely": "^3.1.2",
|
"nestjs-kysely": "^3.1.2",
|
||||||
"nestjs-pino": "^4.6.1",
|
"nestjs-pino": "^4.6.1",
|
||||||
"nestjs-zod": "^5.3.0",
|
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { AcadeniceApiKeyService } from './services/api-key.service';
|
||||||
* AcadeniceApiKeysModule — personal access tokens (R4.5).
|
* AcadeniceApiKeysModule — personal access tokens (R4.5).
|
||||||
*
|
*
|
||||||
* Endpoints:
|
* Endpoints:
|
||||||
* GET /api/v1/api-keys
|
* GET /api/acadenice/api-keys
|
||||||
* POST /api/v1/api-keys
|
* POST /api/acadenice/api-keys
|
||||||
* DELETE /api/v1/api-keys/:id
|
* DELETE /api/acadenice/api-keys/:id
|
||||||
*
|
*
|
||||||
* Token format: acdk_<64 hex chars>
|
* Token format: acdk_<64 hex chars>
|
||||||
* Storage: bcrypt hash only — plaintext returned once at creation.
|
* Storage: bcrypt hash only — plaintext returned once at creation.
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,6 @@ import {
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -33,20 +25,15 @@ import { CreateApiKeySchema } from '../dto/api-key.dto';
|
||||||
/**
|
/**
|
||||||
* AcadeniceApiKeyController — personal access tokens (R4.5).
|
* AcadeniceApiKeyController — personal access tokens (R4.5).
|
||||||
*
|
*
|
||||||
* GET /api/v1/api-keys List caller's tokens (no hashes)
|
* GET /api/acadenice/api-keys List caller's tokens (no hashes)
|
||||||
* POST /api/v1/api-keys Create token — returns plain once
|
* POST /api/acadenice/api-keys Create token — returns plain once
|
||||||
* DELETE /api/v1/api-keys/:id Revoke
|
* DELETE /api/acadenice/api-keys/:id Revoke
|
||||||
*/
|
*/
|
||||||
@ApiTags('api-keys')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/api-keys')
|
@Controller('acadenice/api-keys')
|
||||||
export class AcadeniceApiKeyController {
|
export class AcadeniceApiKeyController {
|
||||||
constructor(private readonly apiKeyService: AcadeniceApiKeyService) {}
|
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()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
|
|
@ -55,11 +42,6 @@ export class AcadeniceApiKeyController {
|
||||||
return this.apiKeyService.list(user.id, workspace.id);
|
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()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
async create(
|
async create(
|
||||||
|
|
@ -82,12 +64,6 @@ export class AcadeniceApiKeyController {
|
||||||
return { token: result.token, keyInfo: result.row };
|
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')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async revoke(
|
async revoke(
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { AcadeniceAuditLogService } from './services/audit-log.service';
|
||||||
/**
|
/**
|
||||||
* AcadeniceAuditLogModule — R4.5.
|
* 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.
|
* Reads directly from the `audit` table via Kysely.
|
||||||
* No EE dependency.
|
* No EE dependency.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,6 @@ import {
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiQuery,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -27,28 +20,15 @@ import { AuditLogQuerySchema } from '../dto/audit-log-query.dto';
|
||||||
/**
|
/**
|
||||||
* AcadeniceAuditLogController — R4.5 read-only audit log.
|
* AcadeniceAuditLogController — R4.5 read-only audit log.
|
||||||
*
|
*
|
||||||
* GET /api/v1/audit-log
|
* GET /api/acadenice/audit-log
|
||||||
* Auth : JWT (admin or owner only)
|
* Auth : JWT (admin or owner only)
|
||||||
* Query : limit, offset, userId, action, since (ISO), until (ISO)
|
* Query : limit, offset, userId, action, since (ISO), until (ISO)
|
||||||
*/
|
*/
|
||||||
@ApiTags('audit-log')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/audit-log')
|
@Controller('acadenice/audit-log')
|
||||||
export class AcadeniceAuditLogController {
|
export class AcadeniceAuditLogController {
|
||||||
constructor(private readonly auditLogService: AcadeniceAuditLogService) {}
|
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()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { PageContentUpdatedListener } from './events/page-content-updated.listen
|
||||||
* - BacklinkParserService : walks Tiptap JSON, extracts links
|
* - BacklinkParserService : walks Tiptap JSON, extracts links
|
||||||
* - BacklinkIndexerService : delete-then-insert reindex per page
|
* - BacklinkIndexerService : delete-then-insert reindex per page
|
||||||
* - BacklinkService : permission-aware query API
|
* - 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
|
* - PageContentUpdatedListener: reacts to collaboration saves
|
||||||
*
|
*
|
||||||
* Dependencies:
|
* Dependencies:
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,6 @@ import {
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -22,17 +15,15 @@ import { BacklinkService, BacklinksResult } from '../services/backlink.service';
|
||||||
/**
|
/**
|
||||||
* REST controller for the backlinks feature.
|
* 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
|
* Authentication: JWT (JwtAuthGuard). The user's read access to each source
|
||||||
* page is enforced inside BacklinkService (space_members / public space check).
|
* page is enforced inside BacklinkService (space_members / public space check).
|
||||||
* We do not require a special Acadenice permission here — any authenticated
|
* We do not require a special Acadenice permission here — any authenticated
|
||||||
* workspace member can query backlinks for pages they can read.
|
* workspace member can query backlinks for pages they can read.
|
||||||
*/
|
*/
|
||||||
@ApiTags('backlinks')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/pages')
|
@Controller('acadenice/pages')
|
||||||
export class BacklinksController {
|
export class BacklinksController {
|
||||||
constructor(private readonly backlinkService: BacklinkService) {}
|
constructor(private readonly backlinkService: BacklinkService) {}
|
||||||
|
|
||||||
|
|
@ -42,11 +33,6 @@ export class BacklinksController {
|
||||||
* The response is filtered to pages the authenticated user can read.
|
* The response is filtered to pages the authenticated user can read.
|
||||||
* Returns an empty result (not 404) when no backlinks exist.
|
* 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')
|
@Get(':pageId/backlinks')
|
||||||
async getBacklinks(
|
async getBacklinks(
|
||||||
@Param('pageId', ParseUUIDPipe) pageId: string,
|
@Param('pageId', ParseUUIDPipe) pageId: string,
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,6 @@ import {
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiHeader,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -58,16 +49,15 @@ const TOKEN_HEADER = 'x-clipper-token';
|
||||||
/**
|
/**
|
||||||
* ClipperController — Web Clipper REST surface (R4.3).
|
* 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).
|
* Auth: X-Clipper-Token header (token validated against DB hash).
|
||||||
* Body: ImportClipDto (Zod).
|
* Body: ImportClipDto (Zod).
|
||||||
*
|
*
|
||||||
* POST /api/v1/clipper/tokens (JWT auth)
|
* POST /api/acadenice/clipper/tokens (JWT auth)
|
||||||
* GET /api/v1/clipper/tokens (JWT auth)
|
* GET /api/acadenice/clipper/tokens (JWT auth)
|
||||||
* DELETE /api/v1/clipper/tokens/:id (JWT auth)
|
* DELETE /api/acadenice/clipper/tokens/:id (JWT auth)
|
||||||
*/
|
*/
|
||||||
@ApiTags('clipper')
|
@Controller('acadenice/clipper')
|
||||||
@Controller('v1/clipper')
|
|
||||||
export class ClipperController {
|
export class ClipperController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly clipperService: ClipperService,
|
private readonly clipperService: ClipperService,
|
||||||
|
|
@ -78,12 +68,6 @@ export class ClipperController {
|
||||||
// Import endpoint — authenticated via X-Clipper-Token header
|
// 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')
|
@Post('import')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
async import(
|
async import(
|
||||||
|
|
@ -117,11 +101,6 @@ export class ClipperController {
|
||||||
// Token management — JWT auth (user manages their own tokens)
|
// 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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('tokens')
|
@Post('tokens')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
|
@ -150,10 +129,6 @@ export class ClipperController {
|
||||||
return { token: result.token, tokenInfo: result.row };
|
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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('tokens')
|
@Get('tokens')
|
||||||
async listTokens(
|
async listTokens(
|
||||||
|
|
@ -163,13 +138,6 @@ export class ClipperController {
|
||||||
return this.tokenService.list(user.id, workspace.id);
|
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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Delete('tokens/:id')
|
@Delete('tokens/:id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { z } from 'zod';
|
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
|
* html_selection is expected to arrive pre-sanitized (DOMPurify on the
|
||||||
* extension side). The server sanitizes again server-side via the existing
|
* 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>;
|
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.
|
* duration: days until expiry, or null for no expiry.
|
||||||
*/
|
*/
|
||||||
export const CreateClipperTokenDto = z.object({
|
export const CreateClipperTokenDto = z.object({
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,6 @@ import {
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -30,23 +23,15 @@ import { ResolvePageCommentDto } from '../dto/comment.dto';
|
||||||
* requiring an open collab websocket.
|
* requiring an open collab websocket.
|
||||||
*
|
*
|
||||||
* Endpoints:
|
* Endpoints:
|
||||||
* POST /api/v1/page-comments/resolve
|
* POST /api/acadenice/page-comments/resolve
|
||||||
*/
|
*/
|
||||||
@ApiTags('comments')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/page-comments')
|
@Controller('acadenice/page-comments')
|
||||||
export class PageCommentsController {
|
export class PageCommentsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pageCommentResolveService: PageCommentResolveService,
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('resolve')
|
@Post('resolve')
|
||||||
async resolve(
|
async resolve(
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,13 @@
|
||||||
import {
|
import {
|
||||||
Body,
|
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Post,
|
||||||
Get,
|
Body,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Req,
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiQuery,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -32,74 +17,42 @@ import {
|
||||||
CreateRowCommentDto,
|
CreateRowCommentDto,
|
||||||
UpdateRowCommentDto,
|
UpdateRowCommentDto,
|
||||||
ResolveRowCommentDto,
|
ResolveRowCommentDto,
|
||||||
|
DeleteRowCommentDto,
|
||||||
ListRowCommentsDto,
|
ListRowCommentsDto,
|
||||||
} from '../dto/comment.dto';
|
} from '../dto/comment.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RowCommentsController — threaded comments on Baserow rows (R3.8 / R5.2).
|
* RowCommentsController — threaded comments on Baserow rows (R3.8).
|
||||||
*
|
*
|
||||||
* All routes are protected by JwtAuthGuard. Acadenice RBAC permission
|
* All routes are protected by JwtAuthGuard. Acadenice RBAC permission
|
||||||
* checks are enforced per-action directly in this controller using the
|
* checks are enforced per-action directly in this controller using the
|
||||||
* user's `acadenice_permissions` JWT claim.
|
* user's `acadenice_permissions` JWT claim.
|
||||||
*
|
*
|
||||||
* Endpoints (REST-compliant, R5.2):
|
* Endpoints:
|
||||||
* GET /api/v1/row-comments list thread for (tableId, rowId) via query params
|
* POST /api/acadenice/row-comments/list list thread for (tableId, rowId)
|
||||||
* POST /api/v1/row-comments create root or reply — 201 Created
|
* POST /api/acadenice/row-comments/create create root or reply
|
||||||
* PATCH /api/v1/row-comments/:id edit own comment — 200 OK
|
* POST /api/acadenice/row-comments/update edit own comment
|
||||||
* PATCH /api/v1/row-comments/:id/resolve resolve/unresolve root thread — 200 OK
|
* POST /api/acadenice/row-comments/resolve resolve/unresolve root thread
|
||||||
* DELETE /api/v1/row-comments/:id delete own (or moderate) — 204 No Content
|
* POST /api/acadenice/row-comments/delete delete own (or moderate)
|
||||||
* GET /api/v1/row-comments/count count comments for a row
|
* POST /api/acadenice/row-comments/count count comments for a row
|
||||||
*/
|
*/
|
||||||
@ApiTags('comments')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/row-comments')
|
@Controller('acadenice/row-comments')
|
||||||
export class RowCommentsController {
|
export class RowCommentsController {
|
||||||
constructor(private readonly rowCommentService: RowCommentService) {}
|
constructor(private readonly rowCommentService: RowCommentService) {}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'List row comments', description: 'Returns paginated comment thread for a Baserow row.' })
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiQuery({ name: 'tableId', required: true, type: 'string', description: 'Baserow table UUID' })
|
@Post('list')
|
||||||
@ApiQuery({ name: 'rowId', required: true, type: 'string', description: 'Baserow row ID' })
|
|
||||||
@ApiQuery({ name: 'limit', required: false, type: 'number' })
|
|
||||||
@ApiQuery({ name: 'cursor', required: false, type: 'string' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Paginated comment list' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@Get()
|
|
||||||
async list(
|
async list(
|
||||||
@Query() query: ListRowCommentsDto,
|
@Body() dto: ListRowCommentsDto,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@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.' })
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiQuery({ name: 'tableId', required: true, type: 'string' })
|
@Post('create')
|
||||||
@ApiQuery({ name: 'rowId', required: true, type: 'string' })
|
|
||||||
@ApiResponse({ status: 200, description: '{ count: number }' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@Get('count')
|
|
||||||
async count(
|
|
||||||
@Query('tableId') tableId: string,
|
|
||||||
@Query('rowId') rowId: string,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const count = await this.rowCommentService.countByRow(
|
|
||||||
workspace.id,
|
|
||||||
tableId,
|
|
||||||
rowId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { count };
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Create row comment', description: 'Creates a new comment or reply on a Baserow row.' })
|
|
||||||
@ApiBody({ schema: { type: 'object', required: ['tableId', 'rowId', 'content'], properties: { tableId: { type: 'string' }, rowId: { type: 'string' }, content: { type: 'string' }, parentId: { type: 'string', format: 'uuid', description: 'Set for replies' } } }, description: 'Comment payload' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Comment created' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@Post()
|
|
||||||
@HttpCode(HttpStatus.CREATED)
|
|
||||||
async create(
|
async create(
|
||||||
@Body() dto: CreateRowCommentDto,
|
@Body() dto: CreateRowCommentDto,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
|
|
@ -108,63 +61,44 @@ export class RowCommentsController {
|
||||||
return this.rowCommentService.create(workspace.id, user.id, dto);
|
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.' })
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiParam({ name: 'id', description: 'Comment UUID', type: 'string' })
|
@Post('update')
|
||||||
@ApiBody({ schema: { type: 'object', required: ['content'], properties: { content: { type: 'string' } } }, description: 'Updated content' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Updated comment' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Caller does not own comment' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Comment not found' })
|
|
||||||
@Patch(':id')
|
|
||||||
async update(
|
async update(
|
||||||
@Param('id', ParseUUIDPipe) commentId: string,
|
|
||||||
@Body() dto: UpdateRowCommentDto,
|
@Body() dto: UpdateRowCommentDto,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return this.rowCommentService.update(
|
return this.rowCommentService.update(
|
||||||
commentId,
|
dto.commentId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
user.id,
|
user.id,
|
||||||
dto,
|
dto,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Resolve row comment thread', description: 'Toggles resolved state of a comment thread.' })
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiParam({ name: 'id', description: 'Root comment UUID', type: 'string' })
|
@Post('resolve')
|
||||||
@ApiBody({ schema: { type: 'object', required: ['resolved'], properties: { resolved: { type: 'boolean' } } }, description: 'Resolve payload' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Updated comment' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Comment not found' })
|
|
||||||
@Patch(':id/resolve')
|
|
||||||
async resolve(
|
async resolve(
|
||||||
@Param('id', ParseUUIDPipe) commentId: string,
|
|
||||||
@Body() dto: ResolveRowCommentDto,
|
@Body() dto: ResolveRowCommentDto,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return this.rowCommentService.resolve(
|
return this.rowCommentService.resolve(
|
||||||
commentId,
|
dto.commentId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
user.id,
|
user.id,
|
||||||
dto,
|
dto,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Delete row comment', description: 'Deletes a comment. Moderators (admin:* or comments:moderate) can delete any comment.' })
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiParam({ name: 'id', description: 'Comment UUID', type: 'string' })
|
@Post('delete')
|
||||||
@ApiResponse({ status: 204, description: 'Deleted' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Caller does not own comment and is not a moderator' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Comment not found' })
|
|
||||||
@Delete(':id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async delete(
|
async delete(
|
||||||
@Param('id', ParseUUIDPipe) commentId: string,
|
@Body() dto: DeleteRowCommentDto,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
): Promise<void> {
|
) {
|
||||||
// Moderators (admin:* or comments:moderate) can delete any comment
|
// Moderators (admin:* or comments:moderate) can delete any comment
|
||||||
const perms: string[] =
|
const perms: string[] =
|
||||||
(req as any)?.user?.acadenice_permissions ?? [];
|
(req as any)?.user?.acadenice_permissions ?? [];
|
||||||
|
|
@ -172,10 +106,27 @@ export class RowCommentsController {
|
||||||
perms.includes('admin:*') || perms.includes('comments:moderate');
|
perms.includes('admin:*') || perms.includes('comments:moderate');
|
||||||
|
|
||||||
await this.rowCommentService.delete(
|
await this.rowCommentService.delete(
|
||||||
commentId,
|
dto.commentId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
user.id,
|
user.id,
|
||||||
isModerator,
|
isModerator,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return { message: 'Comment deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('count')
|
||||||
|
async count(
|
||||||
|
@Body() body: { tableId: string; rowId: string },
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const count = await this.rowCommentService.countByRow(
|
||||||
|
workspace.id,
|
||||||
|
body.tableId,
|
||||||
|
body.rowId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { count };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,19 +32,21 @@ export class CreateRowCommentDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateRowCommentDto {
|
export class UpdateRowCommentDto {
|
||||||
|
@IsUUID()
|
||||||
|
commentId: string;
|
||||||
|
|
||||||
@IsJSON()
|
@IsJSON()
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ResolveRowCommentDto {
|
export class ResolveRowCommentDto {
|
||||||
|
@IsUUID()
|
||||||
|
commentId: string;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated No longer used — delete is handled via path param.
|
|
||||||
* Kept for backwards-compat during transition.
|
|
||||||
*/
|
|
||||||
export class DeleteRowCommentDto {
|
export class DeleteRowCommentDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
commentId: string;
|
commentId: string;
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ describe('RowCommentService', () => {
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.update('c-id', WORKSPACE, USER_B, {
|
service.update('c-id', WORKSPACE, USER_B, {
|
||||||
|
commentId: 'c-id',
|
||||||
content: JSON.stringify({ type: 'doc' }),
|
content: JSON.stringify({ type: 'doc' }),
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
@ -100,6 +101,7 @@ describe('RowCommentService', () => {
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.resolve('reply-id', WORKSPACE, USER_A, {
|
service.resolve('reply-id', WORKSPACE, USER_A, {
|
||||||
|
commentId: 'reply-id',
|
||||||
resolved: true,
|
resolved: true,
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
@ -111,6 +113,7 @@ describe('RowCommentService', () => {
|
||||||
// sql`` will throw on bare {} db — catch it and verify we got past the guard
|
// sql`` will throw on bare {} db — catch it and verify we got past the guard
|
||||||
await expect(
|
await expect(
|
||||||
service.resolve(comment.id, WORKSPACE, USER_A, {
|
service.resolve(comment.id, WORKSPACE, USER_A, {
|
||||||
|
commentId: comment.id,
|
||||||
resolved: true,
|
resolved: true,
|
||||||
}),
|
}),
|
||||||
).rejects.not.toBeInstanceOf(BadRequestException);
|
).rejects.not.toBeInstanceOf(BadRequestException);
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,15 @@ import { RowCommentService } from '../services/row-comment.service';
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for RowCommentsController (R3.8 / R5.2).
|
* Unit tests for RowCommentsController (R3.8).
|
||||||
*
|
*
|
||||||
* RowCommentService is mocked — no DB, no NestJS HTTP stack.
|
* RowCommentService is mocked — no DB, no NestJS HTTP stack.
|
||||||
* Routes refactored to REST conventions in R5.2:
|
|
||||||
* GET /v1/row-comments (list)
|
|
||||||
* GET /v1/row-comments/count (count)
|
|
||||||
* POST /v1/row-comments (create — 201)
|
|
||||||
* PATCH /v1/row-comments/:id (update)
|
|
||||||
* PATCH /v1/row-comments/:id/resolve (resolve)
|
|
||||||
* DELETE /v1/row-comments/:id (delete — 204)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const WORKSPACE_ID = 'ws-0001-0000-0000-000000000000';
|
const WORKSPACE_ID = 'ws-0001-0000-0000-000000000000';
|
||||||
const USER_ID = 'user-0000-0000-0000-000000000000';
|
const USER_ID = 'user-0000-0000-0000-000000000000';
|
||||||
const TABLE_ID = 'table-1';
|
const TABLE_ID = 'table-1';
|
||||||
const ROW_ID = 'row-1';
|
const ROW_ID = 'row-1';
|
||||||
const COMMENT_ID = 'c-0000-0000-0000-000000000000';
|
|
||||||
|
|
||||||
function makeUser() {
|
function makeUser() {
|
||||||
return { id: USER_ID, name: 'Alice' } as any;
|
return { id: USER_ID, name: 'Alice' } as any;
|
||||||
|
|
@ -33,7 +25,7 @@ function makeWorkspace() {
|
||||||
|
|
||||||
function makeComment(overrides: Record<string, unknown> = {}) {
|
function makeComment(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
id: COMMENT_ID,
|
id: 'c-id',
|
||||||
workspaceId: WORKSPACE_ID,
|
workspaceId: WORKSPACE_ID,
|
||||||
tableId: TABLE_ID,
|
tableId: TABLE_ID,
|
||||||
rowId: ROW_ID,
|
rowId: ROW_ID,
|
||||||
|
|
@ -71,15 +63,15 @@ describe('RowCommentsController', () => {
|
||||||
controller = module.get(RowCommentsController);
|
controller = module.get(RowCommentsController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('list delegates to service via query params', async () => {
|
it('list delegates to service', async () => {
|
||||||
service.list.mockResolvedValueOnce([makeComment()]);
|
service.list.mockResolvedValueOnce([makeComment()]);
|
||||||
const query = { tableId: TABLE_ID, rowId: ROW_ID };
|
const dto = { tableId: TABLE_ID, rowId: ROW_ID };
|
||||||
const result = await controller.list(query, makeUser(), makeWorkspace());
|
const result = await controller.list(dto, makeUser(), makeWorkspace());
|
||||||
expect(service.list).toHaveBeenCalledWith(WORKSPACE_ID, query);
|
expect(service.list).toHaveBeenCalledWith(WORKSPACE_ID, dto);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('create delegates to service and returns comment', async () => {
|
it('create delegates to service', async () => {
|
||||||
const comment = makeComment();
|
const comment = makeComment();
|
||||||
service.create.mockResolvedValueOnce(comment);
|
service.create.mockResolvedValueOnce(comment);
|
||||||
const dto = {
|
const dto = {
|
||||||
|
|
@ -92,41 +84,41 @@ describe('RowCommentsController', () => {
|
||||||
expect(result.tableId).toBe(TABLE_ID);
|
expect(result.tableId).toBe(TABLE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update delegates with path param commentId', async () => {
|
it('resolve delegates to service with dto', async () => {
|
||||||
const comment = makeComment();
|
|
||||||
service.update.mockResolvedValueOnce(comment);
|
|
||||||
const dto = { content: JSON.stringify({ type: 'doc' }) };
|
|
||||||
const result = await controller.update(COMMENT_ID, dto, makeUser(), makeWorkspace());
|
|
||||||
expect(service.update).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, dto);
|
|
||||||
expect(result.id).toBe(COMMENT_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolve delegates with path param commentId', async () => {
|
|
||||||
const comment = makeComment({ isResolved: true });
|
const comment = makeComment({ isResolved: true });
|
||||||
service.resolve.mockResolvedValueOnce(comment);
|
service.resolve.mockResolvedValueOnce(comment);
|
||||||
const dto = { resolved: true };
|
const dto = { commentId: 'c-id', resolved: true };
|
||||||
const result = await controller.resolve(COMMENT_ID, dto, makeUser(), makeWorkspace());
|
const result = await controller.resolve(dto, makeUser(), makeWorkspace());
|
||||||
expect(service.resolve).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, dto);
|
expect(service.resolve).toHaveBeenCalledWith('c-id', WORKSPACE_ID, USER_ID, dto);
|
||||||
expect(result.isResolved).toBe(true);
|
expect(result.isResolved).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delete passes isModerator=false for regular user', async () => {
|
it('delete passes isModerator=false for regular user', async () => {
|
||||||
service.delete.mockResolvedValueOnce(undefined);
|
service.delete.mockResolvedValueOnce(undefined);
|
||||||
const req = { user: { acadenice_permissions: ['comments:write'] } } as any;
|
const req = { user: { acadenice_permissions: ['comments:write'] } } as any;
|
||||||
await controller.delete(COMMENT_ID, makeUser(), makeWorkspace(), req);
|
const result = await controller.delete(
|
||||||
expect(service.delete).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, false);
|
{ 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 () => {
|
it('delete passes isModerator=true for admin user', async () => {
|
||||||
service.delete.mockResolvedValueOnce(undefined);
|
service.delete.mockResolvedValueOnce(undefined);
|
||||||
const req = { user: { acadenice_permissions: ['admin:*'] } } as any;
|
const req = { user: { acadenice_permissions: ['admin:*'] } } as any;
|
||||||
await controller.delete(COMMENT_ID, makeUser(), makeWorkspace(), req);
|
await controller.delete({ commentId: 'c-id' }, makeUser(), makeWorkspace(), req);
|
||||||
expect(service.delete).toHaveBeenCalledWith(COMMENT_ID, WORKSPACE_ID, USER_ID, true);
|
expect(service.delete).toHaveBeenCalledWith('c-id', WORKSPACE_ID, USER_ID, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('count returns count from service', async () => {
|
it('count returns count from service', async () => {
|
||||||
service.countByRow.mockResolvedValueOnce(3);
|
service.countByRow.mockResolvedValueOnce(3);
|
||||||
const result = await controller.count(TABLE_ID, ROW_ID, makeWorkspace());
|
const result = await controller.count(
|
||||||
|
{ tableId: TABLE_ID, rowId: ROW_ID },
|
||||||
|
makeWorkspace(),
|
||||||
|
);
|
||||||
expect(result).toEqual({ count: 3 });
|
expect(result).toEqual({ count: 3 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,6 @@ import {
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiQuery,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -22,7 +15,7 @@ import { GraphQuerySchema, GraphResponse } from '../dto/graph.dto';
|
||||||
/**
|
/**
|
||||||
* REST controller for the knowledge-graph endpoint (R3.5.1).
|
* 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
|
* Authentication: JWT (JwtAuthGuard). Permission filtering is applied inside
|
||||||
* GraphService using the same space_members / public visibility model as
|
* 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) —
|
* workspaceId is always resolved from the JWT context (AuthWorkspace) —
|
||||||
* the query param is accepted but ignored to prevent cross-workspace leaks.
|
* the query param is accepted but ignored to prevent cross-workspace leaks.
|
||||||
*/
|
*/
|
||||||
@ApiTags('graph')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/graph')
|
@Controller('acadenice/graph')
|
||||||
export class GraphController {
|
export class GraphController {
|
||||||
constructor(private readonly graphService: GraphService) {}
|
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()
|
@Get()
|
||||||
async getGraph(
|
async getGraph(
|
||||||
@Query() rawQuery: Record<string, string>,
|
@Query() rawQuery: Record<string, string>,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { GraphService } from './services/graph.service';
|
||||||
* Provides:
|
* Provides:
|
||||||
* - GraphService : builds { nodes, edges } from acadenice_backlink (R3.2)
|
* - GraphService : builds { nodes, edges } from acadenice_backlink (R3.2)
|
||||||
* with BFS traversal, permission filtering, Redis cache
|
* with BFS traversal, permission filtering, Redis cache
|
||||||
* - GraphController : REST GET /api/v1/graph
|
* - GraphController : REST GET /api/acadenice/graph
|
||||||
*
|
*
|
||||||
* Dependencies:
|
* Dependencies:
|
||||||
* - KyselyDB is global (AppModule).
|
* - KyselyDB is global (AppModule).
|
||||||
|
|
|
||||||
|
|
@ -424,24 +424,17 @@ export class GraphService {
|
||||||
pageIds: string[],
|
pageIds: string[],
|
||||||
spaceId: string | undefined,
|
spaceId: string | undefined,
|
||||||
): Promise<PageMetaRow[]> {
|
): Promise<PageMetaRow[]> {
|
||||||
// Filter out undefined / null / empty entries before binding — sql.lit
|
if (pageIds.length === 0) return [];
|
||||||
// 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 [];
|
|
||||||
|
|
||||||
try {
|
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 spaceFilter = spaceId ? sql`AND sp.id = ${spaceId}` : sql``;
|
||||||
|
|
||||||
const rows = await sql<PageMetaRow>`
|
const rows = await sql<PageMetaRow>`
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.title,
|
p.title,
|
||||||
p.slug_id AS slug,
|
p.slug,
|
||||||
p.space_id,
|
p.space_id,
|
||||||
sp.name AS space_name,
|
sp.name AS space_name,
|
||||||
p.icon
|
p.icon
|
||||||
|
|
@ -486,7 +479,7 @@ export class GraphService {
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.title,
|
p.title,
|
||||||
p.slug_id AS slug,
|
p.slug,
|
||||||
p.space_id,
|
p.space_id,
|
||||||
sp.name AS space_name,
|
sp.name AS space_name,
|
||||||
p.icon
|
p.icon
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,6 @@ import {
|
||||||
Put,
|
Put,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
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 Docmost JSONB column) — so changes are immediately visible to
|
||||||
* the native notification email pipeline.
|
* the native notification email pipeline.
|
||||||
*/
|
*/
|
||||||
@ApiTags('notifications')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/notification-preferences')
|
@Controller('acadenice/notification-preferences')
|
||||||
export class NotificationPreferencesController {
|
export class NotificationPreferencesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prefsService: NotificationPreferencesService,
|
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()
|
@Get()
|
||||||
async getPreferences(@AuthUser() user: User) {
|
async getPreferences(@AuthUser() user: User) {
|
||||||
return this.prefsService.getPreferences(user.id);
|
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()
|
@Put()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async updatePreferences(
|
async updatePreferences(
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,6 @@ import {
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiQuery,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
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).
|
* REST controller for Acadenice notification endpoints (R3.7).
|
||||||
*
|
*
|
||||||
* This controller is a thin facade over the native Docmost NotificationService.
|
* 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
|
* frontend to discover and poll them without conflicting with the native
|
||||||
* `/notifications` endpoints used by the upstream Docmost UI.
|
* `/notifications` endpoints used by the upstream Docmost UI.
|
||||||
*
|
*
|
||||||
* All mutation endpoints use the same guards and service as the native path.
|
* All mutation endpoints use the same guards and service as the native path.
|
||||||
*/
|
*/
|
||||||
@ApiTags('notifications')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/notifications')
|
@Controller('acadenice/notifications')
|
||||||
export class AcadeniceNotificationsController {
|
export class AcadeniceNotificationsController {
|
||||||
constructor(private readonly notificationService: NotificationService) {}
|
constructor(private readonly notificationService: NotificationService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/notifications
|
* GET /api/acadenice/notifications
|
||||||
* Paginated list of notifications for the authenticated user.
|
* 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()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@AuthUser() user: User,
|
@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')
|
@Get('unread-count')
|
||||||
async unreadCount(@AuthUser() user: User) {
|
async unreadCount(@AuthUser() user: User) {
|
||||||
return this.notificationService.getUnreadCount(user.id);
|
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')
|
@Post('read-all')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async readAll(@AuthUser() user: User): Promise<void> {
|
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')
|
@Post('mark-read')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async markRead(
|
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')
|
@Post(':id/read')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async markOne(
|
async markOne(
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,15 @@ import { NotificationModule } from '../../notification/notification.module';
|
||||||
* Shared with native NotificationPref UI (same keys).
|
* Shared with native NotificationPref UI (same keys).
|
||||||
*
|
*
|
||||||
* 4. AcadeniceNotificationsController
|
* 4. AcadeniceNotificationsController
|
||||||
* GET /api/v1/notifications (paginated)
|
* GET /api/acadenice/notifications (paginated)
|
||||||
* GET /api/v1/notifications/unread-count
|
* GET /api/acadenice/notifications/unread-count
|
||||||
* POST /api/v1/notifications/read-all
|
* POST /api/acadenice/notifications/read-all
|
||||||
* POST /api/v1/notifications/mark-read
|
* POST /api/acadenice/notifications/mark-read
|
||||||
* POST /api/v1/notifications/:id/read
|
* POST /api/acadenice/notifications/:id/read
|
||||||
*
|
*
|
||||||
* 5. NotificationPreferencesController
|
* 5. NotificationPreferencesController
|
||||||
* GET /api/v1/notification-preferences
|
* GET /api/acadenice/notification-preferences
|
||||||
* PUT /api/v1/notification-preferences
|
* PUT /api/acadenice/notification-preferences
|
||||||
*
|
*
|
||||||
* Depends on:
|
* Depends on:
|
||||||
* - NotificationModule (exports NotificationService — also imports it)
|
* - NotificationModule (exports NotificationService — also imports it)
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,6 @@ import {
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -20,15 +14,11 @@ import { AcadeniceRoleService } from '../services/role.service';
|
||||||
|
|
||||||
const ADMIN_WILDCARD_KEY = 'admin:*';
|
const ADMIN_WILDCARD_KEY = 'admin:*';
|
||||||
|
|
||||||
@ApiTags('rbac')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/permissions')
|
@Controller('acadenice/permissions')
|
||||||
export class AcadenicePermissionsController {
|
export class AcadenicePermissionsController {
|
||||||
constructor(private readonly roleService: AcadeniceRoleService) {}
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get()
|
@Get()
|
||||||
list() {
|
list() {
|
||||||
|
|
@ -52,9 +42,6 @@ export class AcadenicePermissionsController {
|
||||||
* holds `admin:*`, the array is short-circuited to `["admin:*"]` and we
|
* holds `admin:*`, the array is short-circuited to `["admin:*"]` and we
|
||||||
* surface the wildcard flag separately for cheap UI checks.
|
* 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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get('me')
|
@Get('me')
|
||||||
async getMyPermissions(
|
async getMyPermissions(
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,6 @@ import {
|
||||||
Put,
|
Put,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -33,28 +25,17 @@ import {
|
||||||
import { AcadenicePermissionsGuard } from '../guards/permissions.guard';
|
import { AcadenicePermissionsGuard } from '../guards/permissions.guard';
|
||||||
import { RequirePermission } from '../guards/require-permission.decorator';
|
import { RequirePermission } from '../guards/require-permission.decorator';
|
||||||
|
|
||||||
@ApiTags('rbac')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard, AcadenicePermissionsGuard)
|
@UseGuards(JwtAuthGuard, AcadenicePermissionsGuard)
|
||||||
@Controller('v1/roles')
|
@Controller('acadenice/roles')
|
||||||
export class AcadeniceRolesController {
|
export class AcadeniceRolesController {
|
||||||
constructor(private readonly roleService: AcadeniceRoleService) {}
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get()
|
@Get()
|
||||||
async list(@AuthWorkspace() workspace: Workspace) {
|
async list(@AuthWorkspace() workspace: Workspace) {
|
||||||
return this.roleService.listRoles(workspace.id);
|
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')
|
@RequirePermission('roles:manage')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@Post()
|
@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')
|
@RequirePermission('roles:manage')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|
@ -86,13 +61,6 @@ export class AcadeniceRolesController {
|
||||||
return this.roleService.getRoleWithPermissions(id, workspace.id);
|
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')
|
@RequirePermission('roles:manage')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Patch(':id')
|
@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')
|
@RequirePermission('roles:manage')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
|
@ -123,11 +85,6 @@ export class AcadeniceRolesController {
|
||||||
await this.roleService.deleteRole(id, workspace.id);
|
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')
|
@RequirePermission('roles:manage')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get(':id/permissions')
|
@Get(':id/permissions')
|
||||||
|
|
@ -142,12 +99,6 @@ export class AcadeniceRolesController {
|
||||||
return { roleId: role.id, permissions: role.permissions };
|
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')
|
@RequirePermission('roles:manage')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Put(':id/permissions')
|
@Put(':id/permissions')
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,6 @@ import {
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -41,18 +33,11 @@ import { permissionMatches } from '../permissions-catalog';
|
||||||
* The guard is intentionally hand-rolled here (no `AcadenicePermissionsGuard`)
|
* The guard is intentionally hand-rolled here (no `AcadenicePermissionsGuard`)
|
||||||
* because the access logic depends on `userId` path param vs the actor.
|
* because the access logic depends on `userId` path param vs the actor.
|
||||||
*/
|
*/
|
||||||
@ApiTags('rbac')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/users/:userId/roles')
|
@Controller('acadenice/users/:userId/roles')
|
||||||
export class AcadeniceUserRolesController {
|
export class AcadeniceUserRolesController {
|
||||||
constructor(private readonly roleService: AcadeniceRoleService) {}
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
|
|
@ -66,12 +51,6 @@ export class AcadeniceUserRolesController {
|
||||||
return this.roleService.listUserRoles(userId, workspace.id);
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post()
|
@Post()
|
||||||
async assign(
|
async assign(
|
||||||
|
|
@ -95,12 +74,6 @@ export class AcadeniceUserRolesController {
|
||||||
return { ok: true };
|
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)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@Delete(':roleId')
|
@Delete(':roleId')
|
||||||
async unassign(
|
async unassign(
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@ import {
|
||||||
Get,
|
Get,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
|
@ -28,21 +22,15 @@ export interface OidcStatusResponse {
|
||||||
/**
|
/**
|
||||||
* AcadeniceOidcStatusController — R4.5 read-only OIDC status for admins.
|
* 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)
|
* Auth : JWT (admin or owner only)
|
||||||
* Returns OIDC configuration derived from env vars — no secrets exposed.
|
* Returns OIDC configuration derived from env vars — no secrets exposed.
|
||||||
*/
|
*/
|
||||||
@ApiTags('security')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/security')
|
@Controller('acadenice/security')
|
||||||
export class AcadeniceOidcStatusController {
|
export class AcadeniceOidcStatusController {
|
||||||
constructor(private readonly env: EnvironmentService) {}
|
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')
|
@Get('oidc-status')
|
||||||
oidcStatus(@AuthUser() user: User): OidcStatusResponse {
|
oidcStatus(@AuthUser() user: User): OidcStatusResponse {
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { AcadeniceOidcStatusController } from './controllers/oidc-status.control
|
||||||
/**
|
/**
|
||||||
* AcadeniceSecurityModule — R4.5.
|
* 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.
|
* Returns OIDC configuration from environment — never exposes client_secret.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,6 @@ import {
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard';
|
import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard';
|
||||||
import { RequirePermission } from '../../rbac/guards/require-permission.decorator';
|
import { RequirePermission } from '../../rbac/guards/require-permission.decorator';
|
||||||
|
|
@ -61,10 +53,8 @@ function parseBody<T>(schema: { parse: (v: unknown) => T }, body: unknown): T {
|
||||||
* Requires permission `slash_commands:manage` (workspace Owner + Admin by
|
* Requires permission `slash_commands:manage` (workspace Owner + Admin by
|
||||||
* default via the seed — see permissions-catalog.ts).
|
* default via the seed — see permissions-catalog.ts).
|
||||||
*/
|
*/
|
||||||
@ApiTags('slash-commands')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/slash-commands')
|
@Controller('acadenice/slash-commands')
|
||||||
export class SlashCommandsController {
|
export class SlashCommandsController {
|
||||||
constructor(private readonly slashCommandService: SlashCommandService) {}
|
constructor(private readonly slashCommandService: SlashCommandService) {}
|
||||||
|
|
||||||
|
|
@ -72,9 +62,6 @@ export class SlashCommandsController {
|
||||||
* Returns all active custom slash commands for the current workspace.
|
* Returns all active custom slash commands for the current workspace.
|
||||||
* Called by the editor runtime hook on every page open.
|
* Called by the editor runtime hook on every page open.
|
||||||
*/
|
*/
|
||||||
@ApiOperation({ summary: 'List slash commands', description: 'Returns all active custom slash commands for the workspace. Called by the editor on mount. Any authenticated member can call this.' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Array of slash command definitions' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
|
@ -87,12 +74,6 @@ export class SlashCommandsController {
|
||||||
* Requires slash_commands:manage to avoid leaking webhook URLs to
|
* Requires slash_commands:manage to avoid leaking webhook URLs to
|
||||||
* non-admin members who only need the runtime menu items.
|
* non-admin members who only need the runtime menu items.
|
||||||
*/
|
*/
|
||||||
@ApiOperation({ summary: 'Get slash command by ID', description: 'Returns full command detail including webhook URL. Requires slash_commands:manage.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Slash command UUID', type: 'string' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Slash command detail' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Missing slash_commands:manage permission' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Command not found' })
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AcadenicePermissionsGuard)
|
@UseGuards(AcadenicePermissionsGuard)
|
||||||
@RequirePermission('slash_commands:manage')
|
@RequirePermission('slash_commands:manage')
|
||||||
|
|
@ -103,14 +84,7 @@ export class SlashCommandsController {
|
||||||
return this.slashCommandService.get(id, workspace.id);
|
return this.slashCommandService.get(id, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Create slash command', description: 'Creates a new custom slash command with a webhook URL. Requires slash_commands:manage.' })
|
|
||||||
@ApiBody({ schema: { type: 'object', required: ['name', 'webhookUrl'], properties: { name: { type: 'string' }, description: { type: 'string' }, webhookUrl: { type: 'string', format: 'uri' }, icon: { type: 'string' } } }, description: 'Command definition' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Command created' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Missing slash_commands:manage permission' })
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
|
||||||
@UseGuards(AcadenicePermissionsGuard)
|
@UseGuards(AcadenicePermissionsGuard)
|
||||||
@RequirePermission('slash_commands:manage')
|
@RequirePermission('slash_commands:manage')
|
||||||
async create(
|
async create(
|
||||||
|
|
@ -122,13 +96,6 @@ export class SlashCommandsController {
|
||||||
return this.slashCommandService.create(workspace.id, user.id, dto);
|
return this.slashCommandService.create(workspace.id, user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Update slash command', description: 'Partial update of a slash command. Requires slash_commands:manage.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Slash command UUID', type: 'string' })
|
|
||||||
@ApiBody({ schema: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' }, webhookUrl: { type: 'string' }, icon: { type: 'string' }, isActive: { type: 'boolean' } } }, description: 'Partial update payload' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Updated command' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Missing slash_commands:manage permission' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Command not found' })
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@UseGuards(AcadenicePermissionsGuard)
|
@UseGuards(AcadenicePermissionsGuard)
|
||||||
@RequirePermission('slash_commands:manage')
|
@RequirePermission('slash_commands:manage')
|
||||||
|
|
@ -141,12 +108,6 @@ export class SlashCommandsController {
|
||||||
return this.slashCommandService.update(id, workspace.id, dto);
|
return this.slashCommandService.update(id, workspace.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Delete slash command', description: 'Permanently deletes a custom slash command. Requires slash_commands:manage.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Slash command UUID', type: 'string' })
|
|
||||||
@ApiResponse({ status: 204, description: 'Command deleted' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Missing slash_commands:manage permission' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Command not found' })
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@UseGuards(AcadenicePermissionsGuard)
|
@UseGuards(AcadenicePermissionsGuard)
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -11,14 +11,6 @@ import {
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
@ -39,25 +31,18 @@ import {
|
||||||
* automatically scoped to the authenticated workspace.
|
* automatically scoped to the authenticated workspace.
|
||||||
*
|
*
|
||||||
* Routes:
|
* Routes:
|
||||||
* POST /api/v1/sync-blocks create master block
|
* POST /api/acadenice/sync-blocks create master block
|
||||||
* GET /api/v1/sync-blocks/:id read content
|
* GET /api/acadenice/sync-blocks/:id read content
|
||||||
* PATCH /api/v1/sync-blocks/:id update content
|
* PATCH /api/acadenice/sync-blocks/:id update content
|
||||||
* DELETE /api/v1/sync-blocks/:id delete master
|
* DELETE /api/acadenice/sync-blocks/:id delete master
|
||||||
* GET /api/v1/sync-blocks/:id/usages list referencing pages
|
* GET /api/acadenice/sync-blocks/:id/usages list referencing pages
|
||||||
*/
|
*/
|
||||||
@ApiTags('sync-blocks')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/sync-blocks')
|
@Controller('acadenice/sync-blocks')
|
||||||
export class SyncBlocksController {
|
export class SyncBlocksController {
|
||||||
constructor(private readonly syncBlocksService: SyncBlocksService) {}
|
constructor(private readonly syncBlocksService: SyncBlocksService) {}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Create sync block', description: 'Creates a master sync block that can be embedded by reference in multiple pages.' })
|
|
||||||
@ApiBody({ schema: { type: 'object', properties: { content: { type: 'object', description: 'ProseMirror JSON content' } } }, description: 'Initial content (optional)' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Sync block created' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
|
||||||
async create(
|
async create(
|
||||||
@Body() dto: CreateSyncBlockDto,
|
@Body() dto: CreateSyncBlockDto,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
|
|
@ -66,11 +51,6 @@ export class SyncBlocksController {
|
||||||
return this.syncBlocksService.create(workspace.id, user.id, dto);
|
return this.syncBlocksService.create(workspace.id, user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Get sync block', description: 'Returns the content of a sync block by ID.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Sync block UUID', type: 'string' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Sync block content' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Sync block not found' })
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(
|
async findOne(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|
@ -79,14 +59,6 @@ export class SyncBlocksController {
|
||||||
return this.syncBlocksService.findById(id, workspace.id);
|
return this.syncBlocksService.findById(id, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Update sync block', description: 'Replaces the content of a sync block. Propagated to all referencing pages.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Sync block UUID', type: 'string' })
|
|
||||||
@ApiBody({ schema: { type: 'object', required: ['content'], properties: { content: { type: 'object' } } }, description: 'New content' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Updated sync block' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Caller does not own the sync block' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Sync block not found' })
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async update(
|
async update(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|
@ -97,12 +69,6 @@ export class SyncBlocksController {
|
||||||
return this.syncBlocksService.update(id, workspace.id, user.id, dto);
|
return this.syncBlocksService.update(id, workspace.id, user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Delete sync block', description: 'Deletes the master sync block. Embedded references become orphaned (rendered as static content).' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Sync block UUID', type: 'string' })
|
|
||||||
@ApiResponse({ status: 204, description: 'Deleted' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Caller does not own the sync block' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Sync block not found' })
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(
|
async remove(
|
||||||
|
|
@ -113,11 +79,6 @@ export class SyncBlocksController {
|
||||||
return this.syncBlocksService.delete(id, workspace.id, user.id);
|
return this.syncBlocksService.delete(id, workspace.id, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'List sync block usages', description: 'Returns all pages that embed this sync block.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Sync block UUID', type: 'string' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Array of { pageId, pageTitle, slugId, spaceId, workspaceId }' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Sync block not found' })
|
|
||||||
@Get(':id/usages')
|
@Get(':id/usages')
|
||||||
async usages(
|
async usages(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,11 @@ export class SyncBlockRepo {
|
||||||
): Promise<SyncBlockResponseDto> {
|
): Promise<SyncBlockResponseDto> {
|
||||||
const result = await sql<{
|
const result = await sql<{
|
||||||
id: string;
|
id: string;
|
||||||
workspaceId: string;
|
workspace_id: string;
|
||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
createdBy: string;
|
created_by: string;
|
||||||
createdAt: Date;
|
created_at: Date;
|
||||||
updatedAt: Date;
|
updated_at: Date;
|
||||||
}>`
|
}>`
|
||||||
INSERT INTO acadenice_sync_block (workspace_id, content, created_by)
|
INSERT INTO acadenice_sync_block (workspace_id, content, created_by)
|
||||||
VALUES (${workspaceId}, ${JSON.stringify(content)}::jsonb, ${createdBy})
|
VALUES (${workspaceId}, ${JSON.stringify(content)}::jsonb, ${createdBy})
|
||||||
|
|
@ -44,11 +44,11 @@ export class SyncBlockRepo {
|
||||||
): Promise<SyncBlockResponseDto | null> {
|
): Promise<SyncBlockResponseDto | null> {
|
||||||
const result = await sql<{
|
const result = await sql<{
|
||||||
id: string;
|
id: string;
|
||||||
workspaceId: string;
|
workspace_id: string;
|
||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
createdBy: string;
|
created_by: string;
|
||||||
createdAt: Date;
|
created_at: Date;
|
||||||
updatedAt: Date;
|
updated_at: Date;
|
||||||
}>`
|
}>`
|
||||||
SELECT id, workspace_id, content, created_by, created_at, updated_at
|
SELECT id, workspace_id, content, created_by, created_at, updated_at
|
||||||
FROM acadenice_sync_block
|
FROM acadenice_sync_block
|
||||||
|
|
@ -67,11 +67,11 @@ export class SyncBlockRepo {
|
||||||
): Promise<SyncBlockResponseDto | null> {
|
): Promise<SyncBlockResponseDto | null> {
|
||||||
const result = await sql<{
|
const result = await sql<{
|
||||||
id: string;
|
id: string;
|
||||||
workspaceId: string;
|
workspace_id: string;
|
||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
createdBy: string;
|
created_by: string;
|
||||||
createdAt: Date;
|
created_at: Date;
|
||||||
updatedAt: Date;
|
updated_at: Date;
|
||||||
}>`
|
}>`
|
||||||
UPDATE acadenice_sync_block
|
UPDATE acadenice_sync_block
|
||||||
SET content = ${JSON.stringify(content)}::jsonb, updated_at = NOW()
|
SET content = ${JSON.stringify(content)}::jsonb, updated_at = NOW()
|
||||||
|
|
@ -101,7 +101,7 @@ export class SyncBlockRepo {
|
||||||
id: string,
|
id: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const result = await sql<{ yjsState: Buffer | null }>`
|
const result = await sql<{ yjs_state: Buffer | null }>`
|
||||||
SELECT yjs_state
|
SELECT yjs_state
|
||||||
FROM acadenice_sync_block
|
FROM acadenice_sync_block
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
|
|
@ -109,7 +109,7 @@ export class SyncBlockRepo {
|
||||||
`.execute(this.db);
|
`.execute(this.db);
|
||||||
|
|
||||||
if (result.rows.length === 0) return null;
|
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> {
|
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
|
// Pages store their content as JSONB. We search for any object in the
|
||||||
// content tree where type='syncBlock' and attrs->>'masterId' = blockId.
|
// content tree where type='syncBlock' and attrs->>'masterId' = blockId.
|
||||||
// Using jsonb_path_exists for recursive search.
|
// 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<{
|
const result = await sql<{
|
||||||
pageId: string;
|
page_id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
slugId: string;
|
slug_id: string;
|
||||||
spaceId: string;
|
space_id: string;
|
||||||
workspaceId: string;
|
workspace_id: string;
|
||||||
}>`
|
}>`
|
||||||
SELECT DISTINCT p.id AS page_id, p.title, p.slug_id, p.space_id, s.workspace_id
|
SELECT DISTINCT p.id AS page_id, p.title, p.slug_id, p.space_id, s.workspace_id
|
||||||
FROM pages p
|
FROM pages p
|
||||||
|
|
@ -161,11 +158,11 @@ export class SyncBlockRepo {
|
||||||
`.execute(this.db);
|
`.execute(this.db);
|
||||||
|
|
||||||
return result.rows.map((r) => ({
|
return result.rows.map((r) => ({
|
||||||
pageId: r.pageId,
|
pageId: r.page_id,
|
||||||
pageTitle: r.title,
|
pageTitle: r.title,
|
||||||
slugId: r.slugId,
|
slugId: r.slug_id,
|
||||||
spaceId: r.spaceId,
|
spaceId: r.space_id,
|
||||||
workspaceId: r.workspaceId,
|
workspaceId: r.workspace_id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,19 +178,19 @@ export class SyncBlockRepo {
|
||||||
|
|
||||||
private mapRow(row: {
|
private mapRow(row: {
|
||||||
id: string;
|
id: string;
|
||||||
workspaceId: string;
|
workspace_id: string;
|
||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
createdBy: string;
|
created_by: string;
|
||||||
createdAt: Date | string;
|
created_at: Date;
|
||||||
updatedAt: Date | string;
|
updated_at: Date;
|
||||||
}): SyncBlockResponseDto {
|
}): SyncBlockResponseDto {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
workspaceId: row.workspaceId,
|
workspaceId: row.workspace_id,
|
||||||
content: row.content,
|
content: row.content,
|
||||||
createdBy: row.createdBy,
|
createdBy: row.created_by,
|
||||||
createdAt: new Date(row.createdAt).toISOString(),
|
createdAt: row.created_at.toISOString(),
|
||||||
updatedAt: new Date(row.updatedAt).toISOString(),
|
updatedAt: row.updated_at.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,6 @@ import {
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiQuery,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard';
|
import { AcadenicePermissionsGuard } from '../../rbac/guards/permissions.guard';
|
||||||
import { RequirePermission } from '../../rbac/guards/require-permission.decorator';
|
import { RequirePermission } from '../../rbac/guards/require-permission.decorator';
|
||||||
|
|
@ -65,30 +56,22 @@ function parseBody<T>(schema: { parse: (v: unknown) => T }, body: unknown): T {
|
||||||
* REST controller for workspace page templates (R3.6).
|
* REST controller for workspace page templates (R3.6).
|
||||||
*
|
*
|
||||||
* Permission matrix:
|
* Permission matrix:
|
||||||
* GET /v1/templates templates:read
|
* GET /acadenice/templates templates:read
|
||||||
* GET /v1/templates/:id templates:read
|
* GET /acadenice/templates/:id templates:read
|
||||||
* POST /v1/templates templates:create
|
* POST /acadenice/templates templates:create
|
||||||
* PATCH /v1/templates/:id owner-or-manage (service enforces)
|
* PATCH /acadenice/templates/:id owner-or-manage (service enforces)
|
||||||
* DELETE /v1/templates/:id owner-or-manage (service enforces)
|
* DELETE /acadenice/templates/:id owner-or-manage (service enforces)
|
||||||
* POST /v1/templates/:id/instantiate templates:read
|
* POST /acadenice/templates/:id/instantiate templates:read
|
||||||
* PATCH /v1/templates/:id/default templates:manage
|
* PATCH /acadenice/templates/:id/default templates:manage
|
||||||
*/
|
*/
|
||||||
@ApiTags('templates')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/templates')
|
@Controller('acadenice/templates')
|
||||||
export class TemplatesController {
|
export class TemplatesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly templateService: TemplateService,
|
private readonly templateService: TemplateService,
|
||||||
private readonly roleService: AcadeniceRoleService,
|
private readonly roleService: AcadeniceRoleService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'List templates', description: 'Returns all page templates for the authenticated workspace. Requires templates:read permission.' })
|
|
||||||
@ApiQuery({ name: 'category', required: false, enum: ['meeting', 'project', 'wiki', 'todo', 'custom'], description: 'Filter by category' })
|
|
||||||
@ApiQuery({ name: 'search', required: false, type: 'string', description: 'Full-text search on template name' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Array of templates' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Missing templates:read permission' })
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AcadenicePermissionsGuard)
|
@UseGuards(AcadenicePermissionsGuard)
|
||||||
@RequirePermission('templates:read')
|
@RequirePermission('templates:read')
|
||||||
|
|
@ -100,12 +83,6 @@ export class TemplatesController {
|
||||||
return this.templateService.list(workspace.id, opts);
|
return this.templateService.list(workspace.id, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Get template by ID', description: 'Returns a single template. Requires templates:read permission.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Template UUID', type: 'string' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Template detail' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Missing templates:read permission' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Template not found' })
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AcadenicePermissionsGuard)
|
@UseGuards(AcadenicePermissionsGuard)
|
||||||
@RequirePermission('templates:read')
|
@RequirePermission('templates:read')
|
||||||
|
|
@ -116,14 +93,7 @@ export class TemplatesController {
|
||||||
return this.templateService.get(id, workspace.id);
|
return this.templateService.get(id, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Create template', description: 'Creates a new page template. Requires templates:create permission.' })
|
|
||||||
@ApiBody({ schema: { type: 'object', required: ['name'], properties: { name: { type: 'string', maxLength: 200 }, description: { type: 'string', maxLength: 2000 }, icon: { type: 'string' }, coverUrl: { type: 'string' }, category: { type: 'string', enum: ['meeting', 'project', 'wiki', 'todo', 'custom'] }, sourcePageId: { type: 'string', format: 'uuid' }, content: { type: 'object' } } }, description: 'Template payload' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Template created' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Missing templates:create permission' })
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
|
||||||
@UseGuards(AcadenicePermissionsGuard)
|
@UseGuards(AcadenicePermissionsGuard)
|
||||||
@RequirePermission('templates:create')
|
@RequirePermission('templates:create')
|
||||||
async create(
|
async create(
|
||||||
|
|
@ -139,14 +109,6 @@ export class TemplatesController {
|
||||||
* Update a template. The service enforces owner-or-manage logic.
|
* Update a template. The service enforces owner-or-manage logic.
|
||||||
* We derive canManage from the user's effective permissions here.
|
* We derive canManage from the user's effective permissions here.
|
||||||
*/
|
*/
|
||||||
@ApiOperation({ summary: 'Update template', description: 'Partial update of a template. Owner can always update their own template; templates:manage required to update others.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Template UUID', type: 'string' })
|
|
||||||
@ApiBody({ schema: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' }, icon: { type: 'string' }, coverUrl: { type: 'string' }, category: { type: 'string' }, content: { type: 'object' } } }, description: 'Partial template payload' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Updated template' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Forbidden' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Template not found' })
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async update(
|
async update(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|
@ -160,12 +122,6 @@ export class TemplatesController {
|
||||||
return this.templateService.update(id, workspace.id, user.id, dto, canManage);
|
return this.templateService.update(id, workspace.id, user.id, dto, canManage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Delete template', description: 'Deletes a template. Owner can delete their own; templates:manage required to delete others.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Template UUID', type: 'string' })
|
|
||||||
@ApiResponse({ status: 204, description: 'Deleted' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Forbidden' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Template not found' })
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(
|
async remove(
|
||||||
|
|
@ -178,16 +134,7 @@ export class TemplatesController {
|
||||||
return this.templateService.delete(id, workspace.id, user.id, canManage);
|
return this.templateService.delete(id, workspace.id, user.id, canManage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Instantiate template', description: 'Creates a new page from the template content in the specified space.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Template UUID', type: 'string' })
|
|
||||||
@ApiBody({ schema: { type: 'object', required: ['spaceId'], properties: { spaceId: { type: 'string', format: 'uuid' }, parentPageId: { type: 'string', format: 'uuid' }, name: { type: 'string' } } }, description: 'Instantiation options' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Returns { pageId, slugId } of the new page' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Missing templates:read permission' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Template not found' })
|
|
||||||
@Post(':id/instantiate')
|
@Post(':id/instantiate')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
|
||||||
@UseGuards(AcadenicePermissionsGuard)
|
@UseGuards(AcadenicePermissionsGuard)
|
||||||
@RequirePermission('templates:read')
|
@RequirePermission('templates:read')
|
||||||
async instantiate(
|
async instantiate(
|
||||||
|
|
@ -200,12 +147,6 @@ export class TemplatesController {
|
||||||
return this.templateService.instantiate(id, workspace.id, user.id, dto);
|
return this.templateService.instantiate(id, workspace.id, user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Set workspace default template', description: 'Marks the template as the workspace default. Requires templates:manage permission.' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Template UUID', type: 'string' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Updated template with isWorkspaceDefault=true' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Missing templates:manage permission' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Template not found' })
|
|
||||||
@Patch(':id/default')
|
@Patch(':id/default')
|
||||||
@UseGuards(AcadenicePermissionsGuard)
|
@UseGuards(AcadenicePermissionsGuard)
|
||||||
@RequirePermission('templates:manage')
|
@RequirePermission('templates:manage')
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,13 @@ const BUILT_IN_TEMPLATES: ReadonlyArray<BuiltInSpec> = [
|
||||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Meeting Note' }] },
|
{ 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: '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: '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: '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: '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: '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: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Scope' }] },
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: 'What is in scope / out of 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: '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: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Timeline' }] },
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Key milestones and deadlines.' }] },
|
{ type: 'paragraph', content: [{ type: 'text', text: 'Key milestones and deadlines.' }] },
|
||||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Risks' }] },
|
{ 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: [
|
content: [
|
||||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Daily Standup' }] },
|
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Daily Standup' }] },
|
||||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Yesterday' }] },
|
{ 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: '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: '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: [
|
content: [
|
||||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Weekly Review' }] },
|
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Weekly Review' }] },
|
||||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Wins' }] },
|
{ 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: '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: '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: '' }] }] }] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export class TemplateService {
|
||||||
${dto.icon ?? null},
|
${dto.icon ?? null},
|
||||||
${dto.coverUrl ?? null},
|
${dto.coverUrl ?? null},
|
||||||
${dto.category ?? null},
|
${dto.category ?? null},
|
||||||
${content as unknown as string}::jsonb,
|
${JSON.stringify(content)}::jsonb,
|
||||||
${dto.sourcePageId ?? null},
|
${dto.sourcePageId ?? null},
|
||||||
false,
|
false,
|
||||||
${userId}
|
${userId}
|
||||||
|
|
@ -227,7 +227,7 @@ export class TemplateService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentParam = dto.content
|
const contentParam = dto.content
|
||||||
? sql`${dto.content as unknown as string}::jsonb`
|
? sql`${JSON.stringify(dto.content)}::jsonb`
|
||||||
: sql`content`;
|
: sql`content`;
|
||||||
|
|
||||||
const result = await sql<TemplateDto>`
|
const result = await sql<TemplateDto>`
|
||||||
|
|
@ -331,7 +331,7 @@ export class TemplateService {
|
||||||
${workspaceId},
|
${workspaceId},
|
||||||
${userId},
|
${userId},
|
||||||
${userId},
|
${userId},
|
||||||
${content as unknown as string}::jsonb,
|
${JSON.stringify(content)}::jsonb,
|
||||||
${textContent ?? ''},
|
${textContent ?? ''},
|
||||||
${ydoc},
|
${ydoc},
|
||||||
${nextPosition},
|
${nextPosition},
|
||||||
|
|
@ -461,7 +461,7 @@ export class TemplateService {
|
||||||
${spec.description},
|
${spec.description},
|
||||||
${spec.icon ?? null},
|
${spec.icon ?? null},
|
||||||
${spec.category},
|
${spec.category},
|
||||||
${spec.content as unknown as string}::jsonb,
|
${JSON.stringify(spec.content)}::jsonb,
|
||||||
true,
|
true,
|
||||||
${systemUserId}
|
${systemUserId}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { Kysely, sql } from 'kysely';
|
||||||
*
|
*
|
||||||
* UNIQUE(workspace_id, keyword) prevents collision within the same workspace.
|
* UNIQUE(workspace_id, keyword) prevents collision within the same workspace.
|
||||||
* Both systems and custom commands share the slash menu — the runtime fetches
|
* 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.
|
* Idempotent: ifNotExists on every CREATE so migration re-runs never fail.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ import fastifyMultipart from '@fastify/multipart';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import fastifyIp from 'fastify-ip';
|
import fastifyIp from 'fastify-ip';
|
||||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
|
||||||
import { cleanupOpenApiDoc } from 'nestjs-zod';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
|
@ -115,38 +113,6 @@ async function bootstrap() {
|
||||||
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
|
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
|
||||||
app.enableShutdownHooks();
|
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');
|
const logger = new Logger('NestApplication');
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
|
|
||||||
107
docs/api-docs.md
107
docs/api-docs.md
|
|
@ -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
76
pnpm-lock.yaml
generated
|
|
@ -624,9 +624,6 @@ importers:
|
||||||
'@nestjs/schedule':
|
'@nestjs/schedule':
|
||||||
specifier: ^6.1.3
|
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)
|
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':
|
'@nestjs/terminus':
|
||||||
specifier: ^11.1.1
|
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)
|
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:
|
nestjs-pino:
|
||||||
specifier: ^4.6.1
|
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)
|
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:
|
nodemailer:
|
||||||
specifier: ^8.0.5
|
specifier: ^8.0.5
|
||||||
version: 8.0.5
|
version: 8.0.5
|
||||||
|
|
@ -3013,9 +3007,6 @@ packages:
|
||||||
'@mermaid-js/parser@1.0.1':
|
'@mermaid-js/parser@1.0.1':
|
||||||
resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==}
|
resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==}
|
||||||
|
|
||||||
'@microsoft/tsdoc@0.16.0':
|
|
||||||
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.29.0':
|
'@modelcontextprotocol/sdk@1.29.0':
|
||||||
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -3215,23 +3206,6 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.2'
|
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':
|
'@nestjs/terminus@11.1.1':
|
||||||
resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==}
|
resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -4477,9 +4451,6 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@scarf/scarf@1.4.0':
|
|
||||||
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
|
||||||
|
|
||||||
'@selderee/plugin-htmlparser2@0.11.0':
|
'@selderee/plugin-htmlparser2@0.11.0':
|
||||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
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
|
pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
rxjs: ^7.1.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:
|
node-abort-controller@3.1.1:
|
||||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||||
|
|
||||||
|
|
@ -10380,9 +10340,6 @@ packages:
|
||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
swagger-ui-dist@5.32.4:
|
|
||||||
resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==}
|
|
||||||
|
|
||||||
symbol-observable@4.0.0:
|
symbol-observable@4.0.0:
|
||||||
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
|
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
|
|
@ -13884,8 +13841,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
langium: 4.2.1
|
langium: 4.2.1
|
||||||
|
|
||||||
'@microsoft/tsdoc@0.16.0': {}
|
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)':
|
'@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hono/node-server': 1.19.13(hono@4.12.14)
|
'@hono/node-server': 1.19.13(hono@4.12.14)
|
||||||
|
|
@ -14122,22 +14077,6 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- chokidar
|
- 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)':
|
'@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:
|
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)
|
'@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':
|
'@rollup/rollup-win32-x64-msvc@4.60.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@scarf/scarf@1.4.0': {}
|
|
||||||
|
|
||||||
'@selderee/plugin-htmlparser2@0.11.0':
|
'@selderee/plugin-htmlparser2@0.11.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
|
|
@ -20539,15 +20476,6 @@ snapshots:
|
||||||
pino-http: 11.0.0
|
pino-http: 11.0.0
|
||||||
rxjs: 7.8.2
|
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-abort-controller@3.1.1: {}
|
||||||
|
|
||||||
node-addon-api@8.5.0: {}
|
node-addon-api@8.5.0: {}
|
||||||
|
|
@ -22312,10 +22240,6 @@ snapshots:
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
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-observable@4.0.0: {}
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue