feat(api): add daily generation credits reset cron job and user settings
This commit is contained in:
@@ -43,6 +43,9 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
|
|||||||
# Logging (optional)
|
# Logging (optional)
|
||||||
# LOG_LEVEL="info" # Options: trace, debug, info, warn, error, fatal
|
# LOG_LEVEL="info" # Options: trace, debug, info, warn, error, fatal
|
||||||
|
|
||||||
|
# Cron Job Secret (for daily credit reset)
|
||||||
|
CRON_SECRET="IkD/VzyrCRc6c/146TjKhIzOZ9HFq+Meo00y+wQpws8="
|
||||||
|
|
||||||
# Media Generation - Wiro.ai (optional)
|
# Media Generation - Wiro.ai (optional)
|
||||||
# WIRO_API_KEY=your_wiro_api_key
|
# WIRO_API_KEY=your_wiro_api_key
|
||||||
# WIRO_VIDEO_MODELS="google/veo3.1-fast" # Comma-separated list of video models
|
# WIRO_VIDEO_MODELS="google/veo3.1-fast" # Comma-separated list of video models
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: Reset Daily Generation Credits
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run at midnight UTC every day
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
reset-credits:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Reset daily generation credits
|
||||||
|
run: |
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
"https://prompts.chat/api/cron/reset-credits" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
echo "Response: $body"
|
||||||
|
echo "HTTP Code: $http_code"
|
||||||
|
|
||||||
|
if [ "$http_code" != "200" ]; then
|
||||||
|
echo "Failed to reset credits"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Credits reset successfully"
|
||||||
+9
-1
@@ -547,7 +547,15 @@
|
|||||||
"unflag": "Unflag User",
|
"unflag": "Unflag User",
|
||||||
"flagged": "User flagged",
|
"flagged": "User flagged",
|
||||||
"unflagged": "User unflagged",
|
"unflagged": "User unflagged",
|
||||||
"flagFailed": "Failed to update flag status"
|
"flagFailed": "Failed to update flag status",
|
||||||
|
"editCredits": "Edit Credits",
|
||||||
|
"editCreditsTitle": "Edit Generation Credits",
|
||||||
|
"editCreditsDescription": "Set daily generation credit limit for @{username}",
|
||||||
|
"dailyLimit": "Daily Credit Limit",
|
||||||
|
"currentCredits": "Currently: {remaining}/{limit} credits remaining",
|
||||||
|
"creditsUpdated": "Credits updated successfully",
|
||||||
|
"creditsUpdateFailed": "Failed to update credits",
|
||||||
|
"save": "Save"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Category Management",
|
"title": "Category Management",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "dailyGenerationLimit" INTEGER NOT NULL DEFAULT 10;
|
||||||
|
ALTER TABLE "users" ADD COLUMN "generationCreditsRemaining" INTEGER NOT NULL DEFAULT 10;
|
||||||
|
ALTER TABLE "users" ADD COLUMN "generationCreditsResetAt" TIMESTAMP(3);
|
||||||
@@ -26,6 +26,9 @@ model User {
|
|||||||
flagged Boolean @default(false)
|
flagged Boolean @default(false)
|
||||||
flaggedAt DateTime?
|
flaggedAt DateTime?
|
||||||
flaggedReason String?
|
flaggedReason String?
|
||||||
|
dailyGenerationLimit Int @default(10)
|
||||||
|
generationCreditsRemaining Int @default(10)
|
||||||
|
generationCreditsResetAt DateTime?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
subscriptions CategorySubscription[]
|
subscriptions CategorySubscription[]
|
||||||
changeRequests ChangeRequest[]
|
changeRequests ChangeRequest[]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { role, verified, flagged, flaggedReason } = body;
|
const { role, verified, flagged, flaggedReason, dailyGenerationLimit } = body;
|
||||||
|
|
||||||
// Build update data
|
// Build update data
|
||||||
const updateData: {
|
const updateData: {
|
||||||
@@ -24,6 +24,8 @@ export async function PATCH(
|
|||||||
flagged?: boolean;
|
flagged?: boolean;
|
||||||
flaggedAt?: Date | null;
|
flaggedAt?: Date | null;
|
||||||
flaggedReason?: string | null;
|
flaggedReason?: string | null;
|
||||||
|
dailyGenerationLimit?: number;
|
||||||
|
generationCreditsRemaining?: number;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (role !== undefined) {
|
if (role !== undefined) {
|
||||||
@@ -48,6 +50,16 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dailyGenerationLimit !== undefined) {
|
||||||
|
const limit = parseInt(dailyGenerationLimit, 10);
|
||||||
|
if (isNaN(limit) || limit < 0) {
|
||||||
|
return NextResponse.json({ error: "Invalid daily generation limit" }, { status: 400 });
|
||||||
|
}
|
||||||
|
updateData.dailyGenerationLimit = limit;
|
||||||
|
// Also reset remaining credits to the new limit
|
||||||
|
updateData.generationCreditsRemaining = limit;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await db.user.update({
|
const user = await db.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
@@ -62,6 +74,8 @@ export async function PATCH(
|
|||||||
flagged: true,
|
flagged: true,
|
||||||
flaggedAt: true,
|
flaggedAt: true,
|
||||||
flaggedReason: true,
|
flaggedReason: true,
|
||||||
|
dailyGenerationLimit: true,
|
||||||
|
generationCreditsRemaining: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export async function GET(request: NextRequest) {
|
|||||||
flagged: true,
|
flagged: true,
|
||||||
flaggedAt: true,
|
flaggedAt: true,
|
||||||
flaggedReason: true,
|
flaggedReason: true,
|
||||||
|
dailyGenerationLimit: true,
|
||||||
|
generationCreditsRemaining: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron job endpoint to reset daily generation credits for all users.
|
||||||
|
* Should be called daily via external cron service (e.g., Vercel Cron, GitHub Actions).
|
||||||
|
*
|
||||||
|
* Requires CRON_SECRET environment variable for authentication.
|
||||||
|
*
|
||||||
|
* Cron Notation: 0 0 * * * (Every day at midnight)
|
||||||
|
*
|
||||||
|
* Example cron call:
|
||||||
|
* curl -X POST https://your-domain.com/api/cron/reset-credits \
|
||||||
|
* -H "Authorization: Bearer YOUR_CRON_SECRET"
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// Verify the secret
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
const cronSecret = process.env.CRON_SECRET;
|
||||||
|
|
||||||
|
if (!cronSecret) {
|
||||||
|
console.error("CRON_SECRET is not configured");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Cron secret not configured" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedSecret = authHeader?.replace("Bearer ", "");
|
||||||
|
|
||||||
|
if (providedSecret !== cronSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reset generation credits for all users to their daily limit
|
||||||
|
const result = await db.$executeRaw`
|
||||||
|
UPDATE users
|
||||||
|
SET "generationCreditsRemaining" = "dailyGenerationLimit",
|
||||||
|
"generationCreditsResetAt" = NOW()
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`Daily credit reset completed. Updated ${result} users.`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Daily generation credits reset successfully",
|
||||||
|
usersUpdated: result,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resetting daily credits:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to reset credits" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also support GET for simple health checks / manual triggers via browser
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return POST(request);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
initializeMediaGenerators,
|
initializeMediaGenerators,
|
||||||
getMediaGeneratorPlugin,
|
getMediaGeneratorPlugin,
|
||||||
@@ -20,10 +21,25 @@ export async function GET() {
|
|||||||
const imageModels = getAvailableModels("image");
|
const imageModels = getAvailableModels("image");
|
||||||
const videoModels = getAvailableModels("video");
|
const videoModels = getAvailableModels("video");
|
||||||
|
|
||||||
|
// Get user's credit info
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: {
|
||||||
|
generationCreditsRemaining: true,
|
||||||
|
dailyGenerationLimit: true,
|
||||||
|
flagged: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
available,
|
available,
|
||||||
imageModels,
|
imageModels,
|
||||||
videoModels,
|
videoModels,
|
||||||
|
credits: {
|
||||||
|
remaining: user?.generationCreditsRemaining ?? 0,
|
||||||
|
daily: user?.dailyGenerationLimit ?? 0,
|
||||||
|
},
|
||||||
|
canGenerate: !user?.flagged && (user?.generationCreditsRemaining ?? 0) > 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +51,35 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Check user's credits and flagged status
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: {
|
||||||
|
generationCreditsRemaining: true,
|
||||||
|
flagged: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block flagged users
|
||||||
|
if (user.flagged) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Your account has been flagged. Media generation is disabled." },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check credits
|
||||||
|
if (user.generationCreditsRemaining <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No generation credits remaining. Credits reset daily." },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { prompt, model, provider, type, inputImageUrl, resolution, aspectRatio } = body;
|
const { prompt, model, provider, type, inputImageUrl, resolution, aspectRatio } = body;
|
||||||
|
|
||||||
@@ -72,6 +117,16 @@ export async function POST(request: NextRequest) {
|
|||||||
aspectRatio,
|
aspectRatio,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deduct one credit after successful generation start
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: {
|
||||||
|
generationCreditsRemaining: {
|
||||||
|
decrement: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
taskId: task.taskId,
|
taskId: task.taskId,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { formatDistanceToNow } from "@/lib/date";
|
import { formatDistanceToNow } from "@/lib/date";
|
||||||
import { MoreHorizontal, Shield, User, Trash2, BadgeCheck, Search, Loader2, ChevronLeft, ChevronRight, Filter, Flag, AlertTriangle } from "lucide-react";
|
import { MoreHorizontal, Shield, User, Trash2, BadgeCheck, Search, Loader2, ChevronLeft, ChevronRight, Filter, Flag, AlertTriangle, Sparkles } from "lucide-react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -54,6 +54,8 @@ interface UserData {
|
|||||||
flagged: boolean;
|
flagged: boolean;
|
||||||
flaggedAt: string | null;
|
flaggedAt: string | null;
|
||||||
flaggedReason: string | null;
|
flaggedReason: string | null;
|
||||||
|
dailyGenerationLimit: number;
|
||||||
|
generationCreditsRemaining: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
_count: {
|
_count: {
|
||||||
prompts: number;
|
prompts: number;
|
||||||
@@ -73,6 +75,8 @@ export function UsersTable() {
|
|||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
|
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
|
||||||
|
const [editCreditsUser, setEditCreditsUser] = useState<UserData | null>(null);
|
||||||
|
const [newCreditLimit, setNewCreditLimit] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Pagination and search state
|
// Pagination and search state
|
||||||
@@ -198,6 +202,35 @@ export function UsersTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditCredits = (user: UserData) => {
|
||||||
|
setEditCreditsUser(user);
|
||||||
|
setNewCreditLimit(user.dailyGenerationLimit.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCredits = async () => {
|
||||||
|
if (!editCreditsUser) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/users/${editCreditsUser.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ dailyGenerationLimit: newCreditLimit }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to update credits");
|
||||||
|
|
||||||
|
toast.success(t("creditsUpdated"));
|
||||||
|
fetchUsers(currentPage, searchQuery, userFilter);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("creditsUpdateFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setEditCreditsUser(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
@@ -295,6 +328,10 @@ export function UsersTable() {
|
|||||||
<Flag className="h-4 w-4 mr-2" />
|
<Flag className="h-4 w-4 mr-2" />
|
||||||
{user.flagged ? t("unflag") : t("flag")}
|
{user.flagged ? t("unflag") : t("flag")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleEditCredits(user)}>
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
{t("editCredits")}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
@@ -393,6 +430,10 @@ export function UsersTable() {
|
|||||||
<Flag className="h-4 w-4 mr-2" />
|
<Flag className="h-4 w-4 mr-2" />
|
||||||
{user.flagged ? t("unflag") : t("flag")}
|
{user.flagged ? t("unflag") : t("flag")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleEditCredits(user)}>
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
{t("editCredits")}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
@@ -466,6 +507,43 @@ export function UsersTable() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Edit Credits Dialog */}
|
||||||
|
<AlertDialog open={!!editCreditsUser} onOpenChange={() => setEditCreditsUser(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("editCreditsTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("editCreditsDescription", { username: editCreditsUser?.username || "" })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">{t("dailyLimit")}</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={newCreditLimit}
|
||||||
|
onChange={(e) => setNewCreditLimit(e.target.value)}
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("currentCredits", {
|
||||||
|
remaining: editCreditsUser?.generationCreditsRemaining ?? 0,
|
||||||
|
limit: editCreditsUser?.dailyGenerationLimit ?? 0
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleSaveCredits} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
{t("save")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user