From cd15c81b5302de8f90e988392bd1eccae3123693 Mon Sep 17 00:00:00 2001 From: Pierre Ryssen Date: Tue, 10 Mar 2026 15:14:14 +0100 Subject: [PATCH] feat: add the tiktok integration --- .env.example | 15 +- Dockerfile | 6 + app/api/auth/callback/tiktok/route.ts | 59 ++++++ app/api/tiktok/callback/route.ts | 51 +++++ app/api/tiktok/connect/route.ts | 34 ++++ app/api/tiktok/disconnect/route.ts | 30 +++ app/api/tiktok/stats/route.ts | 58 ++++++ app/tiktok/page.tsx | 186 ++++++++++++++++-- docker-compose.yml | 11 +- lib/auth.ts | 3 + lib/tiktok.ts | 150 ++++++++++++++ middleware.ts | 8 +- package-lock.json | 2 +- .../migration.sql | 67 +++++++ .../migration.sql | 12 ++ .../migration.sql | 8 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 37 +++- 18 files changed, 701 insertions(+), 39 deletions(-) create mode 100644 app/api/auth/callback/tiktok/route.ts create mode 100644 app/api/tiktok/callback/route.ts create mode 100644 app/api/tiktok/connect/route.ts create mode 100644 app/api/tiktok/disconnect/route.ts create mode 100644 app/api/tiktok/stats/route.ts create mode 100644 lib/tiktok.ts create mode 100644 prisma/migrations/20260309201830_add_tiktok_token/migration.sql create mode 100644 prisma/migrations/20260309203546_add_tiktok_pkce/migration.sql create mode 100644 prisma/migrations/20260309204000_add_userid_to_pkce/migration.sql create mode 100644 prisma/migrations/migration_lock.toml diff --git a/.env.example b/.env.example index 2ff7dc1..d8a7b42 100644 --- a/.env.example +++ b/.env.example @@ -9,15 +9,18 @@ # server with the `prisma dev` CLI command, when not choosing any non-default ports or settings. The API key, unlike the # one found in a remote Prisma Postgres URL, does not contain any sensitive information. +NODE_ENV=production + DATABASE_URL="postgresql://wyview:wyview@localhost:5432/wyview" NEXT_PUBLIC_PASSWORD=Azerty123 -NEXTAUTH_SECRET=unsecretaleatoire -NEXTAUTH_URL=http://localhost:3000 -AUTH_SECRET=secretaleatoire +NEXTAUTH_SECRET=*** +NEXTAUTH_URL=*vps_ip_address* +AUTH_SECRET=*** + # TikTok API credentials -TIKTOK_CLIENT_KEY=******* -TIKTOK_CLIENT_SECRET=******* +TIKTOK_CLIENT_KEY=*** +TIKTOK_CLIENT_SECRET=*** TIKTOK_REDIRECT_URI_DEV=http://localhost:3000/api/tiktok/callback -TIKTOK_REDIRECT_URI_PROD=https://marouette.fun/api/tiktok/callback +TIKTOK_REDIRECT_URI_PROD=*vps_ip_address*/api/tiktok/callback diff --git a/Dockerfile b/Dockerfile index 9fd9e63..17a6d73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,12 @@ WORKDIR /app ARG DATABASE_URL ENV DATABASE_URL=$DATABASE_URL +ARG AUTH_SECRET +ENV AUTH_SECRET=$AUTH_SECRET + +ARG NEXTAUTH_SECRET +ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET + # Copy package files COPY package*.json ./ COPY prisma.config.ts ./ diff --git a/app/api/auth/callback/tiktok/route.ts b/app/api/auth/callback/tiktok/route.ts new file mode 100644 index 0000000..138da24 --- /dev/null +++ b/app/api/auth/callback/tiktok/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { exchangeCodeForTokens } from "@/lib/tiktok"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + if (error || !code || !state) { + return NextResponse.redirect(new URL("/tiktok?error=access_denied", request.url)); + } + + try { + // Récupérer verifier + userId depuis la DB via le state + const pkceRecord = await prisma.tikTokPKCE.findUnique({ where: { state } }); + if (!pkceRecord) { + return NextResponse.redirect(new URL("/tiktok?error=missing_verifier", request.url)); + } + + // Supprimer le record PKCE (usage unique) + await prisma.tikTokPKCE.delete({ where: { state } }); + + const { userId, codeVerifier } = pkceRecord; + + const tokens = await exchangeCodeForTokens(code, codeVerifier); + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); + + await prisma.tikTokToken.upsert({ + where: { userId }, + create: { + userId, + openId: tokens.open_id, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt, + }, + update: { + openId: tokens.open_id, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt, + }, + }); + + const existing = await prisma.trackedAccount.findFirst({ where: { userId, platform: "tiktok" } }); + if (!existing) { + await prisma.trackedAccount.create({ + data: { userId, platform: "tiktok", username: tokens.open_id, accountId: tokens.open_id }, + }); + } + + return NextResponse.redirect(new URL("/tiktok?connected=1", request.url)); + } catch (err) { + console.error("[TikTok callback error]", err); + return NextResponse.redirect(new URL("/tiktok?error=token_exchange", request.url)); + } +} diff --git a/app/api/tiktok/callback/route.ts b/app/api/tiktok/callback/route.ts new file mode 100644 index 0000000..741c194 --- /dev/null +++ b/app/api/tiktok/callback/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { exchangeCodeForTokens } from "@/lib/tiktok"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + if (error || !code || !state) { + return NextResponse.redirect(new URL("/tiktok?error=access_denied", request.url)); + } + + try { + // userId stocké dans le record PKCE lors du /connect (pas besoin de session) + const pkceRecord = await prisma.tikTokPKCE.findUnique({ where: { state } }); + if (!pkceRecord) { + return NextResponse.redirect(new URL("/tiktok?error=missing_verifier", request.url)); + } + + await prisma.tikTokPKCE.delete({ where: { state } }); + + const { userId, codeVerifier } = pkceRecord; + + console.log("[TikTok callback] codeVerifier from DB:", codeVerifier); + console.log("[TikTok callback] code from TikTok:", code); + + const tokens = await exchangeCodeForTokens(code, codeVerifier); + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); + + await prisma.tikTokToken.upsert({ + where: { userId }, + create: { userId, openId: tokens.open_id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt }, + update: { openId: tokens.open_id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt }, + }); + + const existing = await prisma.trackedAccount.findFirst({ where: { userId, platform: "tiktok" } }); + if (!existing) { + await prisma.trackedAccount.create({ + data: { userId, platform: "tiktok", username: tokens.open_id, accountId: tokens.open_id }, + }); + } + + return NextResponse.redirect(new URL("/tiktok?connected=1", request.url)); + } catch (err) { + console.error("[TikTok callback error]", err); + return NextResponse.redirect(new URL("/tiktok?error=token_exchange", request.url)); + } +} + diff --git a/app/api/tiktok/connect/route.ts b/app/api/tiktok/connect/route.ts new file mode 100644 index 0000000..ff4b1f3 --- /dev/null +++ b/app/api/tiktok/connect/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { getTikTokAuthUrl, generateCodeVerifier, generateCodeChallenge } from "@/lib/tiktok"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = (session.user as { id?: string }).id; + if (!userId) { + return NextResponse.json({ error: "Session error" }, { status: 401 }); + } + + const state = crypto.randomUUID(); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + + console.log("[TikTok PKCE] verifier:", codeVerifier); + console.log("[TikTok PKCE] challenge:", codeChallenge); + console.log("[TikTok PKCE] verifier length:", codeVerifier.length); + + // Stocker state + verifier + userId en DB + await prisma.tikTokPKCE.create({ + data: { state, codeVerifier, userId }, + }); + + const authUrl = getTikTokAuthUrl(state, codeChallenge); + console.log("[TikTok PKCE] authUrl:", authUrl); + return NextResponse.redirect(authUrl); +} diff --git a/app/api/tiktok/disconnect/route.ts b/app/api/tiktok/disconnect/route.ts new file mode 100644 index 0000000..3634f73 --- /dev/null +++ b/app/api/tiktok/disconnect/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function POST() { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = (session.user as { id?: string }).id; + if (!userId) { + return NextResponse.json({ error: "Session error" }, { status: 401 }); + } + + try { + // Supprimer le token TikTok + await prisma.tikTokToken.deleteMany({ where: { userId } }); + + // Supprimer le TrackedAccount associé + await prisma.trackedAccount.deleteMany({ where: { userId, platform: "tiktok" } }); + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("[TikTok disconnect error]", err); + return NextResponse.json({ error: "Disconnect failed" }, { status: 500 }); + } +} + diff --git a/app/api/tiktok/stats/route.ts b/app/api/tiktok/stats/route.ts new file mode 100644 index 0000000..09f9520 --- /dev/null +++ b/app/api/tiktok/stats/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { fetchUserStats, refreshAccessToken } from "@/lib/tiktok"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = (session.user as { id?: string }).id; + if (!userId) { + return NextResponse.json({ error: "Session error" }, { status: 401 }); + } + + const tokenRecord = await prisma.tikTokToken.findUnique({ where: { userId } }); + + if (!tokenRecord) { + return NextResponse.json({ error: "Not connected" }, { status: 404 }); + } + + let { accessToken, refreshToken, openId, expiresAt } = tokenRecord; + + // Refresh si expiré (avec 60s de marge) + 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, + }, + }); + + 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 }); + } + } + + try { + const stats = await fetchUserStats(accessToken, openId); + return NextResponse.json(stats); + } catch (err) { + console.error("[TikTok stats fetch error]", err); + return NextResponse.json({ error: "Failed to fetch stats" }, { status: 500 }); + } +} + diff --git a/app/tiktok/page.tsx b/app/tiktok/page.tsx index e18329e..9f013f9 100644 --- a/app/tiktok/page.tsx +++ b/app/tiktok/page.tsx @@ -1,33 +1,179 @@ +"use client"; + +import { Music2, Link2, Unlink, Loader2, AlertTriangle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import StatCard from "@/components/StatCard"; -import { Music2 } from "lucide-react"; + +interface TikTokStats { + followers: number; + likes: number; + videoCount: number; + displayName: string; + avatarUrl: string; +} export default function TikTokPage() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [disconnecting, setDisconnecting] = useState(false); + const searchParams = useSearchParams(); + const router = useRouter(); + + async function loadStats() { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/tiktok/stats"); + if (res.status === 404) { + setStats(null); + } else if (!res.ok) { + const data = await res.json(); + setError(data.error ?? "Erreur inconnue"); + } else { + const data = await res.json(); + setStats(data); + } + } catch { + setError("Erreur réseau"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadStats(); + }, []); + + async function handleDisconnect() { + setDisconnecting(true); + try { + await fetch("/api/tiktok/disconnect", { method: "POST" }); + setStats(null); + router.replace("/tiktok"); + } finally { + setDisconnecting(false); + } + } + + const urlError = searchParams.get("error"); + return (
-
-
- -
-
-
Plateforme
-

