feat(tiktok): publier les reels sur les comptes TikTok des magasins

Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo
sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule
publication.

Un compte TikTok vit dans social_pages comme une page Facebook : il hérite
donc des permissions par utilisateur, de la planification et du calendrier.

Trois particularités de l'API TikTok structurent l'implémentation :
- pas de jeton saisi à la main : chaque compte passe par OAuth, et
  l'access token (24h) est renouvelé via le refresh token (1 an) avant
  chaque publication ;
- la publication est asynchrone : l'upload rend un publish_id, et un
  poller récupère l'identifiant définitif du post ;
- creator_info doit être interrogé avant chaque envoi pour ne demander
  qu'un niveau de confidentialité réellement autorisé sur le compte.

Serveur :
- service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks,
  suivi de statut) et routes de connexion/configuration
- schéma : platform 'tiktok', refresh token et scopes sur social_pages,
  publish_id/publish_status sur scheduled_posts, table tiktok_config
- routage par plateforme dans les deux flux de reels et le planificateur
- cron de suivi des publications, token manager et analytics adaptés

Client :
- connexion et reconnexion des comptes TikTok depuis « Pages gérées »
- sélection combinée pages Facebook / comptes TikTok à la publication
- configuration de l'application TikTok dans les paramètres (admin)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
This commit is contained in:
Claude committed 2026-08-11 14:29:38 +00:00
1 parent 2188fa458d
commit b95cd23ebc
27 files changed
+1679 -183

No files matched your search

