feat(economy): tighten credit earning + deduct on request
Credit economy changes: - creditsPerMinute: 10 -> 2 (45min episode = 90 COOP vs 450) - movieRequestCost: 100 -> 500 (~5.5 episodes per movie) - tvRequestCost: 200 -> 1000 (~11 episodes per season) - tvPerSeasonCost: 50 -> 250 Race condition fix: - Credits now deducted immediately at request time - No more multiple requests before approval - On DECLINED: automatic refund with ADJUSTMENT transaction - On APPROVED: no extra deduction (already paid) Updated schema defaults and all code fallbacks.
This commit is contained in:
@@ -152,14 +152,14 @@ model SystemSettings {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
// Minting settings
|
// Minting settings
|
||||||
creditsPerMinute Int @default(10) @map("credits_per_minute")
|
creditsPerMinute Int @default(2) @map("credits_per_minute")
|
||||||
minWatchPercent Int @default(80) @map("min_watch_percent")
|
minWatchPercent Int @default(80) @map("min_watch_percent")
|
||||||
minWatchMinutes Int @default(5) @map("min_watch_minutes")
|
minWatchMinutes Int @default(5) @map("min_watch_minutes")
|
||||||
|
|
||||||
// Spending settings
|
// Spending settings
|
||||||
movieRequestCost Int @default(100) @map("movie_request_cost")
|
movieRequestCost Int @default(500) @map("movie_request_cost")
|
||||||
tvRequestCost Int @default(200) @map("tv_request_cost")
|
tvRequestCost Int @default(1000) @map("tv_request_cost")
|
||||||
tvPerSeasonCost Int @default(50) @map("tv_per_season_cost")
|
tvPerSeasonCost Int @default(250) @map("tv_per_season_cost")
|
||||||
|
|
||||||
// Multipliers
|
// Multipliers
|
||||||
newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2)
|
newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2)
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ async function backfillUserHistory(user: {
|
|||||||
|
|
||||||
// Get system settings for credit calculation
|
// Get system settings for credit calculation
|
||||||
const settings = await prisma.systemSettings.findFirst();
|
const settings = await prisma.systemSettings.findFirst();
|
||||||
const creditsPerMinute = settings?.creditsPerMinute || 10;
|
const creditsPerMinute = settings?.creditsPerMinute || 2;
|
||||||
const minWatchPercent = settings?.minWatchPercent || 80;
|
const minWatchPercent = settings?.minWatchPercent || 80;
|
||||||
const minWatchMinutes = settings?.minWatchMinutes || 5;
|
const minWatchMinutes = settings?.minWatchMinutes || 5;
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ router.get(
|
|||||||
const settings = await prisma.systemSettings.findFirst();
|
const settings = await prisma.systemSettings.findFirst();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
movie: settings?.movieRequestCost || 100,
|
movie: settings?.movieRequestCost || 500,
|
||||||
tv: settings?.tvRequestCost || 200,
|
tv: settings?.tvRequestCost || 1000,
|
||||||
tvPerSeason: settings?.tvPerSeasonCost || 50,
|
tvPerSeason: settings?.tvPerSeasonCost || 250,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -55,11 +55,11 @@ router.get(
|
|||||||
res.json({
|
res.json({
|
||||||
hasWallet: true,
|
hasWallet: true,
|
||||||
balance: dbBalance,
|
balance: dbBalance,
|
||||||
canRequestMovie: dbBalance >= (settings?.movieRequestCost || 100),
|
canRequestMovie: dbBalance >= (settings?.movieRequestCost || 500),
|
||||||
canRequestTV: dbBalance >= (settings?.tvRequestCost || 200),
|
canRequestTV: dbBalance >= (settings?.tvRequestCost || 1000),
|
||||||
costs: {
|
costs: {
|
||||||
movie: settings?.movieRequestCost || 100,
|
movie: settings?.movieRequestCost || 500,
|
||||||
tv: settings?.tvRequestCost || 200,
|
tv: settings?.tvRequestCost || 1000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -108,11 +108,11 @@ router.post(
|
|||||||
// Calculate cost
|
// Calculate cost
|
||||||
let cost = 0;
|
let cost = 0;
|
||||||
if (mediaType === "movie") {
|
if (mediaType === "movie") {
|
||||||
cost = settings?.movieRequestCost || 100;
|
cost = settings?.movieRequestCost || 500;
|
||||||
} else if (mediaType === "tv") {
|
} else if (mediaType === "tv") {
|
||||||
cost = settings?.tvRequestCost || 200;
|
cost = settings?.tvRequestCost || 1000;
|
||||||
if (seasons && seasons.length > 1) {
|
if (seasons && seasons.length > 1) {
|
||||||
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 50);
|
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create request in Overseer
|
// Create request in Overseer first
|
||||||
const overseerRequest = await overseerClient.post("/request", {
|
const overseerRequest = await overseerClient.post("/request", {
|
||||||
mediaType,
|
mediaType,
|
||||||
mediaId,
|
mediaId,
|
||||||
@@ -147,11 +147,37 @@ router.post(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deduct credits immediately to prevent race condition
|
||||||
|
const transaction = await prisma.transaction.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: "SPEND",
|
||||||
|
amount: cost,
|
||||||
|
requestId: request.id,
|
||||||
|
description: `Request: ${title}`,
|
||||||
|
contentTitle: title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
totalSpent: { increment: cost },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit real-time spend update
|
||||||
|
io.to(`user:${user.id}`).emit("credits_spent", {
|
||||||
|
amount: cost,
|
||||||
|
title,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
request,
|
request,
|
||||||
cost,
|
cost,
|
||||||
message: "Request submitted. Credits will be deducted when approved.",
|
message: `Request submitted. ${cost} $COOP deducted.`,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -182,35 +208,31 @@ router.post(
|
|||||||
data: { status },
|
data: { status },
|
||||||
});
|
});
|
||||||
|
|
||||||
// If approved, deduct credits
|
// Credits were already deducted at request time.
|
||||||
if (status === "APPROVED" && !request.user.totalSpent) {
|
// On approval: just confirm and link transaction to request.
|
||||||
// Note: Actual burning would happen here
|
// On decline: refund credits back to user.
|
||||||
// For now, we just record the transaction
|
if (status === "DECLINED") {
|
||||||
|
const refundTransaction = await prisma.transaction.create({
|
||||||
const transaction = await prisma.transaction.create({
|
|
||||||
data: {
|
data: {
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
type: "SPEND",
|
type: "ADJUSTMENT",
|
||||||
amount: request.creditsCost,
|
amount: request.creditsCost,
|
||||||
requestId: request.id,
|
description: `Refunded: ${request.title}`,
|
||||||
description: `Request: ${request.title}`,
|
|
||||||
contentTitle: request.title,
|
contentTitle: request.title,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user stats
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: request.userId },
|
where: { id: request.userId },
|
||||||
data: {
|
data: {
|
||||||
totalSpent: { increment: request.creditsCost },
|
totalSpent: { decrement: request.creditsCost },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit update
|
io.to(`user:${request.userId}`).emit("bonus_received", {
|
||||||
io.to(`user:${request.userId}`).emit("credits_spent", {
|
|
||||||
amount: request.creditsCost,
|
amount: request.creditsCost,
|
||||||
title: request.title,
|
reason: `Refunded: ${request.title}`,
|
||||||
transaction,
|
transaction: refundTransaction,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+176
-171
@@ -1,204 +1,209 @@
|
|||||||
import { Router } from 'express';
|
import crypto from "crypto";
|
||||||
import { prisma } from '../utils/prisma';
|
import { Router } from "express";
|
||||||
import { mintTokens } from '../services/solana';
|
import { io } from "../index";
|
||||||
import { asyncHandler } from '../middleware/errorHandler';
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
import { io } from '../index';
|
import { mintTokens } from "../services/solana";
|
||||||
import crypto from 'crypto';
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || '';
|
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || "";
|
||||||
|
|
||||||
// Verify webhook signature
|
// Verify webhook signature
|
||||||
function verifyWebhookSignature(payload: string, signature: string): boolean {
|
function verifyWebhookSignature(payload: string, signature: string): boolean {
|
||||||
if (!WEBHOOK_SECRET) return true; // Skip verification if no secret set
|
if (!WEBHOOK_SECRET) return true; // Skip verification if no secret set
|
||||||
|
|
||||||
const expected = crypto
|
const expected = crypto
|
||||||
.createHmac('sha256', WEBHOOK_SECRET)
|
.createHmac("sha256", WEBHOOK_SECRET)
|
||||||
.update(payload)
|
.update(payload)
|
||||||
.digest('hex');
|
.digest("hex");
|
||||||
|
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||||
Buffer.from(signature),
|
|
||||||
Buffer.from(expected)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tautulli webhook endpoint
|
// Tautulli webhook endpoint
|
||||||
router.post('/tautulli',
|
router.post(
|
||||||
asyncHandler(async (req, res) => {
|
"/tautulli",
|
||||||
const webhookSignature = req.headers['x-tautulli-signature'] as string;
|
asyncHandler(async (req, res) => {
|
||||||
const payload = JSON.stringify(req.body);
|
const webhookSignature = req.headers["x-tautulli-signature"] as string;
|
||||||
|
const payload = JSON.stringify(req.body);
|
||||||
// Verify signature if configured
|
|
||||||
if (WEBHOOK_SECRET && webhookSignature && !verifyWebhookSignature(payload, webhookSignature)) {
|
|
||||||
return res.status(401).json({ error: 'Invalid signature' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = req.body;
|
// Verify signature if configured
|
||||||
|
if (
|
||||||
// Only process watched events
|
WEBHOOK_SECRET &&
|
||||||
if (event.action !== 'watched') {
|
webhookSignature &&
|
||||||
return res.json({ message: 'Event type not processed' });
|
!verifyWebhookSignature(payload, webhookSignature)
|
||||||
}
|
) {
|
||||||
|
return res.status(401).json({ error: "Invalid signature" });
|
||||||
|
}
|
||||||
|
|
||||||
// Validate required fields
|
const event = req.body;
|
||||||
if (!event.user_id || !event.rating_key || !event.session_key) {
|
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find user by Plex ID
|
// Only process watched events
|
||||||
const user = await prisma.user.findUnique({
|
if (event.action !== "watched") {
|
||||||
where: { plexId: event.user_id.toString() }
|
return res.json({ message: "Event type not processed" });
|
||||||
});
|
}
|
||||||
|
|
||||||
if (!user) {
|
// Validate required fields
|
||||||
console.log(`User not found for Plex ID: ${event.user_id}`);
|
if (!event.user_id || !event.rating_key || !event.session_key) {
|
||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(400).json({ error: "Missing required fields" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.walletAddress) {
|
// Find user by Plex ID
|
||||||
console.log(`User ${user.plexUsername} has no wallet`);
|
const user = await prisma.user.findUnique({
|
||||||
return res.status(400).json({ error: 'User has no wallet' });
|
where: { plexId: event.user_id.toString() },
|
||||||
}
|
});
|
||||||
|
|
||||||
// Check for duplicate events
|
if (!user) {
|
||||||
const existing = await prisma.watchEvent.findUnique({
|
console.log(`User not found for Plex ID: ${event.user_id}`);
|
||||||
where: { sessionId: event.session_key.toString() }
|
return res.status(404).json({ error: "User not found" });
|
||||||
});
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (!user.walletAddress) {
|
||||||
return res.json({ message: 'Event already processed' });
|
console.log(`User ${user.plexUsername} has no wallet`);
|
||||||
}
|
return res.status(400).json({ error: "User has no wallet" });
|
||||||
|
}
|
||||||
|
|
||||||
// Get system settings
|
// Check for duplicate events
|
||||||
const settings = await prisma.systemSettings.findFirst();
|
const existing = await prisma.watchEvent.findUnique({
|
||||||
const creditsPerMinute = settings?.creditsPerMinute || 10;
|
where: { sessionId: event.session_key.toString() },
|
||||||
const minWatchPercent = settings?.minWatchPercent || 80;
|
});
|
||||||
const minWatchMinutes = settings?.minWatchMinutes || 5;
|
|
||||||
|
|
||||||
// Calculate watch duration
|
if (existing) {
|
||||||
const watchDurationMinutes = Math.floor((event.stopped - event.started) / 60);
|
return res.json({ message: "Event already processed" });
|
||||||
const percentComplete = event.percent_complete || 0;
|
}
|
||||||
|
|
||||||
// Validate minimum requirements
|
// Get system settings
|
||||||
if (percentComplete < minWatchPercent) {
|
const settings = await prisma.systemSettings.findFirst();
|
||||||
return res.json({
|
const creditsPerMinute = settings?.creditsPerMinute || 2;
|
||||||
message: 'Watch percentage too low',
|
const minWatchPercent = settings?.minWatchPercent || 80;
|
||||||
percentComplete,
|
const minWatchMinutes = settings?.minWatchMinutes || 5;
|
||||||
required: minWatchPercent
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (watchDurationMinutes < minWatchMinutes) {
|
// Calculate watch duration
|
||||||
return res.json({
|
const watchDurationMinutes = Math.floor(
|
||||||
message: 'Watch duration too short',
|
(event.stopped - event.started) / 60,
|
||||||
watchDurationMinutes,
|
);
|
||||||
required: minWatchMinutes
|
const percentComplete = event.percent_complete || 0;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate credits
|
// Validate minimum requirements
|
||||||
let creditsEarned = watchDurationMinutes * creditsPerMinute;
|
if (percentComplete < minWatchPercent) {
|
||||||
|
return res.json({
|
||||||
// Apply multipliers
|
message: "Watch percentage too low",
|
||||||
if (settings?.newReleaseMultiplier && event.is_new) {
|
percentComplete,
|
||||||
creditsEarned = Math.floor(creditsEarned * Number(settings.newReleaseMultiplier));
|
required: minWatchPercent,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
if (settings?.bonusMultiplierActive) {
|
|
||||||
creditsEarned = Math.floor(creditsEarned * Number(settings.bonusMultiplier));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create watch event record
|
if (watchDurationMinutes < minWatchMinutes) {
|
||||||
const watchEvent = await prisma.watchEvent.create({
|
return res.json({
|
||||||
data: {
|
message: "Watch duration too short",
|
||||||
userId: user.id,
|
watchDurationMinutes,
|
||||||
sessionId: event.session_key.toString(),
|
required: minWatchMinutes,
|
||||||
ratingKey: event.rating_key.toString(),
|
});
|
||||||
contentType: event.media_type,
|
}
|
||||||
title: event.title,
|
|
||||||
grandparentTitle: event.grandparent_title,
|
|
||||||
duration: event.stopped - event.started,
|
|
||||||
percentComplete: Math.floor(percentComplete),
|
|
||||||
creditsEarned,
|
|
||||||
watchedAt: new Date(event.stopped * 1000)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mint tokens on Solana
|
// Calculate credits
|
||||||
const signature = await mintTokens(
|
let creditsEarned = watchDurationMinutes * creditsPerMinute;
|
||||||
user.walletAddress,
|
|
||||||
creditsEarned,
|
|
||||||
{
|
|
||||||
sessionId: event.session_key.toString(),
|
|
||||||
contentTitle: event.title,
|
|
||||||
watchDurationMinutes
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (signature) {
|
// Apply multipliers
|
||||||
// Create transaction record
|
if (settings?.newReleaseMultiplier && event.is_new) {
|
||||||
const transaction = await prisma.transaction.create({
|
creditsEarned = Math.floor(
|
||||||
data: {
|
creditsEarned * Number(settings.newReleaseMultiplier),
|
||||||
userId: user.id,
|
);
|
||||||
type: 'EARN',
|
}
|
||||||
amount: creditsEarned,
|
|
||||||
watchEventId: watchEvent.id,
|
|
||||||
solanaSignature: signature,
|
|
||||||
description: `Watched ${event.title}`,
|
|
||||||
contentTitle: event.title
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update user stats
|
if (settings?.bonusMultiplierActive) {
|
||||||
await prisma.user.update({
|
creditsEarned = Math.floor(
|
||||||
where: { id: user.id },
|
creditsEarned * Number(settings.bonusMultiplier),
|
||||||
data: {
|
);
|
||||||
totalEarned: { increment: creditsEarned },
|
}
|
||||||
watchTimeMinutes: { increment: watchDurationMinutes }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark watch event as processed
|
// Create watch event record
|
||||||
await prisma.watchEvent.update({
|
const watchEvent = await prisma.watchEvent.create({
|
||||||
where: { id: watchEvent.id },
|
data: {
|
||||||
data: { isProcessed: true }
|
userId: user.id,
|
||||||
});
|
sessionId: event.session_key.toString(),
|
||||||
|
ratingKey: event.rating_key.toString(),
|
||||||
|
contentType: event.media_type,
|
||||||
|
title: event.title,
|
||||||
|
grandparentTitle: event.grandparent_title,
|
||||||
|
duration: event.stopped - event.started,
|
||||||
|
percentComplete: Math.floor(percentComplete),
|
||||||
|
creditsEarned,
|
||||||
|
watchedAt: new Date(event.stopped * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Emit real-time update via WebSocket
|
// Mint tokens on Solana
|
||||||
io.to(`user:${user.id}`).emit('credits_earned', {
|
const signature = await mintTokens(user.walletAddress, creditsEarned, {
|
||||||
amount: creditsEarned,
|
sessionId: event.session_key.toString(),
|
||||||
title: event.title,
|
contentTitle: event.title,
|
||||||
transaction: {
|
watchDurationMinutes,
|
||||||
id: transaction.id,
|
});
|
||||||
type: 'EARN',
|
|
||||||
amount: creditsEarned,
|
|
||||||
contentTitle: event.title,
|
|
||||||
createdAt: transaction.createdAt
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
if (signature) {
|
||||||
success: true,
|
// Create transaction record
|
||||||
creditsEarned,
|
const transaction = await prisma.transaction.create({
|
||||||
solanaSignature: signature,
|
data: {
|
||||||
message: `Minted ${creditsEarned} COOP for watching ${event.title}`
|
userId: user.id,
|
||||||
});
|
type: "EARN",
|
||||||
} else {
|
amount: creditsEarned,
|
||||||
res.status(500).json({ error: 'Failed to mint tokens' });
|
watchEventId: watchEvent.id,
|
||||||
}
|
solanaSignature: signature,
|
||||||
})
|
description: `Watched ${event.title}`,
|
||||||
|
contentTitle: event.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user stats
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
totalEarned: { increment: creditsEarned },
|
||||||
|
watchTimeMinutes: { increment: watchDurationMinutes },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark watch event as processed
|
||||||
|
await prisma.watchEvent.update({
|
||||||
|
where: { id: watchEvent.id },
|
||||||
|
data: { isProcessed: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit real-time update via WebSocket
|
||||||
|
io.to(`user:${user.id}`).emit("credits_earned", {
|
||||||
|
amount: creditsEarned,
|
||||||
|
title: event.title,
|
||||||
|
transaction: {
|
||||||
|
id: transaction.id,
|
||||||
|
type: "EARN",
|
||||||
|
amount: creditsEarned,
|
||||||
|
contentTitle: event.title,
|
||||||
|
createdAt: transaction.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
creditsEarned,
|
||||||
|
solanaSignature: signature,
|
||||||
|
message: `Minted ${creditsEarned} COOP for watching ${event.title}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: "Failed to mint tokens" });
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test webhook endpoint
|
// Test webhook endpoint
|
||||||
router.post('/test',
|
router.post(
|
||||||
asyncHandler(async (req, res) => {
|
"/test",
|
||||||
res.json({
|
asyncHandler(async (req, res) => {
|
||||||
message: 'Webhook endpoint working',
|
res.json({
|
||||||
timestamp: new Date().toISOString(),
|
message: "Webhook endpoint working",
|
||||||
body: req.body
|
timestamp: new Date().toISOString(),
|
||||||
});
|
body: req.body,
|
||||||
})
|
});
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export { router as webhookRouter };
|
export { router as webhookRouter };
|
||||||
|
|||||||
Reference in New Issue
Block a user