Compare commits

..
Author SHA1 Message Date
Philipinho 34e5744850 fix(editor): allow gap cursors between top-level blocks 2026-09-06 19:07:07 +01:00
17 changed files with 253 additions and 490 deletions
@@ -18,7 +18,6 @@ export type FieldProps = {
rowId: string;
readOnly: boolean;
onChange: (value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
};
type FieldShellProps = {
@@ -100,10 +99,9 @@ type DetailFieldProps = {
row: IBaseRow;
readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange: (editing: boolean) => void;
};
export function DetailField({ property, row, readOnly, onUpdate, onEditingChange }: DetailFieldProps) {
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row)
@@ -114,7 +112,6 @@ export function DetailField({ property, row, readOnly, onUpdate, onEditingChange
rowId: row.id,
readOnly,
onChange: (next: unknown) => onUpdate(property.id, next),
onEditingChange
};
switch (property.type) {
@@ -9,13 +9,7 @@ const normalize = (s: string) => {
return trimmed.length ? trimmed : null;
};
export function FieldLongText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -29,7 +23,6 @@ export function FieldLongText({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -57,10 +50,7 @@ export function FieldLongText({
className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }}
value={draft}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -11,13 +11,7 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
export function FieldNumber({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value));
@@ -42,7 +36,6 @@ export function FieldNumber({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
@@ -61,7 +54,6 @@ export function FieldNumber({
onFocus={() => {
setDraft(toDraft(value));
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => {
const v = e.target.value;
@@ -5,13 +5,7 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
export function FieldText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -26,7 +20,6 @@ export function FieldText({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -61,10 +54,7 @@ export function FieldText({
className={classes.fieldInput}
value={draft}
maxLength={1000}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -17,7 +17,6 @@ type PropertyRowProps = {
onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
autoFocusValue?: boolean;
onAutoFocused?: () => void;
};
@@ -30,7 +29,6 @@ export function PropertyRow({
onMenuOpenChange,
onMenuDirtyChange,
onUpdate,
onEditingChange,
autoFocusValue,
onAutoFocused,
}: PropertyRowProps) {
@@ -114,7 +112,6 @@ export function PropertyRow({
row={row}
readOnly={!canEdit}
onUpdate={onUpdate}
onEditingChange={onEditingChange}
/>
</div>
);
@@ -75,7 +75,6 @@ export function RowDetailModal({
const isSaving = updateRowMutation.isPending;
const opened = !!openRowId;
const [editingField, setEditingField] = useState(false);
// One field menu open at a time, mirroring the grid header's semantics.
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
@@ -91,7 +90,6 @@ export function RowDetailModal({
useEffect(() => {
setOpenMenuId(null);
menuDirtyRef.current = false;
setEditingField(false);
}, [openRowId]);
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
@@ -295,7 +293,7 @@ export function RowDetailModal({
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onEditingChange={setEditingField}
onClose={onClose}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
@@ -319,7 +317,6 @@ export function RowDetailModal({
autoFocusValue={property.id === newPropertyId}
onAutoFocused={clearNewProperty}
menuOpened={openMenuId === property.id}
onEditingChange={setEditingField}
onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened)
}
@@ -370,38 +367,16 @@ export function RowDetailModal({
) : null}
</div>
<div className={classes.kbdHint}>
{editingField ? (
{rowIndex >= 0 && rows.length > 1 && (
<>
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Ctrl/Cmd</kbd>
<span className={classes.kbdPlus} >+</span>
<kbd className={classes.kbd}>Enter</kbd>
<span>{t("to save")}</span>
</span>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to reset")}</span>
</span>
</>
) : (
<>
{rowIndex >= 0 && rows.length > 1 && (
<>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
</>
)}
<>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</>
</>
)}
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</div>
</footer>
</>
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts";
@@ -9,7 +9,7 @@ type RowDetailTitleProps = {
primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void;
onEditingChange?: (editing: boolean) => void;
onClose: () => void;
};
export function RowDetailTitle({
@@ -17,24 +17,13 @@ export function RowDetailTitle({
primaryProperty,
canEdit,
onCommit,
onEditingChange,
onClose,
}: RowDetailTitleProps) {
const { t } = useTranslation();
const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
const cancelRef = useRef(false);
const commit = () => {
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setValue(initial);
return;
}
if (value !== initial) onCommit(value);
};
// Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => {
@@ -54,18 +43,18 @@ export function RowDetailTitle({
aria-label={primaryProperty?.name ?? t("Untitled")}
value={value}
maxLength={1000}
onFocus={() => {
onEditingChange?.(true);
}}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={commit}
onBlur={() => {
if (value !== initial) onCommit(value);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if (e.key === "Enter") {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
onClose();
}
}}
/>
@@ -416,25 +416,9 @@
}
.kbdHint {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
width: 100%;
}
.kbdGroup {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
height: fit-content;
}
.kbdPlus {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: 11px;
}
.kbdSeparator {
@@ -15,6 +15,8 @@
--docs-accent: #2b7af1;
--docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent);
/* Cloudflare-style single-ink model: one foreground for headings, bold, and
* body on a just-off-white page; neither end of the scale is pure. */
--docs-bg: oklch(99% 0 0);
--docs-fg: oklch(21% 0 0);
--docs-content-fg: var(--docs-fg);
@@ -408,7 +410,7 @@
}
}
/* Expanded parents read as section headers. */
/* Expanded parents read as section headers, Cloudflare-style. */
.treeRow[data-open-parent="true"] {
color: var(--docs-fg);
font-weight: 500;
@@ -541,6 +543,8 @@
}
}
/* ---------- Sidebar branding experiments (GitBook card / ReadMe line) ---------- */
/* ---------- Footer branding ---------- */
.footer {
@@ -769,11 +773,13 @@
color: inherit;
}
/* Modest semibold heading scale (Cloudflare-style); class doubled to outrank
* the shared editor and .public-typography rules. */
.root.root :global(.ProseMirror) h1 {
font-size: 1.75rem;
font-size: 2.1875rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.3;
letter-spacing: -0.025em;
line-height: 1.25;
}
.root.root :global(.ProseMirror) h2 {
@@ -1,6 +1,5 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
@@ -21,7 +20,7 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
import { generateSlugId } from '../../../common/helpers';
import { getPageTitle } from '../../../common/helpers';
import { dbOrTx, executeTx } from '@docmost/db/utils';
import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { v7 as uuid7 } from 'uuid';
import {
@@ -175,14 +174,10 @@ export class PageService {
return page;
}
async nextPagePosition(
spaceId: string,
parentPageId?: string,
trx?: KyselyTransaction,
) {
async nextPagePosition(spaceId: string, parentPageId?: string) {
let pagePosition: string;
const lastPageQuery = dbOrTx(this.db, trx)
const lastPageQuery = this.db
.selectFrom('pages')
.select(['position'])
.where('spaceId', '=', spaceId)
@@ -396,46 +391,35 @@ export class PageService {
}
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
return executeTx(this.db, async (trx) => {
await this.pageRepo.lockPageHierarchySpaces(
[rootPage.spaceId, spaceId],
trx,
);
let childPageIds: string[] = [];
const currentRootPage = await this.pageRepo.findById(rootPage.id, {
trx,
});
if (!currentRootPage || currentRootPage.deletedAt) {
throw new NotFoundException('Page to move not found');
}
if (currentRootPage.spaceId !== rootPage.spaceId) {
throw new ConflictException('Page location changed; retry the move');
}
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
const allPages = await this.pageRepo.getPageAndDescendants(
currentRootPage.id,
{ includeContent: false, trx },
);
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
currentRootPage.id,
userId,
currentRootPage.spaceId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
const pagesToOrphan = allPages.filter(
(p) =>
!accessibleIds.has(p.id) &&
p.parentPageId &&
accessibleIds.has(p.parentPageId),
);
// Filter to only accessible pages while maintaining tree integrity
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
userId,
rootPage.spaceId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter(
(p) =>
!accessibleIds.has(p.id) &&
p.parentPageId &&
accessibleIds.has(p.parentPageId),
);
await executeTx(this.db, async (trx) => {
// Orphan inaccessible child pages (make them root pages in original space)
for (const page of pagesToOrphan) {
const orphanPosition = await this.nextPagePosition(
currentRootPage.spaceId,
rootPage.spaceId,
null,
trx,
);
await this.pageRepo.updatePage(
{ parentPageId: null, position: orphanPosition },
@@ -445,18 +429,16 @@ export class PageService {
}
// Update root page
const nextPosition = await this.nextPagePosition(spaceId, null, trx);
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
{ spaceId, parentPageId: null, position: nextPosition },
currentRootPage.id,
rootPage.id,
trx,
);
const pageIdsToMove = accessiblePages.map((p) => p.id);
const childPageIds = pageIdsToMove.filter(
(id) => id !== currentRootPage.id,
);
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
@@ -519,7 +501,7 @@ export class PageService {
{
pageIds: pageIdsToMove,
spaceId,
workspaceId: currentRootPage.workspaceId,
workspaceId: rootPage.workspaceId,
},
{
attempts: 2,
@@ -530,9 +512,9 @@ export class PageService {
},
);
}
return { childPageIds };
});
return { childPageIds };
}
async duplicatePage(
@@ -843,59 +825,31 @@ export class PageService {
throw new BadRequestException('A page cannot be its own parent');
}
await executeTx(this.db, async (trx) => {
await this.pageRepo.lockPageHierarchySpaces(
[movedPage.spaceId],
trx,
);
const currentPage = await this.pageRepo.findById(dto.pageId, { trx });
if (!currentPage || currentPage.deletedAt) {
throw new NotFoundException('Moved page not found');
}
if (currentPage.spaceId !== movedPage.spaceId) {
throw new ConflictException('Page location changed; retry the move');
}
let parentPageId = null;
if (currentPage.parentPageId === dto.parentPageId) {
parentPageId = undefined;
} else {
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId, {
trx,
});
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== currentPage.spaceId
) {
throw new NotFoundException('Parent page not found');
}
if (
await this.pageRepo.isPageDescendant(
dto.pageId,
parentPage.id,
trx,
)
) {
throw new BadRequestException(
'A page cannot be moved under its descendant',
);
}
parentPageId = parentPage.id;
let parentPageId = null;
if (movedPage.parentPageId === dto.parentPageId) {
parentPageId = undefined;
} else {
// changing the page's parent
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId);
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== movedPage.spaceId
) {
throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
}
}
await this.pageRepo.updatePage(
{
position: dto.position,
parentPageId: parentPageId,
},
dto.pageId,
trx,
);
});
await this.pageRepo.updatePage(
{
position: dto.position,
parentPageId: parentPageId,
},
dto.pageId,
);
}
async getPageBreadCrumbs(childPageId: string) {
@@ -60,21 +60,16 @@ export class ShareSeoController {
const pageId = this.extractPageSlugId(pageSlug);
let title: string;
let searchIndexing = false;
try {
const shared = await this.shareService.getSharedPage(
{ pageId },
workspace.id,
{ includeContent: false },
);
title = shared.page.title;
searchIndexing = shared.share.searchIndexing;
} catch (err) {
const share = await this.shareService.getShareForPage(
pageId,
workspace.id,
);
if (!share) {
return this.sendIndex(indexFilePath, res);
}
const rawTitle = htmlEscape(title ?? 'untitled');
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
const metaTitle =
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle;
@@ -83,7 +78,7 @@ export class ShareSeoController {
const metaTags = [
`<meta property="og:title" content="${metaTitle}" />`,
`<meta property="twitter:title" content="${metaTitle}" />`,
!searchIndexing ? `<meta name="robots" content="noindex" />` : '',
!share.searchIndexing ? `<meta name="robots" content="noindex" />` : '',
]
.filter(Boolean)
.join('\n ');
+6 -15
View File
@@ -110,11 +110,7 @@ export class ShareService {
}
}
async getSharedPage(
dto: ShareInfoDto,
workspaceId: string,
opts?: { includeContent?: boolean },
) {
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
//TODO: we should resolve the page from the share id
if (!dto.pageId) throw new NotFoundException('Shared page not found');
@@ -124,13 +120,10 @@ export class ShareService {
throw new NotFoundException('Shared page not found');
}
const includeContent = opts?.includeContent !== false;
const page = includeContent
? await this.pageRepo.findById(dto.pageId, {
includeContent: true,
includeCreator: true,
})
: await this.pageRepo.findById(dto.pageId);
const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true,
includeCreator: true,
});
if (!page || page.deletedAt) {
throw new NotFoundException('Shared page not found');
@@ -144,9 +137,7 @@ export class ShareService {
throw new NotFoundException('Shared page not found');
}
if (includeContent) {
page.content = await this.updatePublicAttachments(page);
}
page.content = await this.updatePublicAttachments(page);
return { page, share };
}
@@ -10,7 +10,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
import { InjectKysely } from 'nestjs-kysely';
import { Space, User } from '@docmost/db/types/entity.types';
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
@@ -218,18 +218,41 @@ export class SpaceMemberService {
dto: RemoveSpaceMemberDto,
workspaceId: string,
): Promise<void> {
const memberTypeId = dto.userId
? { userId: dto.userId }
: dto.groupId
? { groupId: dto.groupId }
: null;
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
if (!memberTypeId) {
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
let affectedUserIds: string[] = [];
if (dto.userId) {
affectedUserIds = [dto.userId];
@@ -239,29 +262,7 @@ export class SpaceMemberService {
);
}
const { space, spaceMember } = await executeTx(this.db, async (trx) => {
const space = await this.spaceRepo.findById(
dto.spaceId,
workspaceId,
{ withLock: true, trx },
);
if (!space) {
throw new NotFoundException('Space not found');
}
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
memberTypeId,
trx,
);
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx);
}
await executeTx(this.db, async (trx) => {
await this.spaceMemberRepo.removeSpaceMemberById(
spaceMember.id,
dto.spaceId,
@@ -279,8 +280,6 @@ export class SpaceMemberService {
dto.spaceId,
{ trx },
);
return { space, spaceMember };
});
this.auditService.log({
@@ -305,40 +304,48 @@ export class SpaceMemberService {
dto: UpdateSpaceMemberRoleDto,
workspaceId: string,
): Promise<void> {
const memberTypeId = dto.userId
? { userId: dto.userId }
: dto.groupId
? { groupId: dto.groupId }
: null;
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
if (!memberTypeId) {
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
const result = await executeTx(this.db, async (trx) => {
const space = await this.spaceRepo.findById(
dto.spaceId,
workspaceId,
{ withLock: true, trx },
);
if (!space) {
throw new NotFoundException('Space not found');
}
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
memberTypeId,
trx,
);
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === dto.role) {
return;
}
if (spaceMember.role === dto.role) {
return { changed: false, space, spaceMember };
}
await executeTx(this.db, async (trx) => {
await trx
.selectFrom('spaces')
.select('id')
.where('id', '=', dto.spaceId)
.forUpdate()
.executeTakeFirst();
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx);
@@ -350,16 +357,8 @@ export class SpaceMemberService {
dto.spaceId,
trx,
);
return { changed: true, space, spaceMember };
});
if (!result.changed) {
return;
}
const { space, spaceMember } = result;
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
resourceType: AuditResource.SPACE_MEMBER,
@@ -388,7 +387,7 @@ export class SpaceMemberService {
spaceId,
trx,
);
if (spaceOwnerCount <= 1) {
if (spaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one space admin with full access',
);
@@ -747,61 +747,44 @@ export class WorkspaceService {
userRoleDto: UpdateWorkspaceUserRoleDto,
workspaceId: string,
) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
const newRole = userRoleDto.role.toLowerCase();
const result = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(
userRoleDto.userId,
workspaceId,
{ trx },
);
if (!user) {
throw new BadRequestException('Workspace member not found');
}
if (
isAdminActingOnOwner(authUser.role, newRole) ||
isAdminActingOnOwner(authUser.role, user.role)
) {
throw new ForbiddenException();
}
if (user.role === newRole) {
return { changed: false, user };
}
if (
user.role === UserRole.OWNER &&
!user.deletedAt &&
!user.deactivatedAt
) {
await this.validateLastWorkspaceOwner(workspaceId, trx);
}
await this.userRepo.updateUser(
{
role: newRole,
},
user.id,
workspaceId,
trx,
);
return { changed: true, user };
});
if (!result.changed) {
return result.user;
if (!user) {
throw new BadRequestException('Workspace member not found');
}
const { user } = result;
// prevent ADMIN from managing OWNER role
if (
isAdminActingOnOwner(authUser.role, newRole) ||
isAdminActingOnOwner(authUser.role, user.role)
) {
throw new ForbiddenException();
}
if (user.role === newRole) {
return user;
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
await this.userRepo.updateUser(
{
role: newRole,
},
user.id,
workspaceId,
);
this.auditService.log({
event: AuditEvent.USER_ROLE_CHANGED,
@@ -865,38 +848,40 @@ export class WorkspaceService {
userId: string,
workspaceId: string,
): Promise<void> {
const user = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(userId, workspaceId);
const user = await this.userRepo.findById(userId, workspaceId, { trx });
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (user.deactivatedAt) {
throw new BadRequestException('User is already deactivated');
}
if (user.deactivatedAt) {
throw new BadRequestException('User is already deactivated');
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot deactivate yourself');
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot deactivate yourself');
}
if (isAdminActingOnOwner(authUser.role, user.role)) {
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException(
'You cannot deactivate a user with owner role',
);
}
if (user.role === UserRole.OWNER) {
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (workspaceOwnerCount === 1) {
throw new BadRequestException(
'You cannot deactivate a user with owner role',
'There must be at least one workspace owner',
);
}
}
if (user.role === UserRole.OWNER) {
await this.validateLastWorkspaceOwner(workspaceId, trx);
}
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser(
{ deactivatedAt: new Date() },
userId,
@@ -904,8 +889,6 @@ export class WorkspaceService {
trx,
);
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
return user;
});
this.auditService.log({
@@ -968,34 +951,32 @@ export class WorkspaceService {
userId: string,
workspaceId: string,
): Promise<void> {
const user = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(userId, workspaceId);
const user = await this.userRepo.findById(userId, workspaceId, { trx });
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot delete yourself');
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException(
'You cannot delete a user with owner role',
);
}
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
if (user.role === UserRole.OWNER && !user.deactivatedAt) {
await this.validateLastWorkspaceOwner(workspaceId, trx);
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot delete yourself');
}
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException('You cannot delete a user with owner role');
}
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser(
{
name: 'Deleted user',
@@ -1028,8 +1009,6 @@ export class WorkspaceService {
});
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
return user;
});
this.auditService.log({
@@ -1051,20 +1030,4 @@ export class WorkspaceService {
// empty
}
}
private async validateLastWorkspaceOwner(
workspaceId: string,
trx: KyselyTransaction,
): Promise<void> {
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
trx,
);
if (workspaceOwnerCount <= 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
}
}
@@ -161,22 +161,6 @@ export class PageRepo {
return result;
}
async lockPageHierarchySpaces(
spaceIds: string[],
trx: KyselyTransaction,
): Promise<void> {
const sortedSpaceIds = [...new Set(spaceIds)].sort();
for (const spaceId of sortedSpaceIds) {
await sql`
SELECT pg_advisory_xact_lock(
hashtext('page-hierarchy'),
hashtext(${spaceId})
)
`.execute(trx);
}
}
async insertPage(
insertablePage: InsertablePage,
trx?: KyselyTransaction,
@@ -505,9 +489,9 @@ export class PageRepo {
async getPageAndDescendants(
parentPageId: string,
opts: { includeContent: boolean; trx?: KyselyTransaction },
opts: { includeContent: boolean },
) {
return dbOrTx(this.db, opts.trx)
return this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
@@ -551,36 +535,6 @@ export class PageRepo {
.execute();
}
async isPageDescendant(
ancestorPageId: string,
descendantPageId: string,
trx?: KyselyTransaction,
): Promise<boolean> {
const result = await dbOrTx(this.db, trx)
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
.select(['id', 'parentPageId'])
.where('id', '=', descendantPageId)
.union((exp) =>
exp
.selectFrom('pages as parent')
.select(['parent.id', 'parent.parentPageId'])
.innerJoin(
'page_ancestors as ancestor',
'ancestor.parentPageId',
'parent.id',
),
),
)
.selectFrom('page_ancestors')
.select('id')
.where('id', '=', ancestorPageId)
.executeTakeFirst();
return Boolean(result);
}
/**
* Get page and all descendants, excluding restricted pages and their subtrees.
* More efficient than getPageAndDescendants + filtering because:
@@ -25,11 +25,7 @@ export class SpaceRepo {
async findById(
spaceId: string,
workspaceId: string,
opts?: {
includeMemberCount?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Space> {
const db = dbOrTx(this.db, opts?.trx);
@@ -45,11 +41,6 @@ export class SpaceRepo {
} else {
query = query.where(sql`LOWER(slug)`, '=', sql`LOWER(${spaceId})`);
}
if (opts?.withLock && opts?.trx) {
query = query.forUpdate();
}
return query.executeTakeFirst();
}
@@ -145,16 +145,12 @@ export class UserRepo {
async roleCountByWorkspaceId(
role: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const { count } = await db
const { count } = await this.db
.selectFrom('users')
.select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.executeTakeFirst();
return count as number;