Add revision deletion endpoints (#9240)

This commit is contained in:
Tom Moor
2025-05-21 22:57:02 -04:00
committed by GitHub
parent 22c0f18b6b
commit 3ffee1239b
16 changed files with 185 additions and 147 deletions
+34 -2
View File
@@ -1,5 +1,5 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon } from "outline-icons";
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
@@ -12,7 +12,7 @@ import {
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
name: ({ t }) => t("Restore revision"),
name: ({ t }) => t("Restore"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
@@ -41,6 +41,38 @@ export const restoreRevision = createAction({
},
});
export const deleteRevision = createAction({
name: ({ t }) => t("Delete"),
analyticsName: "Delete revision",
icon: <TrashIcon />,
section: RevisionSection,
dangerous: true,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ t, event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
if (revisionId) {
const revision = stores.revisions.get(revisionId);
await revision?.delete();
toast.success(t("This version of the document was deleted"));
history.push(documentHistoryPath(document));
}
},
});
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
+39 -24
View File
@@ -48,10 +48,12 @@ export type DocumentEvent = {
userId: string;
};
export type Event = { id: string; actorId: string; createdAt: string } & (
| RevisionEvent
| DocumentEvent
);
export type Event = {
id: string;
actorId: string;
createdAt: string;
deletedAt?: string;
} & (RevisionEvent | DocumentEvent);
type Props = {
document: Document;
@@ -85,6 +87,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!event.deletedAt &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
@@ -95,24 +98,31 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = event.latest ? (
<>
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
},
};
{
if (event.deletedAt) {
icon = <TrashIcon />;
meta = t("Revision deleted");
} else {
icon = <EditIcon size={16} />;
meta = event.latest ? (
<>
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
},
};
}
}
break;
case "documents.archive":
@@ -181,7 +191,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
to = undefined;
}
return event.name === "revisions.create" ? (
return event.name === "revisions.create" && !event.deletedAt ? (
<RevisionItem
small
exact
@@ -218,7 +228,12 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={event.createdAt} relative shorten addSuffix />
<Time
dateTime={event.deletedAt ?? event.createdAt}
relative
shorten
addSuffix
/>
</Text>
</EventItem>
);
+2 -2
View File
@@ -3,11 +3,11 @@ import { ProsemirrorData } from "@shared/types";
import { isRTL } from "@shared/utils/rtl";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class Revision extends Model {
class Revision extends ParanoidModel {
static modelName = "Revision";
/** The document ID that the revision is related to */
+6 -8
View File
@@ -50,6 +50,7 @@ function History() {
name: "revisions.create",
actorId: data.createdBy.id,
createdAt: data.createdAt,
deletedAt: data.deletedAt,
latest: false,
} satisfies Event;
}
@@ -70,7 +71,7 @@ function History() {
return [];
}
const [revisionsArr, eventsArr] = await Promise.all([
const [revisionsPage, eventsPage] = await Promise.all([
revisions.fetchPage({
documentId: document.id,
offset: offset.revisions,
@@ -85,7 +86,7 @@ function History() {
]);
const pageEvents = orderBy(
[...revisionsArr, ...eventsArr].map(toEvent),
[...revisionsPage, ...eventsPage].map(toEvent),
"createdAt",
"desc"
).slice(0, Pagination.defaultLimit);
@@ -110,11 +111,8 @@ function History() {
const latestRevisionId = RevisionHelper.latestId(document.id);
return revisions
.filter(
(revision: Revision) =>
revision.id !== latestRevisionId &&
revision.documentId === document.id
)
.getByDocumentId(document.id)
.filter((revision: Revision) => revision.id !== latestRevisionId)
.slice(0, offset.revisions)
.map(toEvent);
}, [document, revisions, offset.revisions, toEvent]);
@@ -123,7 +121,7 @@ function History() {
() =>
document
? events
.filter({ documentId: document.id })
.getByDocumentId(document.id)
.slice(0, offset.events)
.map(toEvent)
: [],
+8 -6
View File
@@ -1,5 +1,3 @@
import orderBy from "lodash/orderBy";
import { computed } from "mobx";
import Event from "~/models/Event";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
@@ -11,8 +9,12 @@ export default class EventsStore extends Store<Event<any>> {
super(rootStore, Event);
}
@computed
get orderedData(): Event<any>[] {
return orderBy(Array.from(this.data.values()), "createdAt", "desc");
}
/**
* Retrieves all events for a given document ID
*
* @param documentId - The ID of the document to retrieve events for
* @returns An array of events for the specified document ID
*/
getByDocumentId = (documentId: string): Event<any>[] =>
this.orderedData.filter((event) => event.documentId === documentId);
}
+9 -59
View File
@@ -1,50 +1,21 @@
import invariant from "invariant";
import filter from "lodash/filter";
import { action, runInAction } from "mobx";
import RootStore from "~/stores/RootStore";
import Store, { RPCAction } from "~/stores/base/Store";
import Store from "~/stores/base/Store";
import Revision from "~/models/Revision";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
export default class RevisionsStore extends Store<Revision> {
actions = [RPCAction.List, RPCAction.Update, RPCAction.Info];
constructor(rootStore: RootStore) {
super(rootStore, Revision);
}
getDocumentRevisions(documentId: string): Revision[] {
const revisions = filter(this.orderedData, {
documentId,
});
const latestRevision = revisions[0];
const document = this.rootStore.documents.get(documentId);
// There is no guarantee that we have a revision that represents the latest
// state of the document. This pushes a fake revision in at the top if there
// isn't one
if (
latestRevision &&
document &&
latestRevision.createdAt !== document.updatedAt
) {
revisions.unshift(
new Revision(
{
id: "latest",
documentId: document.id,
title: document.title,
createdAt: document.updatedAt,
createdBy: document.createdBy,
},
this
)
);
}
return revisions;
}
/**
* Retrieves all revisions for a given document ID
*
* @param documentId - The ID of the document to retrieve revisions for
* @returns An array of revisions for the specified document ID
*/
getByDocumentId = (documentId: string): Revision[] =>
this.orderedData.filter((revision) => revision.documentId === documentId);
/**
* Fetches the latest revision for the given document.
@@ -55,25 +26,4 @@ export default class RevisionsStore extends Store<Revision> {
const res = await client.post(`/revisions.info`, { documentId });
return this.add(res.data);
};
@action
fetchPage = async (
options: { documentId: string } & (PaginationParams | undefined)
): Promise<Revision[]> => {
this.isFetching = true;
try {
const res = await client.post("/revisions.list", options);
invariant(res?.data, "Document revisions not available");
let models: Revision[] = [];
runInAction("RevisionsStore#fetchPage", () => {
models = res.data.map(this.add);
this.isLoaded = true;
});
return models;
} finally {
this.isFetching = false;
}
};
}
@@ -0,0 +1,15 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn("revisions", "deletedAt", {
type: Sequelize.DATE,
allowNull: true
});
},
async down (queryInterface, Sequelize) {
await queryInterface.removeColumn("revisions", "deletedAt");
}
};
+13 -3
View File
@@ -13,12 +13,13 @@ import {
Table,
IsNumeric,
Length as SimpleLength,
BeforeDestroy,
} from "sequelize-typescript";
import type { ProsemirrorData } from "@shared/types";
import { DocumentValidation, RevisionValidation } from "@shared/validations";
import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length";
@@ -34,7 +35,7 @@ import Length from "./validators/Length";
}))
@Table({ tableName: "revisions", modelName: "revision" })
@Fix
class Revision extends IdModel<
class Revision extends ParanoidModel<
InferAttributes<Revision>,
Partial<InferCreationAttributes<Revision>>
> {
@@ -74,7 +75,7 @@ class Revision extends IdModel<
* and is no longer being written.
*/
@Column(DataType.TEXT)
text: string;
text: string | null;
/** The content of the revision as JSON. */
@Column(DataType.JSONB)
@@ -109,6 +110,15 @@ class Revision extends IdModel<
@Column(DataType.UUID)
userId: string;
// hooks
@BeforeDestroy
static async clearData(model: Revision) {
model.content = null;
model.text = null;
model.title = "";
}
// static methods
/**
+1 -1
View File
@@ -104,7 +104,7 @@ export class DocumentHelper {
} else if (document instanceof Collection) {
doc = parser.parse(document.description ?? "");
} else {
doc = parser.parse(document.text);
doc = parser.parse(document.text ?? "");
}
if (doc && options?.signedUrls && options?.teamId) {
+9 -1
View File
@@ -2,10 +2,18 @@ import { User, Revision } from "@server/models";
import { allow } from "./cancan";
import { and, isTeamMutable, or } from "./utils";
allow(User, ["update"], Revision, (actor, revision) =>
allow(User, "update", Revision, (actor, revision) =>
and(
//
or(actor.id === revision?.userId, actor.isAdmin),
isTeamMutable(actor)
)
);
allow(User, "delete", Revision, (actor) =>
and(
//
actor.isAdmin,
isTeamMutable(actor)
)
);
+1
View File
@@ -19,6 +19,7 @@ async function presentRevision(revision: Revision, diff?: string) {
html: diff,
createdAt: revision.createdAt,
createdBy: presentUser(revision.user),
deletedAt: revision.deletedAt,
};
}
+33
View File
@@ -1,5 +1,6 @@
import Router from "koa-router";
import { Op } from "sequelize";
import { UserRole } from "@shared/types";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import slugify from "@shared/utils/slugify";
import { ValidationError } from "@server/errors";
@@ -92,6 +93,37 @@ router.post(
}
);
router.post(
"revisions.delete",
auth({ role: UserRole.Admin }),
validate(T.RevisionsDeleteSchema),
transaction(),
async (ctx: APIContext<T.RevisionsDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const revision = await Revision.findByPk(id, {
rejectOnEmpty: true,
lock: {
of: Revision,
level: transaction.LOCK.UPDATE,
},
});
const document = await Document.findByPk(revision.documentId, {
userId: user.id,
});
authorize(user, "read", document);
authorize(user, "delete", revision);
await revision.destroyWithCtx(ctx);
ctx.body = {
success: true,
};
}
);
router.post(
"revisions.diff",
auth(),
@@ -168,6 +200,7 @@ router.post(
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
paranoid: false,
});
const data = await Promise.all(
revisions.map((revision) => presentRevision(revision))
+8
View File
@@ -59,3 +59,11 @@ export const RevisionsListSchema = z.object({
});
export type RevisionsListReq = z.infer<typeof RevisionsListSchema>;
export const RevisionsDeleteSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
}),
});
export type RevisionsDeleteReq = z.infer<typeof RevisionsDeleteSchema>;
+2 -1
View File
@@ -122,7 +122,7 @@
"Archive all notifications": "Archive all notifications",
"New App": "New App",
"New Application": "New Application",
"Restore revision": "Restore revision",
"This version of the document was deleted": "This version of the document was deleted",
"Link copied": "Link copied",
"Dark": "Dark",
"Light": "Light",
@@ -247,6 +247,7 @@
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.",
"our engineers have been notified": "our engineers have been notified",
"Show detail": "Show detail",
"Revision deleted": "Revision deleted",
"Current version": "Current version",
"{{userName}} edited": "{{userName}} edited",
"{{userName}} archived": "{{userName}} archived",
+1
View File
@@ -55,6 +55,7 @@ export class EventHelper {
"pins.update",
"pins.delete",
"revisions.create",
"revisions.delete",
"shares.create",
"shares.update",
"shares.revoke",
+4 -40
View File
@@ -4167,18 +4167,7 @@
"@smithy/util-utf8" "^4.0.0"
tslib "^2.6.2"
"@smithy/credential-provider-imds@^4.0.4":
version "4.0.4"
resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.4.tgz#01315ab90c4cb3e017c1ee2c6e5f958aeaa7cf78"
integrity sha512-jN6M6zaGVyB8FmNGG+xOPQB4N89M1x97MMdMnm1ESjljLS3Qju/IegQizKujaNcy2vXAvrz0en8bobe6E55FEA==
dependencies:
"@smithy/node-config-provider" "^4.1.1"
"@smithy/property-provider" "^4.0.2"
"@smithy/types" "^4.2.0"
"@smithy/url-parser" "^4.0.2"
tslib "^2.6.2"
"@smithy/credential-provider-imds@^4.0.5":
"@smithy/credential-provider-imds@^4.0.4", "@smithy/credential-provider-imds@^4.0.5":
version "4.0.5"
resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.5.tgz#d44989d783300af37b2be2fc4ec29cdb67540c32"
integrity sha512-saEAGwrIlkb9XxX/m5S5hOtzjoJPEK6Qw2f9pYTbIsMPOFyGSXBBTw95WbOyru8A1vIS2jVCCU1Qhz50QWG3IA==
@@ -4381,15 +4370,7 @@
"@smithy/types" "^4.3.0"
tslib "^2.6.2"
"@smithy/property-provider@^4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.0.2.tgz#4572c10415c9d4215f3df1530ba61b0319b17b55"
integrity sha512-wNRoQC1uISOuNc2s4hkOYwYllmiyrvVXWMtq+TysNRVQaHm4yoafYQyjN/goYZS+QbYlPIbb/QRjaUZMuzwQ7A==
dependencies:
"@smithy/types" "^4.2.0"
tslib "^2.6.2"
"@smithy/property-provider@^4.0.3":
"@smithy/property-provider@^4.0.2", "@smithy/property-provider@^4.0.3":
version "4.0.3"
resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.0.3.tgz#cefeb7bc7a8baaeec9f68e82c3164141703a15d5"
integrity sha512-Wcn17QNdawJZcZZPBuMuzyBENVi1AXl4TdE0jvzo4vWX2x5df/oMlmr/9M5XAAC6+yae4kWZlOYIsNsgDrMU9A==
@@ -4405,16 +4386,7 @@
"@smithy/types" "^4.3.0"
tslib "^2.6.2"
"@smithy/querystring-builder@^4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.0.2.tgz#834cea95bf413ab417bf9c166d60fd80d2cb3016"
integrity sha512-NTOs0FwHw1vimmQM4ebh+wFQvOwkEf/kQL6bSM1Lock+Bv4I89B3hGYoUEPkmvYPkDKyp5UdXJYu+PoTQ3T31Q==
dependencies:
"@smithy/types" "^4.2.0"
"@smithy/util-uri-escape" "^4.0.0"
tslib "^2.6.2"
"@smithy/querystring-builder@^4.0.3":
"@smithy/querystring-builder@^4.0.2", "@smithy/querystring-builder@^4.0.3":
version "4.0.3"
resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.0.3.tgz#056a17082e0a0ab10c817380d96321a8bba588fd"
integrity sha512-UUzIWMVfPmDZcOutk2/r1vURZqavvQW0OHvgsyNV0cKupChvqg+/NKPRMaMEe+i8tP96IthMFeZOZWpV+E4RAw==
@@ -4438,15 +4410,7 @@
dependencies:
"@smithy/types" "^4.3.0"
"@smithy/shared-ini-file-loader@^4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.2.tgz#15043f0516fe09ff4b22982bc5f644dc701ebae5"
integrity sha512-J9/gTWBGVuFZ01oVA6vdb4DAjf1XbDhK6sLsu3OS9qmLrS6KB5ygpeHiM3miIbj1qgSJ96GYszXFWv6ErJ8QEw==
dependencies:
"@smithy/types" "^4.2.0"
tslib "^2.6.2"
"@smithy/shared-ini-file-loader@^4.0.3":
"@smithy/shared-ini-file-loader@^4.0.2", "@smithy/shared-ini-file-loader@^4.0.3":
version "4.0.3"
resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.3.tgz#23fab0e773630b0817846c52c54b435ac32a4dd0"
integrity sha512-vHwlrqhZGIoLwaH8vvIjpHnloShqdJ7SUPNM2EQtEox+yEDFTVQ7E+DLZ+6OhnYEgFUwPByJyz6UZaOu2tny6A==