+11
View File
@@ -2,8 +2,19 @@ import cron from 'node-cron';
import { TokenManager } from './services/token_manager';
import { AnalyticsService } from './services/analytics';
import { purgeOldMedia } from './services/media-purge';
import { tiktokService } from './services/tiktok';
export function startTokenCron() {
// Toutes les 2 minutes — suivi des publications TikTok (API asynchrone)
cron.schedule('*/2 * * * *', async () => {
try {
await tiktokService.syncPendingPublications();
} catch (error) {
console.error('[Cron] TikTok publish status sync failed:', error);
}
});
console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).');
// Run every day at midnight (00:00) — Token health check
cron.schedule('0 0 * * *', async () => {
console.log('[Cron] Running daily token check...');
+30
View File
@@ -17,6 +17,9 @@ export async function migrate() {
// Add 'reel' to post_type enum if not exists
await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`);
// Add 'tiktok' to platform enum if not exists
await client.query(`ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';`);
// 2. Create Tables if not exist
// post_analytics
@@ -158,6 +161,33 @@ export async function migrate() {
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
`);
// tiktok_config (application développeur TikTok, globale)
await client.query(`
CREATE TABLE IF NOT EXISTS "tiktok_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_key" text NOT NULL,
"client_secret" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
`);
// social_pages : champs OAuth TikTok
await client.query(`
ALTER TABLE "social_pages"
ADD COLUMN IF NOT EXISTS "refresh_token" text,
ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp,
ADD COLUMN IF NOT EXISTS "scopes" text,
ADD COLUMN IF NOT EXISTS "avatar_url" text;
`);
// scheduled_posts : suivi de la publication asynchrone TikTok
await client.query(`
ALTER TABLE "scheduled_posts"
ADD COLUMN IF NOT EXISTS "publish_id" text,
ADD COLUMN IF NOT EXISTS "publish_status" text;
`);
console.log("[Migration] Safe migration completed.");
} catch (error) {
console.error("[Migration] Error during migration:", error);
+14 -1
View File
@@ -16,6 +16,7 @@ import { analyticsRouter } from "./routes/analytics";
import { reelsRouter } from "./routes/reels";
import { remotionRouter } from "./routes/remotion";
import { externalRouter } from "./routes/external";
import { tiktokRouter } from "./routes/tiktok";
import { insertAudioTrackSchema } from "@shared/schema";
import * as musicMetadata from "music-metadata";
@@ -458,6 +459,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Analytics Routes
app.use("/api/analytics", requireAuth, analyticsRouter);
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
app.use("/api/tiktok", requireAuth, tiktokRouter);
// Reels & Music Routes
app.use("/api", requireAuth, reelsRouter);
app.use("/api/remotion", requireAuth, remotionRouter);
@@ -1565,6 +1569,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User;
const userId = user.id;
// TikTok n'autorise pas la saisie manuelle d'un token : le compte doit
// passer par le flux OAuth (/api/tiktok/connect).
if (req.body?.platform === 'tiktok') {
return res.status(400).json({
error: "Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.",
});
}
// Calculate token expiration date (60 days from now)
const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
@@ -1598,7 +1610,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
// If accessToken is being updated, recalculate expiration date (60 days from now)
// AND reset the status to valid so the UI updates immediately
if (parsedData.accessToken) {
// (les tokens TikTok ont leur propre cycle de vie, géré par le service OAuth)
if (parsedData.accessToken && existingPage.platform !== 'tiktok') {
const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
pageData = {
+39 -15
View File
@@ -7,6 +7,7 @@ import type { User, Media, SocialPage } from '@shared/schema';
import { storage } from '../storage';
import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { tiktokService } from '../services/tiktok';
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
import { openRouterService } from '../services/openrouter';
import { db } from '../db';
@@ -642,8 +643,12 @@ async function processReelBackground(
continue;
}
if (page.platform !== 'facebook') {
results.push({ pageId, success: false, error: 'Seules les pages Facebook sont supportées' });
if (page.platform !== 'facebook' && page.platform !== 'tiktok') {
results.push({
pageId,
success: false,
error: `Plateforme non supportée pour les reels : ${page.platform}`,
});
continue;
}
@@ -656,23 +661,42 @@ async function processReelBackground(
});
if (!scheduledFor) {
// Publication immédiate
console.log(`🚀 [Background] Publishing to Page ${page.pageName}...`);
// Publication immédiate — la même vidéo part sur chaque destination
console.log(`🚀 [Background] Publishing to ${page.platform} ${page.pageName}...`);
const finalDescription = description || overlayText || '';
const reelId = await facebookService.publishReel(
page,
cloudinaryResult.originalUrl,
finalDescription
);
if (page.platform === 'facebook') {
const reelId = await facebookService.publishReel(
page,
cloudinaryResult.originalUrl,
finalDescription
);
// Mise à jour succès
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
// Mise à jour succès
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
results.push({ pageId, success: true, reelId });
results.push({ pageId, success: true, reelId });
} else {
const publishId = await tiktokService.publishVideoFromBuffer(
page,
videoBuffer,
finalDescription
);
// TikTok finalise la publication de son côté : on marque l'envoi
// comme effectué pour ne pas republier, et le poller de statut
// renseignera l'identifiant définitif du post.
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: 'PROCESSING_UPLOAD',
});
results.push({ pageId, success: true, reelId: publishId });
}
} else {
// Planifié
results.push({ pageId, success: true, reelId: 'scheduled' });
+23 -6
View File
@@ -10,6 +10,7 @@ import { ffmpegService } from "../services/ffmpeg";
import { storage as dbStorage } from "../storage";
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
import { facebookService } from "../services/facebook";
import { tiktokService } from "../services/tiktok";
import * as musicMetadata from "music-metadata";
const execAsync = promisify(exec);
@@ -427,7 +428,10 @@ remotionRouter.post("/publish", async (req, res) => {
try {
const page = await dbStorage.getSocialPage(pageId);
if (!page) { results.push({ pageId, success: false, error: "Page introuvable" }); continue; }
if (page.platform !== "facebook") { results.push({ pageId, success: false, error: "Seules les pages Facebook sont supportées" }); continue; }
if (page.platform !== "facebook" && page.platform !== "tiktok") {
results.push({ pageId, success: false, error: `Plateforme non supportée pour les reels : ${page.platform}` });
continue;
}
const scheduledPost = await dbStorage.createScheduledPost({
postId: post.id,
@@ -437,12 +441,25 @@ remotionRouter.post("/publish", async (req, res) => {
});
if (!scheduledFor) {
console.log(`🚀 Publishing to ${page.pageName} (direct binary upload)...`);
// Upload video bytes directly — Facebook cannot access local URLs
console.log(`🚀 Publishing to ${page.platform} ${page.pageName} (direct binary upload)...`);
// Upload video bytes directly — les APIs ne peuvent pas lire une URL locale
const videoBuffer = await fs.promises.readFile(localPath);
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
results.push({ pageId, success: true, reelId });
if (page.platform === "facebook") {
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
results.push({ pageId, success: true, reelId });
} else {
// TikTok finalise la publication de son côté : le poller de statut
// renseignera l'identifiant définitif du post.
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description || "");
await dbStorage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: "PROCESSING_UPLOAD",
});
results.push({ pageId, success: true, reelId: publishId });
}
} else {
results.push({ pageId, success: true, reelId: "scheduled" });
}
+226
View File
@@ -0,0 +1,226 @@
/**
* Routes de gestion des comptes TikTok.
*
* Un compte TikTok de magasin se connecte en OAuth (TikTok n'autorise pas la
* saisie manuelle d'un token), puis vit dans `social_pages` comme une page
* Facebook : mêmes permissions, même planification, même calendrier.
*/
import crypto from 'crypto';
import { Router, Request, Response } from 'express';
import type { User } from '@shared/schema';
import { storage } from '../storage';
import { tiktokService } from '../services/tiktok';
import { insertTiktokConfigSchema, updateTiktokConfigSchema } from '@shared/schema';
export const tiktokRouter = Router();
/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */
const RETURN_PATH = '/pages';
function isAdmin(req: Request): boolean {
const user = req.user as User | undefined;
return user?.role === 'admin';
}
function buildReturnUrl(status: 'success' | 'error', message: string): string {
const params = new URLSearchParams({ tiktok: status, message });
return `${RETURN_PATH}?${params.toString()}`;
}
/**
* État de la configuration TikTok.
* Le client secret n'est jamais renvoyé au client.
*/
tiktokRouter.get('/config', async (req: Request, res: Response) => {
try {
const config = await storage.getTiktokConfig();
const configured = await tiktokService.isConfigured();
// Tout le monde a besoin de savoir si la connexion est possible ; seuls les
// admins voient les détails de l'application développeur.
if (!isAdmin(req)) {
return res.json({ configured });
}
res.json({
configured,
clientKey: config?.clientKey || (process.env.TIKTOK_CLIENT_KEY ? '(défini par variable d\'environnement)' : ''),
hasClientSecret: !!(config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET),
redirectUri: tiktokService.getRedirectUri(),
});
} catch (error) {
console.error('❌ [TikTok] Erreur lecture configuration:', error);
res.status(500).json({ error: 'Impossible de lire la configuration TikTok' });
}
});
/** Enregistre les identifiants de l'application développeur TikTok (admin). */
tiktokRouter.put('/config', async (req: Request, res: Response) => {
try {
if (!isAdmin(req)) {
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
}
const existing = await storage.getTiktokConfig();
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
const schema = existing ? updateTiktokConfigSchema : insertTiktokConfigSchema;
const data = schema.parse(req.body);
const config = await storage.upsertTiktokConfig(data);
res.json({
success: true,
clientKey: config.clientKey,
redirectUri: tiktokService.getRedirectUri(),
});
} catch (error: any) {
console.error('❌ [TikTok] Erreur enregistrement configuration:', error);
res.status(400).json({
error: error?.errors?.[0]?.message || error?.message || 'Configuration TikTok invalide',
});
}
});
/**
* Démarre la connexion d'un compte TikTok.
* À appeler par navigation complète du navigateur (window.location), pas en fetch.
*/
tiktokRouter.get('/connect', async (req: Request, res: Response) => {
try {
if (!(await tiktokService.isConfigured())) {
return res.redirect(
buildReturnUrl('error', "TikTok n'est pas configuré. Renseignez d'abord le client key et le client secret.")
);
}
// Protection CSRF : l'état est vérifié au retour
const state = crypto.randomBytes(16).toString('hex');
(req.session as any).tiktokOAuthState = state;
const authUrl = await tiktokService.buildAuthorizationUrl(state);
// La session doit être écrite avant la redirection, sinon l'état est perdu
req.session.save((err) => {
if (err) {
console.error('❌ [TikTok] Impossible de sauvegarder la session OAuth:', err);
return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.'));
}
res.redirect(authUrl);
});
} catch (error) {
console.error('❌ [TikTok] Erreur démarrage OAuth:', error);
res.redirect(
buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion TikTok')
);
}
});
/**
* Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le
* compte dans social_pages.
*/
tiktokRouter.get('/callback', async (req: Request, res: Response) => {
const user = req.user as User;
const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query;
try {
if (oauthError) {
const description = (oauthErrorDescription as string) || (oauthError as string);
return res.redirect(buildReturnUrl('error', `Autorisation TikTok refusée : ${description}`));
}
const expectedState = (req.session as any)?.tiktokOAuthState;
delete (req.session as any)?.tiktokOAuthState;
if (!state || !expectedState || state !== expectedState) {
return res.redirect(buildReturnUrl('error', 'Requête de connexion TikTok invalide (état non reconnu).'));
}
if (!code || typeof code !== 'string') {
return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de TikTok."));
}
const tokens = await tiktokService.exchangeCodeForToken(code);
const profile = await tiktokService.getUserInfo(tokens.access_token);
const openId = profile.openId || tokens.open_id;
// Un compte déjà connecté est mis à jour plutôt que dupliqué
const existingPages = await storage.getSocialPages(user.id);
const existing = existingPages.find(p => p.platform === 'tiktok' && p.pageId === openId);
if (existing) {
await storage.updateSocialPage(existing.id, {
pageName: profile.displayName,
avatarUrl: profile.avatarUrl || null,
isActive: 'true',
});
await tiktokService.persistTokens(existing.id, tokens);
console.log(`🔁 [TikTok] Compte reconnecté: ${profile.displayName}`);
return res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" reconnecté.`));
}
const page = await storage.createSocialPage({
userId: user.id,
platform: 'tiktok',
pageId: openId,
pageName: profile.displayName,
accessToken: tokens.access_token,
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
refreshToken: tokens.refresh_token,
refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000),
scopes: tokens.scope,
avatarUrl: profile.avatarUrl || null,
isActive: 'true',
});
// Les non-admins ne voient que les pages qui leur sont explicitement
// attribuées : sans cette permission, ils ne verraient pas leur propre compte.
if (user.role !== 'admin') {
try {
await storage.createPagePermission({ userId: user.id, pageId: page.id });
} catch (permissionError) {
console.error('⚠️ [TikTok] Impossible de créer la permission de page:', permissionError);
}
}
console.log(`✅ [TikTok] Nouveau compte connecté: ${profile.displayName} (${openId})`);
res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" connecté.`));
} catch (error) {
console.error('❌ [TikTok] Erreur callback OAuth:', error);
res.redirect(
buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion TikTok impossible')
);
}
});
/**
* Options réellement autorisées sur un compte (confidentialité, commentaires,
* duo, stitch, durée max). TikTok impose de les afficher avant publication.
*/
tiktokRouter.get('/accounts/:id/creator-info', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const page = await storage.getSocialPage(req.params.id);
if (!page || page.platform !== 'tiktok') {
return res.status(404).json({ error: 'Compte TikTok non trouvé' });
}
if (user.role !== 'admin' && page.userId !== user.id) {
const accessible = await storage.getUserAccessiblePages(user.id);
if (!accessible.some(p => p.id === page.id)) {
return res.status(403).json({ error: 'Accès refusé à ce compte' });
}
}
const creatorInfo = await tiktokService.getCreatorInfo(page);
res.json(creatorInfo);
} catch (error) {
console.error('❌ [TikTok] Erreur récupération creator info:', error);
res.status(502).json({
error: error instanceof Error ? error.message : 'Impossible de contacter TikTok',
});
}
});
+5 -1
View File
@@ -48,6 +48,8 @@ export class AnalyticsService {
for (const instance of publishedInstances) {
if (!instance.externalPostId || !instance.page) continue;
// Les métriques TikTok ne sont pas exposées par la Graph API
if (instance.page.platform === 'tiktok') continue;
try {
// We need the page access token
@@ -278,7 +280,9 @@ export class AnalyticsService {
*/
static async syncAllPages(): Promise<void> {
console.log('[AnalyticsService] Starting sync for all pages...');
const allPages = await db.query.socialPages.findMany();
// Les statistiques passent par la Graph API : les comptes TikTok en sont exclus
const allPages = (await db.query.socialPages.findMany())
.filter(page => page.platform !== 'tiktok');
for (const page of allPages) {
try {
+21
View File
@@ -40,6 +40,27 @@ export function resolveInternalUrl(url: string): string {
return `${base}${url}`;
}
/**
* Charge un média en mémoire, que son URL soit locale (/uploads/...) ou distante.
* Nécessaire pour les APIs qui veulent recevoir le fichier plutôt qu'une URL
* (upload TikTok, Reels Facebook).
*/
export async function readMediaBuffer(url: string): Promise<Buffer> {
if (url.startsWith('/')) {
const localPath = path.join(process.cwd(), url);
if (fs.existsSync(localPath)) {
return await fs.promises.readFile(localPath);
}
}
const absoluteUrl = resolvePublicUrl(url);
const response = await fetch(absoluteUrl);
if (!response.ok) {
throw new Error(`Impossible de récupérer le média depuis ${absoluteUrl} (${response.status})`);
}
return Buffer.from(await response.arrayBuffer());
}
class LocalStorageService {
async uploadMedia(
file: Buffer | string,
+45 -13
View File
@@ -1,7 +1,8 @@
import cron, { ScheduledTask } from "node-cron";
import { storage } from "../storage";
import { facebookService } from "./facebook";
import { minioService as storageService } from "./minio";
import { tiktokService } from "./tiktok";
import { minioService as storageService, readMediaBuffer } from "./minio";
export class SchedulerService {
private task: ScheduledTask | null = null;
@@ -94,11 +95,28 @@ export class SchedulerService {
return;
}
// Publish to Facebook/Instagram
let externalPostId: string;
// Publish to Facebook/Instagram/TikTok
if (page.platform === 'facebook') {
externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList);
const externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList);
// Update the scheduled post as published (clear any previous error)
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId,
error: null,
});
} else if (page.platform === 'tiktok') {
const publishId = await this.publishToTiktok(post, page, mediaList);
// TikTok publie de façon asynchrone : on marque l'envoi comme effectué pour
// ne pas republier à la minute suivante, et le poller de statut renseignera
// l'identifiant définitif du post.
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: 'PROCESSING_UPLOAD',
error: null,
});
} else if (page.platform === 'instagram') {
// Instagram publishing not yet implemented
console.warn(`Instagram publishing not yet implemented for post ${post.id}`);
@@ -113,13 +131,6 @@ export class SchedulerService {
throw new Error(`Unsupported platform: ${page.platform}`);
}
// Update the scheduled post as published (clear any previous error)
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId,
error: null,
});
// Update post status
await storage.updatePost(post.id, {
status: "published",
@@ -143,7 +154,28 @@ export class SchedulerService {
}
}
console.log(`Successfully published post ${post.id} with external ID: ${externalPostId}`);
console.log(`Successfully published post ${post.id} to ${page.platform} page ${page.pageName}`);
}
/**
* Envoie la vidéo d'un post planifié sur un compte TikTok.
* TikTok ne sait pas programmer une publication : c'est notre planificateur qui
* déclenche l'envoi à l'heure voulue.
*
* @returns le publish_id à suivre pour connaître le résultat final
*/
private async publishToTiktok(post: any, page: any, mediaList: any[]): Promise<string> {
const videoMedia = mediaList.find(m => m.type === 'video');
if (!videoMedia) {
throw new Error(
`Le compte TikTok "${page.pageName}" ne peut recevoir que des vidéos : ce post n'en contient pas.`
);
}
const videoBuffer = await readMediaBuffer(videoMedia.originalUrl);
return await tiktokService.publishVideoFromBuffer(page, videoBuffer, post.content);
}
}
+568
View File
@@ -0,0 +1,568 @@
/**
* Service de publication TikTok (Content Posting API v2).
*
* Différences structurantes avec Facebook, qui expliquent la forme de ce service :
* - Pas de token collable à la main : chaque compte magasin passe par OAuth.
* L'access token ne vit que 24h et doit être renouvelé avec le refresh token
* (valable 1 an) avant chaque publication.
* - La publication est asynchrone : l'upload rend un `publish_id`, et l'identifiant
* définitif du post n'est connu qu'en interrogeant /status/fetch/ ensuite.
* - `creator_info/query` doit être appelé avant chaque publication pour connaître
* les niveaux de confidentialité réellement autorisés sur le compte.
*
* Note : tant que l'application n'a pas passé l'audit TikTok, le seul niveau de
* confidentialité disponible est SELF_ONLY (vidéo visible du seul propriétaire du
* compte). Le code s'adapte automatiquement à ce que le compte autorise.
*/
import { storage } from '../storage';
import type { SocialPage } from '@shared/schema';
const AUTH_BASE_URL = 'https://www.tiktok.com/v2/auth/authorize/';
const API_BASE_URL = 'https://open.tiktokapis.com/v2';
/** Scopes demandés à l'autorisation. `video.publish` nécessite l'audit TikTok. */
const DEFAULT_SCOPES = 'user.info.basic,video.publish';
/** TikTok impose des chunks entre 5 Mo et 64 Mo. */
const MAX_SINGLE_CHUNK_SIZE = 64 * 1024 * 1024;
const CHUNK_SIZE = 32 * 1024 * 1024;
/** Longueur maximale de la légende d'une vidéo TikTok. */
const MAX_TITLE_LENGTH = 2200;
/** Renouvelle le token si sa fin de validité est à moins de 5 minutes. */
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
export interface TiktokCreatorInfo {
creatorAvatarUrl?: string;
creatorUsername?: string;
creatorNickname?: string;
privacyLevelOptions: string[];
commentDisabled: boolean;
duetDisabled: boolean;
stitchDisabled: boolean;
maxVideoPostDurationSec?: number;
}
export interface TiktokPublishOptions {
privacyLevel?: string;
disableComment?: boolean;
disableDuet?: boolean;
disableStitch?: boolean;
}
export interface TiktokPublishStatus {
status: string;
failReason?: string;
postId?: string;
}
interface TiktokTokenResponse {
access_token: string;
expires_in: number;
open_id: string;
refresh_token: string;
refresh_expires_in: number;
scope: string;
token_type: string;
}
export class TiktokService {
/**
* Identifiants de l'application développeur TikTok (globaux à l'instance).
* La base prime sur les variables d'environnement.
*/
private async getCredentials(): Promise<{ clientKey: string; clientSecret: string }> {
const config = await storage.getTiktokConfig();
const clientKey = config?.clientKey || process.env.TIKTOK_CLIENT_KEY;
const clientSecret = config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET;
if (!clientKey || !clientSecret) {
throw new Error(
"TikTok n'est pas configuré. Renseignez le client key et le client secret " +
"de votre application TikTok dans les paramètres."
);
}
return { clientKey, clientSecret };
}
async isConfigured(): Promise<boolean> {
try {
await this.getCredentials();
return true;
} catch {
return false;
}
}
getRedirectUri(): string {
if (process.env.TIKTOK_REDIRECT_URI) {
return process.env.TIKTOK_REDIRECT_URI;
}
const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
return `${base}/api/tiktok/callback`;
}
/**
* URL d'autorisation à ouvrir pour connecter le compte TikTok d'un magasin.
* Chaque magasin répète ce flux : un compte = une ligne dans social_pages.
*/
async buildAuthorizationUrl(state: string): Promise<string> {
const { clientKey } = await this.getCredentials();
const params = new URLSearchParams({
client_key: clientKey,
scope: process.env.TIKTOK_SCOPES || DEFAULT_SCOPES,
response_type: 'code',
redirect_uri: this.getRedirectUri(),
state,
});
return `${AUTH_BASE_URL}?${params.toString()}`;
}
/** Échange le code d'autorisation contre un couple access/refresh token. */
async exchangeCodeForToken(code: string): Promise<TiktokTokenResponse> {
const { clientKey, clientSecret } = await this.getCredentials();
return await this.requestToken({
client_key: clientKey,
client_secret: clientSecret,
// TikTok renvoie le code URL-encodé dans la query string
code: decodeURIComponent(code),
grant_type: 'authorization_code',
redirect_uri: this.getRedirectUri(),
});
}
/** Renouvelle un access token expiré. TikTok rend aussi un nouveau refresh token. */
async refreshAccessToken(refreshToken: string): Promise<TiktokTokenResponse> {
const { clientKey, clientSecret } = await this.getCredentials();
return await this.requestToken({
client_key: clientKey,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
}
private async requestToken(body: Record<string, string>): Promise<TiktokTokenResponse> {
const response = await fetch(`${API_BASE_URL}/oauth/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cache-Control': 'no-cache',
},
body: new URLSearchParams(body).toString(),
});
const raw = await response.text();
let data: any;
try {
data = JSON.parse(raw);
} catch {
throw new Error(`Réponse TikTok illisible (${response.status}): ${raw.slice(0, 200)}`);
}
// L'endpoint OAuth signale les erreurs avec `error` + `error_description`
if (!response.ok || data.error) {
const description = data.error_description || data.error || raw.slice(0, 200);
throw new Error(`Erreur OAuth TikTok: ${description}`);
}
if (!data.access_token) {
throw new Error("Réponse TikTok inattendue : aucun access token reçu");
}
return data as TiktokTokenResponse;
}
/**
* Récupère un access token valide pour une page, en le renouvelant si besoin.
* Le nouveau couple de tokens est persisté immédiatement : le refresh token
* TikTok est à usage unique, le perdre déconnecterait le compte.
*/
async getValidAccessToken(page: SocialPage): Promise<string> {
if (page.platform !== 'tiktok') {
throw new Error('Ce service ne gère que les comptes TikTok');
}
const expiresAt = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : 0;
const stillValid = expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now();
if (stillValid && page.accessToken) {
return page.accessToken;
}
if (!page.refreshToken) {
throw new Error(
`Le compte TikTok "${page.pageName}" doit être reconnecté (aucun refresh token enregistré).`
);
}
console.log(`🔄 [TikTok] Renouvellement du token pour ${page.pageName}`);
let tokens: TiktokTokenResponse;
try {
tokens = await this.refreshAccessToken(page.refreshToken);
} catch (error) {
await storage.updateSocialPage(page.id, {
tokenStatus: 'expired',
lastTokenCheck: new Date(),
});
throw new Error(
`Impossible de renouveler le token TikTok de "${page.pageName}". ` +
`Le compte doit être reconnecté. (${error instanceof Error ? error.message : 'erreur inconnue'})`
);
}
await this.persistTokens(page.id, tokens);
return tokens.access_token;
}
/** Enregistre un couple de tokens fraîchement obtenu sur une page. */
async persistTokens(pageId: string, tokens: TiktokTokenResponse): Promise<void> {
await storage.updateSocialPage(pageId, {
accessToken: tokens.access_token,
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
refreshToken: tokens.refresh_token,
refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000),
scopes: tokens.scope,
tokenStatus: 'valid',
lastTokenCheck: new Date(),
});
}
/** Profil public du compte, utilisé pour nommer la page à la connexion. */
async getUserInfo(accessToken: string): Promise<{
openId: string;
displayName: string;
avatarUrl?: string;
}> {
const params = new URLSearchParams({
fields: 'open_id,display_name,avatar_url',
});
const response = await fetch(`${API_BASE_URL}/user/info/?${params.toString()}`, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await this.parseApiResponse(response, 'récupération du profil');
const user = data.user || {};
return {
openId: user.open_id,
displayName: user.display_name || 'Compte TikTok',
avatarUrl: user.avatar_url,
};
}
/**
* Options réellement autorisées sur le compte (confidentialité, commentaires,
* duo, stitch, durée max). TikTok impose cet appel avant chaque publication.
*/
async getCreatorInfo(page: SocialPage): Promise<TiktokCreatorInfo> {
return await this.queryCreatorInfo(await this.getValidAccessToken(page));
}
/**
* Variante prenant un token déjà résolu : le refresh token TikTok tourne à chaque
* renouvellement, donc une même opération ne doit déclencher qu'un seul refresh.
*/
private async queryCreatorInfo(accessToken: string): Promise<TiktokCreatorInfo> {
const response = await fetch(`${API_BASE_URL}/post/publish/creator_info/query/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
});
const data = await this.parseApiResponse(response, 'récupération des options du compte');
return {
creatorAvatarUrl: data.creator_avatar_url,
creatorUsername: data.creator_username,
creatorNickname: data.creator_nickname,
privacyLevelOptions: data.privacy_level_options || [],
commentDisabled: !!data.comment_disabled,
duetDisabled: !!data.duet_disabled,
stitchDisabled: !!data.stitch_disabled,
maxVideoPostDurationSec: data.max_video_post_duration_sec,
};
}
/**
* Publie une vidéo sur un compte TikTok en trois temps :
* 1. init — déclare la taille et le découpage, récupère publish_id + upload_url
* 2. upload — envoie les données binaires par chunks
* 3. (plus tard) status/fetch — le poller récupère l'identifiant du post
*
* On envoie le fichier (FILE_UPLOAD) plutôt qu'une URL (PULL_FROM_URL) : cette
* seconde option exigerait de faire vérifier le domaine de l'instance chez TikTok
* et que les médias soient publiquement accessibles.
*
* @returns le publish_id à suivre pour connaître le résultat final
*/
async publishVideoFromBuffer(
page: SocialPage,
videoBuffer: Buffer,
caption?: string,
options: TiktokPublishOptions = {}
): Promise<string> {
if (page.platform !== 'tiktok') {
throw new Error('Ce service ne gère que les comptes TikTok');
}
// Un seul renouvellement de token pour toute la publication
const accessToken = await this.getValidAccessToken(page);
const creatorInfo = await this.queryCreatorInfo(accessToken);
const privacyLevel = this.resolvePrivacyLevel(creatorInfo, options.privacyLevel, page.pageName);
const videoSize = videoBuffer.length;
const { chunkSize, totalChunkCount } = this.computeChunking(videoSize);
console.log('🎬 [TikTok] Publication en cours:', {
compte: page.pageName,
taille: `${(videoSize / 1024 / 1024).toFixed(2)} Mo`,
chunks: totalChunkCount,
confidentialite: privacyLevel,
});
// ── Étape 1 : init ────────────────────────────────────────────────────────
const initBody = {
post_info: {
title: (caption || '').slice(0, MAX_TITLE_LENGTH),
privacy_level: privacyLevel,
// Une option désactivée côté compte doit obligatoirement être déclarée désactivée
disable_comment: creatorInfo.commentDisabled || !!options.disableComment,
disable_duet: creatorInfo.duetDisabled || !!options.disableDuet,
disable_stitch: creatorInfo.stitchDisabled || !!options.disableStitch,
},
source_info: {
source: 'FILE_UPLOAD',
video_size: videoSize,
chunk_size: chunkSize,
total_chunk_count: totalChunkCount,
},
};
const initResponse = await fetch(`${API_BASE_URL}/post/publish/video/init/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify(initBody),
});
const initData = await this.parseApiResponse(initResponse, 'initialisation de la publication');
const publishId: string = initData.publish_id;
const uploadUrl: string = initData.upload_url;
if (!publishId || !uploadUrl) {
throw new Error("TikTok n'a pas renvoyé d'identifiant de publication");
}
console.log(`📤 [TikTok] Upload initialisé: publish_id=${publishId}`);
// ── Étape 2 : upload des chunks ───────────────────────────────────────────
for (let index = 0; index < totalChunkCount; index++) {
const start = index * chunkSize;
// Le dernier chunk absorbe le reliquat
const end = index === totalChunkCount - 1 ? videoSize - 1 : start + chunkSize - 1;
const chunk = videoBuffer.subarray(start, end + 1);
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'video/mp4',
'Content-Length': chunk.length.toString(),
'Content-Range': `bytes ${start}-${end}/${videoSize}`,
},
body: new Blob([chunk], { type: 'video/mp4' }),
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(
`Échec de l'envoi du chunk ${index + 1}/${totalChunkCount} vers TikTok ` +
`(${uploadResponse.status}): ${errorText.slice(0, 200)}`
);
}
console.log(`📤 [TikTok] Chunk ${index + 1}/${totalChunkCount} envoyé`);
}
console.log(`✅ [TikTok] Vidéo transmise pour ${page.pageName}, traitement en cours côté TikTok`);
return publishId;
}
/** Interroge TikTok sur l'avancement d'une publication. */
async getPublishStatus(page: SocialPage, publishId: string): Promise<TiktokPublishStatus> {
const accessToken = await this.getValidAccessToken(page);
const response = await fetch(`${API_BASE_URL}/post/publish/status/fetch/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify({ publish_id: publishId }),
});
const data = await this.parseApiResponse(response, 'suivi de la publication');
// Le nom du champ comporte bien une faute de frappe côté API TikTok
const postIds: string[] = data.publicaly_available_post_id || data.publicly_available_post_id || [];
return {
status: data.status,
failReason: data.fail_reason,
postId: postIds.length > 0 ? postIds[0] : undefined,
};
}
/**
* Met à jour les publications TikTok encore en cours de traitement.
* Appelé périodiquement : c'est ce qui renseigne l'identifiant définitif du post
* (ou l'erreur) une fois que TikTok a fini d'encoder la vidéo.
*/
async syncPendingPublications(): Promise<void> {
const pending = await storage.getScheduledPostsAwaitingPublishStatus();
if (pending.length === 0) {
return;
}
console.log(`🔎 [TikTok] Suivi de ${pending.length} publication(s) en cours...`);
for (const scheduledPost of pending) {
try {
const page = await storage.getSocialPage(scheduledPost.pageId);
if (!page || page.platform !== 'tiktok' || !scheduledPost.publishId) {
continue;
}
const status = await this.getPublishStatus(page, scheduledPost.publishId);
if (status.status === 'PUBLISH_COMPLETE') {
await storage.updateScheduledPost(scheduledPost.id, {
publishStatus: status.status,
// À défaut d'ID public (compte privé), on garde le publish_id comme référence
externalPostId: status.postId || scheduledPost.publishId,
error: null,
});
console.log(`✅ [TikTok] Publication terminée sur ${page.pageName}`);
continue;
}
if (status.status === 'FAILED') {
await storage.updateScheduledPost(scheduledPost.id, {
publishStatus: 'FAILED',
error: `Publication TikTok échouée : ${status.failReason || 'raison inconnue'}`,
});
console.error(`❌ [TikTok] Publication échouée sur ${page.pageName}: ${status.failReason}`);
continue;
}
// Filet de sécurité : au-delà de 24h, on cesse d'interroger l'API
const sentAt = scheduledPost.publishedAt ? new Date(scheduledPost.publishedAt).getTime() : 0;
if (sentAt && Date.now() - sentAt > 24 * 60 * 60 * 1000) {
await storage.updateScheduledPost(scheduledPost.id, {
publishStatus: 'FAILED',
error: `Statut TikTok toujours "${status.status}" après 24h, suivi abandonné.`,
});
console.error(`❌ [TikTok] Suivi abandonné pour ${page.pageName} (statut ${status.status})`);
continue;
}
await storage.updateScheduledPost(scheduledPost.id, { publishStatus: status.status });
} catch (error) {
console.error(
`⚠️ [TikTok] Impossible de récupérer le statut de la publication ${scheduledPost.id}:`,
error instanceof Error ? error.message : error
);
}
}
}
/**
* Choisit un niveau de confidentialité réellement autorisé sur le compte.
* Sans audit TikTok validé, seul SELF_ONLY est proposé : on s'y rabat plutôt
* que d'échouer, en le signalant dans les logs.
*/
private resolvePrivacyLevel(
creatorInfo: TiktokCreatorInfo,
requested: string | undefined,
pageName: string
): string {
const options = creatorInfo.privacyLevelOptions;
if (options.length === 0) {
throw new Error(
`Le compte TikTok "${pageName}" ne propose aucun niveau de confidentialité. ` +
`Vérifiez que le compte est bien autorisé et que l'application dispose du scope video.publish.`
);
}
const wanted = requested || 'PUBLIC_TO_EVERYONE';
if (options.includes(wanted)) {
return wanted;
}
const fallback = options.includes('SELF_ONLY') ? 'SELF_ONLY' : options[0];
console.warn(
`⚠️ [TikTok] "${wanted}" indisponible sur ${pageName} (options: ${options.join(', ')}). ` +
`Publication en ${fallback}. Tant que l'application TikTok n'est pas auditée, ` +
`les vidéos restent privées.`
);
return fallback;
}
/**
* Découpage du fichier : un seul chunk jusqu'à 64 Mo, sinon des chunks de 32 Mo,
* le dernier absorbant le reliquat (règle imposée par TikTok).
*/
private computeChunking(videoSize: number): { chunkSize: number; totalChunkCount: number } {
if (videoSize <= MAX_SINGLE_CHUNK_SIZE) {
return { chunkSize: videoSize, totalChunkCount: 1 };
}
return {
chunkSize: CHUNK_SIZE,
totalChunkCount: Math.floor(videoSize / CHUNK_SIZE),
};
}
/**
* Les endpoints v2 répondent HTTP 200 avec `error.code === 'ok'` en cas de succès :
* il ne suffit pas de regarder le statut HTTP.
*/
private async parseApiResponse(response: Response, context: string): Promise<any> {
const raw = await response.text();
let payload: any;
try {
payload = JSON.parse(raw);
} catch {
throw new Error(`Réponse TikTok illisible lors de la ${context} (${response.status}): ${raw.slice(0, 200)}`);
}
const errorCode = payload?.error?.code;
if (!response.ok || (errorCode && errorCode !== 'ok')) {
const message = payload?.error?.message || raw.slice(0, 200);
throw new Error(`Erreur TikTok lors de la ${context}: ${message} (code: ${errorCode || response.status})`);
}
return payload.data || {};
}
}
export const tiktokService = new TiktokService();
+36
View File
@@ -60,6 +60,12 @@ export class TokenManager {
}
private static async processPageToken(page: any) {
// Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure
// que 24h et se renouvelle avec le refresh token, pas via la Graph API.
if (page.platform === 'tiktok') {
return await this.processTiktokToken(page);
}
let accessToken = page.accessToken;
// Attempt to decrypt. If fails, assume it's plain text (migration phase)
@@ -93,6 +99,36 @@ export class TokenManager {
}
}
/**
* Vérifie qu'un compte TikTok est toujours utilisable.
* On ne teste pas l'access token (il est renouvelé à la demande avant chaque
* publication) mais le refresh token, dont l'expiration déconnecterait le compte.
*/
private static async processTiktokToken(page: any) {
console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`);
const refreshExpiresAt = page.refreshTokenExpiresAt
? new Date(page.refreshTokenExpiresAt).getTime()
: 0;
let status: "valid" | "expiring" | "expired" = "valid";
if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) {
status = "expired";
console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`);
} else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) {
// Moins de 30 jours de validité restante
status = "expiring";
}
await db.update(socialPages)
.set({
tokenStatus: status,
lastTokenCheck: new Date()
})
.where(eq(socialPages.id, page.id));
}
private static async verifyToken(token: string): Promise<boolean> {
try {
// Simple validation check: Get 'me' (page profile)
+96 -23
View File
@@ -36,11 +36,38 @@ import {
type InsertAudioTrack,
appConfig,
type AppConfig,
tiktokConfig,
type TiktokConfig,
type InsertTiktokConfig,
} from "@shared/schema";
import { db } from "./db";
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray } from "drizzle-orm";
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
/**
* Statuts de publication asynchrone (TikTok) qui ne bougeront plus : inutile de
* continuer à interroger l'API une fois l'un d'eux atteint.
*/
export const TERMINAL_PUBLISH_STATUSES: string[] = [
'PUBLISH_COMPLETE',
'FAILED',
// Vidéo déposée dans la boîte de réception du créateur : elle attend une action
// manuelle dans TikTok et n'évoluera plus côté API.
'SEND_TO_USER_INBOX',
];
/**
* Déchiffre les tokens d'une page avant de la rendre au reste de l'application.
* Les services (Facebook, TikTok) manipulent toujours des tokens en clair.
*/
function decryptPageTokens(page: SocialPage): SocialPage {
return {
...page,
accessToken: decrypt(page.accessToken),
refreshToken: page.refreshToken ? decrypt(page.refreshToken) : page.refreshToken,
};
}
export interface IStorage {
// Users
getUser(id: string): Promise<User | undefined>;
@@ -85,11 +112,16 @@ export interface IStorage {
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
deleteScheduledPost(id: string): Promise<void>;
getPendingScheduledPosts(): Promise<ScheduledPost[]>;
getScheduledPostsAwaitingPublishStatus(): Promise<ScheduledPost[]>;
// AI Generations
getAiGenerations(userId: string): Promise<AiGeneration[]>;
createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration>;
// TikTok Config
getTiktokConfig(): Promise<TiktokConfig | undefined>;
upsertTiktokConfig(config: Partial<InsertTiktokConfig>): Promise<TiktokConfig>;
// Cloudinary Config
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
@@ -160,43 +192,36 @@ export class DatabaseStorage implements IStorage {
// Social Pages
async getSocialPages(userId: string): Promise<SocialPage[]> {
const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId));
// Déchiffrer les tokens pour chaque page
return pages.map(page => ({
...page,
accessToken: decrypt(page.accessToken)
}));
return pages.map(page => decryptPageTokens(page));
}
async getSocialPage(id: string): Promise<SocialPage | undefined> {
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id));
if (page) {
// Déchiffrer le token
page.accessToken = decrypt(page.accessToken);
}
return page || undefined;
return page ? decryptPageTokens(page) : undefined;
}
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
// Chiffrer le token avant stockage
// Chiffrer les tokens avant stockage
const encryptedPage = {
...page,
accessToken: encrypt(page.accessToken)
accessToken: encrypt(page.accessToken),
...(page.refreshToken ? { refreshToken: encrypt(page.refreshToken) } : {}),
};
const [newPage] = await db.insert(socialPages).values(encryptedPage).returning();
// Retourner avec le token déchiffré
newPage.accessToken = decrypt(newPage.accessToken);
return newPage;
return decryptPageTokens(newPage);
}
async updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage> {
// Chiffrer le token si présent dans la mise à jour
const updateData = page.accessToken
? { ...page, accessToken: encrypt(page.accessToken) }
: page;
// Chiffrer les tokens présents dans la mise à jour
const updateData: Partial<SocialPage> = { ...page };
if (page.accessToken) {
updateData.accessToken = encrypt(page.accessToken);
}
if (page.refreshToken) {
updateData.refreshToken = encrypt(page.refreshToken);
}
const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning();
// Retourner avec le token déchiffré
updated.accessToken = decrypt(updated.accessToken);
return updated;
return decryptPageTokens(updated);
}
async deleteSocialPage(id: string): Promise<void> {
@@ -422,6 +447,22 @@ export class DatabaseStorage implements IStorage {
return results;
}
/**
* Publications déjà envoyées à TikTok dont le statut final n'est pas encore
* connu (l'API répond de façon asynchrone via un publish_id).
*/
async getScheduledPostsAwaitingPublishStatus(): Promise<ScheduledPost[]> {
return await db
.select()
.from(scheduledPosts)
.where(
and(
isNotNull(scheduledPosts.publishId),
notInArray(scheduledPosts.publishStatus, TERMINAL_PUBLISH_STATUSES)
)
);
}
// AI Generations
async getAiGenerations(userId: string): Promise<AiGeneration[]> {
return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt));
@@ -571,6 +612,38 @@ export class DatabaseStorage implements IStorage {
return config || undefined;
}
// TikTok Config
async getTiktokConfig(): Promise<TiktokConfig | undefined> {
const [config] = await db.select().from(tiktokConfig).limit(1);
if (!config) return undefined;
return { ...config, clientSecret: decrypt(config.clientSecret) };
}
async upsertTiktokConfig(data: Partial<InsertTiktokConfig>): Promise<TiktokConfig> {
const values: Partial<TiktokConfig> = { ...data };
if (data.clientSecret) {
values.clientSecret = encrypt(data.clientSecret);
}
const [existing] = await db.select().from(tiktokConfig).limit(1);
if (existing) {
const [updated] = await db.update(tiktokConfig)
.set({ ...values, updatedAt: new Date() })
.where(eq(tiktokConfig.id, existing.id))
.returning();
return { ...updated, clientSecret: decrypt(updated.clientSecret) };
}
if (!values.clientKey || !values.clientSecret) {
throw new Error("Le client key et le client secret TikTok sont requis");
}
const [created] = await db.insert(tiktokConfig)
.values({ clientKey: values.clientKey, clientSecret: values.clientSecret })
.returning();
return { ...created, clientSecret: decrypt(created.clientSecret) };
}
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig> {
const existing = await this.getAppConfig();
if (existing) {