feat: add the tiktok integration

This commit is contained in:
Pierre Ryssen
2026-03-10 15:14:14 +01:00
parent 0dca836cf5
commit cd15c81b53
18 changed files with 701 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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

View File

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