Files
Socialflow/shared/schema.ts
T
Claude d7f002a64f 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
2026-09-10 06:11:43 +00:00

562 lines
20 KiB
TypeScript

import { sql } from "drizzle-orm";
import { pgTable, text, varchar, timestamp, jsonb, pgEnum, integer } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
export const platformEnum = pgEnum("platform", ["facebook", "instagram", "tiktok"]);
export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both", "reel"]);
export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
export const userRoleEnum = pgEnum("user_role", ["admin", "user"]);
export const users = pgTable("users", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
username: text("username").notNull().unique(),
password: text("password").notNull(),
role: userRoleEnum("role").notNull().default("user"),
});
// Update existing enums
export const tokenStatusEnum = pgEnum("token_status", ["valid", "expiring", "expired", "error"]);
// ... (existing tables)
export const socialPages = pgTable("social_pages", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
platform: platformEnum("platform").notNull(),
// Identifiant natif de la destination : page_id (Facebook) ou open_id (TikTok)
pageId: text("page_id").notNull(),
pageName: text("page_name").notNull(),
accessToken: text("access_token").notNull(),
tokenExpiresAt: timestamp("token_expires_at"),
followersCount: integer("followers_count").default(0),
isActive: text("is_active").notNull().default("true"),
// NEW FIELDS
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"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scopes: text("scopes"),
avatarUrl: text("avatar_url"),
createdAt: timestamp("created_at").defaultNow(),
});
// ... (existing tables)
// NEW TABLES
export const postAnalytics = pgTable("post_analytics", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
postId: varchar("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
fetchedAt: timestamp("fetched_at").defaultNow(),
// Standard FB Metrics
impressions: integer("impressions").default(0),
reach: integer("reach").default(0),
engagement: integer("engagement").default(0),
reactions: integer("reactions").default(0),
comments: integer("comments").default(0),
shares: integer("shares").default(0),
clicks: integer("clicks").default(0),
rawData: jsonb("raw_data"), // Backup of full JSON response
});
export const pageAnalyticsHistory = pgTable("page_analytics_history", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
pageId: varchar("page_id").notNull().references(() => socialPages.id, { onDelete: "cascade" }),
date: timestamp("date").notNull(), // Snapshot date
followersCount: integer("followers_count").default(0),
pageViews: integer("page_views").default(0),
pageReach: integer("page_reach").default(0),
pageEngagement: integer("page_engagement").default(0),
createdAt: timestamp("created_at").defaultNow(),
});
// Relations Updates
// Insert Schemas Updates
export const insertSocialPageSchema = createInsertSchema(socialPages).omit({
id: true,
createdAt: true,
tokenStatus: true, // Managed by System
lastTokenCheck: true, // Managed by System
tokenError: true, // Managed by System
});
export const insertPostAnalyticsSchema = createInsertSchema(postAnalytics).omit({
id: true,
fetchedAt: true,
});
export const insertPageAnalyticsHistorySchema = createInsertSchema(pageAnalyticsHistory).omit({
id: true,
createdAt: true,
});
// Types
export type PostAnalytics = typeof postAnalytics.$inferSelect;
export type InsertPostAnalytics = z.infer<typeof insertPostAnalyticsSchema>;
export type PageAnalyticsHistory = typeof pageAnalyticsHistory.$inferSelect;
export type InsertPageAnalyticsHistory = z.infer<typeof insertPageAnalyticsHistorySchema>;
// ... (rest of existing types)
export type UserPagePermission = typeof userPagePermissions.$inferSelect;
export type InsertUserPagePermission = z.infer<typeof insertUserPagePermissionSchema>;
export const cloudinaryConfig = pgTable("cloudinary_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
cloudName: text("cloud_name").notNull(),
apiKey: text("api_key").notNull(),
apiSecret: text("api_secret").notNull(),
logoPublicId: text("logo_public_id"),
endpointUrl: text("endpoint_url"),
publicUrl: text("public_url"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const openrouterConfig = pgTable("openrouter_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
apiKey: text("api_key").notNull(),
model: text("model").notNull().default("anthropic/claude-sonnet-4.5"),
systemPrompt: text("system_prompt").notNull().default("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// 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(),
clientSecret: text("client_secret").notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const media = pgTable("media", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
type: mediaTypeEnum("type").notNull(),
cloudinaryPublicId: text("cloudinary_public_id"),
originalUrl: text("original_url").notNull(),
facebookLandscapeUrl: text("facebook_landscape_url"),
facebookFeedUrl: text("facebook_feed_url"),
instagramFeedUrl: text("instagram_feed_url"),
instagramStoryUrl: text("instagram_story_url"),
// Image extraite d'une vidéo, conservée après la suppression du fichier source
// pour que l'historique reste illustré.
thumbnailUrl: text("thumbnail_url"),
fileName: text("file_name").notNull(),
fileSize: integer("file_size").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
export const posts = pgTable("posts", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
content: text("content").notNull(),
aiGenerated: text("ai_generated").notNull().default("false"),
productInfo: jsonb("product_info"),
status: postStatusEnum("status").notNull().default("draft"),
scheduledFor: timestamp("scheduled_for"),
// Reel generation tracking
generationStatus: text("generation_status"), // 'pending' | 'processing' | 'completed' | 'failed'
generationProgress: integer("generation_progress").default(0),
generationError: text("generation_error"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const postMedia = pgTable("post_media", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
postId: varchar("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
mediaId: varchar("media_id").notNull().references(() => media.id, { onDelete: "cascade" }),
displayOrder: integer("display_order").notNull().default(0),
});
export const scheduledPosts = pgTable("scheduled_posts", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
postId: varchar("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
pageId: varchar("page_id").notNull().references(() => socialPages.id, { onDelete: "cascade" }),
postType: postTypeEnum("post_type").notNull(),
scheduledAt: timestamp("scheduled_at").notNull(),
publishedAt: timestamp("published_at"),
externalPostId: text("external_post_id"),
error: text("error"),
// TikTok publie de façon asynchrone : l'upload rend un publish_id, et le
// post_id définitif n'est connu qu'après interrogation du statut.
publishId: text("publish_id"),
publishStatus: text("publish_status"),
});
export const aiGenerations = pgTable("ai_generations", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
productInfo: jsonb("product_info").notNull(),
generatedTexts: jsonb("generated_texts").notNull(),
selectedText: text("selected_text"),
createdAt: timestamp("created_at").defaultNow(),
});
export const musicFavorites = pgTable("music_favorites", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
trackId: text("track_id").notNull(),
title: text("title").notNull(),
artist: text("artist").notNull(),
albumName: text("album_name"),
duration: integer("duration").notNull(),
previewUrl: text("preview_url").notNull(),
downloadUrl: text("download_url").notNull(),
imageUrl: text("image_url"),
license: text("license"),
createdAt: timestamp("created_at").defaultNow(),
});
export const appConfig = pgTable("app_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
externalApiKey: text("external_api_key"),
geminiApiKey: text("gemini_api_key"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const audioTracks = pgTable("audio_tracks", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").references(() => users.id, { onDelete: "cascade" }), // Nullable if uploaded by system/admin globally
title: text("title").notNull(),
fileName: text("file_name").notNull(),
url: text("url").notNull(),
duration: integer("duration").default(0),
createdAt: timestamp("created_at").defaultNow(),
});
export const userPagePermissions = pgTable("user_page_permissions", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
pageId: varchar("page_id").notNull().references(() => socialPages.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow(),
});
export const socialPagesRelations = relations(socialPages, ({ one, many }) => ({
user: one(users, {
fields: [socialPages.userId],
references: [users.id],
}),
scheduledPosts: many(scheduledPosts),
userPermissions: many(userPagePermissions),
analyticsHistory: many(pageAnalyticsHistory), // Relation
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
user: one(users, {
fields: [posts.userId],
references: [users.id],
}),
postMedia: many(postMedia),
scheduledPosts: many(scheduledPosts),
analytics: many(postAnalytics), // Relation
}));
export const postAnalyticsRelations = relations(postAnalytics, ({ one }) => ({
post: one(posts, {
fields: [postAnalytics.postId],
references: [posts.id],
}),
}));
export const pageAnalyticsHistoryRelations = relations(pageAnalyticsHistory, ({ one }) => ({
page: one(socialPages, {
fields: [pageAnalyticsHistory.pageId],
references: [socialPages.id],
}),
}));
// Relations
export const usersRelations = relations(users, ({ many, one }) => ({
socialPages: many(socialPages),
media: many(media),
posts: many(posts),
aiGenerations: many(aiGenerations),
pagePermissions: many(userPagePermissions),
cloudinaryConfig: one(cloudinaryConfig),
openrouterConfig: one(openrouterConfig),
}));
// Pas de relation User pour CloudinaryConfig (maintenant global)
export const openrouterConfigRelations = relations(openrouterConfig, ({ one }) => ({
user: one(users, {
fields: [openrouterConfig.userId],
references: [users.id],
}),
}));
export const mediaRelations = relations(media, ({ one, many }) => ({
user: one(users, {
fields: [media.userId],
references: [users.id],
}),
postMedia: many(postMedia),
}));
export const postMediaRelations = relations(postMedia, ({ one }) => ({
post: one(posts, {
fields: [postMedia.postId],
references: [posts.id],
}),
media: one(media, {
fields: [postMedia.mediaId],
references: [media.id],
}),
}));
export const scheduledPostsRelations = relations(scheduledPosts, ({ one }) => ({
post: one(posts, {
fields: [scheduledPosts.postId],
references: [posts.id],
}),
page: one(socialPages, {
fields: [scheduledPosts.pageId],
references: [socialPages.id],
}),
}));
export const aiGenerationsRelations = relations(aiGenerations, ({ one }) => ({
user: one(users, {
fields: [aiGenerations.userId],
references: [users.id],
}),
}));
export const musicFavoritesRelations = relations(musicFavorites, ({ one }) => ({
user: one(users, {
fields: [musicFavorites.userId],
references: [users.id],
}),
}));
export const userPagePermissionsRelations = relations(userPagePermissions, ({ one }) => ({
user: one(users, {
fields: [userPagePermissions.userId],
references: [users.id],
}),
page: one(socialPages, {
fields: [userPagePermissions.pageId],
references: [socialPages.id],
}),
}));
export const audioTracksRelations = relations(audioTracks, ({ one }) => ({
user: one(users, {
fields: [audioTracks.userId],
references: [users.id],
}),
}));
// Insert schemas
export const insertUserSchema = createInsertSchema(users).pick({
username: true,
password: true,
role: true,
});
export const insertMediaSchema = createInsertSchema(media).omit({
id: true,
createdAt: true,
});
export const insertPostSchema = createInsertSchema(posts).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertScheduledPostSchema = createInsertSchema(scheduledPosts).omit({
id: true,
publishedAt: true,
externalPostId: true,
error: true,
});
export const insertAiGenerationSchema = createInsertSchema(aiGenerations).omit({
id: true,
createdAt: true,
});
export const insertCloudinaryConfigSchema = createInsertSchema(cloudinaryConfig).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertOpenrouterConfigSchema = createInsertSchema(openrouterConfig).omit({
id: true,
createdAt: true,
updatedAt: true,
}).extend({
apiKey: z.string().trim().min(1, "La clé API ne peut pas être vide"),
});
export const updateOpenrouterConfigSchema = insertOpenrouterConfigSchema.partial({
apiKey: true,
});
export const updateCloudinaryConfigSchema = insertCloudinaryConfigSchema.partial({
apiKey: true,
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,
updatedAt: true,
}).extend({
clientKey: z.string().trim().min(1, "Le client key ne peut pas être vide"),
clientSecret: z.string().trim().min(1, "Le client secret ne peut pas être vide"),
});
export const updateTiktokConfigSchema = insertTiktokConfigSchema.partial({
clientSecret: true,
});
export const insertUserPagePermissionSchema = createInsertSchema(userPagePermissions).omit({
id: true,
createdAt: true,
});
export const insertPostMediaSchema = createInsertSchema(postMedia).omit({
id: true,
});
export const insertMusicFavoriteSchema = createInsertSchema(musicFavorites).omit({
id: true,
createdAt: true,
});
export const insertAudioTrackSchema = createInsertSchema(audioTracks).omit({
id: true,
createdAt: true,
});
// Types
export type User = typeof users.$inferSelect;
export type InsertUser = z.infer<typeof insertUserSchema>;
export type SocialPage = typeof socialPages.$inferSelect;
export type InsertSocialPage = z.infer<typeof insertSocialPageSchema>;
export type Media = typeof media.$inferSelect;
export type InsertMedia = z.infer<typeof insertMediaSchema>;
export type Post = typeof posts.$inferSelect;
export type InsertPost = z.infer<typeof insertPostSchema>;
export type ScheduledPost = typeof scheduledPosts.$inferSelect;
export type InsertScheduledPost = z.infer<typeof insertScheduledPostSchema>;
export type AiGeneration = typeof aiGenerations.$inferSelect;
export type InsertAiGeneration = z.infer<typeof insertAiGenerationSchema>;
export type CloudinaryConfig = typeof cloudinaryConfig.$inferSelect;
export type InsertCloudinaryConfig = z.infer<typeof insertCloudinaryConfigSchema>;
export type UpdateCloudinaryConfig = z.infer<typeof updateCloudinaryConfigSchema>;
export type OpenrouterConfig = typeof openrouterConfig.$inferSelect;
export type InsertOpenrouterConfig = z.infer<typeof insertOpenrouterConfigSchema>;
export type UpdateOpenrouterConfig = z.infer<typeof updateOpenrouterConfigSchema>;
export type PostMedia = typeof postMedia.$inferSelect;
export type InsertPostMedia = z.infer<typeof insertPostMediaSchema>;
export type MusicFavorite = typeof musicFavorites.$inferSelect;
export type InsertMusicFavorite = z.infer<typeof insertMusicFavoriteSchema>;
export type AudioTrack = typeof audioTracks.$inferSelect;
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>;