From 2c71ee73cecc8278eb14d5cb5072fd804216303c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Thu, 25 Dec 2025 02:53:26 +0300 Subject: [PATCH] feat(api): add daily generation credits reset cron job and user settings --- .env.example | 3 + .github/workflows/reset-credits.yml | 29 +++++++ messages/en.json | 10 ++- .../migration.sql | 4 + prisma/schema.prisma | 3 + src/app/api/admin/users/[id]/route.ts | 16 +++- src/app/api/admin/users/route.ts | 2 + src/app/api/cron/reset-credits/route.ts | 66 +++++++++++++++ src/app/api/media-generate/route.ts | 55 +++++++++++++ src/components/admin/users-table.tsx | 80 ++++++++++++++++++- 10 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/reset-credits.yml create mode 100644 prisma/migrations/20251225000000_add_generation_credits/migration.sql create mode 100644 src/app/api/cron/reset-credits/route.ts diff --git a/.env.example b/.env.example index 167b1f06..225a2b75 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/reset-credits.yml b/.github/workflows/reset-credits.yml new file mode 100644 index 00000000..9d8167a0 --- /dev/null +++ b/.github/workflows/reset-credits.yml @@ -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" diff --git a/messages/en.json b/messages/en.json index d57e5c3d..ab416fd0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/prisma/migrations/20251225000000_add_generation_credits/migration.sql b/prisma/migrations/20251225000000_add_generation_credits/migration.sql new file mode 100644 index 00000000..2dcd670d --- /dev/null +++ b/prisma/migrations/20251225000000_add_generation_credits/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8a5bbeb8..76816bb8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index c0708d34..99ec5b92 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -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, }, }); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 1018153f..cb88e593 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -102,6 +102,8 @@ export async function GET(request: NextRequest) { flagged: true, flaggedAt: true, flaggedReason: true, + dailyGenerationLimit: true, + generationCreditsRemaining: true, createdAt: true, _count: { select: { diff --git a/src/app/api/cron/reset-credits/route.ts b/src/app/api/cron/reset-credits/route.ts new file mode 100644 index 00000000..802f6cff --- /dev/null +++ b/src/app/api/cron/reset-credits/route.ts @@ -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); +} diff --git a/src/app/api/media-generate/route.ts b/src/app/api/media-generate/route.ts index 2e6ee3f4..e99814c9 100644 --- a/src/app/api/media-generate/route.ts +++ b/src/app/api/media-generate/route.ts @@ -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, diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index f1e5b372..2447fe7e 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -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(null); + const [editCreditsUser, setEditCreditsUser] = useState(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 ( <>
@@ -295,6 +328,10 @@ export function UsersTable() { {user.flagged ? t("unflag") : t("flag")} + handleEditCredits(user)}> + + {t("editCredits")} + {user.flagged ? t("unflag") : t("flag")} + handleEditCredits(user)}> + + {t("editCredits")} + + + {/* Edit Credits Dialog */} + setEditCreditsUser(null)}> + + + {t("editCreditsTitle")} + + {t("editCreditsDescription", { username: editCreditsUser?.username || "" })} + + +
+
+ + setNewCreditLimit(e.target.value)} + placeholder="10" + /> +

+ {t("currentCredits", { + remaining: editCreditsUser?.generationCreditsRemaining ?? 0, + limit: editCreditsUser?.dailyGenerationLimit ?? 0 + })} +

+
+
+ + {t("cancel")} + + {loading ? : null} + {t("save")} + + +
+
); }