TikTok

+ {/* Header */} +
+
+
+ +
+
+
Plateforme
+

TikTok

+
+ + {stats && !loading && ( + + )}
-
- - - - -
+ {/* Erreur URL */} + {urlError && ( +
+ + {urlError === "access_denied" && "Accès refusé par TikTok."} + {urlError === "token_exchange" && "Erreur lors de l'échange du token."} + {urlError === "session_error" && "Erreur de session."} + {!["access_denied", "token_exchange", "session_error"].includes(urlError) && `Erreur : ${urlError}`} +
+ )} -
- -

- CONNECTEZ VOTRE COMPTE TIKTOK
POUR AFFICHER VOS STATISTIQUES -

-
+ {/* Chargement */} + {loading && ( +
+ + Chargement... +
+ )} + + {/* Connecté : stats */} + {!loading && stats && ( + <> + {stats.displayName && ( +
+ {stats.avatarUrl && ( + avatar + )} + {stats.displayName} + Connecté +
+ )} + +
+ + + + 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—"} + sub="Moy. likes par vidéo" + accent="gold" + /> +
+ + )} + + {/* Non connecté */} + {!loading && !stats && !error && ( +
+ +

+ Connectez votre compte TikTok
pour afficher vos statistiques +

+ + + Connecter TikTok + +
+ )} + + {/* Erreur API */} + {!loading && error && ( +
+ +

{error}

+ +
+ )}
); } diff --git a/docker-compose.yml b/docker-compose.yml index 3ae6987..4b7bc34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,9 +31,14 @@ services: - "3001:3001" environment: - NODE_ENV=production - - DATABASE_URL=postgresql://wyview:wyview_password@postgres:5432/wyview - - NEXTAUTH_URL=http://localhost:3001 - - NEXTAUTH_SECRET=your-secret-key-change-this-in-production + - DATABASE_URL=${DATABASE_URL} + - NEXTAUTH_URL=${NEXTAUTH_URL} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - AUTH_SECRET=${AUTH_SECRET} + - TIKTOK_CLIENT_KEY=${TIKTOK_CLIENT_KEY} + - TIKTOK_CLIENT_SECRET=${TIKTOK_CLIENT_SECRET} + - TIKTOK_REDIRECT_URI_PROD=${TIKTOK_REDIRECT_URI_PROD} + - TIKTOK_REDIRECT_URI_DEV=${TIKTOK_REDIRECT_URI_DEV} depends_on: postgres: condition: service_healthy diff --git a/lib/auth.ts b/lib/auth.ts index 8c78429..c0afccb 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -32,6 +32,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ ], callbacks: { async session({ session, token }) { + if (token?.id && session.user) { + (session.user as { id?: string }).id = token.id as string; + } return session; }, async jwt({ token, user }) { diff --git a/lib/tiktok.ts b/lib/tiktok.ts new file mode 100644 index 0000000..c0a9750 --- /dev/null +++ b/lib/tiktok.ts @@ -0,0 +1,150 @@ +const TIKTOK_AUTH_URL = "https://www.tiktok.com/v2/auth/authorize"; +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!; + +function getRedirectUri(): string { + return process.env.NODE_ENV === "production" + ? process.env.TIKTOK_REDIRECT_URI_PROD! + : process.env.TIKTOK_REDIRECT_URI_DEV!; +} + +// ── PKCE helpers ────────────────────────────────────────────── +export function generateCodeVerifier(): string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + const array = new Uint8Array(64); + crypto.getRandomValues(array); + for (let i = 0; i < 64; i++) { + result += charset[array[i] % charset.length]; + } + return result; +} + +export async function generateCodeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier); + const digest = await globalThis.crypto.subtle.digest("SHA-256", data); + const bytes = Array.from(new Uint8Array(digest)); + return btoa(bytes.map((b) => String.fromCharCode(b)).join("")) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} +// ────────────────────────────────────────────────────────────── + +export function getTikTokAuthUrl(state: string, codeChallenge: string): string { + const params = new URLSearchParams({ + client_key: CLIENT_KEY, + response_type: "code", + scope: "user.info.basic,user.info.stats", + redirect_uri: getRedirectUri(), + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + return `${TIKTOK_AUTH_URL}?${params.toString()}`; +} + +export interface TikTokTokenResponse { + access_token: string; + refresh_token: string; + open_id: string; + expires_in: number; + refresh_expires_in: number; + token_type: string; + scope: string; + error?: string; + error_description?: string; +} + +export async function exchangeCodeForTokens(code: string, codeVerifier: string): Promise { + const body = new URLSearchParams({ + client_key: CLIENT_KEY, + client_secret: CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: getRedirectUri(), + code_verifier: codeVerifier, + }); + + 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 || "TikTok token exchange failed"); + } + + return data; +} + +export async function refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams({ + client_key: CLIENT_KEY, + client_secret: CLIENT_SECRET, + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + + 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 || "TikTok token refresh failed"); + } + + return data; +} + +export interface TikTokUserStats { + followers: number; + likes: number; + videoCount: number; + username: string; + displayName: string; + avatarUrl: string; +} + +export async function fetchUserStats(accessToken: string, openId: string): Promise { + const fields = "follower_count,following_count,likes_count,video_count,display_name,avatar_url"; + const url = `${TIKTOK_USER_INFO_URL}?fields=${fields}`; + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + const data = await res.json(); + + if (!res.ok || data.error?.code !== "ok") { + throw new Error(data.error?.message || "TikTok user info fetch failed"); + } + + const user = data.data?.user ?? {}; + + return { + followers: user.follower_count ?? 0, + likes: user.likes_count ?? 0, + videoCount: user.video_count ?? 0, + username: openId, + displayName: user.display_name ?? "", + avatarUrl: user.avatar_url ?? "", + }; +} + + + + + diff --git a/middleware.ts b/middleware.ts index 9d948e8..171ab7e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -7,7 +7,13 @@ export async function middleware(req: NextRequest) { const { pathname } = req.nextUrl; const isProtected = protectedPaths.some((p) => pathname.startsWith(p)); - const token = await getToken({ req, secret: process.env.AUTH_SECRET }); + const token = await getToken({ + req, + secret: process.env.AUTH_SECRET, + cookieName: process.env.NODE_ENV === "production" + ? "__Secure-authjs.session-token" + : "authjs.session-token" + }); if (isProtected && !token) { const loginUrl = new URL("/", req.url); diff --git a/package-lock.json b/package-lock.json index 0af6ddb..4909cf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.27", "bcryptjs": "^3.0.3", + "dotenv": "^16.4.7", "lucide-react": "^0.575.0", "next": "^15.5.12", "next-auth": "^5.0.0-beta.30", @@ -1545,7 +1546,6 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" diff --git a/prisma/migrations/20260309201830_add_tiktok_token/migration.sql b/prisma/migrations/20260309201830_add_tiktok_token/migration.sql new file mode 100644 index 0000000..8555a9b --- /dev/null +++ b/prisma/migrations/20260309201830_add_tiktok_token/migration.sql @@ -0,0 +1,67 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT, + "role" TEXT NOT NULL DEFAULT 'member', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TikTokToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "openId" TEXT NOT NULL, + "accessToken" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TikTokToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TrackedAccount" ( + "id" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "username" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TrackedAccount_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Snapshot" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "followers" INTEGER NOT NULL, + "views" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Snapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "TikTokToken_userId_key" ON "TikTokToken"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TikTokToken_openId_key" ON "TikTokToken"("openId"); + +-- AddForeignKey +ALTER TABLE "TikTokToken" ADD CONSTRAINT "TikTokToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TrackedAccount" ADD CONSTRAINT "TrackedAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Snapshot" ADD CONSTRAINT "Snapshot_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "TrackedAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260309203546_add_tiktok_pkce/migration.sql b/prisma/migrations/20260309203546_add_tiktok_pkce/migration.sql new file mode 100644 index 0000000..337b880 --- /dev/null +++ b/prisma/migrations/20260309203546_add_tiktok_pkce/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "TikTokPKCE" ( + "id" TEXT NOT NULL, + "state" TEXT NOT NULL, + "codeVerifier" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TikTokPKCE_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TikTokPKCE_state_key" ON "TikTokPKCE"("state"); diff --git a/prisma/migrations/20260309204000_add_userid_to_pkce/migration.sql b/prisma/migrations/20260309204000_add_userid_to_pkce/migration.sql new file mode 100644 index 0000000..0d6671a --- /dev/null +++ b/prisma/migrations/20260309204000_add_userid_to_pkce/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `userId` to the `TikTokPKCE` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "TikTokPKCE" ADD COLUMN "userId" TEXT NOT NULL; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7496a2e..c1ef6b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,14 +8,35 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - password String - name String? - role String @default("member") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - accounts TrackedAccount[] + id String @id @default(cuid()) + email String @unique + password String + name String? + role String @default("member") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accounts TrackedAccount[] + tiktokToken TikTokToken? +} + +model TikTokToken { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + openId String @unique + accessToken String + refreshToken String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model TikTokPKCE { + id String @id @default(cuid()) + state String @unique + codeVerifier String + userId String + createdAt DateTime @default(now()) } model TrackedAccount {