refactor(base): fold /bases/inline-embed into /bases/create with parentPageId

The inline-embed endpoint was a thin wrapper around baseService.create:
its only differences from /bases/create were that it derived the
spaceId/workspaceId from a parent page, gated permission via
pageAccessService.validateCanEdit on that parent, and seeded inline
defaults (Title + Text 1 + Text 2 + one row). All of that fits
naturally on /bases/create as a parentPageId branch.

  - CreateBaseDto: spaceId is now optional. Either spaceId or
    parentPageId must be supplied; the controller validates the
    cross-field requirement and 400s otherwise.
  - /bases/create: with parentPageId, derive workspaceId/spaceId from
    the parent, validateCanEdit on the parent, apply inline defaults.
    Without parentPageId, gate on space-level Create, Page (the
    standalone path).
  - InlineEmbedBaseDto + createInlineEmbed deleted.
  - Slash command in the editor now POSTs /bases/create with
    { parentPageId } — the server picks up the inline branch.
This commit is contained in:
Philipinho
2026-04-28 20:29:07 +01:00
parent 0615bcf222
commit 5069573e8a
4 changed files with 43 additions and 41 deletions
@@ -529,7 +529,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run(); .run();
try { try {
const res = await api.post<{ id: string }>("/bases/inline-embed", { const res = await api.post<{ id: string }>("/bases/create", {
parentPageId, parentPageId,
}); });
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
ForbiddenException, ForbiddenException,
@@ -19,7 +20,6 @@ import { UpdateBaseDto } from '../dto/update-base.dto';
import { BaseIdDto } from '../dto/base.dto'; import { BaseIdDto } from '../dto/base.dto';
import { ExportBaseCsvDto } from '../dto/export-base.dto'; import { ExportBaseCsvDto } from '../dto/export-base.dto';
import { ResolvePagesDto } from '../dto/resolve-pages.dto'; import { ResolvePagesDto } from '../dto/resolve-pages.dto';
import { InlineEmbedBaseDto } from '../dto/inline-embed-base.dto';
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';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
@@ -56,9 +56,38 @@ export class BaseController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
// Bases are pages — use the same SpaceCaslSubject.Page check the // One endpoint, two modes:
// page controller uses for its own create endpoint, so a single // - parentPageId set → inline embed inside that page. Derive
// role definition (admin/writer/reader) governs both. // spaceId/workspaceId from the parent and gate on Edit access
// to the parent (same check the editor uses for any other
// content the user adds while editing). Seed two extra text
// columns + one row so the freshly-inserted base looks like
// a typical database on first paint.
// - parentPageId unset → standalone base. Gate on space-level
// Create, Page (the same check the page controller uses for
// its own create endpoint).
if (dto.parentPageId) {
const parent = await this.pageRepo.findById(dto.parentPageId);
if (!parent) {
throw new NotFoundException('Parent page not found');
}
await this.pageAccessService.validateCanEdit(parent, user);
return this.baseService.create(
user.id,
parent.workspaceId,
{
...dto,
spaceId: parent.spaceId,
},
{ extraTextProperties: 2, defaultRows: 1 },
);
}
if (!dto.spaceId) {
throw new BadRequestException('spaceId or parentPageId is required');
}
const ability = await this.spaceAbility.createForUser(user, dto.spaceId); const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
@@ -169,33 +198,4 @@ export class BaseController {
return { items }; return { items };
} }
@HttpCode(HttpStatus.OK)
@Post('inline-embed')
async createInlineEmbed(
@Body() dto: InlineEmbedBaseDto,
@AuthUser() user: User,
) {
const parent = await this.pageRepo.findById(dto.parentPageId);
if (!parent) {
throw new NotFoundException('Parent page not found');
}
await this.pageAccessService.validateCanEdit(parent, user);
// Inline embeds land mid-document and need to be visually meaningful
// on first paint — a single "Title" column with no rows looks broken.
// Seed two extra text columns and one empty row so the freshly-
// created base looks like a typical database (Title + Text 1 + Text 2,
// one ready-to-fill row).
return this.baseService.create(
user.id,
parent.workspaceId,
{
spaceId: parent.spaceId,
parentPageId: dto.parentPageId,
name: 'Untitled',
},
{ extraTextProperties: 2, defaultRows: 1 },
);
}
} }
@@ -9,8 +9,16 @@ export class CreateBaseDto {
@IsOptional() @IsOptional()
icon?: string; icon?: string;
// Either `spaceId` or `parentPageId` must be supplied. When
// parentPageId is given, the base is being inserted as an inline
// embed inside that page — spaceId/workspaceId are derived from
// the parent and the seeded shape includes a couple of default
// columns + one row. When only spaceId is supplied, the base is a
// standalone page in that space's tree. The controller validates
// the cross-field requirement and picks the right permission gate.
@IsUUID() @IsUUID()
spaceId!: string; @IsOptional()
spaceId?: string;
@IsUUID() @IsUUID()
@IsOptional() @IsOptional()
@@ -1,6 +0,0 @@
import { IsUUID } from 'class-validator';
export class InlineEmbedBaseDto {
@IsUUID()
parentPageId!: string;
}