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

+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) {