Skip to content

Commit 2aec94a

Browse files
committed
feat(territory): Founder disable territory deletes
1 parent a794b59 commit 2aec94a

File tree

17 files changed

+134
-29
lines changed

17 files changed

+134
-29
lines changed

api/paidAction/lib/item.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,21 @@ export async function performBotBehavior ({ text, id }, { me, tx }) {
4949
// delete any existing deleteItem or reminder jobs for this item
5050
const userId = me?.id || USER_ID.anon
5151
id = Number(id)
52+
const item = await tx.item.findUnique({
53+
where: { id },
54+
include: { sub: true }
55+
})
56+
5257
await tx.$queryRaw`
5358
DELETE FROM pgboss.job
5459
WHERE name = 'deleteItem'
5560
AND data->>'id' = ${id}::TEXT
5661
AND state <> 'completed'`
57-
await deleteReminders({ id, userId, models: tx })
5862

5963
if (text) {
6064
const deleteAt = getDeleteAt(text)
61-
if (deleteAt) {
65+
const deletionDisabled = item?.subName && item?.sub?.disableDeletion
66+
if (deleteAt && !deletionDisabled) {
6267
await tx.$queryRaw`
6368
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
6469
VALUES (
@@ -67,7 +72,9 @@ export async function performBotBehavior ({ text, id }, { me, tx }) {
6772
${deleteAt}::TIMESTAMP WITH TIME ZONE,
6873
${deleteAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')`
6974
}
70-
75+
}
76+
await deleteReminders({ id, userId, models: tx })
77+
if (text) {
7178
const remindAt = getRemindAt(text)
7279
if (remindAt) {
7380
await tx.$queryRaw`

api/resolvers/item.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -880,15 +880,37 @@ export default {
880880
return { id }
881881
},
882882
deleteItem: async (parent, { id }, { me, models }) => {
883-
const old = await models.item.findUnique({ where: { id: Number(id) } })
884-
if (Number(old.userId) !== Number(me?.id)) {
883+
const old = await models.item.findUnique({
884+
where: { id: Number(id) },
885+
include: {
886+
sub: true,
887+
root: {
888+
include: { sub: true }
889+
}
890+
}
891+
})
892+
893+
const itemSub = old.subName ? old.sub : old.root?.sub
894+
const isFounder = itemSub && Number(itemSub.userId) === Number(me?.id)
895+
const deletionDisabled = itemSub && itemSub.disableDeletion
896+
897+
// Allow deletion if:
898+
// 1. User owns the item, OR
899+
// 2. User is founder and deletion is disabled (can delete any post)
900+
if (Number(old.userId) !== Number(me?.id) && !(isFounder && deletionDisabled)) {
885901
throw new GqlInputError('item does not belong to you')
886902
}
903+
887904
if (old.bio) {
888905
throw new GqlInputError('cannot delete bio')
889906
}
890907

891-
return await deleteItemByAuthor({ models, id, item: old })
908+
if (deletionDisabled && !isFounder) {
909+
throw new GqlInputError('deletion is disabled in this territory. Only the founder can delete posts.')
910+
}
911+
912+
const founderOnly = isFounder && Number(old.userId) !== Number(me?.id)
913+
return await deleteItemByAuthor({ models, id, item: old, founderOnly })
892914
},
893915
upsertLink: async (parent, { id, ...item }, { me, models, lnd }) => {
894916
await validateSchema(linkSchema, item, { models, me })

api/typeDefs/sub.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,15 @@ export default gql`
2222
replyCost: Int!,
2323
postTypes: [String!]!,
2424
billingType: String!, billingAutoRenew: Boolean!,
25-
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
25+
moderated: Boolean!, nsfw: Boolean!, disableDeletion: Boolean!): SubPaidAction!
2626
paySub(name: String!): SubPaidAction!
2727
toggleMuteSub(name: String!): Boolean!
2828
toggleSubSubscription(name: String!): Boolean!
2929
transferTerritory(subName: String!, userName: String!): Sub
3030
unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
3131
replyCost: Int!, postTypes: [String!]!,
3232
billingType: String!, billingAutoRenew: Boolean!,
33-
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
33+
moderated: Boolean!, nsfw: Boolean!, disableDeletion: Boolean!): SubPaidAction!
3434
}
3535
3636
type Sub {
@@ -55,6 +55,7 @@ export default gql`
5555
moderatedCount: Int!
5656
meMuteSub: Boolean!
5757
nsfw: Boolean!
58+
disableDeletion: Boolean!
5859
nposts(when: String, from: String, to: String): Int!
5960
ncomments(when: String, from: String, to: String): Int!
6061
meSubscription: Boolean!

components/delete.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Dropdown from 'react-bootstrap/Dropdown'
77
import { useShowModal } from './modal'
88
import { useToast } from './toast'
99

10-
export default function Delete ({ itemId, children, onDelete, type = 'post' }) {
10+
export default function Delete ({ itemId, children, onDelete, type = 'post', founder = false }) {
1111
const showModal = useShowModal()
1212

1313
const [deleteItem] = useMutation(
@@ -43,6 +43,7 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) {
4343
return (
4444
<DeleteConfirm
4545
type={type}
46+
founder={founder}
4647
onConfirm={async () => {
4748
const { error } = await deleteItem({ variables: { id: itemId } })
4849
if (error) {
@@ -62,36 +63,43 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) {
6263
)
6364
}
6465

65-
export function DeleteConfirm ({ onConfirm, type }) {
66+
export function DeleteConfirm ({ onConfirm, type, founder }) {
6667
const [error, setError] = useState()
6768
const toaster = useToast()
6869

6970
return (
7071
<>
7172
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}
72-
<p className='fw-bolder'>Are you sure? This is a gone forever kind of delete.</p>
73+
<p className='fw-bolder'>
74+
{founder
75+
? `Are you sure? This will permanently delete this ${type.toLowerCase()} as the territory founder.`
76+
: 'Are you sure? This is a gone forever kind of delete.'}
77+
</p>
7378
<div className='d-flex justify-content-end'>
7479
<Button
7580
variant='danger' onClick={async () => {
7681
try {
7782
await onConfirm()
78-
toaster.success(`deleted ${type.toLowerCase()}`)
83+
toaster.success(founder
84+
? `deleted ${type.toLowerCase()} as founder`
85+
: `deleted ${type.toLowerCase()}`)
7986
} catch (e) {
8087
setError(e.message || e)
8188
}
8289
}}
83-
>delete
90+
>{founder ? 'delete as founder' : 'delete'}
8491
</Button>
8592
</div>
8693
</>
8794
)
8895
}
8996

9097
export function DeleteDropdownItem (props) {
98+
const { founder } = props || {}
9199
return (
92100
<Delete {...props}>
93101
<Dropdown.Item>
94-
delete
102+
{founder ? 'delete as founder' : 'delete'}
95103
</Dropdown.Item>
96104
</Delete>
97105
)

components/item-info.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,27 @@ export default function ItemInfo ({
223223
<PinSubDropdownItem item={item} />
224224
</>}
225225
{item.mine && !item.position && !item.deletedAt && !item.bio &&
226+
(!sub?.disableDeletion || (me && sub?.userId === Number(me.id))) &&
227+
<>
228+
<hr className='dropdown-divider' />
229+
<DeleteDropdownItem
230+
itemId={item.id}
231+
type={item.title ? 'post' : 'comment'}
232+
founder={sub?.disableDeletion && me && sub?.userId === Number(me.id)}
233+
/>
234+
</>}
235+
{item.mine && !item.position && !item.deletedAt && !item.bio && sub?.disableDeletion &&
236+
me && sub?.userId !== Number(me.id) &&
237+
<>
238+
<hr className='dropdown-divider' />
239+
<Dropdown.Item disabled className='text-muted'>
240+
delete disabled by territory founder
241+
</Dropdown.Item>
242+
</>}
243+
{!item.mine && sub?.disableDeletion && me && sub?.userId === Number(me.id) &&
226244
<>
227245
<hr className='dropdown-divider' />
228-
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />
246+
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} founder />
229247
</>}
230248
{me && !item.mine &&
231249
<>

components/post.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ export function PostForm ({ type, sub, children }) {
111111
size='medium'
112112
sub={sub?.name}
113113
info={sub && <TerritoryInfo sub={sub} includeLink />}
114-
hint={sub?.moderated && 'this territory is moderated'}
114+
hint={[
115+
sub?.moderated && 'this territory is moderated',
116+
sub?.disableDeletion && 'this territory has deletion disabled'
117+
].filter(Boolean).join(' and ')}
115118
/>
116119
<div>
117120
{postButtons}
@@ -177,7 +180,10 @@ export default function Post ({ sub }) {
177180
size='medium'
178181
label='territory'
179182
info={sub && <TerritoryInfo sub={sub} includeLink />}
180-
hint={sub?.moderated && 'this territory is moderated'}
183+
hint={[
184+
sub?.moderated && 'this territory is moderated',
185+
sub?.disableDeletion && 'this territory has deletion disabled'
186+
].filter(Boolean).join(' and ')}
181187
/>}
182188
</PostForm>
183189
</>

components/reply.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,10 @@ export default forwardRef(function Reply ({
186186
required
187187
appendValue={quote}
188188
placeholder={placeholder}
189-
hint={sub?.moderated && 'this territory is moderated'}
189+
hint={[
190+
sub?.moderated && 'this territory is moderated',
191+
sub?.disableDeletion && 'this territory has deletion disabled'
192+
].filter(Boolean).join(' and ')}
190193
/>
191194
<ItemButtonBar createText='reply' hasCancel={false} />
192195
</Form>

components/territory-form.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export default function TerritoryForm ({ sub }) {
9696
billingType: sub?.billingType || 'MONTHLY',
9797
billingAutoRenew: sub?.billingAutoRenew || false,
9898
moderated: sub?.moderated || false,
99+
disableDeletion: sub?.disableDeletion || false,
99100
nsfw: sub?.nsfw || false
100101
}}
101102
schema={schema}
@@ -258,6 +259,24 @@ export default function TerritoryForm ({ sub }) {
258259
name='moderated'
259260
groupClassName='ms-1'
260261
/>
262+
<BootstrapForm.Label>deletion control</BootstrapForm.Label>
263+
<Checkbox
264+
inline
265+
label={
266+
<div className='d-flex align-items-center'>disable post deletion
267+
<Info>
268+
<ol>
269+
<li>Only territory founders can delete posts and comments</li>
270+
<li>Authors cannot delete their own content</li>
271+
<li>Delete bot commands will be ignored</li>
272+
<li>Your territory will get a <Badge bg='warning'>no deletion</Badge> badge</li>
273+
</ol>
274+
</Info>
275+
</div>
276+
}
277+
name='disableDeletion'
278+
groupClassName='ms-1'
279+
/>
261280
<BootstrapForm.Label>nsfw</BootstrapForm.Label>
262281
<Checkbox
263282
inline

components/territory-header.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function TerritoryDetails ({ sub, children }) {
3232
{sub.name}
3333
{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}
3434
{(sub.moderated || sub.moderatedCount > 0) && <Badge className='ms-2' bg='secondary'>moderated{sub.moderatedCount > 0 && ` ${sub.moderatedCount}`}</Badge>}
35+
{sub.disableDeletion && <Badge className='ms-2' bg='warning'>no deletion</Badge>}
3536
{(sub.nsfw) && <Badge className='ms-2' bg='secondary'>nsfw</Badge>}
3637
</small>
3738
}

fragments/comments.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export const COMMENTS_ITEM_EXT_FIELDS = gql`
121121
name
122122
userId
123123
moderated
124+
disableDeletion
124125
meMuteSub
125126
}
126127
user {

0 commit comments

Comments
 (0)