Files

274 lines
7.2 KiB
TypeScript

import type { Optional } from "utility-types";
import { TextHelper } from "@shared/utils/TextHelper";
import { Collection, Document, type Template } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { authorize } from "@server/policies";
import type { APIContext } from "@server/types";
import { assertPresent } from "@server/validation";
type Props = Optional<
Pick<
Document,
| "id"
| "urlId"
| "title"
| "text"
| "content"
| "icon"
| "color"
| "collectionId"
| "parentDocumentId"
| "importId"
| "apiImportId"
| "fullWidth"
| "sourceMetadata"
| "editorVersion"
| "publishedAt"
| "createdAt"
| "updatedAt"
| "createdById"
| "lastModifiedById"
>
> & {
state?: Buffer;
publish?: boolean;
template?: Template | null;
index?: number;
};
type CreateLocation = {
/** The collection to place the document in, if any. */
collectionId?: string | null;
/** The parent document to nest the new document under, if any. */
parentDocumentId?: string | null;
};
/**
* Authorizes the creation of a document at the requested location and resolves
* the collection and parent document it will belong to. Shared by the
* documents.create API route and the MCP create_document tool so that both
* enforce identical permissions, including the team-level check that prevents
* viewers and guests from creating drafts with no collection.
*
* @param ctx the API context containing the acting user.
* @param location the requested collection and/or parent document.
* @returns the resolved collection and parent document, when applicable.
* @throws AuthorizationError when the user may not create the document.
*/
export async function authorizeDocumentCreate(
ctx: APIContext,
{ collectionId, parentDocumentId }: CreateLocation
): Promise<{
collection?: Collection | null;
parentDocument?: Document | null;
}> {
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
if (parentDocumentId) {
const parentDocument = await Document.findByPk(parentDocumentId, {
userId: user.id,
transaction,
});
const collection = parentDocument?.collectionId
? await Collection.findByPk(parentDocument.collectionId, {
userId: user.id,
transaction,
})
: undefined;
authorize(user, "createChildDocument", parentDocument, { collection });
return { collection, parentDocument };
}
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "createDocument", collection);
return { collection };
}
authorize(user, "createDocument", user.team);
return {};
}
/**
* Authorizes publishing a document into a collection and resolves the target
* collection. Shared by the documents.update API route and the MCP
* update_document tool. Publishing places a document into a collection, so it
* requires create permission on the destination — separate from the update
* permission that governs editing a draft's content.
*
* @param ctx the API context containing the acting user.
* @param document the document being published.
* @param collectionId the destination collection, required when publishing a draft that has none.
* @returns the resolved destination collection.
* @throws AuthorizationError when the user may not publish into the collection.
*/
export async function authorizeDocumentPublish(
ctx: APIContext,
document: Document,
collectionId?: string | null
): Promise<Collection | null | undefined> {
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
let collection = document.collection;
if (document.isDraft) {
authorize(user, "publish", document);
}
if (!document.collectionId) {
assertPresent(
collectionId,
"collectionId is required to publish a draft without collection"
);
collection = await Collection.findByPk(collectionId!, {
userId: user.id,
transaction,
});
}
if (document.parentDocumentId) {
const parentDocument = await Document.findByPk(document.parentDocumentId, {
userId: user.id,
transaction,
});
authorize(user, "createChildDocument", parentDocument, { collection });
} else {
authorize(user, "createDocument", collection);
}
return collection;
}
export default async function documentCreator(
ctx: APIContext,
{
title,
text,
icon,
color,
state,
id,
urlId,
publish,
index,
collectionId,
parentDocumentId,
content,
template,
fullWidth,
importId,
apiImportId,
createdAt,
// allows override for import
updatedAt,
editorVersion,
publishedAt,
sourceMetadata,
createdById,
lastModifiedById,
}: Props
): Promise<Document> {
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const templateId = template ? template.id : undefined;
const eventData = importId || apiImportId ? { source: "import" } : undefined;
if (state && template) {
throw new Error(
"State cannot be set when creating a document from a template"
);
}
if (urlId) {
const existing = await Document.unscoped().findOne({
attributes: ["id"],
transaction,
where: {
urlId,
},
});
if (existing) {
urlId = undefined;
}
}
const titleWithReplacements =
title ??
(template ? TextHelper.replaceTemplateVariables(template.title, user) : "");
const contentWithReplacements = content
? content
: text
? ProsemirrorHelper.toProsemirror(text).toJSON()
: template
? ProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(template),
user
)
: ProsemirrorHelper.toProsemirror("").toJSON();
const document = Document.build({
id,
urlId,
parentDocumentId,
editorVersion,
collectionId,
teamId: user.teamId,
createdAt,
updatedAt: updatedAt ?? createdAt,
lastModifiedById: lastModifiedById ?? createdById ?? user.id,
createdById: createdById ?? user.id,
templateId,
publishedAt,
importId,
apiImportId,
sourceMetadata,
fullWidth: fullWidth ?? template?.fullWidth,
icon: icon ?? template?.icon,
color: color ?? template?.color,
title: titleWithReplacements,
content: contentWithReplacements,
state,
});
document.text = await DocumentHelper.toMarkdown(document, {
includeTitle: false,
});
await document.saveWithCtx(
ctx,
{
silent: !!createdAt,
},
{ data: eventData }
);
if (publish) {
if (!collectionId) {
throw new Error("Collection ID is required to publish");
}
await document.publish(ctx, {
collectionId,
silent: true,
index,
event: !!document.title,
data: eventData,
});
}
// reload to get all of the data needed to present (user, collection etc)
// we need to specify publishedAt to bypass default scope that only returns
// published documents
return Document.findByPk(document.id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
}