shop stipe
This commit is contained in:
Generated
+20
-3
@@ -16,9 +16,9 @@
|
||||
"multer": "^2.2.0",
|
||||
"prisma": "^6.11.1",
|
||||
"sharp": "^0.34.4",
|
||||
"socket.io": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {}
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^22.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
@@ -2154,6 +2154,23 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "22.3.2",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.2.tgz",
|
||||
"integrity": "sha512-O13QOvgEIQvDlTy6Ubb5kB980wpbhmoZNsgCXKILjCMZS67f+bW+6w99k3gnSi/N1lkryoj1WYdpGT5Wc5edjg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
|
||||
+2
-1
@@ -25,6 +25,7 @@
|
||||
"multer": "^2.2.0",
|
||||
"prisma": "^6.11.1",
|
||||
"sharp": "^0.34.4",
|
||||
"socket.io": "^4.8.3"
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^22.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import { ensureAchievements, checkAchievements } from './achievements.js';
|
||||
import { ensureCategories } from './categories.js';
|
||||
import { ensureShopOsuPacks } from './shopOsu.js';
|
||||
import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js';
|
||||
import { paymentsProvider } from './shopCatalog.js';
|
||||
import { handleStripeWebhook } from './payments.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
@@ -92,6 +94,28 @@ const sessionMiddleware = session({
|
||||
});
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// Stripe webhook needs the raw body for signature verification — must run
|
||||
// before express.json() for this path only.
|
||||
app.post(
|
||||
'/api/shop/osu/webhook',
|
||||
express.raw({ type: 'application/json' }),
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (paymentsProvider() !== 'stripe') {
|
||||
return res.status(404).json({ error: 'Stripe webhooks not enabled' });
|
||||
}
|
||||
const signature = req.headers['stripe-signature'];
|
||||
const result = await handleStripeWebhook(req.body, signature);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const status = err.status || 400;
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({ error: err.message || 'Webhook error' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(sessionMiddleware);
|
||||
// Serialize BigInt fields (balance / valueCents) as strings for JSON responses
|
||||
|
||||
+208
-45
@@ -1,11 +1,23 @@
|
||||
import Stripe from 'stripe';
|
||||
import { prisma } from './db.js';
|
||||
import { formatFiat, paymentsProvider } from './shopCatalog.js';
|
||||
import { getPurchasableOsuPack } from './shopOsu.js';
|
||||
import { checkAchievements } from './achievements.js';
|
||||
|
||||
function clientOrigin() {
|
||||
return String(process.env.CLIENT_ORIGIN || 'http://localhost:5888').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function getStripe() {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) return null;
|
||||
return new Stripe(key, { apiVersion: '2026-06-24.dahlia' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pending osu top-up order.
|
||||
* Mock provider completes client-side via /confirm.
|
||||
* Stripe provider (when configured) would return a clientSecret.
|
||||
* Mock: client confirms via /confirm.
|
||||
* Stripe: redirects to hosted Checkout; webhook credits osu.
|
||||
*/
|
||||
export async function createOsuCheckout(userId, packId) {
|
||||
const pack = await getPurchasableOsuPack(packId);
|
||||
@@ -33,28 +45,76 @@ export async function createOsuCheckout(userId, packId) {
|
||||
},
|
||||
});
|
||||
|
||||
const packPayload = {
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
osu: pack.osu,
|
||||
priceCents: pack.priceCents,
|
||||
currency: pack.currency,
|
||||
displayPrice: formatFiat(pack.priceCents, pack.currency),
|
||||
};
|
||||
|
||||
if (provider === 'stripe') {
|
||||
// Placeholder for Stripe PaymentIntent wiring.
|
||||
// Set PAYMENTS_PROVIDER=stripe + STRIPE_SECRET_KEY when ready.
|
||||
const err = new Error(
|
||||
'Stripe checkout is configured but not wired yet — set PAYMENTS_PROVIDER=mock for local buys'
|
||||
);
|
||||
err.status = 501;
|
||||
throw err;
|
||||
const stripe = getStripe();
|
||||
if (!stripe) {
|
||||
const err = new Error('STRIPE_SECRET_KEY is not configured');
|
||||
err.status = 500;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
client_reference_id: order.id,
|
||||
metadata: {
|
||||
orderId: order.id,
|
||||
userId: String(userId),
|
||||
packId: pack.id,
|
||||
},
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: String(pack.currency || 'eur').toLowerCase(),
|
||||
unit_amount: pack.priceCents,
|
||||
product_data: {
|
||||
name: `${pack.name} — ${pack.osu} osu`,
|
||||
description: 'CaseOrion premium wallet top-up',
|
||||
// Required by Stripe Managed Payments (default on new accounts).
|
||||
// https://docs.stripe.com/payments/managed-payments/eligibility
|
||||
tax_code: 'txcd_10000000',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
success_url: `${clientOrigin()}/shop?osu=success&order=${encodeURIComponent(order.id)}`,
|
||||
cancel_url: `${clientOrigin()}/shop?osu=cancel`,
|
||||
});
|
||||
|
||||
await prisma.paymentOrder.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
providerRef: session.id,
|
||||
meta: JSON.stringify({
|
||||
packName: pack.name,
|
||||
displayPrice: formatFiat(pack.priceCents, pack.currency),
|
||||
checkoutSessionId: session.id,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
order: serializeOrder({ ...order, providerRef: session.id }),
|
||||
pack: packPayload,
|
||||
mode: 'stripe',
|
||||
url: session.url,
|
||||
confirmable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
order: serializeOrder(order),
|
||||
pack: {
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
osu: pack.osu,
|
||||
priceCents: pack.priceCents,
|
||||
currency: pack.currency,
|
||||
displayPrice: formatFiat(pack.priceCents, pack.currency),
|
||||
},
|
||||
pack: packPayload,
|
||||
mode: 'mock',
|
||||
/** Client calls POST /api/shop/osu/confirm with this id in mock mode */
|
||||
confirmable: true,
|
||||
};
|
||||
}
|
||||
@@ -70,6 +130,12 @@ export async function confirmMockOsuPayment(userId, orderId) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (paymentsProvider() === 'stripe' && process.env.NODE_ENV === 'production') {
|
||||
const err = new Error('Mock confirm is disabled when Stripe is active');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const order = await tx.paymentOrder.findUnique({ where: { id } });
|
||||
if (!order || order.userId !== Number(userId)) {
|
||||
@@ -92,36 +158,133 @@ export async function confirmMockOsuPayment(userId, orderId) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updatedOrder = await tx.paymentOrder.update({
|
||||
where: { id: order.id },
|
||||
data: { status: 'paid', paidAt: now },
|
||||
});
|
||||
|
||||
const user = await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { osuBalance: { increment: order.osuAmount } },
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId,
|
||||
type: 'osu_purchase',
|
||||
amount: String(order.osuAmount),
|
||||
meta: JSON.stringify({
|
||||
orderId: order.id,
|
||||
packId: order.packId,
|
||||
priceCents: order.priceCents,
|
||||
currency: order.currency,
|
||||
provider: order.provider,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return { order: serializeOrder(updatedOrder), user, alreadyPaid: false };
|
||||
return fulfillPaidOrder(tx, order);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark order paid + credit osu (idempotent). Shared by mock confirm & Stripe webhook. */
|
||||
async function fulfillPaidOrder(tx, order) {
|
||||
if (order.status === 'paid') {
|
||||
const user = await tx.user.findUnique({ where: { id: order.userId } });
|
||||
return { order: serializeOrder(order), user, alreadyPaid: true };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updatedOrder = await tx.paymentOrder.update({
|
||||
where: { id: order.id },
|
||||
data: { status: 'paid', paidAt: now },
|
||||
});
|
||||
|
||||
const user = await tx.user.update({
|
||||
where: { id: order.userId },
|
||||
data: { osuBalance: { increment: order.osuAmount } },
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: order.userId,
|
||||
type: 'osu_purchase',
|
||||
amount: String(order.osuAmount),
|
||||
meta: JSON.stringify({
|
||||
orderId: order.id,
|
||||
packId: order.packId,
|
||||
priceCents: order.priceCents,
|
||||
currency: order.currency,
|
||||
provider: order.provider,
|
||||
providerRef: order.providerRef || '',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return { order: serializeOrder(updatedOrder), user, alreadyPaid: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fulfill a Stripe Checkout session (webhook). Idempotent.
|
||||
*/
|
||||
export async function fulfillStripeCheckoutSession(session) {
|
||||
const orderId =
|
||||
session?.metadata?.orderId || session?.client_reference_id || '';
|
||||
if (!orderId) {
|
||||
const err = new Error('Checkout session missing order id');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const order = await tx.paymentOrder.findUnique({ where: { id: String(orderId) } });
|
||||
if (!order) {
|
||||
const err = new Error('Order not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
if (order.provider !== 'stripe') {
|
||||
const err = new Error('Order is not a Stripe order');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (order.status === 'paid') {
|
||||
const user = await tx.user.findUnique({ where: { id: order.userId } });
|
||||
return { order: serializeOrder(order), user, alreadyPaid: true };
|
||||
}
|
||||
|
||||
// Persist session id if not already stored
|
||||
let working = order;
|
||||
if (session?.id && order.providerRef !== session.id) {
|
||||
working = await tx.paymentOrder.update({
|
||||
where: { id: order.id },
|
||||
data: { providerRef: session.id },
|
||||
});
|
||||
}
|
||||
|
||||
return fulfillPaidOrder(tx, working);
|
||||
});
|
||||
|
||||
if (result.user?.id && !result.alreadyPaid) {
|
||||
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Stripe webhook signature and handle checkout.session.completed.
|
||||
* `rawBody` must be the raw Buffer/string (not parsed JSON).
|
||||
*/
|
||||
export async function handleStripeWebhook(rawBody, signatureHeader) {
|
||||
const stripe = getStripe();
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!stripe || !secret) {
|
||||
const err = new Error('Stripe webhook is not configured');
|
||||
err.status = 500;
|
||||
throw err;
|
||||
}
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signatureHeader, secret);
|
||||
} catch (err) {
|
||||
const e = new Error(`Webhook signature verification failed: ${err.message}`);
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
if (session.payment_status === 'paid' || session.status === 'complete') {
|
||||
await fulfillStripeCheckoutSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
return { received: true, type: event.type };
|
||||
}
|
||||
|
||||
export async function getOrderForUser(userId, orderId) {
|
||||
const order = await prisma.paymentOrder.findUnique({ where: { id: String(orderId || '') } });
|
||||
if (!order || order.userId !== Number(userId)) return null;
|
||||
return serializeOrder(order);
|
||||
}
|
||||
|
||||
export function serializeOrder(order) {
|
||||
return {
|
||||
id: order.id,
|
||||
|
||||
@@ -111,7 +111,7 @@ export const PRESTIGE_SKILLS = {
|
||||
delta: 1,
|
||||
maxLevel: 20,
|
||||
title: 'Respin',
|
||||
description: 'Chance to free-respin the open (shows first result, then reels restart)',
|
||||
description: 'Chance to free-respin the open (shows first result, then reels restart). With auto-sell on, items above your threshold are kept',
|
||||
unit: '%',
|
||||
branch: 'drops',
|
||||
},
|
||||
|
||||
+29
-11
@@ -277,33 +277,48 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
|
||||
let { drops, createdTxIds } = await createDrops(count);
|
||||
|
||||
// Respin: re-roll all drops for free (replace first batch).
|
||||
// preRespinItems animate the discarded roll. Auto-sell-eligible items stay in
|
||||
// inventory (with inventoryId) so the client can credit them when the first
|
||||
// reels finish; the rest are discarded immediately.
|
||||
// Respin: free re-roll after showing the first result.
|
||||
// - Auto-sell ON: junk (< threshold) stays for deferred sell; keepers (>= threshold)
|
||||
// stay in inventory; second roll is extra loot.
|
||||
// - Auto-sell OFF: classic replace — first roll is discarded entirely.
|
||||
let preRespinItems = null;
|
||||
let keptPreRespinItems = [];
|
||||
const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
|
||||
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
|
||||
if (didRespin) {
|
||||
const discardIds = drops
|
||||
.filter((d) => !d.willAutoSell)
|
||||
.map((d) => d.inventoryId)
|
||||
.filter(Boolean);
|
||||
const autoSellOn = Boolean(user.autoSellEnabled);
|
||||
const pendingAutoSellIds = drops
|
||||
.filter((d) => d.willAutoSell)
|
||||
.map((d) => d.inventoryId)
|
||||
.filter(Boolean);
|
||||
keptPreRespinItems = autoSellOn
|
||||
? drops.filter((d) => !d.willAutoSell && d.inventoryId)
|
||||
: [];
|
||||
const keepIdSet = new Set([
|
||||
...pendingAutoSellIds,
|
||||
...keptPreRespinItems.map((d) => d.inventoryId),
|
||||
]);
|
||||
const discardIds = drops
|
||||
.map((d) => d.inventoryId)
|
||||
.filter((id) => id && !keepIdSet.has(id));
|
||||
|
||||
preRespinItems = drops.map((d) => ({
|
||||
...d,
|
||||
inventoryId: d.willAutoSell ? d.inventoryId : null,
|
||||
inventoryId: keepIdSet.has(d.inventoryId) ? d.inventoryId : null,
|
||||
preRespin: true,
|
||||
kept: Boolean(d.inventoryId && keepIdSet.has(d.inventoryId) && !d.willAutoSell),
|
||||
}));
|
||||
|
||||
if (discardIds.length) {
|
||||
await tx.inventoryItem.deleteMany({ where: { id: { in: discardIds } } });
|
||||
}
|
||||
await tx.transaction.deleteMany({ where: { id: { in: createdTxIds } } });
|
||||
// Drop open_case txs for discarded / pending-auto-sell rows; keep txs for keepers.
|
||||
const deleteTxIds = drops
|
||||
.map((d, i) => (keepIdSet.has(d.inventoryId) && !d.willAutoSell ? null : createdTxIds[i]))
|
||||
.filter(Boolean);
|
||||
if (deleteTxIds.length) {
|
||||
await tx.transaction.deleteMany({ where: { id: { in: deleteTxIds } } });
|
||||
}
|
||||
const again = await createDrops(count);
|
||||
drops = again.drops;
|
||||
createdTxIds = again.createdTxIds;
|
||||
@@ -317,6 +332,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
openCount: count,
|
||||
inventoryItemIds: drops.map((d) => d.inventoryId),
|
||||
preRespinPendingAutoSellIds: pendingAutoSellIds,
|
||||
preRespinKeptIds: keptPreRespinItems.map((d) => d.inventoryId),
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -330,7 +346,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
tx,
|
||||
userId,
|
||||
caseId,
|
||||
drops.map((d) => d.id)
|
||||
[...keptPreRespinItems.map((d) => d.id), ...drops.map((d) => d.id)]
|
||||
);
|
||||
|
||||
const applied = applyOpens(progressRow, count, 0);
|
||||
@@ -367,6 +383,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
caseName: c.name,
|
||||
items: drops,
|
||||
preRespinItems,
|
||||
keptPreRespinItems,
|
||||
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
|
||||
keysGained: applied.keysGained,
|
||||
catalog,
|
||||
@@ -386,6 +403,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
count: result.items.length,
|
||||
items: result.items,
|
||||
preRespinItems: result.preRespinItems,
|
||||
keptPreRespinItems: result.keptPreRespinItems,
|
||||
keyProgress: result.keyProgress,
|
||||
keysGained: result.keysGained,
|
||||
catalog: result.catalog,
|
||||
|
||||
@@ -114,7 +114,11 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const prestigeCount = (user.prestigeCount ?? 0) + 1;
|
||||
|
||||
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
|
||||
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
|
||||
// Keep lifetime opens-by-case stats; reset key progress only.
|
||||
await tx.caseKeyProgress.updateMany({
|
||||
where: { userId: user.id },
|
||||
data: { keys: 1, opensSinceLastKey: 0 },
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
|
||||
+20
-12
@@ -11,7 +11,11 @@ import {
|
||||
scaleCreditPacks,
|
||||
} from '../shopCatalog.js';
|
||||
import { listEnabledShopOsuPacks, isOsuTopupsSectionEnabled } from '../shopOsu.js';
|
||||
import { confirmMockOsuPayment, createOsuCheckout } from '../payments.js';
|
||||
import {
|
||||
confirmMockOsuPayment,
|
||||
createOsuCheckout,
|
||||
getOrderForUser,
|
||||
} from '../payments.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -42,15 +46,18 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
const packages = scaleCreditPacks(user.balance);
|
||||
const sectionEnabled = await isOsuTopupsSectionEnabled();
|
||||
const osuTopups = sectionEnabled ? await listEnabledShopOsuPacks() : [];
|
||||
const provider = paymentsProvider();
|
||||
|
||||
res.json({
|
||||
packages,
|
||||
osuTopups,
|
||||
osuTopupsEnabled: sectionEnabled && osuTopups.length > 0,
|
||||
payments: {
|
||||
provider: paymentsProvider(),
|
||||
provider,
|
||||
currency: 'eur',
|
||||
mock: paymentsProvider() === 'mock',
|
||||
mock: provider === 'mock',
|
||||
publishableKey:
|
||||
provider === 'stripe' ? process.env.STRIPE_PUBLISHABLE_KEY || '' : '',
|
||||
},
|
||||
user: publicUser(user),
|
||||
ad: adPayload(user),
|
||||
@@ -123,7 +130,7 @@ router.post('/buy', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Start real-money → osu checkout (mock or future Stripe). */
|
||||
/** Start real-money → osu checkout (mock or Stripe Checkout). */
|
||||
router.post('/osu/checkout', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const packId = String(req.body.packId || '');
|
||||
@@ -155,15 +162,16 @@ router.post('/osu/confirm', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stripe webhook stub — verify signature & fulfill when PAYMENTS_PROVIDER=stripe.
|
||||
* Keep raw body parsing in mind when wiring Stripe.
|
||||
*/
|
||||
router.post('/osu/webhook', async (req, res) => {
|
||||
if (paymentsProvider() !== 'stripe') {
|
||||
return res.status(404).json({ error: 'Stripe webhooks not enabled' });
|
||||
/** Poll order status after Stripe redirect (webhook may lag a second). */
|
||||
router.get('/osu/orders/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const order = await getOrderForUser(req.session.userId, req.params.id);
|
||||
if (!order) return res.status(404).json({ error: 'Order not found' });
|
||||
res.json({ order });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to load order' });
|
||||
}
|
||||
res.status(501).json({ error: 'Stripe webhook not implemented yet' });
|
||||
});
|
||||
|
||||
router.post('/ad', requireAuth, async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user