diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index f530399f52..cadd542bcc 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -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({}) @@ -227,6 +228,8 @@ const handlers: { [k in APIPath]: APIHandler } = { '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 }), diff --git a/backend/api/src/delete-group.ts b/backend/api/src/delete-group.ts new file mode 100644 index 0000000000..8b7d73e917 --- /dev/null +++ b/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]) + }) +} diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 80421256d7..29d1de39e0 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -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', diff --git a/web/components/topics/delete-topic-modal.tsx b/web/components/topics/delete-topic-modal.tsx new file mode 100644 index 0000000000..3b10ed108d --- /dev/null +++ b/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 ( + + Delete {name}? +

+ Deleting a topic is permanent. All admins and followers will be removed + and no one will be able to find this topic anywhere. +

+ {privacyStatus === 'public' && ( +

+ Topics should only be deleted if they are low quality or duplicate. + Ask @moderators on discord if you aren't sure. +

+ )} +

+ To delete, first untag all questions tagged with this topic, then type " + {name}" below to confirm. +

+ + setConfirm(e.target.value)} + /> + + + + {error &&

{error}

} +
+ ) +} diff --git a/web/components/topics/topic-options.tsx b/web/components/topics/topic-options.tsx index 6bab0a0a04..1b6bd2b5ba 100644 --- a/web/components/topics/topic-options.tsx +++ b/web/components/topics/topic-options.tsx @@ -7,6 +7,7 @@ import { DotsVerticalIcon, PencilIcon, PlusCircleIcon, + TrashIcon, } from '@heroicons/react/solid' import DropdownMenu, { DropdownItem, @@ -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 @@ -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() @@ -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: , + onClick: () => setShowDelete(true), + } ) as DropdownItem[] return ( e.stopPropagation()}> @@ -101,6 +109,11 @@ export function TopicOptions(props: { user={user} /> )} + ) }