diff --git a/api/paidAction/lib/item.js b/api/paidAction/lib/item.js index 879b1cb53d..b45c3fb30d 100644 --- a/api/paidAction/lib/item.js +++ b/api/paidAction/lib/item.js @@ -49,16 +49,27 @@ export async function performBotBehavior ({ text, id }, { me, tx }) { // delete any existing deleteItem or reminder jobs for this item const userId = me?.id || USER_ID.anon id = Number(id) + const item = await tx.item.findUnique({ + where: { id }, + include: { + sub: true, + root: { + include: { sub: true } + } + } + }) await tx.$queryRaw` DELETE FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = ${id}::TEXT AND state <> 'completed'` - await deleteReminders({ id, userId, models: tx }) if (text) { const deleteAt = getDeleteAt(text) - if (deleteAt) { + const itemSub = item.subName ? item.sub : item.root?.sub + const deletionDisabled = itemSub && itemSub.disableDeletion + + if (deleteAt && !deletionDisabled) { await tx.$queryRaw` INSERT INTO pgboss.job (name, data, startafter, keepuntil) VALUES ( @@ -67,7 +78,9 @@ export async function performBotBehavior ({ text, id }, { me, tx }) { ${deleteAt}::TIMESTAMP WITH TIME ZONE, ${deleteAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')` } - + } + await deleteReminders({ id, userId, models: tx }) + if (text) { const remindAt = getRemindAt(text) if (remindAt) { await tx.$queryRaw` diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 14905f744e..51c3313de8 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -880,15 +880,37 @@ export default { return { id } }, deleteItem: async (parent, { id }, { me, models }) => { - const old = await models.item.findUnique({ where: { id: Number(id) } }) - if (Number(old.userId) !== Number(me?.id)) { + const old = await models.item.findUnique({ + where: { id: Number(id) }, + include: { + sub: true, + root: { + include: { sub: true } + } + } + }) + + const itemSub = old.subName ? old.sub : old.root?.sub + const isFounder = itemSub && Number(itemSub.userId) === Number(me?.id) + const deletionDisabled = itemSub && itemSub.disableDeletion + + // Allow deletion if: + // 1. User owns the item, OR + // 2. User is founder and deletion is disabled (can delete any post) + if (Number(old.userId) !== Number(me?.id) && !(isFounder && deletionDisabled)) { throw new GqlInputError('item does not belong to you') } + if (old.bio) { throw new GqlInputError('cannot delete bio') } - return await deleteItemByAuthor({ models, id, item: old }) + if (deletionDisabled && !isFounder) { + throw new GqlInputError('deletion is disabled in this territory. Only the founder can delete posts.') + } + + const founderOnly = isFounder && Number(old.userId) !== Number(me?.id) + return await deleteItemByAuthor({ models, id, item: old, founderOnly }) }, upsertLink: async (parent, { id, ...item }, { me, models, lnd }) => { await validateSchema(linkSchema, item, { models, me }) diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 8bf8bd2a14..ecbb4e3740 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -22,7 +22,7 @@ export default gql` replyCost: Int!, postTypes: [String!]!, billingType: String!, billingAutoRenew: Boolean!, - moderated: Boolean!, nsfw: Boolean!): SubPaidAction! + moderated: Boolean!, nsfw: Boolean!, disableDeletion: Boolean!): SubPaidAction! paySub(name: String!): SubPaidAction! toggleMuteSub(name: String!): Boolean! toggleSubSubscription(name: String!): Boolean! @@ -30,7 +30,7 @@ export default gql` unarchiveTerritory(name: String!, desc: String, baseCost: Int!, replyCost: Int!, postTypes: [String!]!, billingType: String!, billingAutoRenew: Boolean!, - moderated: Boolean!, nsfw: Boolean!): SubPaidAction! + moderated: Boolean!, nsfw: Boolean!, disableDeletion: Boolean!): SubPaidAction! } type Sub { @@ -55,6 +55,7 @@ export default gql` moderatedCount: Int! meMuteSub: Boolean! nsfw: Boolean! + disableDeletion: Boolean! nposts(when: String, from: String, to: String): Int! ncomments(when: String, from: String, to: String): Int! meSubscription: Boolean! diff --git a/components/delete.js b/components/delete.js index 1189276517..aaccd32402 100644 --- a/components/delete.js +++ b/components/delete.js @@ -7,7 +7,7 @@ import Dropdown from 'react-bootstrap/Dropdown' import { useShowModal } from './modal' import { useToast } from './toast' -export default function Delete ({ itemId, children, onDelete, type = 'post' }) { +export default function Delete ({ itemId, children, onDelete, type = 'post', founder = false }) { const showModal = useShowModal() const [deleteItem] = useMutation( @@ -43,6 +43,7 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) { return ( { const { error } = await deleteItem({ variables: { id: itemId } }) if (error) { @@ -62,25 +63,31 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) { ) } -export function DeleteConfirm ({ onConfirm, type }) { +export function DeleteConfirm ({ onConfirm, type, founder }) { const [error, setError] = useState() const toaster = useToast() return ( <> {error && setError(undefined)} dismissible>{error}} -

Are you sure? This is a gone forever kind of delete.

+

+ {founder + ? `Are you sure? This will permanently delete this ${type.toLowerCase()} as the territory founder.` + : 'Are you sure? This is a gone forever kind of delete.'} +

@@ -88,10 +95,11 @@ export function DeleteConfirm ({ onConfirm, type }) { } export function DeleteDropdownItem (props) { + const { founder } = props || {} return ( - delete + {founder ? 'delete as founder' : 'delete'} ) diff --git a/components/item-info.js b/components/item-info.js index 63129fcda0..9fcac3c9bf 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -223,9 +223,27 @@ export default function ItemInfo ({ } {item.mine && !item.position && !item.deletedAt && !item.bio && + (!sub?.disableDeletion || (me && sub?.userId === Number(me.id))) && + <> +
+ + } + {item.mine && !item.position && !item.deletedAt && !item.bio && sub?.disableDeletion && + me && sub?.userId !== Number(me.id) && + <> +
+ + delete disabled by territory founder + + } + {!item.mine && sub?.disableDeletion && me && sub?.userId === Number(me.id) && <>
- + } {me && !item.mine && <> diff --git a/components/post.js b/components/post.js index cce606f825..4c88cd1681 100644 --- a/components/post.js +++ b/components/post.js @@ -111,7 +111,10 @@ export function PostForm ({ type, sub, children }) { size='medium' sub={sub?.name} info={sub && } - hint={sub?.moderated && 'this territory is moderated'} + hint={[ + sub?.moderated && 'this territory is moderated', + sub?.disableDeletion && 'this territory has deletion disabled' + ].filter(Boolean).join(' and ')} />
{postButtons} @@ -177,7 +180,10 @@ export default function Post ({ sub }) { size='medium' label='territory' info={sub && } - hint={sub?.moderated && 'this territory is moderated'} + hint={[ + sub?.moderated && 'this territory is moderated', + sub?.disableDeletion && 'this territory has deletion disabled' + ].filter(Boolean).join(' and ')} />} diff --git a/components/reply.js b/components/reply.js index 329a938263..4cfc1abe45 100644 --- a/components/reply.js +++ b/components/reply.js @@ -186,7 +186,10 @@ export default forwardRef(function Reply ({ required appendValue={quote} placeholder={placeholder} - hint={sub?.moderated && 'this territory is moderated'} + hint={[ + sub?.moderated && 'this territory is moderated', + sub?.disableDeletion && 'this territory has deletion disabled' + ].filter(Boolean).join(' and ')} /> diff --git a/components/territory-form.js b/components/territory-form.js index 0983002c85..45b5dad177 100644 --- a/components/territory-form.js +++ b/components/territory-form.js @@ -96,6 +96,7 @@ export default function TerritoryForm ({ sub }) { billingType: sub?.billingType || 'MONTHLY', billingAutoRenew: sub?.billingAutoRenew || false, moderated: sub?.moderated || false, + disableDeletion: sub?.disableDeletion || false, nsfw: sub?.nsfw || false }} schema={schema} @@ -258,6 +259,24 @@ export default function TerritoryForm ({ sub }) { name='moderated' groupClassName='ms-1' /> + deletion control + disable post deletion + +
    +
  1. Only territory founders can delete posts and comments
  2. +
  3. Authors cannot delete their own content
  4. +
  5. Delete bot commands will be ignored
  6. +
  7. Your territory will get a no deletion badge
  8. +
+
+
+ } + name='disableDeletion' + groupClassName='ms-1' + /> nsfw archived} {(sub.moderated || sub.moderatedCount > 0) && moderated{sub.moderatedCount > 0 && ` ${sub.moderatedCount}`}} + {sub.disableDeletion && no deletion} {(sub.nsfw) && nsfw} } diff --git a/fragments/comments.js b/fragments/comments.js index e9ddfd1666..f40386d1a6 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -121,6 +121,7 @@ export const COMMENTS_ITEM_EXT_FIELDS = gql` name userId moderated + disableDeletion meMuteSub } user { diff --git a/fragments/items.js b/fragments/items.js index 1b7f8073a1..bdacecf985 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -32,6 +32,7 @@ export const ITEM_FIELDS = gql` name userId moderated + disableDeletion meMuteSub meSubscription nsfw @@ -110,6 +111,7 @@ export const ITEM_FULL_FIELDS = gql` name userId moderated + disableDeletion meMuteSub meSubscription replyCost diff --git a/fragments/paidAction.js b/fragments/paidAction.js index 94c319fcf9..07c229b6f3 100644 --- a/fragments/paidAction.js +++ b/fragments/paidAction.js @@ -265,10 +265,10 @@ export const UPSERT_SUB = gql` ${PAID_ACTION} mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!, $replyCost: Int!, $postTypes: [String!]!, $billingType: String!, - $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) { + $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!, $disableDeletion: Boolean!) { upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost, replyCost: $replyCost, postTypes: $postTypes, billingType: $billingType, - billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) { + billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw, disableDeletion: $disableDeletion) { result { name } @@ -280,10 +280,10 @@ export const UNARCHIVE_TERRITORY = gql` ${PAID_ACTION} mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!, $replyCost: Int!, $postTypes: [String!]!, $billingType: String!, - $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) { + $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!, $disableDeletion: Boolean!) { unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost, replyCost: $replyCost, postTypes: $postTypes, billingType: $billingType, - billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) { + billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw, disableDeletion: $disableDeletion) { result { name } diff --git a/fragments/subs.js b/fragments/subs.js index 6c5ab8d945..d6e560e05a 100644 --- a/fragments/subs.js +++ b/fragments/subs.js @@ -34,6 +34,7 @@ export const SUB_FIELDS = gql` meMuteSub meSubscription nsfw + disableDeletion }` export const SUB_FULL_FIELDS = gql` diff --git a/lib/item.js b/lib/item.js index 7bde10af17..33da8a6cc5 100644 --- a/lib/item.js +++ b/lib/item.js @@ -61,20 +61,32 @@ export const getReminderCommand = (text) => { export const hasReminderCommand = (text) => !!getReminderCommand(text) -export const deleteItemByAuthor = async ({ models, id, item }) => { +export const deleteItemByAuthor = async ({ models, id, item, founderOnly = false }) => { if (!item) { - item = await models.item.findUnique({ where: { id: Number(id) } }) + item = await models.item.findUnique({ + where: { id: Number(id) }, + include: { sub: true } + }) } if (!item) { console.log('attempted to delete an item that does not exist', id) return } const updateData = { deletedAt: new Date() } - if (item.text) { - updateData.text = '*deleted by author*' - } - if (item.title) { - updateData.title = 'deleted by author' + if (founderOnly) { + if (item.text) { + updateData.text = '*deleted by territory founder*' + } + if (item.title) { + updateData.title = 'deleted by territory founder' + } + } else { + if (item.text) { + updateData.text = '*deleted by author*' + } + if (item.title) { + updateData.title = 'deleted by author' + } } if (item.url) { updateData.url = null diff --git a/lib/validate.js b/lib/validate.js index ea1fa25c8f..7340a48c8c 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -325,6 +325,7 @@ export function territorySchema (args) { .max(100000, 'must be at most 100k'), postTypes: array().of(string().oneOf(POST_TYPES)).min(1, 'must support at least one post type'), billingType: string().required('required').oneOf(TERRITORY_BILLING_TYPES, 'required'), + disableDeletion: boolean(), nsfw: boolean() }) } diff --git a/prisma/migrations/20250903190049_disable_delete/migration.sql b/prisma/migrations/20250903190049_disable_delete/migration.sql new file mode 100644 index 0000000000..c9bb3d2712 --- /dev/null +++ b/prisma/migrations/20250903190049_disable_delete/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Sub" ADD COLUMN "disableDeletion" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 168d1a91dc..b3789e2f2e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -689,6 +689,7 @@ model Sub { moderated Boolean @default(false) moderatedCount Int @default(0) nsfw Boolean @default(false) + disableDeletion Boolean @default(false) parent Sub? @relation("ParentChildren", fields: [parentName], references: [name]) children Sub[] @relation("ParentChildren")