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==