Skip to content

Commit

Permalink
Add ability to delete groups with no markets (#2521)
Browse files Browse the repository at this point in the history
* Add ability to delete groups

* Instruct user to untag markets first

* Check permissions

* replace instead of push

* log the deleted group
  • Loading branch information
sipec committed Mar 13, 2024
1 parent 3d8bde8 commit 5837acf
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 1 deletion.
3 changes: 3 additions & 0 deletions backend/api/src/app.ts
Expand Up @@ -154,6 +154,7 @@ import { createPublicChatMessage } from 'api/create-public-chat-message'
import { createAnswerDpm } from 'api/create-answer-dpm'
import { getFollowedGroups } from './get-followed-groups'
import { getUniqueBetGroupCount } from 'api/get-unique-bet-groups'
import { deleteGroup } from './delete-group'

const allowCorsUnrestricted: RequestHandler = cors({})

Expand Down Expand Up @@ -227,6 +228,8 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'group/by-id/:id': getGroup,
'group/by-id/:id/markets': ({ id, limit }, ...rest) =>
getMarkets({ groupId: id, limit }, ...rest),
'group/:slug/delete': deleteGroup,
'group/by-id/:id/delete': deleteGroup,
groups: getGroups,
'market/:id': getMarket,
'market/:id/lite': ({ id }) => getMarket({ id, lite: true }),
Expand Down
71 changes: 71 additions & 0 deletions backend/api/src/delete-group.ts
@@ -0,0 +1,71 @@
import { isModId } from 'common/envs/constants'
import { run } from 'common/supabase/utils'
import { log } from 'shared/log'
import {
createSupabaseClient,
createSupabaseDirectClient,
} from 'shared/supabase/init'
import { APIError, type AuthedUser } from './helpers/endpoint'

export const deleteGroup = async (
props: { id: string } | { slug: string },
auth: AuthedUser
) => {
const db = createSupabaseClient()
const pg = createSupabaseDirectClient()

const q = db.from('groups').select()
if ('id' in props) {
q.eq('id', props.id)
} else {
q.eq('slug', props.slug)
}

const { data: groups } = await run(q)

if (groups.length == 0) {
throw new APIError(404, 'Group not found')
}

const group = groups[0]

log(
`delete group ${group.name} ${group.slug} initiated by ${auth.uid}`,
group
)

const id = group.id

if (!isModId(auth.uid)) {
const requester = await pg.oneOrNone(
'select role from group_members where group_id = $1 and member_id = $2',
[id, auth.uid]
)

if (requester?.role !== 'admin') {
throw new APIError(403, 'You do not have permission to delete this group')
}
}

// fail if there are contracts tagged with this group
// we could just untag contracts like in scripts/deleteGroup.ts
// but I don't trust the mods. I'm forcing them to manually untag or retag contracts to make them reckon with the responsibility of what deleting a group means.
const { count: contractCount } = await pg.one(
`select count(*) from group_contracts where group_id = $1`,
[id]
)

if (contractCount > 0) {
throw new APIError(
400,
`Only topics with no questions can be deleted. There are still ${contractCount} questions tagged with this topic.`
)
}

await pg.tx(async (tx) => {
log('removing group members')
await tx.none('delete from group_members where group_id = $1', [id])
log('deleting group ', id)
await tx.none('delete from groups where id = $1', [id])
})
}
12 changes: 12 additions & 0 deletions common/src/api/schema.ts
Expand Up @@ -245,6 +245,18 @@ export const API = (_apiTypeCheck = {
})
.strict(),
},
'group/:slug/delete': {
method: 'POST',
visibility: 'public',
authed: true,
props: z.object({ slug: z.string() }),
},
'group/by-id/:id/delete': {
method: 'POST',
visibility: 'public',
authed: true,
props: z.object({ id: z.string() }),
},
groups: {
method: 'GET',
visibility: 'public',
Expand Down
81 changes: 81 additions & 0 deletions web/components/topics/delete-topic-modal.tsx
@@ -0,0 +1,81 @@
import { type PrivacyStatusType } from 'common/group'
import { useRouter } from 'next/router'
import { useState } from 'react'
import toast from 'react-hot-toast'
import { api } from 'web/lib/firebase/api'
import { Button } from '../buttons/button'
import { Modal } from '../layout/modal'
import { Input } from '../widgets/input'
import { Title } from '../widgets/title'

export function DeleteTopicModal(props: {
group: { id: string; name: string; privacyStatus: PrivacyStatusType }
open: boolean
setOpen: (open: boolean) => void
}) {
const { open, setOpen } = props
const { name, id, privacyStatus } = props.group

const [loading, setLoading] = useState(false)
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')

const router = useRouter()

return (
<Modal
open={open}
setOpen={setOpen}
className="bg-canvas-50 rounded-xl p-4 sm:p-6"
size="md"
>
<Title>Delete {name}?</Title>
<p className="mb-2">
Deleting a topic is permanent. All admins and followers will be removed
and no one will be able to find this topic anywhere.
</p>
{privacyStatus === 'public' && (
<p className="mb-2">
Topics should only be deleted if they are low quality or duplicate.
Ask @moderators on discord if you aren't sure.
</p>
)}
<p className="mb-2">
To delete, first untag all questions tagged with this topic, then type "
{name}" below to confirm.
</p>

<Input
placeholder="The name of this group"
className="mb-2 mt-2 w-full"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>

<Button
onClick={() => {
setLoading(true)
api('group/by-id/:id/delete', { id })
.then(() => {
setLoading(false)
toast.success('Topic deleted')
router.replace('/browse')
})
.catch((e) => {
setLoading(false)
console.error(e)
setError(e.message || 'Failed to delete topic')
})
}}
color="red"
disabled={loading || confirm != name}
size="xl"
className="w-full"
>
{loading ? 'Deleting...' : 'Delete Topic'}
</Button>

{error && <p className="mt-2 text-red-500">{error}</p>}
</Modal>
)
}
15 changes: 14 additions & 1 deletion web/components/topics/topic-options.tsx
Expand Up @@ -7,6 +7,7 @@ import {
DotsVerticalIcon,
PencilIcon,
PlusCircleIcon,
TrashIcon,
} from '@heroicons/react/solid'
import DropdownMenu, {
DropdownItem,
Expand All @@ -25,6 +26,7 @@ import { BiSolidVolumeMute } from 'react-icons/bi'
import { usePrivateUser } from 'web/hooks/use-user'
import { blockGroup, unBlockGroup } from 'web/components/topics/topic-dropdown'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { DeleteTopicModal } from './delete-topic-modal'

export function TopicOptions(props: {
group: Group
Expand All @@ -36,6 +38,7 @@ export function TopicOptions(props: {
const privateUser = usePrivateUser()
const [editingName, setEditingName] = useState(false)
const [showAddContract, setShowAddContract] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const userRole = useGroupRole(group.id, user)
const isCreator = group.creatorId == user?.id
const isMobile = useIsMobile()
Expand Down Expand Up @@ -68,7 +71,12 @@ export function TopicOptions(props: {
privateUser.blockedGroupSlugs?.includes(group.slug)
? unBlockGroup(privateUser, group.slug)
: blockGroup(privateUser, group.slug),
}
},
userRole === 'admin' && {
name: 'Delete',
icon: <TrashIcon className="text-scarlet-500 h-5 w-5" />,
onClick: () => setShowDelete(true),
}
) as DropdownItem[]
return (
<Col onClick={(e) => e.stopPropagation()}>
Expand Down Expand Up @@ -101,6 +109,11 @@ export function TopicOptions(props: {
user={user}
/>
)}
<DeleteTopicModal
group={group}
open={showDelete}
setOpen={setShowDelete}
/>
</Col>
)
}
Expand Down

0 comments on commit 5837acf

Please sign in to comment.