refactor(acadedoc): rename API routes /api/acadenice -> /api/v1 — R5.1

Replace all @Controller('acadenice/...') decorators with 'v1/...' on 16 NestJS controllers. Update all client services, hooks, tests, extension-clipper, and doc comments to match. DB table names (acadenice_*) and folder structure untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-08 14:52:49 +02:00
parent 3af579498b
commit 9dd283ced6
48 changed files with 164 additions and 164 deletions

View file

@ -30,8 +30,8 @@ The native Docmost system already provides the complete mention notification pip
R3.7 adds:
1. `MentionDetectorService` — pure service that walks Tiptap JSON and extracts user mentions (no DB). Used by the emitter and independently testable.
2. `NotificationEmitterService` — listens to `acadenice.page.content.updated` (REST API save path, not collab) and queues `PAGE_MENTION_NOTIFICATION`. Bridges the gap between the collab-only native detection and pages saved via REST (templates instantiate, import, etc.).
3. `AcadeniceNotificationsController` — facade over native `NotificationService`, prefix `/api/acadenice/notifications`.
4. `NotificationPreferencesController` — GET/PUT `/api/acadenice/notification-preferences` (reads/writes native `users.settings.notifications`).
3. `AcadeniceNotificationsController` — facade over native `NotificationService`, prefix `/api/v1/notifications`.
4. `NotificationPreferencesController` — GET/PUT `/api/v1/notification-preferences` (reads/writes native `users.settings.notifications`).
5. Frontend `/notifications` page — full inbox using native `NotificationItem` component.
6. Frontend `/settings/notifications` preferences page — dedicated toggles via Acadenice API.
@ -47,8 +47,8 @@ R3.7 adds:
| `apps/server/src/core/acadenice/notifications/services/mention-detector.service.ts` | Tiptap mention walker (pure) |
| `apps/server/src/core/acadenice/notifications/services/notification-emitter.service.ts` | Event listener -> PAGE_MENTION_NOTIFICATION queue |
| `apps/server/src/core/acadenice/notifications/services/notification-preferences.service.ts` | Read/write users.settings.notifications |
| `apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts` | REST facade /api/acadenice/notifications |
| `apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts` | REST /api/acadenice/notification-preferences |
| `apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts` | REST facade /api/v1/notifications |
| `apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts` | REST /api/v1/notification-preferences |
| `apps/server/src/core/acadenice/notifications/spec/mention-detector.service.spec.ts` | 18 unit tests |
| `apps/server/src/core/acadenice/notifications/spec/notifications.controller.spec.ts` | 10 unit tests |
| `apps/server/src/core/acadenice/notifications/spec/notification-preferences.spec.ts` | 4 unit tests |
@ -77,13 +77,13 @@ R3.7 adds:
### Endpoints
```
GET /api/acadenice/notifications paginated list
GET /api/acadenice/notifications/unread-count unread badge count (polled 30s)
POST /api/acadenice/notifications/read-all mark all read
POST /api/acadenice/notifications/mark-read bulk mark read
POST /api/acadenice/notifications/:id/read single mark read
GET /api/acadenice/notification-preferences get prefs
PUT /api/acadenice/notification-preferences update prefs
GET /api/v1/notifications paginated list
GET /api/v1/notifications/unread-count unread badge count (polled 30s)
POST /api/v1/notifications/read-all mark all read
POST /api/v1/notifications/mark-read bulk mark read
POST /api/v1/notifications/:id/read single mark read
GET /api/v1/notification-preferences get prefs
PUT /api/v1/notification-preferences update prefs
```
### Tests

View file

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

View file

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

View file

