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; export type PageAnalyticsHistory = typeof pageAnalyticsHistory.$inferSelect; export type InsertPageAnalyticsHistory = z.infer; // ... (rest of existing types) export type UserPagePermission = typeof userPagePermissions.$inferSelect; export type InsertUserPagePermission = z.infer; 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"), // Service Qwen3-TTS (local ou serveur GPU distant) : adresse et clé d'accès qwenTtsUrl: text("qwen_tts_url"), qwenTtsApiKey: text("qwen_tts_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(), }); /** * File d'attente persistante des rendus de Reels. * * Un job survit à un redémarrage du serveur : le worker le réserve avec * `FOR UPDATE SKIP LOCKED`, entretient `heartbeat_at` pendant le rendu, et les * jobs dont le cœur ne bat plus sont remis en file au démarrage suivant. */ export const reelJobs = pgTable("reel_jobs", { id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // Post suivi par l'interface (reel vidéo). Absent pour un rendu d'images non publié. postId: varchar("post_id").references(() => posts.id, { onDelete: "cascade" }), kind: text("kind").notNull(), // 'video' | 'images' params: jsonb("params").notNull(), status: text("status").notNull().default("pending"), // 'pending' | 'processing' | 'completed' | 'failed' step: text("step"), progress: integer("progress").notNull().default(0), attempts: integer("attempts").notNull().default(0), result: jsonb("result"), error: text("error"), heartbeatAt: timestamp("heartbeat_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().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; export type SocialPage = typeof socialPages.$inferSelect; export type InsertSocialPage = z.infer; export type Media = typeof media.$inferSelect; export type InsertMedia = z.infer; export type Post = typeof posts.$inferSelect; export type InsertPost = z.infer; export type ScheduledPost = typeof scheduledPosts.$inferSelect; export type InsertScheduledPost = z.infer; export type AiGeneration = typeof aiGenerations.$inferSelect; export type InsertAiGeneration = z.infer; export type CloudinaryConfig = typeof cloudinaryConfig.$inferSelect; export type InsertCloudinaryConfig = z.infer; export type UpdateCloudinaryConfig = z.infer; export type OpenrouterConfig = typeof openrouterConfig.$inferSelect; export type InsertOpenrouterConfig = z.infer; export type UpdateOpenrouterConfig = z.infer; export type PostMedia = typeof postMedia.$inferSelect; export type InsertPostMedia = z.infer; export type MusicFavorite = typeof musicFavorites.$inferSelect; export type InsertMusicFavorite = z.infer; export type AudioTrack = typeof audioTracks.$inferSelect; export type InsertAudioTrack = z.infer; 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; export type UpdateFacebookConfig = z.infer; export type TiktokConfig = typeof tiktokConfig.$inferSelect; export type InsertTiktokConfig = z.infer; export type UpdateTiktokConfig = z.infer; export type ReelJob = typeof reelJobs.$inferSelect; export type InsertReelJob = typeof reelJobs.$inferInsert;