mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e5744850 |
@@ -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 ');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user