diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index f13fb0475b..5c26052a58 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -153,6 +153,7 @@ import { getSeenMarketIds } from 'api/get-seen-market-ids' import { recordContractView } from 'api/record-contract-view' import { createPublicChatMessage } from 'api/create-public-chat-message' import { createAnswerDpm } from 'api/create-answer-dpm' +import { deleteGroup } from './delete-group' const allowCorsUnrestricted: RequestHandler = cors({}) @@ -226,6 +227,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..d707d4d02e --- /dev/null +++ b/backend/api/src/delete-group.ts @@ -0,0 +1,43 @@ +import { createSupabaseClient } from 'shared/supabase/init' +import { APIError } from './helpers/endpoint' +import { run } from 'common/supabase/utils' +import { log } from 'shared/log' + +export const deleteGroup = async (props: { id: string } | { slug: string }) => { + const db = createSupabaseClient() + + const q = db.from('groups').select('id') + 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 id = groups[0].id + + // check if any contracts tagged with this + const { count: contractCount } = await run( + db + .from('contract_groups') + .select('*', { head: true, count: 'exact' }) + .eq('groupId', 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.` + ) + } + + log('removing group members') + await db.from('group_members').delete().eq('group_id', id) + log('deleting group ', id) + await db.from('groups').delete().eq('id', id) +} diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 31b44f3d54..4d974e28af 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -233,6 +233,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..d1b82fdaa1 --- /dev/null +++ b/web/components/topics/delete-topic-modal.tsx @@ -0,0 +1,77 @@ +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 members will be removed and no one + will be able to find this topic. +

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

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

+ )} + + 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} /> )} + ) }