feat(api): add daily generation credits reset cron job and user settings

This commit is contained in:
Fatih Kadir Akın
2025-12-25 02:53:26 +03:00
parent 87a34b0e8b
commit 2c71ee73ce
10 changed files with 265 additions and 3 deletions

View File

@@ -43,6 +43,9 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
# Logging (optional)
# 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)
# WIRO_API_KEY=your_wiro_api_key
# WIRO_VIDEO_MODELS="google/veo3.1-fast" # Comma-separated list of video models

29
.github/workflows/reset-credits.yml vendored Normal file
View File

@@ -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"

View File

@@ -547,7 +547,15 @@
"unflag": "Unflag User",
"flagged": "User flagged",
"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": {
"title": "Category Management",

View File

@@ -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);

View File

@@ -26,6 +26,9 @@ model User {
flagged Boolean @default(false)
flaggedAt DateTime?
flaggedReason String?
dailyGenerationLimit Int @default(10)
generationCreditsRemaining Int @default(10)
generationCreditsResetAt DateTime?
accounts Account[]
subscriptions CategorySubscription[]
changeRequests ChangeRequest[]

View File

@@ -15,7 +15,7 @@ export async function PATCH(
const { id } = await params;
const body = await request.json();
const { role, verified, flagged, flaggedReason } = body;
const { role, verified, flagged, flaggedReason, dailyGenerationLimit } = body;
// Build update data
const updateData: {
@@ -24,6 +24,8 @@ export async function PATCH(
flagged?: boolean;
flaggedAt?: Date | null;
flaggedReason?: string | null;
dailyGenerationLimit?: number;
generationCreditsRemaining?: number;
} = {};
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({
where: { id },
data: updateData,
@@ -62,6 +74,8 @@ export async function PATCH(
flagged: true,
flaggedAt: true,
flaggedReason: true,
dailyGenerationLimit: true,
generationCreditsRemaining: true,
createdAt: true,
},
});

View File

@@ -102,6 +102,8 @@ export async function GET(request: NextRequest) {
flagged: true,
flaggedAt: true,
flaggedReason: true,
dailyGenerationLimit: true,
generationCreditsRemaining: true,
createdAt: true,
_count: {
select: {

View File

@@ -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);
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import {
initializeMediaGenerators,
getMediaGeneratorPlugin,
@@ -20,10 +21,25 @@ export async function GET() {
const imageModels = getAvailableModels("image");
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({
available,
imageModels,
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 {
// 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 { prompt, model, provider, type, inputImageUrl, resolution, aspectRatio } = body;
@@ -72,6 +117,16 @@ export async function POST(request: NextRequest) {
aspectRatio,
});
// Deduct one credit after successful generation start
await db.user.update({
where: { id: session.user.id },
data: {
generationCreditsRemaining: {
decrement: 1,
},
},
});
return NextResponse.json({
success: true,
taskId: task.taskId,

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTranslations, useLocale } from "next-intl";
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -54,6 +54,8 @@ interface UserData {
flagged: boolean;
flaggedAt: string | null;
flaggedReason: string | null;
dailyGenerationLimit: number;
generationCreditsRemaining: number;
createdAt: string;
_count: {
prompts: number;
@@ -73,6 +75,8 @@ export function UsersTable() {
const tCommon = useTranslations("common");
const locale = useLocale();
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
const [editCreditsUser, setEditCreditsUser] = useState<UserData | null>(null);
const [newCreditLimit, setNewCreditLimit] = useState("");
const [loading, setLoading] = useState(false);
// 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 (
<>
<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" />
{user.flagged ? t("unflag") : t("flag")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEditCredits(user)}>
<Sparkles className="h-4 w-4 mr-2" />
{t("editCredits")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
@@ -393,6 +430,10 @@ export function UsersTable() {
<Flag className="h-4 w-4 mr-2" />
{user.flagged ? t("unflag") : t("flag")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEditCredits(user)}>
<Sparkles className="h-4 w-4 mr-2" />
{t("editCredits")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
@@ -466,6 +507,43 @@ export function UsersTable() {
</AlertDialogFooter>
</AlertDialogContent>
</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>
</>
);
}