mirror of
https://github.com/outline/outline.git
synced 2025-12-30 07:19:52 -06:00
Add name column to revisions (#8603)
* fix: Flaky test * Migration, model interface * Add policies to revisions * Add revisions.update endpoint * tests * lint
This commit is contained in:
@@ -19,6 +19,9 @@ class Revision extends Model {
|
||||
/** The document title when the revision was created */
|
||||
title: string;
|
||||
|
||||
/** An optional name for the revision */
|
||||
name: string | null;
|
||||
|
||||
/** Prosemirror data of the content when revision was created */
|
||||
data: ProsemirrorData;
|
||||
|
||||
|
||||
15
server/migrations/20250301234423-add-name-to-revisions.js
Normal file
15
server/migrations/20250301234423-add-name-to-revisions.js
Normal file
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("revisions", "name", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("revisions", "name");
|
||||
},
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Length as SimpleLength,
|
||||
} from "sequelize-typescript";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import { DocumentValidation, RevisionValidation } from "@shared/validations";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
@@ -42,6 +42,7 @@ class Revision extends IdModel<
|
||||
@Column(DataType.SMALLINT)
|
||||
version?: number | null;
|
||||
|
||||
/** The editor version at the time of the revision */
|
||||
@SimpleLength({
|
||||
max: 255,
|
||||
msg: `editorVersion must be 255 characters or less`,
|
||||
@@ -49,6 +50,7 @@ class Revision extends IdModel<
|
||||
@Column
|
||||
editorVersion: string;
|
||||
|
||||
/** The document title at the time of the revision */
|
||||
@Length({
|
||||
max: DocumentValidation.maxTitleLength,
|
||||
msg: `Revision title must be ${DocumentValidation.maxTitleLength} characters or less`,
|
||||
@@ -56,6 +58,14 @@ class Revision extends IdModel<
|
||||
@Column
|
||||
title: string;
|
||||
|
||||
/** An optional name for the revision */
|
||||
@Length({
|
||||
max: RevisionValidation.maxNameLength,
|
||||
msg: `Revision name must be ${RevisionValidation.maxNameLength} characters or less`,
|
||||
})
|
||||
@Column
|
||||
name: string | null;
|
||||
|
||||
/**
|
||||
* The content of the revision as Markdown.
|
||||
*
|
||||
@@ -65,13 +75,11 @@ class Revision extends IdModel<
|
||||
@Column(DataType.TEXT)
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* The content of the revision as JSON.
|
||||
*/
|
||||
/** The content of the revision as JSON. */
|
||||
@Column(DataType.JSONB)
|
||||
content: ProsemirrorData | null;
|
||||
|
||||
/** An icon to use as the document icon. */
|
||||
/** The icon at the time of the revision. */
|
||||
@Length({
|
||||
max: 50,
|
||||
msg: `icon must be 50 characters or less`,
|
||||
@@ -79,7 +87,7 @@ class Revision extends IdModel<
|
||||
@Column
|
||||
icon: string | null;
|
||||
|
||||
/** The color of the icon. */
|
||||
/** The color at the time of the revision. */
|
||||
@IsHexColor
|
||||
@Column
|
||||
color: string | null;
|
||||
|
||||
@@ -12,6 +12,7 @@ import "./fileOperation";
|
||||
import "./integration";
|
||||
import "./pins";
|
||||
import "./reaction";
|
||||
import "./revision";
|
||||
import "./searchQuery";
|
||||
import "./share";
|
||||
import "./star";
|
||||
|
||||
11
server/policies/revision.ts
Normal file
11
server/policies/revision.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { User, Revision } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isTeamMutable, or } from "./utils";
|
||||
|
||||
allow(User, ["update"], Revision, (actor, revision) =>
|
||||
and(
|
||||
//
|
||||
or(actor.id === revision?.userId, actor.isAdmin),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
@@ -12,6 +12,7 @@ async function presentRevision(revision: Revision, diff?: string) {
|
||||
id: revision.id,
|
||||
documentId: revision.documentId,
|
||||
title: strippedTitle,
|
||||
name: revision.name,
|
||||
data: await DocumentHelper.toJSON(revision),
|
||||
icon: revision.icon ?? emoji,
|
||||
color: revision.color,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UserMembership, Revision } from "@server/models";
|
||||
import {
|
||||
buildAdmin,
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildUser,
|
||||
@@ -42,6 +43,61 @@ describe("#revisions.info", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#revisions.update", () => {
|
||||
it("should update a document revision", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
name: "new name",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual("new name");
|
||||
});
|
||||
|
||||
it("should allow an admin to update a document revision", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const document = await buildDocument({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: revision.id,
|
||||
name: "new name",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual("new name");
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/revisions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
name: "new name",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#revisions.diff", () => {
|
||||
it("should return the document HTML if no previous revision", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
@@ -4,11 +4,12 @@ import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Document, Revision } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentRevision } from "@server/presenters";
|
||||
import { presentPolicies, presentRevision } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
@@ -57,6 +58,36 @@ router.post(
|
||||
includeStyles: false,
|
||||
})
|
||||
),
|
||||
policies: presentPolicies(user, [after]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"revisions.update",
|
||||
auth(),
|
||||
validate(T.RevisionsUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.RevisionsUpdateReq>) => {
|
||||
const { id, name } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const revision = await Revision.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
const document = await Document.findByPk(revision.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "update", document);
|
||||
authorize(user, "update", revision);
|
||||
|
||||
revision.name = name;
|
||||
await revision.save({ transaction });
|
||||
|
||||
ctx.body = {
|
||||
data: await presentRevision(revision),
|
||||
policies: presentPolicies(user, [revision]),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -110,6 +141,7 @@ router.post(
|
||||
|
||||
ctx.body = {
|
||||
data: content,
|
||||
policies: presentPolicies(user, [revision]),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -144,6 +176,7 @@ router.post(
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
policies: presentPolicies(user, revisions),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { RevisionValidation } from "@shared/validations";
|
||||
import { Revision } from "@server/models";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
@@ -25,6 +26,19 @@ export const RevisionsDiffSchema = BaseSchema.extend({
|
||||
|
||||
export type RevisionsDiffReq = z.infer<typeof RevisionsDiffSchema>;
|
||||
|
||||
export const RevisionsUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: z.string().uuid(),
|
||||
|
||||
name: z
|
||||
.string()
|
||||
.min(RevisionValidation.minNameLength)
|
||||
.max(RevisionValidation.maxNameLength),
|
||||
}),
|
||||
});
|
||||
|
||||
export type RevisionsUpdateReq = z.infer<typeof RevisionsUpdateSchema>;
|
||||
|
||||
export const RevisionsListSchema = z.object({
|
||||
body: z.object({
|
||||
direction: z
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("#shares.list", () => {
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
title: "hardcoded",
|
||||
});
|
||||
await buildShare({
|
||||
documentId: document.id,
|
||||
|
||||
@@ -51,6 +51,11 @@ export const DocumentValidation = {
|
||||
maxStateLength: 1500 * 1024,
|
||||
};
|
||||
|
||||
export const RevisionValidation = {
|
||||
minNameLength: 1,
|
||||
maxNameLength: 255,
|
||||
};
|
||||
|
||||
export const PinValidation = {
|
||||
/** The maximum number of pinned documents on an individual collection or home screen */
|
||||
max: 8,
|
||||
|
||||
Reference in New Issue
Block a user