diff --git a/app/api/tiktok/snapshots/route.ts b/app/api/tiktok/snapshots/route.ts index e69de29..1ed2e54 100644 --- a/app/api/tiktok/snapshots/route.ts +++ b/app/api/tiktok/snapshots/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +const PLAN_RANK: Record = { free: 0, pro: 1, elite: 2, team: 3 }; + +const PERIOD_MIN_PLAN: Record = { + "7d": "free", + "30d": "pro", + "90d": "pro", + "all": "elite", +}; + +const PERIOD_DAYS: Record = { + "7d": 7, + "30d": 30, + "90d": 90, + "all": null, +}; + +// Plan max history cap (even for "all") +const PLAN_MAX_DAYS: Record = { + free: 7, + pro: 90, + elite: null, // illimité + team: null, +}; + +async function getAuthedUserId() { + const session = await auth(); + if (!session?.user) return null; + return (session.user as { id?: string }).id ?? null; +} + +export async function GET(req: NextRequest) { + const userId = await getAuthedUserId(); + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const period = req.nextUrl.searchParams.get("period") ?? "7d"; + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); + + const plan = (user as any).plan ?? "free"; + + // Vérif accès à la période demandée + const requiredPlan = PERIOD_MIN_PLAN[period] ?? "free"; + if (PLAN_RANK[plan] < PLAN_RANK[requiredPlan]) { + return NextResponse.json({ error: "Plan insuffisant pour cette période" }, { status: 403 }); + } + + const account = await prisma.trackedAccount.findFirst({ + where: { userId, platform: "tiktok" }, + }); + if (!account) return NextResponse.json([], { status: 200 }); + + // Calcul de la date de début + const periodDays = PERIOD_DAYS[period]; + const planMaxDays = PLAN_MAX_DAYS[plan]; + + let since: Date | undefined; + if (periodDays !== null) { + since = new Date(Date.now() - periodDays * 86_400_000); + } else if (planMaxDays !== null) { + since = new Date(Date.now() - planMaxDays * 86_400_000); + } + // sinon : illimité (team/elite + all) + + const snapshots = await prisma.snapshot.findMany({ + where: { + accountId: account.id, + ...(since ? { createdAt: { gte: since } } : {}), + }, + orderBy: { createdAt: "asc" }, + select: { + createdAt: true, + followers: true, + likes: true, + videoCount: true, + }, + }); + + return NextResponse.json(snapshots); +} + +export async function POST(req: NextRequest) { + const userId = await getAuthedUserId(); + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json(); + const { followers, likes, videoCount, displayName, openId } = body; + + if (followers === undefined) { + return NextResponse.json({ error: "Champ 'followers' requis" }, { status: 400 }); + } + + // Upsert TrackedAccount + let account = await prisma.trackedAccount.findFirst({ + where: { userId, platform: "tiktok" }, + }); + if (!account) { + account = await prisma.trackedAccount.create({ + data: { + userId, + platform: "tiktok", + username: displayName ?? openId ?? "unknown", + accountId: openId ?? userId, + }, + }); + } + + const snapshot = await prisma.snapshot.create({ + data: { + accountId: account.id, + followers: followers ?? 0, + likes: likes ?? 0, + videoCount: videoCount ?? 0, + views: 0, + }, + }); + + return NextResponse.json(snapshot, { status: 201 }); +} + diff --git a/app/api/tiktok/stats/route.ts b/app/api/tiktok/stats/route.ts index 4b7cc1f..b70e58f 100644 --- a/app/api/tiktok/stats/route.ts +++ b/app/api/tiktok/stats/route.ts @@ -16,12 +16,14 @@ export async function GET() { } if (process.env.NODE_ENV === "development") { + const user = await prisma.user.findUnique({ where: { id: userId } }); return NextResponse.json({ followers: 124, likes: 856, videoCount: 1, displayName: "CrowMate studio", avatarUrl: "", + plan: (user as any)?.plan ?? "free", }); } @@ -36,20 +38,16 @@ export async function GET() { if (expiresAt.getTime() - Date.now() < 60_000) { try { const refreshed = await refreshAccessToken(refreshToken); - const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000); - await prisma.tikTokToken.update({ where: { userId }, data: { accessToken: refreshed.access_token, refreshToken: refreshed.refresh_token, - expiresAt: newExpiresAt, + expiresAt: new Date(Date.now() + refreshed.expires_in * 1000), }, }); accessToken = refreshed.access_token; - refreshToken = refreshed.refresh_token; - expiresAt = newExpiresAt; } catch (err) { console.error("[TikTok stats refresh error]", err); return NextResponse.json({ error: "Token refresh failed" }, { status: 401 }); @@ -58,7 +56,38 @@ export async function GET() { try { const stats = await fetchUserStats(accessToken, openId); - return NextResponse.json(stats); + + // Upsert TrackedAccount + snapshot automatique + try { + let account = await prisma.trackedAccount.findFirst({ + where: { userId, platform: "tiktok" }, + }); + if (!account) { + account = await prisma.trackedAccount.create({ + data: { + userId, + platform: "tiktok", + username: stats.displayName ?? openId, + accountId: openId, + }, + }); + } + await prisma.snapshot.create({ + data: { + accountId: account.id, + followers: stats.followers ?? 0, + likes: stats.likes ?? 0, + videoCount: stats.videoCount ?? 0, + views: 0, + }, + }); + } catch (snapshotErr) { + console.error("[TikTok snapshot save error]", snapshotErr); + } + + // Inclure le plan dans la réponse + const user = await prisma.user.findUnique({ where: { id: userId } }); + return NextResponse.json({ ...stats, plan: (user as any)?.plan ?? "free" }); } catch (err) { console.error("[TikTok stats fetch error]", err); return NextResponse.json({ error: "Failed to fetch stats" }, { status: 500 }); diff --git a/components/StatsCharts.tsx b/components/StatsCharts.tsx index 63499e2..ade19c6 100644 --- a/components/StatsCharts.tsx +++ b/components/StatsCharts.tsx @@ -2,8 +2,6 @@ import { useState, useEffect } from "react"; import { - LineChart, - Line, XAxis, YAxis, CartesianGrid, @@ -72,8 +70,11 @@ function getDelta(snapshots: Snapshot[], metric: Metric) { function CustomTooltip({ active, payload, label, period }: any) { if (!active || !payload?.length) return null; return ( -
-
{formatDate(label, period)}
+
+
+ {formatDate(label, period)} +
{payload.map((p: any) => (
{p.name} @@ -131,15 +132,16 @@ export default function StatsChart({ plan }: StatsChartProps) { key={p.value} onClick={() => accessible && setPeriod(p.value)} disabled={!accessible} - className={` + className={` relative flex items-center gap-1 px-2.5 py-1 text-[9px] font-mono tracking-widest uppercase rounded-sm transition-all ${active ? "bg-pink-500/15 border border-pink-500/40 text-pink-300" : accessible - ? "border border-transparent text-white/30 hover:text-white/60 hover:border-white/10" - : "border border-transparent text-white/15 cursor-not-allowed" + ? "border border-transparent hover:border-white/10" + : "border border-transparent cursor-not-allowed opacity-30" } `} + style={!active && accessible ? { color: "var(--text-secondary)" } : undefined} > {!accessible && } {p.label} @@ -156,15 +158,15 @@ export default function StatsChart({ plan }: StatsChartProps) { key={m.value} onClick={() => setMetric(m.value)} className={`flex items-center gap-1.5 text-[9px] font-mono tracking-widest uppercase transition-all pb-0.5 ${ - metric === m.value - ? "border-b-2" - : "text-white/30 hover:text-white/60" + metric === m.value ? "border-b-2" : "" }`} - style={metric === m.value ? { color: m.color, borderColor: m.color } : {}} + style={metric === m.value + ? { color: m.color, borderColor: m.color } + : { color: "var(--text-secondary)" }} > {m.label} @@ -178,32 +180,33 @@ export default function StatsChart({ plan }: StatsChartProps) { ) : Number(delta.diff) < 0 ? ( ) : ( - + )} - 0 ? "#34d399" : Number(delta.diff) < 0 ? "#f87171" : "rgba(255,255,255,0.3)" }}> + 0 ? "#34d399" : Number(delta.diff) < 0 ? "#f87171" : "var(--text-secondary)" }}> {Number(delta.diff) > 0 ? "+" : ""}{formatValue(delta.diff)} {delta.pct && ` (${Number(delta.diff) > 0 ? "+" : ""}${delta.pct}%)`} - sur la période + sur la période
)}
{loading ? ( -
- Chargement... +
+ Chargement...
) : error ? ( -
+
{error}
) : snapshots.length < 2 ? (
- + Pas encore assez de données - + Les snapshots s'accumulent toutes les heures
@@ -218,27 +221,28 @@ export default function StatsChart({ plan }: StatsChartProps) { formatDate(d, period)} - tick={{ fill: "rgba(255,255,255,0.2)", fontSize: 9, fontFamily: "monospace" }} + tick={{ fill: "var(--text-secondary)", fontSize: 9, fontFamily: "monospace" }} tickLine={false} axisLine={false} interval="preserveStartEnd" /> } - cursor={{ stroke: "rgba(255,255,255,0.06)", strokeWidth: 1 }} + cursor={{ stroke: "var(--border)", strokeWidth: 1 }} /> =18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -1838,6 +2281,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2002,6 +2487,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -3202,6 +3700,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -3593,6 +4101,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 4147023..b1f2f90 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/pg": "^8.18.0", - "prisma": "^7.4.2" + "prisma": "^7.4.2", + "tsx": "^4.21.0" } } diff --git a/prisma/migrations/20260312103801_add_plan_and_snapshot_fields/migration.sql b/prisma/migrations/20260312103801_add_plan_and_snapshot_fields/migration.sql new file mode 100644 index 0000000..8d7224c --- /dev/null +++ b/prisma/migrations/20260312103801_add_plan_and_snapshot_fields/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "Snapshot" ADD COLUMN "likes" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "videoCount" INTEGER NOT NULL DEFAULT 0, +ALTER COLUMN "views" SET DEFAULT 0; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "plan" TEXT NOT NULL DEFAULT 'free'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1b16f84..9f37be2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,7 @@ model User { password String name String? role String @default("member") + plan String @default("free") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accounts TrackedAccount[] @@ -51,10 +52,12 @@ model TrackedAccount { } model Snapshot { - id String @id @default(cuid()) - accountId String - account TrackedAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) - followers Int - views Int - createdAt DateTime @default(now()) + id String @id @default(cuid()) + accountId String + account TrackedAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) + followers Int + likes Int @default(0) + videoCount Int @default(0) + views Int @default(0) + createdAt DateTime @default(now()) } \ No newline at end of file diff --git a/worker/Dockerfile b/worker/Dockerfile new file mode 100644 index 0000000..b6dadaf --- /dev/null +++ b/worker/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copie les fichiers nécessaires +COPY package*.json ./ +COPY prisma.config.ts ./ +COPY prisma ./prisma/ +COPY tsconfig.json ./ + +# Install deps +RUN npm ci + +# Génère le client Prisma dans app/generated/prisma (output défini dans schema.prisma) +RUN npx prisma generate + +# Copie le code du worker ET le client Prisma généré +COPY worker ./worker/ +COPY app/generated ./app/generated/ + +# Lance le worker avec tsx +CMD ["npx", "tsx", "worker/snapshot-worker.ts"] diff --git a/worker/snapshot-worker.ts b/worker/snapshot-worker.ts new file mode 100644 index 0000000..0e30a15 --- /dev/null +++ b/worker/snapshot-worker.ts @@ -0,0 +1,172 @@ +/** + * Snapshot Worker + * Tourne toutes les heures, indépendamment de Next.js. + * Pour chaque user avec un token TikTok valide : + * 1. Rafraîchit le token si nécessaire + * 2. Récupère les stats TikTok + * 3. Sauvegarde un snapshot en base (max 1 par heure par compte) + */ + +import { PrismaClient } from "../app/generated/prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/"; +const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/"; +const CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY!; +const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!; +const INTERVAL_MS = 60 * 60 * 1000; // 1 heure +const DEDUP_WINDOW_MS = 55 * 60 * 1000; // 55 min — évite les doublons si restart + +// ── Graceful shutdown ───────────────────────────────────────── + +async function shutdown() { + console.log("[worker] arrêt propre..."); + await prisma.$disconnect(); + await pool.end(); + process.exit(0); +} +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +// ── TikTok helpers ──────────────────────────────────────────── + +async function refreshTikTokToken(refreshTokenStr: string) { + const body = new URLSearchParams({ + client_key: CLIENT_KEY, + client_secret: CLIENT_SECRET, + grant_type: "refresh_token", + refresh_token: refreshTokenStr, + }); + + const res = await fetch(TIKTOK_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + const data = await res.json(); + + if (!res.ok || data.error) { + throw new Error(data.error_description ?? data.error ?? "Refresh failed"); + } + return data as { access_token: string; refresh_token: string; expires_in: number }; +} + +async function fetchTikTokStats(accessToken: string) { + const fields = "follower_count,likes_count,video_count,display_name"; + const res = await fetch(`${TIKTOK_USER_INFO_URL}?fields=${fields}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const data = await res.json(); + + if (!res.ok || data.error?.code !== "ok") { + throw new Error(data.error?.message ?? "Stats fetch failed"); + } + + const user = data.data?.user ?? {}; + return { + followers: (user.follower_count ?? 0) as number, + likes: (user.likes_count ?? 0) as number, + videoCount: (user.video_count ?? 0) as number, + displayName: (user.display_name ?? "") as string, + }; +} + +// ── Core job ────────────────────────────────────────────────── + +async function runSnapshots() { + console.log(`[worker] ${new Date().toISOString()} — début du run`); + + const tokens = await prisma.tikTokToken.findMany({ + include: { user: { include: { accounts: { where: { platform: "tiktok" } } } } }, + }); + + console.log(`[worker] ${tokens.length} compte(s) TikTok à traiter`); + + for (const token of tokens) { + const { userId, openId } = token; + let { accessToken, refreshToken: rt, expiresAt } = token; + + try { + // 1. Refresh si nécessaire + if (expiresAt.getTime() - Date.now() < 60_000) { + console.log(`[worker] refresh token userId=${userId}`); + const refreshed = await refreshTikTokToken(rt); + await prisma.tikTokToken.update({ + where: { userId }, + data: { + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token, + expiresAt: new Date(Date.now() + refreshed.expires_in * 1000), + }, + }); + accessToken = refreshed.access_token; + } + + // 2. Upsert TrackedAccount + let account = token.user.accounts[0] ?? null; + if (!account) { + const stats0 = await fetchTikTokStats(accessToken); + account = await prisma.trackedAccount.create({ + data: { userId, platform: "tiktok", username: stats0.displayName || openId, accountId: openId }, + }); + } + + // 3. Déduplication — skip si snapshot < 55 min + const lastSnapshot = await prisma.snapshot.findFirst({ + where: { accountId: account.id }, + orderBy: { createdAt: "desc" }, + }); + if (lastSnapshot && Date.now() - lastSnapshot.createdAt.getTime() < DEDUP_WINDOW_MS) { + console.log(`[worker] skip userId=${userId} — snapshot trop récent (${Math.round((Date.now() - lastSnapshot.createdAt.getTime()) / 60_000)}min)`); + continue; + } + + // 4. Fetch stats + const stats = await fetchTikTokStats(accessToken); + + // 5. Sauvegarde snapshot + await prisma.snapshot.create({ + data: { + accountId: account.id, + followers: stats.followers, + likes: stats.likes, + videoCount: stats.videoCount, + views: 0, + }, + }); + + console.log(`[worker] ✓ userId=${userId} — followers=${stats.followers} likes=${stats.likes} videos=${stats.videoCount}`); + + } catch (err) { + console.error(`[worker] ✗ erreur userId=${userId}:`, err); + // Non bloquant — on continue avec le user suivant + } + } + + console.log(`[worker] ${new Date().toISOString()} — run terminé`); +} + +// ── Loop principale ─────────────────────────────────────────── + +async function main() { + console.log(`[worker] démarrage — intervalle ${INTERVAL_MS / 60_000}min`); + + // Run immédiat au démarrage + await runSnapshots().catch(err => console.error("[worker] erreur:", err)); + + // Puis toutes les heures + setInterval(() => { + runSnapshots().catch(err => console.error("[worker] erreur:", err)); + }, INTERVAL_MS); +} + +main().catch(err => { + console.error("[worker] crash fatal:", err); + process.exit(1); +}); +