Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { NangoModule } from './nango/nango.module';
import { McpModule } from './mcp/mcp.module';
import { auth } from '../libs/better-auth/auth';
import { IcpModule } from './icp/icp.module';
import { PagesModule } from './pages/pages.module';

const authModule = AuthModule.forRoot({ auth, disableGlobalAuthGuard: true });

Expand Down Expand Up @@ -48,6 +49,7 @@ const authModule = AuthModule.forRoot({ auth, disableGlobalAuthGuard: true });
NangoModule,
IntegrationsModule,
IcpModule,
PagesModule,
McpModule,
],
controllers: [AppController],
Expand Down
30 changes: 30 additions & 0 deletions apps/backend/src/app/pages/dto/page.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { z } from 'zod';

// Editor.js block (the shape the wiki editor saves and the MCP tools exchange).
const EditorJsBlockSchema = z.object({
id: z.string().optional(),
type: z.string(),
data: z.record(z.string(), z.any()),
});

// Create a standalone page. Everything is optional — a blank, untitled,
// org-wide top-level page is valid. `parentId` nests it under another page
// (subpages). The organization comes from the request context, never the body.
export const CreatePageSchema = z.object({
title: z.string().optional(),
parentId: z.number().int().min(1).optional(),
});

export type CreatePageDto = z.infer<typeof CreatePageSchema>;

// Update a standalone page. Omitting a field leaves it unchanged.
// `parentId: null` moves the page back to top level. `blocks` saves editor
// content (full replacement — Editor.js always saves the whole document).
export const UpdatePageSchema = z.object({
title: z.string().nullish(),
parentId: z.number().int().min(1).nullish(),
blocks: z.array(EditorJsBlockSchema).optional(),
version: z.string().optional(),
});

export type UpdatePageDto = z.infer<typeof UpdatePageSchema>;
99 changes: 99 additions & 0 deletions apps/backend/src/app/pages/pages.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthGuard } from '@thallesp/nestjs-better-auth';
import { OrganizationGuard } from '../../common/auth/organization.guard';
import { OrgId } from '../../common/auth/org-id.decorator';
import { UserId } from '../../common/auth/user-id.decorator';
import { ZodPipe } from '../../common/pipes/zod.pipe';
import { PagesService } from './pages.service';
import {
type CreatePageDto,
CreatePageSchema,
type UpdatePageDto,
UpdatePageSchema,
} from './dto/page.dto';

@ApiTags('Pages')
@ApiBearerAuth('session')
@Controller('pages')
@UseGuards(AuthGuard, OrganizationGuard)
export class PagesController {
constructor(private readonly pagesService: PagesService) {}

@Get()
@ApiOperation({ summary: 'List wiki pages for the organization (flat)' })
@ApiResponse({
status: 200,
description: 'Flat page list; tree via parentId',
})
async findAll(@OrgId() organizationId: number) {
return this.pagesService.findAll(organizationId);
}

@Get(':id')
@ApiOperation({ summary: 'Get a single page incl. content and subpages' })
@ApiParam({ name: 'id', type: Number })
@ApiResponse({ status: 200, description: 'Page detail' })
@ApiResponse({ status: 404, description: 'Not found' })
async findOne(
@OrgId() organizationId: number,
@Param('id', ParseIntPipe) id: number,
) {
return this.pagesService.findOne(id, organizationId);
}

@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a page (optionally nested via parentId)' })
@ApiResponse({ status: 201, description: 'Page created' })
async create(
@OrgId() organizationId: number,
@UserId() userId: number,
@Body(new ZodPipe(CreatePageSchema)) dto: CreatePageDto,
) {
return this.pagesService.create(dto, organizationId, userId);
}

@Patch(':id')
@ApiOperation({ summary: 'Update title, content, or parent of a page' })
@ApiParam({ name: 'id', type: Number })
@ApiResponse({ status: 200, description: 'Updated page detail' })
@ApiResponse({ status: 404, description: 'Not found' })
async update(
@OrgId() organizationId: number,
@Param('id', ParseIntPipe) id: number,
@Body(new ZodPipe(UpdatePageSchema)) dto: UpdatePageDto,
) {
return this.pagesService.update(id, dto, organizationId);
}

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a page (subtree removed via cascade)' })
@ApiParam({ name: 'id', type: Number })
@ApiResponse({ status: 204, description: 'Deleted' })
async remove(
@OrgId() organizationId: number,
@Param('id', ParseIntPipe) id: number,
) {
await this.pagesService.remove(id, organizationId);
}
}
15 changes: 15 additions & 0 deletions apps/backend/src/app/pages/pages.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { OrganizationGuard } from '../../common/auth/organization.guard';

import { PagesController } from './pages.controller';
import { PagesRepository } from './pages.repository';
import { PagesService } from './pages.service';

@Module({
imports: [PrismaModule],
controllers: [PagesController],
providers: [OrganizationGuard, PagesService, PagesRepository],
exports: [PagesService],
})
export class PagesModule {}
106 changes: 106 additions & 0 deletions apps/backend/src/app/pages/pages.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';

@Injectable()
export class PagesRepository {
constructor(private readonly prisma: PrismaService) {}

/** Flat org-wide listing; the client builds the tree from `parentId`. */
async findAllByOrganization(organizationId: number) {
return this.prisma.page.findMany({
where: { organizationId },
select: {
id: true,
title: true,
parentId: true,
updatedAt: true,
},
orderBy: { updatedAt: 'desc' },
});
}

/** Fetch a single page by id, scoped to the organization (detail view). */
async findByIdForOrganization(pageId: number, organizationId: number) {
return this.prisma.page.findFirst({
where: { id: pageId, organizationId },
select: {
id: true,
title: true,
blocks: true,
version: true,
parentId: true,
createdBy: true,
updatedAt: true,
},
});
}

/** Direct children of a page (subpage links on the detail view). */
async findSubpages(parentId: number, organizationId: number) {
return this.prisma.page.findMany({
where: { parentId, organizationId },
select: { id: true, title: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
});
}

/** Insert a new standalone page. */
async createPage(input: {
title?: string | null;
parentId?: number | null;
userId: number;
organizationId: number;
}) {
return this.prisma.page.create({
data: {
title: input.title ?? null,
blocks: { time: Date.now(), blocks: [], version: '2.29.0' },
version: '2.29.0',
createdBy: input.userId,
organizationId: input.organizationId,
parentId: input.parentId ?? null,
},
select: { id: true },
});
}

/** Write-permission gate for PATCH and MCP writes. */
async findAccessibleCreatedPage(pageId: number, organizationId: number) {
return this.prisma.page.findFirst({
where: { id: pageId, organizationId },
select: { id: true, blocks: true },
});
}

/** True if the page exists within the organization (parent guard). */
async pageIsInOrg(pageId: number, organizationId: number) {
const found = await this.prisma.page.findFirst({
where: { id: pageId, organizationId },
select: { id: true },
});
return Boolean(found);
}

/** parentId of an org page, or undefined if the page isn't in the org. */
async getParentId(pageId: number, organizationId: number) {
const page = await this.prisma.page.findFirst({
where: { id: pageId, organizationId },
select: { parentId: true },
});
return page?.parentId;
}

async updatePage(pageId: number, data: Prisma.PageUpdateInput) {
return this.prisma.page.update({
where: { id: pageId },
data,
select: { id: true },
});
}

/** Delete a page; DB cascade removes the subtree. */
async deletePage(pageId: number) {
await this.prisma.page.delete({ where: { id: pageId } });
}
}
Loading
Loading