@ -18,12 +18,12 @@ describe('clipperClient', () => {
afterEach(() => jest.resetAllMocks());
describe('listTokens', () => {
it('GETs /api/acadenice/clipper/tokens', async () => {
it('GETs /api/v1/clipper/tokens', async () => {
mockedAxios.get = jest.fn().mockResolvedValue({ data: [sampleToken] });
const result = await clipperClient.listTokens();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('tk-1');
expect(mockedAxios.get).toHaveBeenCalledWith('/api/acadenice/clipper/tokens');
expect(mockedAxios.get).toHaveBeenCalledWith('/api/v1/clipper/tokens');
});
});
@ -37,7 +37,7 @@ describe('clipperClient', () => {
expect(result.token).toBe('clip_abc123');
expect(result.tokenInfo.label).toBe('My token');
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/acadenice/clipper/tokens',
'/api/v1/clipper/tokens',
{ label: 'My token', duration_days: 30 },
);
});
@ -47,7 +47,7 @@ describe('clipperClient', () => {
it('DELETEs the token by id', async () => {
mockedAxios.delete = jest.fn().mockResolvedValue({});
await clipperClient.revokeToken('tk-1');
expect(mockedAxios.delete).toHaveBeenCalledWith('/api/acadenice/clipper/tokens/tk-1');
expect(mockedAxios.delete).toHaveBeenCalledWith('/api/v1/clipper/tokens/tk-1');
});
});
});

View file

@ -1,6 +1,6 @@
import axios from 'axios';
const BASE = '/api/acadenice/clipper';
const BASE = '/api/v1/clipper';
export interface ClipperTokenInfo {
id: string;

View file

@ -51,14 +51,14 @@ describe("row-comments-client", () => {
vi.clearAllMocks();
});
it("listRowComments posts to /acadenice/row-comments/list", async () => {
it("listRowComments posts to /v1/row-comments/list", async () => {
const comment = makeComment();
mockApi.post.mockResolvedValueOnce({ data: [comment] });
const result = await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID });
expect(mockApi.post).toHaveBeenCalledWith(
"/acadenice/row-comments/list",
"/v1/row-comments/list",
{ tableId: TABLE_ID, rowId: ROW_ID },
);
expect(result).toHaveLength(1);
@ -69,12 +69,12 @@ describe("row-comments-client", () => {
mockApi.post.mockResolvedValueOnce({ data: [] });
await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID, resolved: true });
expect(mockApi.post).toHaveBeenCalledWith(
"/acadenice/row-comments/list",
"/v1/row-comments/list",
{ tableId: TABLE_ID, rowId: ROW_ID, resolved: true },
);
});
it("createRowComment posts to /acadenice/row-comments/create", async () => {
it("createRowComment posts to /v1/row-comments/create", async () => {
const comment = makeComment();
mockApi.post.mockResolvedValueOnce({ data: comment });
@ -86,42 +86,42 @@ describe("row-comments-client", () => {
const result = await createRowComment(params);
expect(mockApi.post).toHaveBeenCalledWith(
"/acadenice/row-comments/create",
"/v1/row-comments/create",
params,
);
expect(result.id).toBe(COMMENT_ID);
});
it("updateRowComment posts to /acadenice/row-comments/update", async () => {
it("updateRowComment posts to /v1/row-comments/update", async () => {
const comment = makeComment();
mockApi.post.mockResolvedValueOnce({ data: comment });
await updateRowComment(COMMENT_ID, JSON.stringify({ type: "doc" }));
expect(mockApi.post).toHaveBeenCalledWith(
"/acadenice/row-comments/update",
"/v1/row-comments/update",
{ commentId: COMMENT_ID, content: JSON.stringify({ type: "doc" }) },
);
});
it("resolveRowComment posts to /acadenice/row-comments/resolve", async () => {
it("resolveRowComment posts to /v1/row-comments/resolve", async () => {
const comment = { ...makeComment(), isResolved: true };
mockApi.post.mockResolvedValueOnce({ data: comment });
const result = await resolveRowComment(COMMENT_ID, true);
expect(mockApi.post).toHaveBeenCalledWith(
"/acadenice/row-comments/resolve",
"/v1/row-comments/resolve",
{ commentId: COMMENT_ID, resolved: true },
);
expect(result.isResolved).toBe(true);
});
it("deleteRowComment posts to /acadenice/row-comments/delete", async () => {
it("deleteRowComment posts to /v1/row-comments/delete", async () => {
mockApi.post.mockResolvedValueOnce({ data: undefined });
await deleteRowComment(COMMENT_ID);
expect(mockApi.post).toHaveBeenCalledWith(
"/acadenice/row-comments/delete",
"/v1/row-comments/delete",
{ commentId: COMMENT_ID },
);
});
@ -131,7 +131,7 @@ describe("row-comments-client", () => {
const count = await countRowComments(TABLE_ID, ROW_ID);
expect(count).toBe(7);
expect(mockApi.post).toHaveBeenCalledWith(
"/acadenice/row-comments/count",
"/v1/row-comments/count",
{ tableId: TABLE_ID, rowId: ROW_ID },
);
});

View file

@ -34,7 +34,7 @@ export async function listRowComments(
params: ListRowCommentsParams,
): Promise<RowComment[]> {
const res = await api.post<RowComment[]>(
"/acadenice/row-comments/list",
"/v1/row-comments/list",
params,
);
return res.data;
@ -44,7 +44,7 @@ export async function createRowComment(
params: CreateRowCommentParams,
): Promise<RowComment> {
const res = await api.post<RowComment>(
"/acadenice/row-comments/create",
"/v1/row-comments/create",
params,
);
return res.data;
@ -54,7 +54,7 @@ export async function updateRowComment(
commentId: string,
content: string,
): Promise<RowComment> {
const res = await api.post<RowComment>("/acadenice/row-comments/update", {
const res = await api.post<RowComment>("/v1/row-comments/update", {
commentId,
content,
});
@ -65,7 +65,7 @@ export async function resolveRowComment(
commentId: string,
resolved: boolean,
): Promise<RowComment> {
const res = await api.post<RowComment>("/acadenice/row-comments/resolve", {
const res = await api.post<RowComment>("/v1/row-comments/resolve", {
commentId,
resolved,
});
@ -73,7 +73,7 @@ export async function resolveRowComment(
}
export async function deleteRowComment(commentId: string): Promise<void> {
await api.post("/acadenice/row-comments/delete", { commentId });
await api.post("/v1/row-comments/delete", { commentId });
}
export async function countRowComments(
@ -81,7 +81,7 @@ export async function countRowComments(
rowId: string,
): Promise<number> {
const res = await api.post<{ count: number }>(
"/acadenice/row-comments/count",
"/v1/row-comments/count",
{ tableId, rowId },
);
return res.data.count;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -35,7 +35,7 @@ export interface CreateSlashCommandPayload {
export type UpdateSlashCommandPayload = Partial<CreateSlashCommandPayload>;
const BASE = '/api/acadenice/slash-commands';
const BASE = '/api/v1/slash-commands';
export const slashCommandsClient = {
list(): Promise<SlashCommandDto[]> {

View file

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

View file

@ -17,7 +17,7 @@ export interface SyncBlockUsageDto {
workspaceId: string;
}
const BASE = '/api/acadenice/sync-blocks';
const BASE = '/api/v1/sync-blocks';
export const syncBlocksClient = {
create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> {

View file

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

View file

@ -36,7 +36,7 @@ export interface InstantiatePayload {
name?: string;
}
const BASE = '/api/acadenice/templates';
const BASE = '/api/v1/templates';
export const templatesClient = {
list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> {

View file

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

View file

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

View file

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

View file

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

View file

@ -25,12 +25,12 @@ import { CreateApiKeySchema } from '../dto/api-key.dto';
/**
* AcadeniceApiKeyController personal access tokens (R4.5).
*
* GET /api/acadenice/api-keys List caller's tokens (no hashes)
* POST /api/acadenice/api-keys Create token returns plain once
* DELETE /api/acadenice/api-keys/:id Revoke
* GET /api/v1/api-keys List caller's tokens (no hashes)
* POST /api/v1/api-keys Create token returns plain once
* DELETE /api/v1/api-keys/:id Revoke
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/api-keys')
@Controller('v1/api-keys')
export class AcadeniceApiKeyController {
constructor(private readonly apiKeyService: AcadeniceApiKeyService) {}

View file

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

View file

@ -20,12 +20,12 @@ import { AuditLogQuerySchema } from '../dto/audit-log-query.dto';
/**
* AcadeniceAuditLogController R4.5 read-only audit log.
*
* GET /api/acadenice/audit-log
* GET /api/v1/audit-log
* Auth : JWT (admin or owner only)
* Query : limit, offset, userId, action, since (ISO), until (ISO)
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/audit-log')
@Controller('v1/audit-log')
export class AcadeniceAuditLogController {
constructor(private readonly auditLogService: AcadeniceAuditLogService) {}

View file

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

View file

@ -15,7 +15,7 @@ import { BacklinkService, BacklinksResult } from '../services/backlink.service';
/**
* REST controller for the backlinks feature.
*
* Route: GET /api/acadenice/pages/:pageId/backlinks
* Route: GET /api/v1/pages/:pageId/backlinks
*
* Authentication: JWT (JwtAuthGuard). The user's read access to each source
* page is enforced inside BacklinkService (space_members / public space check).
@ -23,7 +23,7 @@ import { BacklinkService, BacklinksResult } from '../services/backlink.service';
* workspace member can query backlinks for pages they can read.
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/pages')
@Controller('v1/pages')
export class BacklinksController {
constructor(private readonly backlinkService: BacklinkService) {}

View file

@ -49,15 +49,15 @@ const TOKEN_HEADER = 'x-clipper-token';
/**
* ClipperController Web Clipper REST surface (R4.3).
*
* POST /api/acadenice/clipper/import
* POST /api/v1/clipper/import
* Auth: X-Clipper-Token header (token validated against DB hash).
* Body: ImportClipDto (Zod).
*
* POST /api/acadenice/clipper/tokens (JWT auth)
* GET /api/acadenice/clipper/tokens (JWT auth)
* DELETE /api/acadenice/clipper/tokens/:id (JWT auth)
* POST /api/v1/clipper/tokens (JWT auth)
* GET /api/v1/clipper/tokens (JWT auth)
* DELETE /api/v1/clipper/tokens/:id (JWT auth)
*/
@Controller('acadenice/clipper')
@Controller('v1/clipper')
export class ClipperController {
constructor(
private readonly clipperService: ClipperService,

View file

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

View file

@ -23,10 +23,10 @@ import { ResolvePageCommentDto } from '../dto/comment.dto';
* requiring an open collab websocket.
*
* Endpoints:
* POST /api/acadenice/page-comments/resolve
* POST /api/v1/page-comments/resolve
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/page-comments')
@Controller('v1/page-comments')
export class PageCommentsController {
constructor(
private readonly pageCommentResolveService: PageCommentResolveService,

View file

@ -29,15 +29,15 @@ import {
* user's `acadenice_permissions` JWT claim.
*
* Endpoints:
* POST /api/acadenice/row-comments/list list thread for (tableId, rowId)
* POST /api/acadenice/row-comments/create create root or reply
* POST /api/acadenice/row-comments/update edit own comment
* POST /api/acadenice/row-comments/resolve resolve/unresolve root thread
* POST /api/acadenice/row-comments/delete delete own (or moderate)
* POST /api/acadenice/row-comments/count count comments for a row
* POST /api/v1/row-comments/list list thread for (tableId, rowId)
* POST /api/v1/row-comments/create create root or reply
* POST /api/v1/row-comments/update edit own comment
* POST /api/v1/row-comments/resolve resolve/unresolve root thread
* POST /api/v1/row-comments/delete delete own (or moderate)
* POST /api/v1/row-comments/count count comments for a row
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/row-comments')
@Controller('v1/row-comments')
export class RowCommentsController {
constructor(private readonly rowCommentService: RowCommentService) {}

View file

@ -15,7 +15,7 @@ import { GraphQuerySchema, GraphResponse } from '../dto/graph.dto';
/**
* REST controller for the knowledge-graph endpoint (R3.5.1).
*
* Route: GET /api/acadenice/graph
* Route: GET /api/v1/graph
*
* Authentication: JWT (JwtAuthGuard). Permission filtering is applied inside
* GraphService using the same space_members / public visibility model as
@ -26,7 +26,7 @@ import { GraphQuerySchema, GraphResponse } from '../dto/graph.dto';
* the query param is accepted but ignored to prevent cross-workspace leaks.
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/graph')
@Controller('v1/graph')
export class GraphController {
constructor(private readonly graphService: GraphService) {}

View file

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

View file

@ -28,7 +28,7 @@ import { ZodError } from 'zod';
* the native notification email pipeline.
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/notification-preferences')
@Controller('v1/notification-preferences')
export class NotificationPreferencesController {
constructor(
private readonly prefsService: NotificationPreferencesService,

View file

@ -45,19 +45,19 @@ function parseQuery<T>(schema: { parse: (v: unknown) => T }, raw: unknown): T {
* REST controller for Acadenice notification endpoints (R3.7).
*
* This controller is a thin facade over the native Docmost NotificationService.
* Endpoints are prefixed `/api/acadenice/notifications` to allow the Acadenice
* Endpoints are prefixed `/api/v1/notifications` to allow the Acadenice
* frontend to discover and poll them without conflicting with the native
* `/notifications` endpoints used by the upstream Docmost UI.
*
* All mutation endpoints use the same guards and service as the native path.
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/notifications')
@Controller('v1/notifications')
export class AcadeniceNotificationsController {
constructor(private readonly notificationService: NotificationService) {}
/**
* GET /api/acadenice/notifications
* GET /api/v1/notifications
* Paginated list of notifications for the authenticated user.
*/
@Get()
@ -82,7 +82,7 @@ export class AcadeniceNotificationsController {
}
/**
* GET /api/acadenice/notifications/unread-count
* GET /api/v1/notifications/unread-count
*/
@Get('unread-count')
async unreadCount(@AuthUser() user: User) {
@ -90,7 +90,7 @@ export class AcadeniceNotificationsController {
}
/**
* POST /api/acadenice/notifications/read-all
* POST /api/v1/notifications/read-all
*/
@Post('read-all')
@HttpCode(HttpStatus.NO_CONTENT)
@ -99,7 +99,7 @@ export class AcadeniceNotificationsController {
}
/**
* POST /api/acadenice/notifications/mark-read
* POST /api/v1/notifications/mark-read
*/
@Post('mark-read')
@HttpCode(HttpStatus.NO_CONTENT)
@ -115,7 +115,7 @@ export class AcadeniceNotificationsController {
}
/**
* POST /api/acadenice/notifications/:id/read
* POST /api/v1/notifications/:id/read
*/
@Post(':id/read')
@HttpCode(HttpStatus.NO_CONTENT)

View file

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

View file

@ -15,7 +15,7 @@ import { AcadeniceRoleService } from '../services/role.service';
const ADMIN_WILDCARD_KEY = 'admin:*';
@UseGuards(JwtAuthGuard)
@Controller('acadenice/permissions')
@Controller('v1/permissions')
export class AcadenicePermissionsController {
constructor(private readonly roleService: AcadeniceRoleService) {}

View file

@ -26,7 +26,7 @@ import { AcadenicePermissionsGuard } from '../guards/permissions.guard';
import { RequirePermission } from '../guards/require-permission.decorator';
@UseGuards(JwtAuthGuard, AcadenicePermissionsGuard)
@Controller('acadenice/roles')
@Controller('v1/roles')
export class AcadeniceRolesController {
constructor(private readonly roleService: AcadeniceRoleService) {}

View file

@ -34,7 +34,7 @@ import { permissionMatches } from '../permissions-catalog';
* because the access logic depends on `userId` path param vs the actor.
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/users/:userId/roles')
@Controller('v1/users/:userId/roles')
export class AcadeniceUserRolesController {
constructor(private readonly roleService: AcadeniceRoleService) {}

View file

@ -22,12 +22,12 @@ export interface OidcStatusResponse {
/**
* AcadeniceOidcStatusController R4.5 read-only OIDC status for admins.
*
* GET /api/acadenice/security/oidc-status
* GET /api/v1/security/oidc-status
* Auth : JWT (admin or owner only)
* Returns OIDC configuration derived from env vars no secrets exposed.
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/security')
@Controller('v1/security')
export class AcadeniceOidcStatusController {
constructor(private readonly env: EnvironmentService) {}

View file

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

View file

@ -54,7 +54,7 @@ function parseBody<T>(schema: { parse: (v: unknown) => T }, body: unknown): T {
* default via the seed see permissions-catalog.ts).
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/slash-commands')
@Controller('v1/slash-commands')
export class SlashCommandsController {
constructor(private readonly slashCommandService: SlashCommandService) {}

View file

@ -31,14 +31,14 @@ import {
* automatically scoped to the authenticated workspace.
*
* Routes:
* POST /api/acadenice/sync-blocks create master block
* GET /api/acadenice/sync-blocks/:id read content
* PATCH /api/acadenice/sync-blocks/:id update content
* DELETE /api/acadenice/sync-blocks/:id delete master
* GET /api/acadenice/sync-blocks/:id/usages list referencing pages
* POST /api/v1/sync-blocks create master block
* GET /api/v1/sync-blocks/:id read content
* PATCH /api/v1/sync-blocks/:id update content
* DELETE /api/v1/sync-blocks/:id delete master
* GET /api/v1/sync-blocks/:id/usages list referencing pages
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/sync-blocks')
@Controller('v1/sync-blocks')
export class SyncBlocksController {
constructor(private readonly syncBlocksService: SyncBlocksService) {}

View file

@ -56,16 +56,16 @@ function parseBody<T>(schema: { parse: (v: unknown) => T }, body: unknown): T {
* REST controller for workspace page templates (R3.6).
*
* Permission matrix:
* GET /acadenice/templates templates:read
* GET /acadenice/templates/:id templates:read
* POST /acadenice/templates templates:create
* PATCH /acadenice/templates/:id owner-or-manage (service enforces)
* DELETE /acadenice/templates/:id owner-or-manage (service enforces)
* POST /acadenice/templates/:id/instantiate templates:read
* PATCH /acadenice/templates/:id/default templates:manage
* GET /v1/templates templates:read
* GET /v1/templates/:id templates:read
* POST /v1/templates templates:create
* PATCH /v1/templates/:id owner-or-manage (service enforces)
* DELETE /v1/templates/:id owner-or-manage (service enforces)
* POST /v1/templates/:id/instantiate templates:read
* PATCH /v1/templates/:id/default templates:manage
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/templates')
@Controller('v1/templates')
export class TemplatesController {
constructor(
private readonly templateService: TemplateService,

View file

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