feat(facebook): renouvellement automatique des jetons de page

Un jeton de page collé à la main meurt au bout de 60 jours — ou du jour au
lendemain si le mot de passe change, si le compte perd son rôle d'admin ou si
Meta révoque l'autorisation — et on ne l'apprenait qu'à la première publication
en échec : le contrôle quotidien existant se contentait de marquer la page
« expired ».

On reprend la chaîne recommandée par Meta, comme dans PleinR : autorisation
OAuth, échange du code contre un jeton utilisateur longue durée (~60 j), puis
dérivation des jetons de page — qui n'expirent pas. Le jeton utilisateur est
conservé chiffré sur chaque page : c'est lui qui permet au TokenManager de
régénérer un jeton révoqué sans intervention humaine, et de se prolonger
lui-même avant échéance.

- facebook_config : App ID / App Secret de l'application, saisis dans les
  paramètres (chiffrés en base) ou par variables d'environnement.
- /api/facebook : configuration, connexion OAuth, callback qui crée ou met à
  jour les pages, contrôle d'une page à la demande, contrôle global.
- TokenManager : /debug_token pour connaître l'état réel du jeton, puis
  renouvellement automatique 15 jours avant l'échéance ou dès révocation ;
  repli sur le contrôle historique tant que l'application n'est pas configurée
  ou si Facebook est injoignable, pour ne pas passer l'UI au rouge sur une
  panne réseau. Le cron passe à deux fois par jour.
- Les comptes Instagram professionnels rattachés à une page reçoivent le même
  traitement, puisqu'ils publient avec le jeton de cette page.
- L'UI montre l'état constaté (jeton valide, bientôt expiré, révoqué), l'erreur
  à corriger et un bouton de contrôle immédiat ; un bandeau signale les pages à
  reconnecter.
- /api/pages ne renvoie plus aucun jeton au navigateur, seulement un indicateur
  de renouvellement automatique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
This commit is contained in:
Claude committed 2026-09-10 06:11:43 +00:00
1 parent 55e502aa8d
commit d7f002a64f
13 files changed
+1385 -249

No files matched your search

+50
View File
@@ -38,6 +38,15 @@ export const socialPages = pgTable("social_pages", {
tokenStatus: tokenStatusEnum("token_status").default("valid"),
lastTokenCheck: timestamp("last_token_check"),
// Jeton utilisateur longue durée (~60 j) obtenu via OAuth Facebook, chiffré au
// repos. C'est lui qui permet de régénérer un token de page sans intervention
// humaine : sans lui, un token révoqué impose une reconnexion manuelle.
userAccessToken: text("user_access_token"),
userTokenExpiresAt: timestamp("user_token_expires_at"),
// Dernière erreur rencontrée lors du contrôle du token, affichée dans l'UI
// pour que l'utilisateur sache quoi faire (reconnecter, réautoriser…).
tokenError: text("token_error"),
// OAuth TikTok : l'access token n'est valable que 24h, il doit être renouvelé
// avec le refresh token (valable 1 an) avant chaque publication.
refreshToken: text("refresh_token"),
@@ -93,6 +102,7 @@ export const insertSocialPageSchema = createInsertSchema(socialPages).omit({
createdAt: true,
tokenStatus: true, // Managed by System
lastTokenCheck: true, // Managed by System
tokenError: true, // Managed by System
});
export const insertPostAnalyticsSchema = createInsertSchema(postAnalytics).omit({
@@ -143,6 +153,20 @@ export const openrouterConfig = pgTable("openrouter_config", {
// Application développeur TikTok (globale) : un seul client_key pour toute
// l'instance, chaque magasin autorise ensuite son propre compte via OAuth.
/**
* Application développeur Facebook (globale à l'instance). Elle rend possible le
* renouvellement automatique des tokens de page : sans app id / app secret, on
* ne peut ni échanger un code OAuth, ni prolonger un jeton, ni interroger
* /debug_token.
*/
export const facebookConfig = pgTable("facebook_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
appId: text("app_id").notNull(),
appSecret: text("app_secret").notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const tiktokConfig = pgTable("tiktok_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
clientKey: text("client_key").notNull(),
@@ -432,6 +456,19 @@ export const updateCloudinaryConfigSchema = insertCloudinaryConfigSchema.partial
apiSecret: true,
});
export const insertFacebookConfigSchema = createInsertSchema(facebookConfig).omit({
id: true,
createdAt: true,
updatedAt: true,
}).extend({
appId: z.string().trim().min(1, "L'App ID ne peut pas être vide"),
appSecret: z.string().trim().min(1, "L'App Secret ne peut pas être vide"),
});
export const updateFacebookConfigSchema = insertFacebookConfigSchema.partial({
appSecret: true,
});
export const insertTiktokConfigSchema = createInsertSchema(tiktokConfig).omit({
id: true,
createdAt: true,
@@ -504,6 +541,19 @@ export type InsertAudioTrack = z.infer<typeof insertAudioTrackSchema>;
export type AppConfig = typeof appConfig.$inferSelect;
/**
* Page telle qu'elle est renvoyée au navigateur : les jetons restent au serveur,
* `autoRenew` dit simplement si la page sait se renouveler toute seule.
*/
export type ClientSocialPage = Omit<
SocialPage,
'accessToken' | 'userAccessToken' | 'refreshToken'
> & { autoRenew: boolean };
export type FacebookConfig = typeof facebookConfig.$inferSelect;
export type InsertFacebookConfig = z.infer<typeof insertFacebookConfigSchema>;
export type UpdateFacebookConfig = z.infer<typeof updateFacebookConfigSchema>;
export type TiktokConfig = typeof tiktokConfig.$inferSelect;
export type InsertTiktokConfig = z.infer<typeof insertTiktokConfigSchema>;
export type UpdateTiktokConfig = z.infer<typeof updateTiktokConfigSchema>;