feat/TikTok-graph #1
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ dist/
|
|||||||
build/
|
build/
|
||||||
/app/generated/prisma
|
/app/generated/prisma
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
deploy.sh
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { exchangeCodeForTokens } from "@/lib/tiktok";
|
import { exchangeCodeForTokens } from "@/lib/tiktok";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || "https://marouette.fun";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const code = searchParams.get("code");
|
const code = searchParams.get("code");
|
||||||
@@ -9,14 +11,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const error = searchParams.get("error");
|
const error = searchParams.get("error");
|
||||||
|
|
||||||
if (error || !code || !state) {
|
if (error || !code || !state) {
|
||||||
return NextResponse.redirect(new URL("/tiktok?error=access_denied", request.url));
|
return NextResponse.redirect(`${baseUrl}/tiktok?error=access_denied`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// userId stocké dans le record PKCE lors du /connect (pas besoin de session)
|
|
||||||
const pkceRecord = await prisma.tikTokPKCE.findUnique({ where: { state } });
|
const pkceRecord = await prisma.tikTokPKCE.findUnique({ where: { state } });
|
||||||
if (!pkceRecord) {
|
if (!pkceRecord) {
|
||||||
return NextResponse.redirect(new URL("/tiktok?error=missing_verifier", request.url));
|
return NextResponse.redirect(`${baseUrl}/tiktok?error=missing_verifier`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.tikTokPKCE.delete({ where: { state } });
|
await prisma.tikTokPKCE.delete({ where: { state } });
|
||||||
@@ -29,23 +30,48 @@ export async function GET(request: NextRequest) {
|
|||||||
const tokens = await exchangeCodeForTokens(code, codeVerifier);
|
const tokens = await exchangeCodeForTokens(code, codeVerifier);
|
||||||
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||||
|
|
||||||
|
// Upsert sur userId — plusieurs users peuvent partager le même compte TikTok
|
||||||
await prisma.tikTokToken.upsert({
|
await prisma.tikTokToken.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
create: { userId, openId: tokens.open_id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt },
|
update: {
|
||||||
update: { openId: tokens.open_id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt },
|
openId: tokens.open_id,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
openId: tokens.open_id,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const existing = await prisma.trackedAccount.findFirst({ where: { userId, platform: "tiktok" } });
|
// TrackedAccount : update si existe, sinon create
|
||||||
if (!existing) {
|
const existing = await prisma.trackedAccount.findFirst({
|
||||||
|
where: { userId, platform: "tiktok" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.trackedAccount.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { username: tokens.open_id, accountId: tokens.open_id },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await prisma.trackedAccount.create({
|
await prisma.trackedAccount.create({
|
||||||
data: { userId, platform: "tiktok", username: tokens.open_id, accountId: tokens.open_id },
|
data: {
|
||||||
|
userId,
|
||||||
|
platform: "tiktok",
|
||||||
|
username: tokens.open_id,
|
||||||
|
accountId: tokens.open_id,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.redirect(new URL("/tiktok?connected=1", request.url));
|
return NextResponse.redirect(`${baseUrl}/tiktok?connected=1`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TikTok callback error]", err);
|
console.error("[TikTok callback error]", err);
|
||||||
return NextResponse.redirect(new URL("/tiktok?error=token_exchange", request.url));
|
return NextResponse.redirect(`${baseUrl}/tiktok?error=token_exchange`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ services:
|
|||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- AUTH_URL=${AUTH_URL}
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export function getTikTokAuthUrl(state: string, codeChallenge: string): string {
|
|||||||
state,
|
state,
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
|
// "select_account" seul est ignoré par TikTok si une session est active.
|
||||||
|
// On passe les deux pour forcer l'affichage du sélecteur de compte à chaque fois.
|
||||||
|
prompt: "select_account",
|
||||||
|
force_login: "true",
|
||||||
});
|
});
|
||||||
return `${TIKTOK_AUTH_URL}?${params.toString()}`;
|
return `${TIKTOK_AUTH_URL}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
@@ -142,9 +146,4 @@ export async function fetchUserStats(accessToken: string, openId: string): Promi
|
|||||||
displayName: user.display_name ?? "",
|
displayName: user.display_name ?? "",
|
||||||
avatarUrl: user.avatar_url ?? "",
|
avatarUrl: user.avatar_url ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "TikTokToken_openId_key";
|
||||||
@@ -23,7 +23,7 @@ model TikTokToken {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
openId String @unique
|
openId String
|
||||||
accessToken String
|
accessToken String
|
||||||
refreshToken String
|
refreshToken String
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
|
|||||||
Reference in New Issue
Block a user