diff --git a/app/actions/definitions/revisions.tsx b/app/actions/definitions/revisions.tsx
index a0202e0d11..c79497b33c 100644
--- a/app/actions/definitions/revisions.tsx
+++ b/app/actions/definitions/revisions.tsx
@@ -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: ,
section: RevisionSection,
@@ -41,6 +41,38 @@ export const restoreRevision = createAction({
},
});
+export const deleteRevision = createAction({
+ name: ({ t }) => t("Delete"),
+ analyticsName: "Delete revision",
+ icon: ,
+ 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",
diff --git a/app/components/EventListItem.tsx b/app/components/EventListItem.tsx
index d774f23ffb..01dab0496b 100644
--- a/app/components/EventListItem.tsx
+++ b/app/components/EventListItem.tsx
@@ -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 = ;
- meta = event.latest ? (
- <>
- {t("Current version")} · {actor?.name}
- >
- ) : (
- t("{{userName}} edited", opts)
- );
- to = {
- pathname: documentHistoryPath(
- document,
- isDerivedFromDocument ? "latest" : event.id
- ),
- state: {
- sidebarContext,
- retainScrollPosition: true,
- },
- };
+ {
+ if (event.deletedAt) {
+ icon = ;
+ meta = t("Revision deleted");
+ } else {
+ icon = ;
+ meta = event.latest ? (
+ <>
+ {t("Current version")} · {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 ? (
{
{meta} ·{" "}
-
+
);
diff --git a/app/models/Revision.ts b/app/models/Revision.ts
index 818d11dc67..ce38ed0c32 100644
--- a/app/models/Revision.ts
+++ b/app/models/Revision.ts
@@ -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 */
diff --git a/app/scenes/Document/components/History.tsx b/app/scenes/Document/components/History.tsx
index 815a625555..9957483b15 100644
--- a/app/scenes/Document/components/History.tsx
+++ b/app/scenes/Document/components/History.tsx
@@ -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)
: [],
diff --git a/app/stores/EventsStore.ts b/app/stores/EventsStore.ts
index 901dd1b198..7d668d8558 100644
--- a/app/stores/EventsStore.ts
+++ b/app/stores/EventsStore.ts
@@ -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> {
super(rootStore, Event);
}
- @computed
- get orderedData(): Event[] {
- 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[] =>
+ this.orderedData.filter((event) => event.documentId === documentId);
}
diff --git a/app/stores/RevisionsStore.ts b/app/stores/RevisionsStore.ts
index 8546aea985..3d434edb71 100644
--- a/app/stores/RevisionsStore.ts
+++ b/app/stores/RevisionsStore.ts
@@ -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 {
- 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 {
const res = await client.post(`/revisions.info`, { documentId });
return this.add(res.data);
};
-
- @action
- fetchPage = async (
- options: { documentId: string } & (PaginationParams | undefined)
- ): Promise => {
- 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;
- }
- };
}
diff --git a/server/migrations/20250515031645-add-revisions-deleted-at.js b/server/migrations/20250515031645-add-revisions-deleted-at.js
new file mode 100644
index 0000000000..2310bc9b89
--- /dev/null
+++ b/server/migrations/20250515031645-add-revisions-deleted-at.js
@@ -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");
+ }
+};
diff --git a/server/models/Revision.ts b/server/models/Revision.ts
index 2a9cee2d77..7eabc9d249 100644
--- a/server/models/Revision.ts
+++ b/server/models/Revision.ts
@@ -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,
Partial>
> {
@@ -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
/**
diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx
index cd9add19c1..4fed968de7 100644
--- a/server/models/helpers/DocumentHelper.tsx
+++ b/server/models/helpers/DocumentHelper.tsx
@@ -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) {
diff --git a/server/policies/revision.ts b/server/policies/revision.ts
index 06e931868e..cabce9c99f 100644
--- a/server/policies/revision.ts
+++ b/server/policies/revision.ts
@@ -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)
+ )
+);
diff --git a/server/presenters/revision.ts b/server/presenters/revision.ts
index 2461839625..338b118762 100644
--- a/server/presenters/revision.ts
+++ b/server/presenters/revision.ts
@@ -19,6 +19,7 @@ async function presentRevision(revision: Revision, diff?: string) {
html: diff,
createdAt: revision.createdAt,
createdBy: presentUser(revision.user),
+ deletedAt: revision.deletedAt,
};
}
diff --git a/server/routes/api/revisions/revisions.ts b/server/routes/api/revisions/revisions.ts
index 54c27ec7f3..5665818776 100644
--- a/server/routes/api/revisions/revisions.ts
+++ b/server/routes/api/revisions/revisions.ts
@@ -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) => {
+ 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))
diff --git a/server/routes/api/revisions/schema.ts b/server/routes/api/revisions/schema.ts
index 313dff751d..5aaf8c1132 100644
--- a/server/routes/api/revisions/schema.ts
+++ b/server/routes/api/revisions/schema.ts
@@ -59,3 +59,11 @@ export const RevisionsListSchema = z.object({
});
export type RevisionsListReq = z.infer;
+
+export const RevisionsDeleteSchema = BaseSchema.extend({
+ body: z.object({
+ id: z.string().uuid(),
+ }),
+});
+
+export type RevisionsDeleteReq = z.infer;
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 1a4a6ed3ea..0769366adb 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -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",
diff --git a/shared/utils/EventHelper.ts b/shared/utils/EventHelper.ts
index 18e61735cd..24cb02a79b 100644
--- a/shared/utils/EventHelper.ts
+++ b/shared/utils/EventHelper.ts
@@ -55,6 +55,7 @@ export class EventHelper {
"pins.update",
"pins.delete",
"revisions.create",
+ "revisions.delete",
"shares.create",
"shares.update",
"shares.revoke",
diff --git a/yarn.lock b/yarn.lock
index 2ce851c36a..1a2ba659a8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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==