From db1838bc9be6524695abf00137f165b355c7a85a Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 18 Feb 2025 13:37:15 +0100 Subject: [PATCH 01/13] OldItem trigger, item history; Shadow edits; items can always be edited; don't record bios --- api/paidAction/itemUpdate.js | 5 +- api/resolvers/item.js | 17 +-- api/typeDefs/item.js | 14 +++ components/comment.js | 4 +- components/discussion-form.js | 6 +- components/item-history.js | 21 ++++ components/item-info.js | 42 +++++--- components/job-form.js | 4 +- components/post.js | 4 +- components/use-can-edit.js | 16 +-- components/wallet-buttonbar.js | 4 +- fragments/comments.js | 12 +++ fragments/items.js | 12 +++ pages/items/[id]/edit.js | 6 +- .../migration.sql | 101 ++++++++++++++++++ prisma/schema.prisma | 26 +++++ 16 files changed, 251 insertions(+), 43 deletions(-) create mode 100644 components/item-history.js create mode 100644 prisma/migrations/20250217233746_perpetual_edit/migration.sql diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index 3aad8e448..3e7bf86b3 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -40,6 +40,9 @@ export async function perform (args, context) { } }) + console.log(data) + console.log(old) + const newBoost = boost - old.boost const itemActs = [] if (newBoost > 0) { @@ -59,7 +62,7 @@ export async function perform (args, context) { const mentions = await getMentions(args, context) const itemMentions = await getItemMentions(args, context) const itemUploads = uploadIds.map(id => ({ uploadId: id })) - + console.log(old) await tx.upload.updateMany({ where: { id: { in: uploadIds } }, data: { paid: true } diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 2ff086337..203792849 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -9,7 +9,6 @@ import { USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS, BOOST_MULT, - ITEM_EDIT_SECONDS, COMMENTS_LIMIT, COMMENTS_OF_COMMENT_LIMIT, FULL_COMMENTS_THRESHOLD @@ -1206,6 +1205,12 @@ export default { } return await models.user.findUnique({ where: { id: item.userId } }) }, + oldVersions: async (item, args, { models }) => { + return await models.oldItem.findMany({ + where: { originalItemId: item.id }, + orderBy: { cloneDiedAt: 'desc' } + }) + }, forwards: async (item, args, { models }) => { return await models.itemForward.findMany({ where: { @@ -1487,13 +1492,12 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. const user = await models.user.findUnique({ where: { id: meId } }) - // edits are only allowed for own items within 10 minutes - // but forever if an admin is editing an "admin item", it's their bio or a job + // edit always allowed for own items + // or if it's an admin item, their bio or a job TODO: adjust every edit const myBio = user.bioId === old.id - const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { seconds: ITEM_EDIT_SECONDS }) - const canEdit = (timer && ownerEdit) || adminEdit || myBio || isJob(old) + const canEdit = ownerEdit || adminEdit || myBio || isJob(old) if (!canEdit) { - throw new GqlInputError('item can no longer be edited') + throw new GqlInputError('item cannot be edited') } if (item.url && !isJob(item)) { @@ -1515,7 +1519,6 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. // never change author of item item.userId = old.userId - const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd }) resultItem.comments = [] diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 730f61830..a77ba21fe 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -90,6 +90,17 @@ export default gql` ad: Item } + type OldItem { + id: ID! + title: String + text: String + url: String + createdAt: Date + updatedAt: Date + cloneBornAt: Date + cloneDiedAt: Date + } + type Comments { cursor: String comments: [Item!]! @@ -165,6 +176,9 @@ export default gql` parentOtsHash: String forwards: [ItemForward] imgproxyUrls: JSONObject + cloneBornAt: Date + cloneDiedAt: Date + oldVersions: [OldItem!] rel: String apiKey: Boolean invoice: Invoice diff --git a/components/comment.js b/components/comment.js index 67b4bc84e..41f1406fa 100644 --- a/components/comment.js +++ b/components/comment.js @@ -201,8 +201,8 @@ export default function Comment ({ } edit={edit} - toggleEdit={e => { setEdit(!edit) }} - editText={edit ? 'cancel' : 'edit'} + toggleShadowEdit={e => { setEdit(!edit) }} + shadowEditText={edit ? 'cancel' : 'edit'} />} {!includeParent && (collapse === 'yep' diff --git a/components/discussion-form.js b/components/discussion-form.js index 1f8c02b9f..7f625ee4a 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -16,7 +16,7 @@ import { UPSERT_DISCUSSION } from '@/fragments/paidAction' import useItemSubmit from './use-item-submit' export function DiscussionForm ({ - item, sub, editThreshold, titleLabel = 'title', + item, sub, shadowEditThreshold, titleLabel = 'title', textLabel = 'text', handleSubmit, children }) { @@ -75,8 +75,8 @@ export function DiscussionForm ({ label={<>{textLabel} optional} name='text' minRows={6} - hint={editThreshold - ?
+ hint={shadowEditThreshold // TODO: when countdown expires don't show it, we need the countdown to know if can shadow edit + ?
: null} /> diff --git a/components/item-history.js b/components/item-history.js new file mode 100644 index 000000000..3e15c4eaf --- /dev/null +++ b/components/item-history.js @@ -0,0 +1,21 @@ +import { timeSince } from '@/lib/time' +import styles from './item.module.css' + +// TODO: PAID add a button to restore the item to the version +// TODO: styling +// TODO: render it as Item + +export default function ItemHistory ({ item }) { + return ( +
+ {item.oldVersions.map(version => ( +
+ {timeSince(new Date(version.cloneBornAt || version.createdAt))} +

{version.title}

+

{version.text}

+

{version.url}

+
+ ))} +
+ ) +} diff --git a/components/item-info.js b/components/item-info.js index 17c96cc6c..ee09cbf98 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -28,7 +28,8 @@ import { useToast } from './toast' import { useShowModal } from './modal' import classNames from 'classnames' import SubPopover from './sub-popover' -import useCanEdit from './use-can-edit' +import useCanShadowEdit from './use-can-edit' +import ItemHistory from './item-history' function itemTitle (item) { let title = '' @@ -65,16 +66,17 @@ function itemTitle (item) { export default function ItemInfo ({ item, full, commentsText = 'comments', - commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleEdit, editText, + commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleShadowEdit, shadowEditText, onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true, setDisableRetry, disableRetry }) { const { me } = useMe() + const showModal = useShowModal() const router = useRouter() const [hasNewComments, setHasNewComments] = useState(false) const root = useRoot() const sub = item?.sub || root?.sub - const [canEdit, setCanEdit, editThreshold] = useCanEdit(item) + const [canShadowEdit, setCanShadowEdit, shadowEditThreshold] = useCanShadowEdit(item) useEffect(() => { if (!full) { @@ -145,6 +147,13 @@ export default function ItemInfo ({ yesterday } + {item.oldVersions?.length > 0 && + <> + \ + showModal((onClose) => )} className='text-reset' title={item.cloneBornAt}> + edited {item.oldVersions.length} times + + } {item.subName && @@ -171,8 +180,8 @@ export default function ItemInfo ({ showActionDropdown && <> @@ -219,6 +228,13 @@ export default function ItemInfo ({
} + {item.mine && !item.position && !item.deletedAt && !item.bio && // TODO: adjust every edit + <> +
+ + edit + + } {item.mine && !item.position && !item.deletedAt && !item.bio && <>
@@ -339,22 +355,22 @@ function PaymentInfo ({ item, disableRetry, setDisableRetry }) { ) } -function EditInfo ({ item, edit, canEdit, setCanEdit, toggleEdit, editText, editThreshold }) { +function EditInfo ({ item, edit, canShadowEdit, setCanShadowEdit, toggleShadowEdit, shadowEditText, shadowEditThreshold }) { const router = useRouter() - if (canEdit) { + if (canShadowEdit) { return ( <> \ toggleEdit ? toggleEdit() : router.push(`/items/${item.id}/edit`)} + onClick={() => toggleShadowEdit ? toggleShadowEdit() : router.push(`/items/${item.id}/edit`)} > - {editText || 'edit'} + {shadowEditText || 'edit'} {(!item.invoice?.actionState || item.invoice?.actionState === 'PAID') ? { setCanEdit(false) }} + date={shadowEditThreshold} + onComplete={() => { setCanShadowEdit(false) }} /> : 10:00} @@ -362,14 +378,14 @@ function EditInfo ({ item, edit, canEdit, setCanEdit, toggleEdit, editText, edit ) } - if (edit && !canEdit) { + if (edit && !canShadowEdit) { // if we're still editing after timer ran out return ( <> \ toggleEdit ? toggleEdit() : router.push(`/items/${item.id}`)} + onClick={() => toggleShadowEdit ? toggleShadowEdit() : router.push(`/items/${item.id}`)} > cancel 00:00 diff --git a/components/job-form.js b/components/job-form.js index f8533586b..cdf6fdb82 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -134,7 +134,7 @@ export default function JobForm ({ item, sub }) { export function JobButtonBar ({ itemId, disable, className, children, handleStop, onCancel, hasCancel = true, - createText = 'post', editText = 'save', stopText = 'remove' + createText = 'post', shadowEditText = 'save', stopText = 'remove' }) { return (
@@ -145,7 +145,7 @@ export function JobButtonBar ({
{hasCancel && } diff --git a/components/post.js b/components/post.js index 6245d1a16..3689d0e38 100644 --- a/components/post.js +++ b/components/post.js @@ -187,7 +187,7 @@ export default function Post ({ sub }) { export function ItemButtonBar ({ itemId, canDelete = true, disable, className, children, onDelete, onCancel, hasCancel = true, - createText = 'post', editText = 'save', deleteText = 'delete' + createText = 'post', shadowEditText = 'save', deleteText = 'delete' }) { const router = useRouter() @@ -205,7 +205,7 @@ export function ItemButtonBar ({
{hasCancel && } diff --git a/components/use-can-edit.js b/components/use-can-edit.js index b97596e4e..33c154cf8 100644 --- a/components/use-can-edit.js +++ b/components/use-can-edit.js @@ -3,24 +3,24 @@ import { datePivot } from '@/lib/time' import { useMe } from '@/components/me' import { ITEM_EDIT_SECONDS, USER_ID } from '@/lib/constants' -export default function useCanEdit (item) { +export default function useCanShadowEdit (item) { const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { seconds: ITEM_EDIT_SECONDS }) const { me } = useMe() - // deleted items can never be edited and every item has a 10 minute edit window + // deleted items can never be edited and every item has a 10 minute shadow edit window // except bios, they can always be edited but they should never show the countdown - const noEdit = !!item.deletedAt || (Date.now() >= editThreshold) || item.bio + const noEdit = !!item.deletedAt || item.bio // TODO: check for backwards compatibility const authorEdit = me && item.mine - const [canEdit, setCanEdit] = useState(!noEdit && authorEdit) + const [canShadowEdit, setCanShadowEdit] = useState(!noEdit && authorEdit) useEffect(() => { - // allow anon edits if they have the correct hmac for the item invoice + // allow anon shadow edits if they have the correct hmac for the item invoice // (the server will verify the hmac) const invParams = window.localStorage.getItem(`item:${item.id}:hash:hmac`) const anonEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon - // anonEdit should not override canEdit, but only allow edits if they aren't already allowed - setCanEdit(canEdit => canEdit || anonEdit) + // anonEdit should not override canShadowEdit, but only allow edits if they aren't already allowed + setCanShadowEdit(canShadowEdit => canShadowEdit || anonEdit) }, []) - return [canEdit, setCanEdit, editThreshold] + return [canShadowEdit, setCanShadowEdit, editThreshold] } diff --git a/components/wallet-buttonbar.js b/components/wallet-buttonbar.js index 465729199..5f3fef3a5 100644 --- a/components/wallet-buttonbar.js +++ b/components/wallet-buttonbar.js @@ -6,7 +6,7 @@ import { isConfigured } from '@/wallets/common' export default function WalletButtonBar ({ wallet, disable, className, children, onDelete, onCancel, hasCancel = true, - createText = 'attach', deleteText = 'detach', editText = 'save' + createText = 'attach', deleteText = 'detach', shadowEditText = 'save' }) { return (
@@ -16,7 +16,7 @@ export default function WalletButtonBar ({ {children}
{hasCancel && } - {isConfigured(wallet) ? editText : createText} + {isConfigured(wallet) ? shadowEditText : createText}
diff --git a/fragments/comments.js b/fragments/comments.js index 04b2a71af..eb270a9a5 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -48,6 +48,18 @@ export const COMMENT_FIELDS = gql` ncomments nDirectComments imgproxyUrls + cloneBornAt + cloneDiedAt + oldVersions { + id + title + text + url + createdAt + updatedAt + cloneBornAt + cloneDiedAt + } rel apiKey invoice { diff --git a/fragments/items.js b/fragments/items.js index c9c2a8da2..675bd97bb 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -73,6 +73,18 @@ export const ITEM_FIELDS = gql` uploadId mine imgproxyUrls + cloneBornAt + cloneDiedAt + oldVersions { + id + title + text + url + createdAt + updatedAt + cloneBornAt + cloneDiedAt + } rel apiKey invoice { diff --git a/pages/items/[id]/edit.js b/pages/items/[id]/edit.js index 707da6fd1..4f78fe1ac 100644 --- a/pages/items/[id]/edit.js +++ b/pages/items/[id]/edit.js @@ -12,7 +12,7 @@ import { useRouter } from 'next/router' import PageLoading from '@/components/page-loading' import { FeeButtonProvider } from '@/components/fee-button' import SubSelect from '@/components/sub-select' -import useCanEdit from '@/components/use-can-edit' +import useCanShadowEdit from '@/components/use-can-edit' export const getServerSideProps = getGetServerSideProps({ query: ITEM, @@ -27,7 +27,7 @@ export default function PostEdit ({ ssrData }) { const { item } = data || ssrData const [sub, setSub] = useState(item.subName) - const [,, editThreshold] = useCanEdit(item) + const [,, shadowEditThreshold] = useCanShadowEdit(item) let FormType = DiscussionForm let itemType = 'DISCUSSION' @@ -59,7 +59,7 @@ export default function PostEdit ({ ssrData }) { return ( - + {!item.isJob && Date: Tue, 18 Feb 2025 15:27:01 +0100 Subject: [PATCH 02/13] edits to OldItem; comments should be edited in place; also delete OldItems on delete --- api/paidAction/itemUpdate.js | 5 +---- api/typeDefs/item.js | 2 ++ components/item-info.js | 4 ++-- fragments/comments.js | 2 ++ fragments/items.js | 2 ++ lib/item.js | 1 + .../migration.sql | 19 +++++++++---------- prisma/schema.prisma | 5 ++--- 8 files changed, 21 insertions(+), 19 deletions(-) diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index 3e7bf86b3..50799fb10 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -40,9 +40,6 @@ export async function perform (args, context) { } }) - console.log(data) - console.log(old) - const newBoost = boost - old.boost const itemActs = [] if (newBoost > 0) { @@ -62,7 +59,7 @@ export async function perform (args, context) { const mentions = await getMentions(args, context) const itemMentions = await getItemMentions(args, context) const itemUploads = uploadIds.map(id => ({ uploadId: id })) - console.log(old) + console.log('performing update') await tx.upload.updateMany({ where: { id: { in: uploadIds } }, data: { paid: true } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index a77ba21fe..662c772cd 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -99,6 +99,8 @@ export default gql` updatedAt: Date cloneBornAt: Date cloneDiedAt: Date + pollCost: Int + deletedAt: Date } type Comments { diff --git a/components/item-info.js b/components/item-info.js index ee09cbf98..44318bc7d 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -231,9 +231,9 @@ export default function ItemInfo ({ {item.mine && !item.position && !item.deletedAt && !item.bio && // TODO: adjust every edit <>
- + !item.parentId ? router.push(`/items/${item.id}/edit`) : toggleShadowEdit(true)} className='text-reset dropdown-item'> edit - + } {item.mine && !item.position && !item.deletedAt && !item.bio && <> diff --git a/fragments/comments.js b/fragments/comments.js index eb270a9a5..716e36670 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -59,6 +59,8 @@ export const COMMENT_FIELDS = gql` updatedAt cloneBornAt cloneDiedAt + pollCost + deletedAt } rel apiKey diff --git a/fragments/items.js b/fragments/items.js index 675bd97bb..d40b76ed6 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -84,6 +84,8 @@ export const ITEM_FIELDS = gql` updatedAt cloneBornAt cloneDiedAt + pollCost + deletedAt } rel apiKey diff --git a/lib/item.js b/lib/item.js index 7bde10af1..cc164df6d 100644 --- a/lib/item.js +++ b/lib/item.js @@ -84,6 +84,7 @@ export const deleteItemByAuthor = async ({ models, id, item }) => { } await deleteReminders({ id, userId: item.userId, models }) + await models.oldItem.updateMany({ where: { originalItemId: Number(id) }, data: updateData }) return await models.item.update({ where: { id: Number(id) }, data: updateData }) } diff --git a/prisma/migrations/20250217233746_perpetual_edit/migration.sql b/prisma/migrations/20250217233746_perpetual_edit/migration.sql index bbce375fc..37d5cb703 100644 --- a/prisma/migrations/20250217233746_perpetual_edit/migration.sql +++ b/prisma/migrations/20250217233746_perpetual_edit/migration.sql @@ -16,12 +16,11 @@ CREATE TABLE "OldItem" ( "url" TEXT, "userId" INTEGER NOT NULL, "subName" CITEXT, - "otsFile" BYTEA, - "otsHash" TEXT, - "imgproxyUrls" JSONB, "cloneBornAt" TIMESTAMP(3), "cloneDiedAt" TIMESTAMP(3), "uploadId" INTEGER, + "pollCost" INTEGER, + "deletedAt" TIMESTAMP(3), "original_itemId" INTEGER NOT NULL, CONSTRAINT "OldItem_pkey" PRIMARY KEY ("id") @@ -44,7 +43,9 @@ RETURNS TRIGGER AS $$ BEGIN -- history shall be written only if the item is older than 10 minutes and content has changed IF (OLD."created_at" < now() - interval '10 minutes') - AND (OLD."bio" IS FALSE OR OLD."text" != NEW."text" OR OLD."title" != NEW."title" OR OLD."url" != NEW."url") + AND OLD."bio" IS FALSE + AND NEW."deletedAt" IS NULL + AND (OLD."text" != NEW."text" OR OLD."title" != NEW."title" OR OLD."url" != NEW."url") THEN -- TODO honestly find a better way to do this, I mean this works but it's bad INSERT INTO "OldItem" ( @@ -55,12 +56,11 @@ BEGIN "url", "userId", "subName", - "otsFile", - "otsHash", - "imgproxyUrls", "cloneBornAt", "cloneDiedAt", "uploadId", + "pollCost", + "deletedAt", "original_itemId" ) VALUES ( @@ -71,12 +71,11 @@ BEGIN OLD."url", OLD."userId", OLD."subName", - OLD."otsFile", - OLD."otsHash", - OLD."imgproxyUrls", OLD."cloneBornAt", OLD."cloneDiedAt", OLD."uploadId", + OLD."pollCost", + OLD."deletedAt", OLD."id" ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43b098cd4..a0bdbd821 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -615,12 +615,11 @@ model OldItem { url String? userId Int subName String? @db.Citext - otsFile Bytes? - otsHash String? - imgproxyUrls Json? cloneBornAt DateTime? cloneDiedAt DateTime? uploadId Int? + pollCost Int? + deletedAt DateTime? originalItemId Int @map("original_itemId") originalItem Item @relation(fields: [originalItemId], references: [id], onDelete: Cascade) From 6d9ca6987904943b0e8afa6e4bcf694913a9e34e Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 18 Feb 2025 20:00:55 +0100 Subject: [PATCH 03/13] Early history UI --- components/item-history.js | 14 +++++--------- components/item-info.js | 24 ++++++++++++++++++++---- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/components/item-history.js b/components/item-history.js index 3e15c4eaf..8d631b134 100644 --- a/components/item-history.js +++ b/components/item-history.js @@ -5,17 +5,13 @@ import styles from './item.module.css' // TODO: styling // TODO: render it as Item -export default function ItemHistory ({ item }) { +export default function ItemHistory ({ version }) { return (
- {item.oldVersions.map(version => ( -
- {timeSince(new Date(version.cloneBornAt || version.createdAt))} -

{version.title}

-

{version.text}

-

{version.url}

-
- ))} + {timeSince(new Date(version.cloneBornAt || version.createdAt))} +

{version.title}

+

{version.text}

+

{version.url}

) } diff --git a/components/item-info.js b/components/item-info.js index 44318bc7d..61b615da0 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -147,12 +147,28 @@ export default function ItemInfo ({ yesterday } - {item.oldVersions?.length > 0 && + {item.oldVersions?.length > 0 && !item.deletedAt && <> \ - showModal((onClose) => )} className='text-reset' title={item.cloneBornAt}> - edited {item.oldVersions.length} times - + + e.preventDefault()}> + edited + + + + edited {item.oldVersions.length} times + +
+ + edited {timeSince(new Date(item.cloneBornAt))} ago (most recent) + + {item.oldVersions.map((version) => ( // TODO: prettier + showModal((onClose) => )}> + {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago + + ))} +
+
} {item.subName && From 3fe1ecd452e86e273f921e6d047d0571fd37fd34 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 18 Feb 2025 21:12:44 +0100 Subject: [PATCH 04/13] OldItem modal, re-implement imgproxyUrls, fix typos --- api/paidAction/itemUpdate.js | 2 +- components/item-history.js | 18 +++++++++++------- components/item-info.js | 4 ++-- components/wallet-buttonbar.js | 4 ++-- .../migration.sql | 3 +++ prisma/schema.prisma | 1 + 6 files changed, 20 insertions(+), 12 deletions(-) diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index 50799fb10..3aad8e448 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -59,7 +59,7 @@ export async function perform (args, context) { const mentions = await getMentions(args, context) const itemMentions = await getItemMentions(args, context) const itemUploads = uploadIds.map(id => ({ uploadId: id })) - console.log('performing update') + await tx.upload.updateMany({ where: { id: { in: uploadIds } }, data: { paid: true } diff --git a/components/item-history.js b/components/item-history.js index 8d631b134..5d73c3c9d 100644 --- a/components/item-history.js +++ b/components/item-history.js @@ -1,17 +1,21 @@ import { timeSince } from '@/lib/time' import styles from './item.module.css' +import Text from './text' // TODO: PAID add a button to restore the item to the version // TODO: styling // TODO: render it as Item -export default function ItemHistory ({ version }) { +export default function OldItem ({ version }) { return ( -
- {timeSince(new Date(version.cloneBornAt || version.createdAt))} -

{version.title}

-

{version.text}

-

{version.url}

-
+ <> +
+ {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago +
+
+
{version.title}
+ {version.text} +
+ ) } diff --git a/components/item-info.js b/components/item-info.js index 61b615da0..f0d9010b3 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -29,7 +29,7 @@ import { useShowModal } from './modal' import classNames from 'classnames' import SubPopover from './sub-popover' import useCanShadowEdit from './use-can-edit' -import ItemHistory from './item-history' +import OldItem from './item-history' function itemTitle (item) { let title = '' @@ -163,7 +163,7 @@ export default function ItemInfo ({ edited {timeSince(new Date(item.cloneBornAt))} ago (most recent) {item.oldVersions.map((version) => ( // TODO: prettier - showModal((onClose) => )}> + showModal((onClose) => )}> {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago ))} diff --git a/components/wallet-buttonbar.js b/components/wallet-buttonbar.js index 5f3fef3a5..465729199 100644 --- a/components/wallet-buttonbar.js +++ b/components/wallet-buttonbar.js @@ -6,7 +6,7 @@ import { isConfigured } from '@/wallets/common' export default function WalletButtonBar ({ wallet, disable, className, children, onDelete, onCancel, hasCancel = true, - createText = 'attach', deleteText = 'detach', shadowEditText = 'save' + createText = 'attach', deleteText = 'detach', editText = 'save' }) { return (
@@ -16,7 +16,7 @@ export default function WalletButtonBar ({ {children}
{hasCancel && } - {isConfigured(wallet) ? shadowEditText : createText} + {isConfigured(wallet) ? editText : createText}
diff --git a/prisma/migrations/20250217233746_perpetual_edit/migration.sql b/prisma/migrations/20250217233746_perpetual_edit/migration.sql index 37d5cb703..62ab15d51 100644 --- a/prisma/migrations/20250217233746_perpetual_edit/migration.sql +++ b/prisma/migrations/20250217233746_perpetual_edit/migration.sql @@ -16,6 +16,7 @@ CREATE TABLE "OldItem" ( "url" TEXT, "userId" INTEGER NOT NULL, "subName" CITEXT, + "imgproxyUrls" JSONB, "cloneBornAt" TIMESTAMP(3), "cloneDiedAt" TIMESTAMP(3), "uploadId" INTEGER, @@ -56,6 +57,7 @@ BEGIN "url", "userId", "subName", + "imgproxyUrls", "cloneBornAt", "cloneDiedAt", "uploadId", @@ -71,6 +73,7 @@ BEGIN OLD."url", OLD."userId", OLD."subName", + OLD."imgproxyUrls", OLD."cloneBornAt", OLD."cloneDiedAt", OLD."uploadId", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a0bdbd821..dec5c4032 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -615,6 +615,7 @@ model OldItem { url String? userId Int subName String? @db.Citext + imgproxyUrls Json? cloneBornAt DateTime? cloneDiedAt DateTime? uploadId Int? From 5ae815ec43319378a963657924602893615280d2 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 19 Feb 2025 10:19:49 +0100 Subject: [PATCH 05/13] restore missing columns, fix timestamp issues with Item History --- api/typeDefs/item.js | 8 ++++++-- components/item-info.js | 8 ++++---- fragments/comments.js | 8 ++++++-- fragments/items.js | 8 ++++++-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 662c772cd..f09885cf8 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -92,13 +92,17 @@ export default gql` type OldItem { id: ID! + createdAt: Date + updatedAt: Date title: String text: String url: String - createdAt: Date - updatedAt: Date + userId: Int + subName: String + imgproxyUrls: JSONObject cloneBornAt: Date cloneDiedAt: Date + uploadId: Int pollCost: Int deletedAt: Date } diff --git a/components/item-info.js b/components/item-info.js index f0d9010b3..d2f9f876c 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -147,7 +147,7 @@ export default function ItemInfo ({ yesterday } - {item.oldVersions?.length > 0 && !item.deletedAt && + {item.oldVersions?.length > 0 && !item.deletedAt && // TODO: better way to handle this <> \ @@ -159,11 +159,11 @@ export default function ItemInfo ({ edited {item.oldVersions.length} times
- - edited {timeSince(new Date(item.cloneBornAt))} ago (most recent) + + edited {timeSince(new Date(item.oldVersions[0].cloneDiedAt))} ago (most recent) {item.oldVersions.map((version) => ( // TODO: prettier - showModal((onClose) => )}> + showModal((onClose) => )}> {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago ))} diff --git a/fragments/comments.js b/fragments/comments.js index 716e36670..6855aada6 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -52,13 +52,17 @@ export const COMMENT_FIELDS = gql` cloneDiedAt oldVersions { id + createdAt + updatedAt title text url - createdAt - updatedAt + userId + subName + imgproxyUrls cloneBornAt cloneDiedAt + uploadId pollCost deletedAt } diff --git a/fragments/items.js b/fragments/items.js index d40b76ed6..fdc652872 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -77,13 +77,17 @@ export const ITEM_FIELDS = gql` cloneDiedAt oldVersions { id + createdAt + updatedAt title text url - createdAt - updatedAt + userId + subName + imgproxyUrls cloneBornAt cloneDiedAt + uploadId pollCost deletedAt } From 4a528bda337b38ee2b11a369a11c3732052b1ffb Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 19 Feb 2025 11:06:30 +0100 Subject: [PATCH 06/13] adjust shadow edit countdown and references, scrollable item history --- components/discussion-form.js | 2 +- components/item-info.js | 13 ++++++------- components/post.js | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/components/discussion-form.js b/components/discussion-form.js index 7f625ee4a..3eaf201a8 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -75,7 +75,7 @@ export function DiscussionForm ({ label={<>{textLabel} optional} name='text' minRows={6} - hint={shadowEditThreshold // TODO: when countdown expires don't show it, we need the countdown to know if can shadow edit + hint={shadowEditThreshold && shadowEditThreshold > Date.now() // when shadow edit countdown expires don't show it ?
: null} /> diff --git a/components/item-info.js b/components/item-info.js index d2f9f876c..3fef46e43 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -149,12 +149,12 @@ export default function ItemInfo ({ } {item.oldVersions?.length > 0 && !item.deletedAt && // TODO: better way to handle this <> - \ + e.preventDefault()}> edited - + edited {item.oldVersions.length} times @@ -247,9 +247,9 @@ export default function ItemInfo ({ {item.mine && !item.position && !item.deletedAt && !item.bio && // TODO: adjust every edit <>
- !item.parentId ? router.push(`/items/${item.id}/edit`) : toggleShadowEdit(true)} className='text-reset dropdown-item'> + !item.parentId ? router.push(`/items/${item.id}/edit`) : toggleShadowEdit(true)} className='text-reset dropdown-item'> edit - +
} {item.mine && !item.position && !item.deletedAt && !item.bio && <> @@ -395,7 +395,7 @@ function EditInfo ({ item, edit, canShadowEdit, setCanShadowEdit, toggleShadowEd } if (edit && !canShadowEdit) { - // if we're still editing after timer ran out + // we're not in shadow editing mode anymore return ( <> \ @@ -403,8 +403,7 @@ function EditInfo ({ item, edit, canShadowEdit, setCanShadowEdit, toggleShadowEd className='text-reset pointer fw-bold font-monospace' onClick={() => toggleShadowEdit ? toggleShadowEdit() : router.push(`/items/${item.id}`)} > - cancel - 00:00 + cancel ) diff --git a/components/post.js b/components/post.js index 3689d0e38..6245d1a16 100644 --- a/components/post.js +++ b/components/post.js @@ -187,7 +187,7 @@ export default function Post ({ sub }) { export function ItemButtonBar ({ itemId, canDelete = true, disable, className, children, onDelete, onCancel, hasCancel = true, - createText = 'post', shadowEditText = 'save', deleteText = 'delete' + createText = 'post', editText = 'save', deleteText = 'delete' }) { const router = useRouter() @@ -205,7 +205,7 @@ export function ItemButtonBar ({
{hasCancel && } From b689eacae71b7343b712e766cc6d81428ee143e4 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 19 Feb 2025 15:56:40 +0100 Subject: [PATCH 07/13] ItemHistory refactor --- components/item-history.js | 36 +++++++++++++++++++++++++++++++----- components/item-info.js | 27 ++++----------------------- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/components/item-history.js b/components/item-history.js index 5d73c3c9d..5382e06f4 100644 --- a/components/item-history.js +++ b/components/item-history.js @@ -1,12 +1,11 @@ import { timeSince } from '@/lib/time' import styles from './item.module.css' import Text from './text' +import { Dropdown } from 'react-bootstrap' +import { useRouter } from 'next/router' +import { useShowModal } from './modal' -// TODO: PAID add a button to restore the item to the version -// TODO: styling -// TODO: render it as Item - -export default function OldItem ({ version }) { +export function OldItem ({ version }) { return ( <>
@@ -19,3 +18,30 @@ export default function OldItem ({ version }) { ) } + +export default function HistoryDropdownItem ({ item }) { + const router = useRouter() + const showModal = useShowModal() + + return ( + + e.preventDefault()}> + edited + + + + edited {item.oldVersions.length} times + +
+ router.push(`/items/${item.id}`)}> + edited {timeSince(new Date(item.oldVersions[0].cloneDiedAt))} ago (most recent) + + {item.oldVersions.map((version) => ( // TODO: prettier + showModal((onClose) => )}> + {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago + + ))} +
+
+ ) +} diff --git a/components/item-info.js b/components/item-info.js index 3fef46e43..60aaca9bc 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -29,7 +29,7 @@ import { useShowModal } from './modal' import classNames from 'classnames' import SubPopover from './sub-popover' import useCanShadowEdit from './use-can-edit' -import OldItem from './item-history' +import HistoryDropdownItem from './item-history' function itemTitle (item) { let title = '' @@ -71,7 +71,6 @@ export default function ItemInfo ({ setDisableRetry, disableRetry }) { const { me } = useMe() - const showModal = useShowModal() const router = useRouter() const [hasNewComments, setHasNewComments] = useState(false) const root = useRoot() @@ -147,28 +146,10 @@ export default function ItemInfo ({ yesterday } - {item.oldVersions?.length > 0 && !item.deletedAt && // TODO: better way to handle this + {item.oldVersions?.length > 0 && !item.deletedAt && <> - - e.preventDefault()}> - edited - - - - edited {item.oldVersions.length} times - -
- - edited {timeSince(new Date(item.oldVersions[0].cloneDiedAt))} ago (most recent) - - {item.oldVersions.map((version) => ( // TODO: prettier - showModal((onClose) => )}> - {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago - - ))} -
-
+ } {item.subName && @@ -244,7 +225,7 @@ export default function ItemInfo ({
} - {item.mine && !item.position && !item.deletedAt && !item.bio && // TODO: adjust every edit + {item.mine && sub && !item.deletedAt && !item.bio && // has to have a sub for edit page <>
!item.parentId ? router.push(`/items/${item.id}/edit`) : toggleShadowEdit(true)} className='text-reset dropdown-item'> From d8aa1ea403b2c182c62a26e75a7f11d21504235a Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 19 Feb 2025 18:30:14 +0100 Subject: [PATCH 08/13] fix typeDefs and fragments, better naming, decluttering --- api/typeDefs/item.js | 1 + components/item-history.js | 28 +++++++++++++++++++++++----- components/item-info.js | 4 ++-- fragments/comments.js | 1 + fragments/items.js | 1 + 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index f09885cf8..c50b425d3 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -105,6 +105,7 @@ export default gql` uploadId: Int pollCost: Int deletedAt: Date + originalItemId: Int } type Comments { diff --git a/components/item-history.js b/components/item-history.js index 5382e06f4..4f755c77a 100644 --- a/components/item-history.js +++ b/components/item-history.js @@ -19,10 +19,21 @@ export function OldItem ({ version }) { ) } -export default function HistoryDropdownItem ({ item }) { +export default function HistoryDropdown ({ item }) { const router = useRouter() const showModal = useShowModal() + const lastEdited = new Date(item.oldVersions[0].cloneDiedAt) + + // TODO: overengineering? not handling it just closes the modal + /* const handleLastEdit = () => { + if (!item.parentId) { + router.replace(`/items/${item.id}`) + } else { + router.replace(`/items/${item.parentId}/?commentId=${item.id}`) + } + } */ + return ( e.preventDefault()}> @@ -33,11 +44,18 @@ export default function HistoryDropdownItem ({ item }) { edited {item.oldVersions.length} times
- router.push(`/items/${item.id}`)}> - edited {timeSince(new Date(item.oldVersions[0].cloneDiedAt))} ago (most recent) + + edited {timeSince(lastEdited)} ago (most recent) - {item.oldVersions.map((version) => ( // TODO: prettier - showModal((onClose) => )}> + {item.oldVersions.map((version) => ( + showModal((onClose) => )} + > {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago ))} diff --git a/components/item-info.js b/components/item-info.js index 60aaca9bc..40d142570 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -29,7 +29,7 @@ import { useShowModal } from './modal' import classNames from 'classnames' import SubPopover from './sub-popover' import useCanShadowEdit from './use-can-edit' -import HistoryDropdownItem from './item-history' +import HistoryDropdown from './item-history' function itemTitle (item) { let title = '' @@ -149,7 +149,7 @@ export default function ItemInfo ({ {item.oldVersions?.length > 0 && !item.deletedAt && <> - + } {item.subName && diff --git a/fragments/comments.js b/fragments/comments.js index 6855aada6..da23706ab 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -65,6 +65,7 @@ export const COMMENT_FIELDS = gql` uploadId pollCost deletedAt + originalItemId } rel apiKey diff --git a/fragments/items.js b/fragments/items.js index fdc652872..f1f8fe5ea 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -90,6 +90,7 @@ export const ITEM_FIELDS = gql` uploadId pollCost deletedAt + originalItemId } rel apiKey From 0a6bcb698a5e967ecd0cbca0df9c589be2f51099 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 20 Feb 2025 13:32:22 +0100 Subject: [PATCH 09/13] fix lint --- components/item-history.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/components/item-history.js b/components/item-history.js index 4f755c77a..a71a8fcfa 100644 --- a/components/item-history.js +++ b/components/item-history.js @@ -2,7 +2,6 @@ import { timeSince } from '@/lib/time' import styles from './item.module.css' import Text from './text' import { Dropdown } from 'react-bootstrap' -import { useRouter } from 'next/router' import { useShowModal } from './modal' export function OldItem ({ version }) { @@ -20,20 +19,9 @@ export function OldItem ({ version }) { } export default function HistoryDropdown ({ item }) { - const router = useRouter() const showModal = useShowModal() - const lastEdited = new Date(item.oldVersions[0].cloneDiedAt) - // TODO: overengineering? not handling it just closes the modal - /* const handleLastEdit = () => { - if (!item.parentId) { - router.replace(`/items/${item.id}`) - } else { - router.replace(`/items/${item.parentId}/?commentId=${item.id}`) - } - } */ - return ( e.preventDefault()}> From f219ebab32a6e00539f3415b8e873a96cb86aa11 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 20 Feb 2025 22:57:26 +0100 Subject: [PATCH 10/13] Prisma-handled history; prevent history also on jobs and special items --- api/paidAction/itemUpdate.js | 24 ++++++- api/typeDefs/item.js | 2 - components/item-history.js | 1 - components/item-info.js | 4 +- fragments/items.js | 2 - .../migration.sql | 65 ------------------- prisma/schema.prisma | 2 - 7 files changed, 25 insertions(+), 75 deletions(-) diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index 3aad8e448..b80f98dba 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -1,4 +1,4 @@ -import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { PAID_ACTION_PAYMENT_METHODS, USER_ID, ADMIN_ITEMS } from '@/lib/constants' import { uploadFees } from '../resolvers/upload' import { getItemMentions, getMentions, performBotBehavior } from './lib/item' import { notifyItemMention, notifyMention } from '@/lib/webPush' @@ -65,6 +65,28 @@ export async function perform (args, context) { data: { paid: true } }) + // history tracking + // has to be older than 10 minutes, not a bio, not a job, not deleted, not a special item. + const adminItem = ADMIN_ITEMS.includes(old.id) + if (old.createdAt < new Date(Date.now() - 10 * 60 * 1000) && !old.bio && old.subName !== 'jobs' && !data.deletedAt && !adminItem) { + await tx.oldItem.create({ + data: { + title: old.title, + text: old.text, + url: old.url, + userId: old.userId, + subName: old.subName, + imgproxyUrls: old.imgproxyUrls, + cloneBornAt: old.cloneBornAt, + cloneDiedAt: new Date(), + originalItemId: parseInt(id) + } + }) + + data.cloneBornAt = new Date() + data.cloneDiedAt = null + } + // we put boost in the where clause because we don't want to update the boost // if it has changed concurrently await tx.item.update({ diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index c50b425d3..c289f4803 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -102,8 +102,6 @@ export default gql` imgproxyUrls: JSONObject cloneBornAt: Date cloneDiedAt: Date - uploadId: Int - pollCost: Int deletedAt: Date originalItemId: Int } diff --git a/components/item-history.js b/components/item-history.js index a71a8fcfa..37b839caf 100644 --- a/components/item-history.js +++ b/components/item-history.js @@ -34,7 +34,6 @@ export default function HistoryDropdown ({ item }) {
edited {timeSince(lastEdited)} ago (most recent) diff --git a/components/item-info.js b/components/item-info.js index 40d142570..81474d015 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -146,7 +146,7 @@ export default function ItemInfo ({ yesterday } - {item.oldVersions?.length > 0 && !item.deletedAt && + {item.oldVersions?.length > 0 && !item.deletedAt && // bios, jobs and admin items are not tracked <> @@ -225,7 +225,7 @@ export default function ItemInfo ({
} - {item.mine && sub && !item.deletedAt && !item.bio && // has to have a sub for edit page + {item.mine && !item.deletedAt && sub && // has to have a sub for edit page <>
!item.parentId ? router.push(`/items/${item.id}/edit`) : toggleShadowEdit(true)} className='text-reset dropdown-item'> diff --git a/fragments/items.js b/fragments/items.js index f1f8fe5ea..cde940502 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -87,8 +87,6 @@ export const ITEM_FIELDS = gql` imgproxyUrls cloneBornAt cloneDiedAt - uploadId - pollCost deletedAt originalItemId } diff --git a/prisma/migrations/20250217233746_perpetual_edit/migration.sql b/prisma/migrations/20250217233746_perpetual_edit/migration.sql index 62ab15d51..d26f16d84 100644 --- a/prisma/migrations/20250217233746_perpetual_edit/migration.sql +++ b/prisma/migrations/20250217233746_perpetual_edit/migration.sql @@ -19,8 +19,6 @@ CREATE TABLE "OldItem" ( "imgproxyUrls" JSONB, "cloneBornAt" TIMESTAMP(3), "cloneDiedAt" TIMESTAMP(3), - "uploadId" INTEGER, - "pollCost" INTEGER, "deletedAt" TIMESTAMP(3), "original_itemId" INTEGER NOT NULL, @@ -38,66 +36,3 @@ CREATE INDEX "OldItem_original_itemId_idx" ON "OldItem"("original_itemId"); -- AddForeignKey ALTER TABLE "OldItem" ADD CONSTRAINT "OldItem_original_itemId_fkey" FOREIGN KEY ("original_itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -CREATE OR REPLACE FUNCTION before_item_update() -RETURNS TRIGGER AS $$ -BEGIN - -- history shall be written only if the item is older than 10 minutes and content has changed - IF (OLD."created_at" < now() - interval '10 minutes') - AND OLD."bio" IS FALSE - AND NEW."deletedAt" IS NULL - AND (OLD."text" != NEW."text" OR OLD."title" != NEW."title" OR OLD."url" != NEW."url") - THEN - -- TODO honestly find a better way to do this, I mean this works but it's bad - INSERT INTO "OldItem" ( - "created_at", - "updated_at", - "title", - "text", - "url", - "userId", - "subName", - "imgproxyUrls", - "cloneBornAt", - "cloneDiedAt", - "uploadId", - "pollCost", - "deletedAt", - "original_itemId" - ) - VALUES ( - OLD."created_at", - OLD."updated_at", - OLD."title", - OLD."text", - OLD."url", - OLD."userId", - OLD."subName", - OLD."imgproxyUrls", - OLD."cloneBornAt", - OLD."cloneDiedAt", - OLD."uploadId", - OLD."pollCost", - OLD."deletedAt", - OLD."id" - ); - - -- item shall die - UPDATE "OldItem" - SET "cloneDiedAt" = now() - WHERE "original_itemId" = OLD.id - AND "cloneDiedAt" IS NULL; - - -- to be born again - NEW."cloneBornAt" = now(); - NEW."cloneDiedAt" = NULL; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER item_history_trigger - BEFORE UPDATE ON "Item" - FOR EACH ROW - EXECUTE PROCEDURE before_item_update(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dec5c4032..81d17276b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -618,8 +618,6 @@ model OldItem { imgproxyUrls Json? cloneBornAt DateTime? cloneDiedAt DateTime? - uploadId Int? - pollCost Int? deletedAt DateTime? originalItemId Int @map("original_itemId") originalItem Item @relation(fields: [originalItemId], references: [id], onDelete: Cascade) From db459161bd68f55efceb4be9edde9aa424d5e113 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 21 Feb 2025 10:14:12 +0100 Subject: [PATCH 11/13] cleanup; add comments --- api/paidAction/itemUpdate.js | 2 +- api/resolvers/item.js | 2 +- components/item-history.js | 13 ++++++------- components/item-info.js | 3 ++- components/use-can-edit.js | 2 +- fragments/comments.js | 2 -- lib/item.js | 2 +- .../20250217233746_perpetual_edit/migration.sql | 2 -- 8 files changed, 12 insertions(+), 16 deletions(-) diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index b80f98dba..0a57a6e3e 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -83,7 +83,7 @@ export async function perform (args, context) { } }) - data.cloneBornAt = new Date() + data.cloneBornAt = new Date() // we can use this to determine if the item has been edited data.cloneDiedAt = null } diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 203792849..fdf3e8e42 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1208,7 +1208,7 @@ export default { oldVersions: async (item, args, { models }) => { return await models.oldItem.findMany({ where: { originalItemId: item.id }, - orderBy: { cloneDiedAt: 'desc' } + orderBy: { cloneDiedAt: 'desc' } // ordering by cloneDiedAt allows us to see the most recent edits first }) }, forwards: async (item, args, { models }) => { diff --git a/components/item-history.js b/components/item-history.js index 37b839caf..cf94c6db0 100644 --- a/components/item-history.js +++ b/components/item-history.js @@ -4,6 +4,7 @@ import Text from './text' import { Dropdown } from 'react-bootstrap' import { useShowModal } from './modal' +// OldItem: takes a version and shows the old item export function OldItem ({ version }) { return ( <> @@ -18,9 +19,9 @@ export function OldItem ({ version }) { ) } +// History dropdown: takes an item and by mapping over the oldVersions, it will show the history of the item export default function HistoryDropdown ({ item }) { const showModal = useShowModal() - const lastEdited = new Date(item.oldVersions[0].cloneDiedAt) return ( @@ -32,16 +33,14 @@ export default function HistoryDropdown ({ item }) { edited {item.oldVersions.length} times
- - edited {timeSince(lastEdited)} ago (most recent) + + edited {timeSince(new Date(item.oldVersions[0].cloneDiedAt))} ago (most recent) {item.oldVersions.map((version) => ( showModal((onClose) => )} + title={version.cloneBornAt || version.createdAt} + onClick={() => showModal((onClose) => )} > {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago diff --git a/components/item-info.js b/components/item-info.js index 81474d015..c153a995d 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -178,7 +178,8 @@ export default function ItemInfo ({ <> diff --git a/components/use-can-edit.js b/components/use-can-edit.js index 33c154cf8..2ba76551c 100644 --- a/components/use-can-edit.js +++ b/components/use-can-edit.js @@ -9,7 +9,7 @@ export default function useCanShadowEdit (item) { // deleted items can never be edited and every item has a 10 minute shadow edit window // except bios, they can always be edited but they should never show the countdown - const noEdit = !!item.deletedAt || item.bio // TODO: check for backwards compatibility + const noEdit = !!item.deletedAt || item.bio const authorEdit = me && item.mine const [canShadowEdit, setCanShadowEdit] = useState(!noEdit && authorEdit) diff --git a/fragments/comments.js b/fragments/comments.js index da23706ab..1bf94490d 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -62,8 +62,6 @@ export const COMMENT_FIELDS = gql` imgproxyUrls cloneBornAt cloneDiedAt - uploadId - pollCost deletedAt originalItemId } diff --git a/lib/item.js b/lib/item.js index cc164df6d..296f03c28 100644 --- a/lib/item.js +++ b/lib/item.js @@ -84,7 +84,7 @@ export const deleteItemByAuthor = async ({ models, id, item }) => { } await deleteReminders({ id, userId: item.userId, models }) - await models.oldItem.updateMany({ where: { originalItemId: Number(id) }, data: updateData }) + await models.oldItem.updateMany({ where: { originalItemId: Number(id) }, data: updateData }) // also delete old revisions return await models.item.update({ where: { id: Number(id) }, data: updateData }) } diff --git a/prisma/migrations/20250217233746_perpetual_edit/migration.sql b/prisma/migrations/20250217233746_perpetual_edit/migration.sql index d26f16d84..b281d2951 100644 --- a/prisma/migrations/20250217233746_perpetual_edit/migration.sql +++ b/prisma/migrations/20250217233746_perpetual_edit/migration.sql @@ -5,8 +5,6 @@ ALTER TABLE "Item" ADD COLUMN "cloneBornAt" TIMESTAMP(3), ADD COLUMN "cloneDiedAt" TIMESTAMP(3); -- CreateTable --- TODO Postgres supports Inheritance but Prisma doesn't support it yet --- Figure out a better way to do this CREATE TABLE "OldItem" ( "id" SERIAL NOT NULL, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, From 7500a2f6298cad5b33cfbf152285022a74a5296b Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 21 Feb 2025 21:05:04 +0100 Subject: [PATCH 12/13] adjust timestamps, fetch OldItem by id, less expensive oldVersions --- api/paidAction/itemUpdate.js | 2 ++ api/resolvers/item.js | 11 +++++++ api/typeDefs/item.js | 1 + components/item-history.js | 62 ++++++++++++++++++++++++++---------- fragments/comments.js | 7 ---- fragments/items.js | 34 ++++++++++++++++---- 6 files changed, 87 insertions(+), 30 deletions(-) diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index 0a57a6e3e..c94cc67b7 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -71,6 +71,8 @@ export async function perform (args, context) { if (old.createdAt < new Date(Date.now() - 10 * 60 * 1000) && !old.bio && old.subName !== 'jobs' && !data.deletedAt && !adminItem) { await tx.oldItem.create({ data: { + createdAt: old.createdAt, + updatedAt: old.updatedAt, title: old.title, text: old.text, url: old.url, diff --git a/api/resolvers/item.js b/api/resolvers/item.js index fdf3e8e42..693e0c795 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -632,6 +632,9 @@ export default { } }, item: getItem, + oldItem: async (parent, { id }, { models }) => { + return await models.oldItem.findUnique({ where: { id: Number(id) } }) + }, pageTitleAndUnshorted: async (parent, { url }, { models }) => { const res = {} try { @@ -1207,6 +1210,14 @@ export default { }, oldVersions: async (item, args, { models }) => { return await models.oldItem.findMany({ + select: { + id: true, + createdAt: true, + updatedAt: true, + cloneBornAt: true, + cloneDiedAt: true, + originalItemId: true + }, where: { originalItemId: item.id }, orderBy: { cloneDiedAt: 'desc' } // ordering by cloneDiedAt allows us to see the most recent edits first }) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index c289f4803..45b3e0d49 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -11,6 +11,7 @@ export default gql` auctionPosition(sub: String, id: ID, boost: Int): Int! boostPosition(sub: String, id: ID, boost: Int): BoostPositions! itemRepetition(parentId: ID): Int! + oldItem(id: ID!): OldItem } type BoostPositions { diff --git a/components/item-history.js b/components/item-history.js index cf94c6db0..034a8273c 100644 --- a/components/item-history.js +++ b/components/item-history.js @@ -3,26 +3,62 @@ import styles from './item.module.css' import Text from './text' import { Dropdown } from 'react-bootstrap' import { useShowModal } from './modal' +import { EDIT } from '@/fragments/items' +import { useQuery } from '@apollo/client' +import PageLoading from './page-loading' + +// OldItem: takes a versionId and shows the old item +export function OldItem ({ versionId }) { + const { data } = useQuery(EDIT, { variables: { id: versionId } }) + if (!data) return + + const actionType = data?.oldItem?.cloneBornAt ? 'edited' : 'created' + const timestamp = data?.oldItem?.cloneBornAt + ? data?.oldItem?.cloneDiedAt + : data?.oldItem?.createdAt -// OldItem: takes a version and shows the old item -export function OldItem ({ version }) { return ( <>
- {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago + {actionType} {timeSince(new Date(timestamp))} ago
-
{version.title}
- {version.text} +
{data?.oldItem?.title}
+ + {data?.oldItem?.text} +
) } -// History dropdown: takes an item and by mapping over the oldVersions, it will show the history of the item -export default function HistoryDropdown ({ item }) { +export const HistoryDropdownItem = ({ version }) => { const showModal = useShowModal() + const actionType = !version.cloneBornAt ? 'created' : 'edited' + const timestamp = version.cloneBornAt + ? version.cloneDiedAt + : version.createdAt + + return ( + showModal((onClose) => )} + > + {actionType} {timeSince(new Date(timestamp))} ago + + ) +} + +// History dropdown: takes an item and maps over the oldVersions +export default function HistoryDropdown ({ item }) { + const mostRecentTimestamp = item.cloneBornAt || item.oldVersions[0].createdAt + return ( e.preventDefault()}> @@ -33,17 +69,11 @@ export default function HistoryDropdown ({ item }) { edited {item.oldVersions.length} times
- - edited {timeSince(new Date(item.oldVersions[0].cloneDiedAt))} ago (most recent) + + edited {timeSince(new Date(mostRecentTimestamp))} ago (most recent) {item.oldVersions.map((version) => ( - showModal((onClose) => )} - > - {!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago - + ))}
diff --git a/fragments/comments.js b/fragments/comments.js index 1bf94490d..cec071885 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -54,15 +54,8 @@ export const COMMENT_FIELDS = gql` id createdAt updatedAt - title - text - url - userId - subName - imgproxyUrls cloneBornAt cloneDiedAt - deletedAt originalItemId } rel diff --git a/fragments/items.js b/fragments/items.js index cde940502..7fc547b4b 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -79,15 +79,8 @@ export const ITEM_FIELDS = gql` id createdAt updatedAt - title - text - url - userId - subName - imgproxyUrls cloneBornAt cloneDiedAt - deletedAt originalItemId } rel @@ -136,6 +129,24 @@ export const ITEM_FULL_FIELDS = gql` } }` +export const OLDITEM_FIELDS = gql` + fragment OldItemFields on OldItem { + id + createdAt + updatedAt + title + text + url + userId + subName + imgproxyUrls + cloneBornAt + cloneDiedAt + deletedAt + originalItemId + } +` + export const ITEM_OTS_FIELDS = gql` fragment ItemOtsFields on Item { id @@ -226,3 +237,12 @@ export const RELATED_ITEMS_WITH_ITEM = gql` } } ` + +export const EDIT = gql` + ${OLDITEM_FIELDS} + query Edit($id: ID!) { + oldItem(id: $id) { + ...OldItemFields + } + } +` From cb2b692be3258e5fef424bc6c045a49210350ce5 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 22 Feb 2025 21:56:37 +0100 Subject: [PATCH 13/13] enhance: faster history retrieving --- prisma/migrations/20250217233746_perpetual_edit/migration.sql | 3 +++ prisma/schema.prisma | 1 + 2 files changed, 4 insertions(+) diff --git a/prisma/migrations/20250217233746_perpetual_edit/migration.sql b/prisma/migrations/20250217233746_perpetual_edit/migration.sql index b281d2951..5848269e5 100644 --- a/prisma/migrations/20250217233746_perpetual_edit/migration.sql +++ b/prisma/migrations/20250217233746_perpetual_edit/migration.sql @@ -32,5 +32,8 @@ CREATE INDEX "OldItem_userId_idx" ON "OldItem"("userId"); -- CreateIndex CREATE INDEX "OldItem_original_itemId_idx" ON "OldItem"("original_itemId"); +-- CreateIndex -- history of the item +CREATE INDEX "OldItem_original_itemId_cloneDiedAt_idx" ON "OldItem"("original_itemId", "cloneDiedAt" DESC); + -- AddForeignKey ALTER TABLE "OldItem" ADD CONSTRAINT "OldItem_original_itemId_fkey" FOREIGN KEY ("original_itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 81d17276b..de9f7e180 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -625,6 +625,7 @@ model OldItem { @@index([createdAt]) @@index([userId]) @@index([originalItemId]) + @@index([originalItemId, cloneDiedAt(sort: Desc)]) } // we use this to denormalize a user's aggregated interactions (zaps) with an item