Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to delete groups with no markets #2521

Merged
merged 5 commits into from Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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