🐛 (stripe) Fix plan update and management
This commit is contained in:
committed by
Baptiste Arnaud
parent
f83e0efea2
commit
6384a3adae
@@ -7,6 +7,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Heading,
|
Heading,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
|
import { useToast } from 'components/shared/hooks/useToast'
|
||||||
import { PlanTag } from 'components/shared/PlanTag'
|
import { PlanTag } from 'components/shared/PlanTag'
|
||||||
import { Plan } from 'db'
|
import { Plan } from 'db'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
@@ -26,38 +27,48 @@ export const CurrentSubscriptionContent = ({
|
|||||||
const [isCancelling, setIsCancelling] = useState(false)
|
const [isCancelling, setIsCancelling] = useState(false)
|
||||||
const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] =
|
const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] =
|
||||||
useState(false)
|
useState(false)
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
const cancelSubscription = async () => {
|
const cancelSubscription = async () => {
|
||||||
if (!stripeId) return
|
if (!stripeId) return
|
||||||
setIsCancelling(true)
|
setIsCancelling(true)
|
||||||
await cancelSubscriptionQuery(stripeId)
|
const { error } = await cancelSubscriptionQuery(stripeId)
|
||||||
|
if (error) {
|
||||||
|
showToast({ description: error.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
onCancelSuccess()
|
onCancelSuccess()
|
||||||
setIsCancelling(false)
|
setIsCancelling(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSubscribed = (plan === Plan.STARTER || plan === Plan.PRO) && stripeId
|
const isSubscribed = (plan === Plan.STARTER || plan === Plan.PRO) && stripeId
|
||||||
|
|
||||||
if (isCancelling) return <Spinner colorScheme="gray" />
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="2">
|
<Stack gap="2">
|
||||||
<Heading fontSize="3xl">Subscription</Heading>
|
<Heading fontSize="3xl">Subscription</Heading>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Text>Current workspace subscription: </Text>
|
<Text>Current workspace subscription: </Text>
|
||||||
<PlanTag plan={plan} />
|
{isCancelling ? (
|
||||||
{isSubscribed && (
|
<Spinner color="gray.500" size="xs" />
|
||||||
<Link
|
) : (
|
||||||
as="button"
|
<>
|
||||||
color="gray.500"
|
<PlanTag plan={plan} />
|
||||||
textDecor="underline"
|
{isSubscribed && (
|
||||||
fontSize="sm"
|
<Link
|
||||||
onClick={cancelSubscription}
|
as="button"
|
||||||
>
|
color="gray.500"
|
||||||
Cancel my subscription
|
textDecor="underline"
|
||||||
</Link>
|
fontSize="sm"
|
||||||
|
onClick={cancelSubscription}
|
||||||
|
>
|
||||||
|
Cancel my subscription
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{isSubscribed && (
|
{isSubscribed && !isCancelling && (
|
||||||
<>
|
<>
|
||||||
<Stack gap="1">
|
<Stack gap="1">
|
||||||
<Text fontSize="sm">
|
<Text fontSize="sm">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const ChangePlanForm = () => {
|
|||||||
selectedStorageLimitIndex === undefined
|
selectedStorageLimitIndex === undefined
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
await pay({
|
const response = await pay({
|
||||||
stripeId: workspace.stripeId ?? undefined,
|
stripeId: workspace.stripeId ?? undefined,
|
||||||
user,
|
user,
|
||||||
plan,
|
plan,
|
||||||
@@ -43,6 +43,10 @@ export const ChangePlanForm = () => {
|
|||||||
additionalChats: selectedChatsLimitIndex,
|
additionalChats: selectedChatsLimitIndex,
|
||||||
additionalStorage: selectedStorageLimitIndex,
|
additionalStorage: selectedStorageLimitIndex,
|
||||||
})
|
})
|
||||||
|
if (typeof response === 'object' && response?.error) {
|
||||||
|
showToast({ description: response.error.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
refreshCurrentSubscriptionInfo({
|
refreshCurrentSubscriptionInfo({
|
||||||
additionalChatsIndex: selectedChatsLimitIndex,
|
additionalChatsIndex: selectedChatsLimitIndex,
|
||||||
additionalStorageIndex: selectedStorageLimitIndex,
|
additionalStorageIndex: selectedStorageLimitIndex,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type UpgradeProps = {
|
|||||||
export const pay = async ({
|
export const pay = async ({
|
||||||
stripeId,
|
stripeId,
|
||||||
...props
|
...props
|
||||||
}: UpgradeProps): Promise<{ newPlan: Plan } | undefined | void> =>
|
}: UpgradeProps): Promise<{ newPlan?: Plan; error?: Error } | void> =>
|
||||||
isDefined(stripeId)
|
isDefined(stripeId)
|
||||||
? updatePlan({ ...props, stripeId })
|
? updatePlan({ ...props, stripeId })
|
||||||
: redirectToCheckout(props)
|
: redirectToCheckout(props)
|
||||||
@@ -31,13 +31,13 @@ export const updatePlan = async ({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
additionalChats,
|
additionalChats,
|
||||||
additionalStorage,
|
additionalStorage,
|
||||||
}: Omit<UpgradeProps, 'user'>): Promise<{ newPlan: Plan } | undefined> => {
|
}: Omit<UpgradeProps, 'user'>): Promise<{ newPlan?: Plan; error?: Error }> => {
|
||||||
const { data, error } = await sendRequest<{ message: string }>({
|
const { data, error } = await sendRequest<{ message: string }>({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
url: '/api/stripe/subscription',
|
url: '/api/stripe/subscription',
|
||||||
body: { workspaceId, plan, stripeId, additionalChats, additionalStorage },
|
body: { workspaceId, plan, stripeId, additionalChats, additionalStorage },
|
||||||
})
|
})
|
||||||
if (error || !data) return
|
if (error || !data) return { error }
|
||||||
return { newPlan: plan }
|
return { newPlan: plan }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SupportBubble } from 'components/shared/SupportBubble'
|
|||||||
import { WorkspaceContext } from 'contexts/WorkspaceContext'
|
import { WorkspaceContext } from 'contexts/WorkspaceContext'
|
||||||
import { toTitleCase } from 'utils'
|
import { toTitleCase } from 'utils'
|
||||||
import { Session } from 'next-auth'
|
import { Session } from 'next-auth'
|
||||||
|
import { Plan } from 'db'
|
||||||
|
|
||||||
const { ToastContainer, toast } = createStandaloneToast(customTheme)
|
const { ToastContainer, toast } = createStandaloneToast(customTheme)
|
||||||
|
|
||||||
@@ -35,7 +36,14 @@ const App = ({
|
|||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
displayStripeCallbackMessage(query.stripe?.toString(), toast)
|
const newPlan = query.stripe?.toString()
|
||||||
|
if (newPlan === Plan.STARTER || newPlan === Plan.PRO)
|
||||||
|
toast({
|
||||||
|
position: 'bottom-right',
|
||||||
|
status: 'success',
|
||||||
|
title: 'Upgrade success!',
|
||||||
|
description: `Workspace upgraded to ${toTitleCase(status)} 🎉`,
|
||||||
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isReady])
|
}, [isReady])
|
||||||
|
|
||||||
@@ -68,19 +76,4 @@ const App = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayStripeCallbackMessage = (
|
|
||||||
status: string | undefined,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
toast: any
|
|
||||||
) => {
|
|
||||||
if (status && ['pro', 'team'].includes(status)) {
|
|
||||||
toast({
|
|
||||||
position: 'bottom-right',
|
|
||||||
status: 'success',
|
|
||||||
title: 'Upgrade success!',
|
|
||||||
description: `Workspace upgraded to ${toTitleCase(status)} 🎉`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@@ -54,12 +54,12 @@ const getSubscriptionDetails =
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
additionalChatsIndex:
|
additionalChatsIndex:
|
||||||
subscriptions.data[0].items.data.find(
|
subscriptions.data[0]?.items.data.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
|
item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
|
||||||
)?.quantity ?? 0,
|
)?.quantity ?? 0,
|
||||||
additionalStorageIndex:
|
additionalStorageIndex:
|
||||||
subscriptions.data[0].items.data.find(
|
subscriptions.data[0]?.items.data.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
|
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
|
||||||
)?.quantity ?? 0,
|
)?.quantity ?? 0,
|
||||||
@@ -100,33 +100,34 @@ const createCheckoutSession = (req: NextApiRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateSubscription = async (req: NextApiRequest) => {
|
const updateSubscription = async (req: NextApiRequest) => {
|
||||||
const { customerId, plan, workspaceId, additionalChats, additionalStorage } =
|
const { stripeId, plan, workspaceId, additionalChats, additionalStorage } = (
|
||||||
(typeof req.body === 'string' ? JSON.parse(req.body) : req.body) as {
|
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||||
customerId: string
|
) as {
|
||||||
workspaceId: string
|
stripeId: string
|
||||||
additionalChats: number
|
workspaceId: string
|
||||||
additionalStorage: number
|
additionalChats: number
|
||||||
plan: 'STARTER' | 'PRO'
|
additionalStorage: number
|
||||||
}
|
plan: 'STARTER' | 'PRO'
|
||||||
|
}
|
||||||
if (!process.env.STRIPE_SECRET_KEY)
|
if (!process.env.STRIPE_SECRET_KEY)
|
||||||
throw Error('STRIPE_SECRET_KEY var is missing')
|
throw Error('STRIPE_SECRET_KEY var is missing')
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
apiVersion: '2022-08-01',
|
apiVersion: '2022-08-01',
|
||||||
})
|
})
|
||||||
const { data } = await stripe.subscriptions.list({
|
const { data } = await stripe.subscriptions.list({
|
||||||
customer: customerId,
|
customer: stripeId,
|
||||||
})
|
})
|
||||||
const subscription = data[0]
|
const subscription = data[0] as Stripe.Subscription | undefined
|
||||||
const currentStarterPlanItemId = subscription.items.data.find(
|
const currentStarterPlanItemId = subscription?.items.data.find(
|
||||||
(item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID
|
(item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID
|
||||||
)?.id
|
)?.id
|
||||||
const currentProPlanItemId = subscription.items.data.find(
|
const currentProPlanItemId = subscription?.items.data.find(
|
||||||
(item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID
|
(item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID
|
||||||
)?.id
|
)?.id
|
||||||
const currentAdditionalChatsItemId = subscription.items.data.find(
|
const currentAdditionalChatsItemId = subscription?.items.data.find(
|
||||||
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
|
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
|
||||||
)?.id
|
)?.id
|
||||||
const currentAdditionalStorageItemId = subscription.items.data.find(
|
const currentAdditionalStorageItemId = subscription?.items.data.find(
|
||||||
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
|
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
|
||||||
)?.id
|
)?.id
|
||||||
const items = [
|
const items = [
|
||||||
@@ -155,9 +156,18 @@ const updateSubscription = async (req: NextApiRequest) => {
|
|||||||
deleted: additionalStorage === 0,
|
deleted: additionalStorage === 0,
|
||||||
},
|
},
|
||||||
].filter(isDefined)
|
].filter(isDefined)
|
||||||
await stripe.subscriptions.update(subscription.id, {
|
|
||||||
items,
|
if (subscription) {
|
||||||
})
|
await stripe.subscriptions.update(subscription.id, {
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await stripe.subscriptions.create({
|
||||||
|
customer: stripeId,
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.workspace.update({
|
await prisma.workspace.update({
|
||||||
where: { id: workspaceId },
|
where: { id: workspaceId },
|
||||||
data: {
|
data: {
|
||||||
@@ -187,7 +197,10 @@ const cancelSubscription =
|
|||||||
const existingSubscription = await stripe.subscriptions.list({
|
const existingSubscription = await stripe.subscriptions.list({
|
||||||
customer: workspace.stripeId,
|
customer: workspace.stripeId,
|
||||||
})
|
})
|
||||||
await stripe.subscriptions.del(existingSubscription.data[0].id)
|
const currentSubscriptionId = existingSubscription.data[0]?.id
|
||||||
|
if (currentSubscriptionId)
|
||||||
|
await stripe.subscriptions.del(currentSubscriptionId)
|
||||||
|
|
||||||
await prisma.workspace.update({
|
await prisma.workspace.update({
|
||||||
where: { id: workspace.id },
|
where: { id: workspace.id },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
Workspace,
|
Workspace,
|
||||||
} from 'db'
|
} from 'db'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { createFakeResults } from 'utils'
|
import { injectFakeResults } from 'utils'
|
||||||
import { encrypt } from 'utils/api'
|
import { encrypt } from 'utils/api'
|
||||||
import Stripe from 'stripe'
|
import Stripe from 'stripe'
|
||||||
|
|
||||||
@@ -75,7 +75,10 @@ export const addSubscriptionToWorkspace = async (
|
|||||||
customer: stripeId,
|
customer: stripeId,
|
||||||
items,
|
items,
|
||||||
default_payment_method: paymentId,
|
default_payment_method: paymentId,
|
||||||
currency: 'usd',
|
currency: 'eur',
|
||||||
|
})
|
||||||
|
await stripe.customers.update(stripeId, {
|
||||||
|
invoice_settings: { default_payment_method: paymentId },
|
||||||
})
|
})
|
||||||
await prisma.workspace.update({
|
await prisma.workspace.update({
|
||||||
where: { id: workspaceId },
|
where: { id: workspaceId },
|
||||||
@@ -264,7 +267,7 @@ export const updateUser = (data: Partial<User>) =>
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createResults = createFakeResults(prisma)
|
export const createResults = injectFakeResults(prisma)
|
||||||
|
|
||||||
export const createFolder = (workspaceId: string, name: string) =>
|
export const createFolder = (workspaceId: string, name: string) =>
|
||||||
prisma.dashboardFolder.create({
|
prisma.dashboardFolder.create({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from 'models'
|
} from 'models'
|
||||||
import { GraphNavigation, Plan, PrismaClient, WorkspaceRole } from 'db'
|
import { GraphNavigation, Plan, PrismaClient, WorkspaceRole } from 'db'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { createFakeResults } from 'utils'
|
import { injectFakeResults } from 'utils'
|
||||||
import { encrypt } from 'utils/api'
|
import { encrypt } from 'utils/api'
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
const prisma = new PrismaClient()
|
||||||
@@ -221,7 +221,7 @@ export const importTypebotInDatabase = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createResults = createFakeResults(prisma)
|
export const createResults = injectFakeResults(prisma)
|
||||||
|
|
||||||
export const createSmtpCredentials = (
|
export const createSmtpCredentials = (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { PrismaClient } from 'db'
|
import { PrismaClient } from 'db'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import { injectFakeResults } from 'utils'
|
||||||
|
|
||||||
require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: path.join(
|
path: path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
process.env.NODE_ENV === 'production'
|
process.env.NODE_ENV === 'staging' ? '.env.staging' : '.env.local'
|
||||||
? '.env.production'
|
|
||||||
: process.env.NODE_ENV === 'staging'
|
|
||||||
? '.env.staging'
|
|
||||||
: '.env.local'
|
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
const main = async () => {}
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
await injectFakeResults(prisma)({
|
||||||
|
count: 150,
|
||||||
|
typebotId: 'cl89sq4vb030109laivd9ck97',
|
||||||
|
isChronological: false,
|
||||||
|
idPrefix: 'batch2',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
main().then()
|
main().then()
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start:local": "ts-node index.ts",
|
"start:local": "ts-node index.ts",
|
||||||
"start:staging": "NODE_ENV=staging ts-node index.ts",
|
"start:staging": "NODE_ENV=staging ts-node index.ts",
|
||||||
"start:prod": "NODE_ENV=production ts-node index.ts",
|
"start:prod": "NODE_ENV=production ts-node index.ts"
|
||||||
"start:workspaces:migration": "ts-node workspaceMigration.ts",
|
|
||||||
"start:workspaces:migration:recover": "ts-node workspaceMigrationRecover.ts"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "18.7.16",
|
"@types/node": "18.7.16",
|
||||||
@@ -17,6 +15,6 @@
|
|||||||
"models": "workspace:*",
|
"models": "workspace:*",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^4.8.3",
|
"typescript": "^4.8.3",
|
||||||
"utils": "*"
|
"utils": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
|
|
||||||
export const prepareEmojis = () => {
|
|
||||||
const emojiData = JSON.parse(fs.readFileSync('./emojiData.json', 'utf8'))
|
|
||||||
const strippedEmojiData = {
|
|
||||||
'Smileys & Emotion': emojiData['Smileys & Emotion'].map(
|
|
||||||
(emoji: { emoji: any }) => emoji.emoji
|
|
||||||
),
|
|
||||||
'People & Body': emojiData['People & Body'].map(
|
|
||||||
(emoji: { emoji: any }) => emoji.emoji
|
|
||||||
),
|
|
||||||
'Animals & Nature': emojiData['Animals & Nature'].map(
|
|
||||||
(emoji: { emoji: any }) => emoji.emoji
|
|
||||||
),
|
|
||||||
'Food & Drink': emojiData['Food & Drink'].map(
|
|
||||||
(emoji: { emoji: any }) => emoji.emoji
|
|
||||||
),
|
|
||||||
'Travel & Places': emojiData['Travel & Places'].map(
|
|
||||||
(emoji: { emoji: any }) => emoji.emoji
|
|
||||||
),
|
|
||||||
Activities: emojiData['Activities'].map(
|
|
||||||
(emoji: { emoji: any }) => emoji.emoji
|
|
||||||
),
|
|
||||||
Objects: emojiData['Objects'].map((emoji: { emoji: any }) => emoji.emoji),
|
|
||||||
Symbols: emojiData['Symbols'].map((emoji: { emoji: any }) => emoji.emoji),
|
|
||||||
Flags: emojiData['Flags'].map((emoji: { emoji: any }) => emoji.emoji),
|
|
||||||
}
|
|
||||||
fs.writeFileSync(
|
|
||||||
'strippedEmojis.json',
|
|
||||||
JSON.stringify(strippedEmojiData),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ type CreateFakeResultsProps = {
|
|||||||
fakeStorage?: number
|
fakeStorage?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createFakeResults =
|
export const injectFakeResults =
|
||||||
(prisma: PrismaClient) =>
|
(prisma: PrismaClient) =>
|
||||||
async ({
|
async ({
|
||||||
count,
|
count,
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -520,7 +520,7 @@ importers:
|
|||||||
models: workspace:*
|
models: workspace:*
|
||||||
ts-node: ^10.9.1
|
ts-node: ^10.9.1
|
||||||
typescript: ^4.8.3
|
typescript: ^4.8.3
|
||||||
utils: '*'
|
utils: workspace:*
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node': 18.7.16
|
'@types/node': 18.7.16
|
||||||
db: link:../db
|
db: link:../db
|
||||||
|
|||||||
Reference in New Issue
Block a user