mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-12 01:36:54 +02:00
Montage (étape 3) : - Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles : Impact, Surligné, Épuré), logo, effet de fin et fondu, en React - Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière image figée) et la piste son finale (/prepare-reel) ; Remotion compose - Reels d'images sur les mêmes composants, avec le vrai minutage de la voix - Police Montserrat embarquée (plus de dépendance à Google Fonts) - REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours - Script de pré-bundle réparé (échouait en silence : require en ESM) Interface (étape 4) : - Aperçu en direct avec @remotion/player, identique au rendu final ; la voix testée cale les sous-titres, sinon minutage estimé - Choix du style de sous-titres sur les 4 pages Reel - Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur cause ; fin de la barre simulée et du faux « publié avec succès » Outillage (étape 5) : - Tests vitest (minutage identique à Python, validation, sécurité) et pytest ; CI GitHub Actions (tsc, tests, build, ruff) - Captures d'écran, out.mp4 et scripts de test retirés de la racine Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
813 lines
29 KiB
TypeScript
813 lines
29 KiB
TypeScript
import {
|
|
users,
|
|
socialPages,
|
|
media,
|
|
posts,
|
|
reelJobs,
|
|
postMedia,
|
|
scheduledPosts,
|
|
aiGenerations,
|
|
cloudinaryConfig,
|
|
openrouterConfig,
|
|
userPagePermissions,
|
|
type User,
|
|
type InsertUser,
|
|
type SocialPage,
|
|
type InsertSocialPage,
|
|
type Media,
|
|
type InsertMedia,
|
|
type Post,
|
|
type InsertPost,
|
|
type PostMedia,
|
|
type ScheduledPost,
|
|
type InsertScheduledPost,
|
|
type AiGeneration,
|
|
type InsertAiGeneration,
|
|
type CloudinaryConfig,
|
|
type InsertCloudinaryConfig,
|
|
type OpenrouterConfig,
|
|
type InsertOpenrouterConfig,
|
|
type UserPagePermission,
|
|
type InsertUserPagePermission,
|
|
musicFavorites,
|
|
type MusicFavorite,
|
|
type InsertMusicFavorite,
|
|
audioTracks,
|
|
type AudioTrack,
|
|
type InsertAudioTrack,
|
|
appConfig,
|
|
type AppConfig,
|
|
tiktokConfig,
|
|
type TiktokConfig,
|
|
type InsertTiktokConfig,
|
|
facebookConfig,
|
|
type FacebookConfig,
|
|
type InsertFacebookConfig,
|
|
} from "@shared/schema";
|
|
import { db } from "./db";
|
|
import { eq, and, or, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } 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,
|
|
};
|
|
}
|
|
|
|
/** Bornes temporelles utilisées pour les comparaisons du tableau de bord. */
|
|
export interface DashboardStatsRange {
|
|
yesterdayStart: Date;
|
|
yesterdayEnd: Date;
|
|
lastMonth: Date;
|
|
}
|
|
|
|
export interface DashboardStats {
|
|
scheduledPosts: number;
|
|
scheduledPostsLastMonth: number;
|
|
connectedPages: number;
|
|
aiTextsGenerated: number;
|
|
aiTextsYesterday: number;
|
|
mediaStored: number;
|
|
}
|
|
|
|
/**
|
|
* Colonnes renvoyées pour une publication planifiée.
|
|
*
|
|
* On ne sélectionne pas la ligne `social_pages` entière : elle porte les jetons
|
|
* d'accès Facebook et TikTok, qui partaient jusqu'ici dans chaque réponse HTTP
|
|
* — y compris vers le navigateur, qui n'en fait rien.
|
|
*/
|
|
const SCHEDULED_POST_COLUMNS = {
|
|
...getTableColumns(scheduledPosts),
|
|
post: {
|
|
id: posts.id,
|
|
userId: posts.userId,
|
|
content: posts.content,
|
|
status: posts.status,
|
|
scheduledFor: posts.scheduledFor,
|
|
aiGenerated: posts.aiGenerated,
|
|
generationStatus: posts.generationStatus,
|
|
generationProgress: posts.generationProgress,
|
|
createdAt: posts.createdAt,
|
|
},
|
|
page: {
|
|
id: socialPages.id,
|
|
pageId: socialPages.pageId,
|
|
pageName: socialPages.pageName,
|
|
platform: socialPages.platform,
|
|
avatarUrl: socialPages.avatarUrl,
|
|
},
|
|
};
|
|
|
|
export interface IStorage {
|
|
// Users
|
|
getUser(id: string): Promise<User | undefined>;
|
|
getUserByUsername(username: string): Promise<User | undefined>;
|
|
getAllUsers(): Promise<User[]>;
|
|
getDashboardStats(userId: string, options: DashboardStatsRange): Promise<DashboardStats>;
|
|
createUser(user: InsertUser): Promise<User>;
|
|
updateUser(id: string, user: Partial<InsertUser>): Promise<User>;
|
|
deleteUser(id: string): Promise<void>;
|
|
|
|
// Social Pages
|
|
getSocialPages(userId: string): Promise<SocialPage[]>;
|
|
getAllSocialPages(): Promise<SocialPage[]>;
|
|
getSocialPage(id: string): Promise<SocialPage | undefined>;
|
|
createSocialPage(page: InsertSocialPage): Promise<SocialPage>;
|
|
updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage>;
|
|
deleteSocialPage(id: string): Promise<void>;
|
|
|
|
// Media
|
|
getMedia(userId: string, options?: { limit?: number; offset?: number }): Promise<Media[]>;
|
|
getMediaByIds(ids: string[]): Promise<Media[]>;
|
|
getMediaCount(userId: string): Promise<number>;
|
|
getMediaById(id: string): Promise<Media | undefined>;
|
|
createMedia(media: InsertMedia): Promise<Media>;
|
|
deleteMedia(id: string): Promise<void>;
|
|
getPostMedia(postId: string): Promise<PostMedia[]>;
|
|
|
|
// Posts
|
|
getPosts(userId: string): Promise<Post[]>;
|
|
getPost(id: string): Promise<Post | undefined>;
|
|
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
|
getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]>;
|
|
createPost(post: InsertPost): Promise<Post>;
|
|
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
|
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
|
|
deletePost(id: string): Promise<void>;
|
|
updatePostMedia(postId: string, mediaIds: string[]): Promise<void>;
|
|
|
|
// Scheduled Posts
|
|
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
|
|
getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
|
|
getScheduledPost(id: string): Promise<ScheduledPost | undefined>;
|
|
getScheduledPostsByPost(postId: string): Promise<ScheduledPost[]>;
|
|
createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost>;
|
|
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>;
|
|
getFacebookConfig(): Promise<FacebookConfig | undefined>;
|
|
upsertFacebookConfig(config: Partial<InsertFacebookConfig>): Promise<FacebookConfig>;
|
|
|
|
// Cloudinary Config
|
|
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
|
|
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
|
|
updateCloudinaryConfig(config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig>;
|
|
|
|
// OpenRouter Config
|
|
getOpenrouterConfig(userId: string): Promise<OpenrouterConfig | undefined>;
|
|
getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined>;
|
|
createOpenrouterConfig(config: InsertOpenrouterConfig): Promise<OpenrouterConfig>;
|
|
updateOpenrouterConfig(userId: string, config: Partial<InsertOpenrouterConfig>): Promise<OpenrouterConfig>;
|
|
|
|
// User Page Permissions
|
|
getUserPagePermissions(userId: string): Promise<UserPagePermission[]>;
|
|
getPagePermissions(pageId: string): Promise<UserPagePermission[]>;
|
|
createPagePermission(permission: InsertUserPagePermission): Promise<UserPagePermission>;
|
|
deletePagePermission(userId: string, pageId: string): Promise<void>;
|
|
deleteAllUserPagePermissions(userId: string): Promise<void>;
|
|
getUserAccessiblePages(userId: string): Promise<SocialPage[]>;
|
|
|
|
// Music Favorites
|
|
getMusicFavorites(userId: string): Promise<MusicFavorite[]>;
|
|
addMusicFavorite(favorite: InsertMusicFavorite): Promise<MusicFavorite>;
|
|
removeMusicFavorite(userId: string, trackId: string): Promise<void>;
|
|
isMusicFavorite(userId: string, trackId: string): Promise<boolean>;
|
|
|
|
// Audio Tracks
|
|
getAudioTracks(): Promise<AudioTrack[]>;
|
|
// Audio Tracks
|
|
getAudioTrack(id: string): Promise<AudioTrack | undefined>;
|
|
createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack>;
|
|
deleteAudioTrack(id: string): Promise<void>;
|
|
|
|
// App Config
|
|
getAppConfig(): Promise<AppConfig | undefined>;
|
|
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig>;
|
|
}
|
|
|
|
export class DatabaseStorage implements IStorage {
|
|
// Users
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
const [user] = await db.select().from(users).where(eq(users.id, id));
|
|
return user || undefined;
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
const [user] = await db.select().from(users).where(eq(users.username, username));
|
|
return user || undefined;
|
|
}
|
|
|
|
async getAllUsers(): Promise<User[]> {
|
|
return await db.select().from(users);
|
|
}
|
|
|
|
async createUser(insertUser: InsertUser): Promise<User> {
|
|
const [user] = await db.insert(users).values(insertUser).returning();
|
|
return user;
|
|
}
|
|
|
|
async updateUser(id: string, updateData: Partial<InsertUser>): Promise<User> {
|
|
const [updated] = await db.update(users).set(updateData).where(eq(users.id, id)).returning();
|
|
return updated;
|
|
}
|
|
|
|
async deleteUser(id: string): Promise<void> {
|
|
await db.delete(users).where(eq(users.id, id));
|
|
}
|
|
|
|
/**
|
|
* Compteurs du tableau de bord.
|
|
*
|
|
* Comptés en SQL : les charger en mémoire revenait à lire quatre tables
|
|
* entières — tous les posts, toutes les pages, tous les médias, toutes les
|
|
* générations — pour n'en garder que le nombre de lignes.
|
|
*/
|
|
async getDashboardStats(userId: string, range: DashboardStatsRange): Promise<DashboardStats> {
|
|
const [postCounts, generationCounts, pageCount, mediaCount] = await Promise.all([
|
|
db
|
|
.select({
|
|
scheduled: sql<string>`count(*) filter (where ${posts.status} = 'scheduled')`,
|
|
scheduledLastMonth: sql<string>`count(*) filter (where ${posts.status} = 'scheduled' and ${posts.createdAt} < ${range.lastMonth})`,
|
|
})
|
|
.from(posts)
|
|
.where(eq(posts.userId, userId)),
|
|
db
|
|
.select({
|
|
total: sql<string>`count(*)`,
|
|
yesterday: sql<string>`count(*) filter (where ${aiGenerations.createdAt} >= ${range.yesterdayStart} and ${aiGenerations.createdAt} < ${range.yesterdayEnd})`,
|
|
})
|
|
.from(aiGenerations)
|
|
.where(eq(aiGenerations.userId, userId)),
|
|
db.select({ total: sql<string>`count(*)` }).from(socialPages).where(eq(socialPages.userId, userId)),
|
|
db.select({ total: sql<string>`count(*)` }).from(media).where(eq(media.userId, userId)),
|
|
]);
|
|
|
|
return {
|
|
scheduledPosts: Number(postCounts[0]?.scheduled ?? 0),
|
|
scheduledPostsLastMonth: Number(postCounts[0]?.scheduledLastMonth ?? 0),
|
|
connectedPages: Number(pageCount[0]?.total ?? 0),
|
|
aiTextsGenerated: Number(generationCounts[0]?.total ?? 0),
|
|
aiTextsYesterday: Number(generationCounts[0]?.yesterday ?? 0),
|
|
mediaStored: Number(mediaCount[0]?.total ?? 0),
|
|
};
|
|
}
|
|
|
|
// Social Pages
|
|
async getSocialPages(userId: string): Promise<SocialPage[]> {
|
|
const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId));
|
|
return pages.map(page => decryptPageTokens(page));
|
|
}
|
|
|
|
/**
|
|
* Toutes les pages de l'instance, pour les vues d'administration.
|
|
* Remplace un `getAllUsers()` suivi d'une requête par utilisateur.
|
|
*/
|
|
async getAllSocialPages(): Promise<SocialPage[]> {
|
|
const pages = await db.select().from(socialPages);
|
|
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));
|
|
return page ? decryptPageTokens(page) : undefined;
|
|
}
|
|
|
|
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
|
|
// Chiffrer les tokens avant stockage
|
|
const encryptedPage = {
|
|
...page,
|
|
accessToken: encrypt(page.accessToken),
|
|
...(page.refreshToken ? { refreshToken: encrypt(page.refreshToken) } : {}),
|
|
};
|
|
const [newPage] = await db.insert(socialPages).values(encryptedPage).returning();
|
|
return decryptPageTokens(newPage);
|
|
}
|
|
|
|
async updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage> {
|
|
// 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();
|
|
return decryptPageTokens(updated);
|
|
}
|
|
|
|
async deleteSocialPage(id: string): Promise<void> {
|
|
await db.delete(socialPages).where(eq(socialPages.id, id));
|
|
}
|
|
|
|
// Media
|
|
/**
|
|
* Médias d'un utilisateur, du plus récent au plus ancien.
|
|
* `limit` sert aux écrans qui n'en affichent qu'une poignée : sans lui, la
|
|
* médiathèque entière transitait pour en montrer cinq.
|
|
*/
|
|
async getMedia(userId: string, options: { limit?: number; offset?: number } = {}): Promise<Media[]> {
|
|
let query = db
|
|
.select()
|
|
.from(media)
|
|
.where(eq(media.userId, userId))
|
|
.orderBy(desc(media.createdAt))
|
|
.$dynamic();
|
|
|
|
if (options.limit !== undefined) query = query.limit(options.limit);
|
|
if (options.offset !== undefined) query = query.offset(options.offset);
|
|
|
|
return await query;
|
|
}
|
|
|
|
async getMediaByIds(ids: string[]): Promise<Media[]> {
|
|
if (ids.length === 0) return [];
|
|
return await db.select().from(media).where(inArray(media.id, ids));
|
|
}
|
|
|
|
/** Total de médias, pour que la pagination sache où elle s'arrête. */
|
|
async getMediaCount(userId: string): Promise<number> {
|
|
const [row] = await db
|
|
.select({ total: sql<string>`count(*)` })
|
|
.from(media)
|
|
.where(eq(media.userId, userId));
|
|
return Number(row?.total ?? 0);
|
|
}
|
|
|
|
async getMediaById(id: string): Promise<Media | undefined> {
|
|
const [mediaItem] = await db.select().from(media).where(eq(media.id, id));
|
|
return mediaItem || undefined;
|
|
}
|
|
|
|
async createMedia(mediaItem: InsertMedia): Promise<Media> {
|
|
const [newMedia] = await db.insert(media).values(mediaItem).returning();
|
|
return newMedia;
|
|
}
|
|
|
|
async deleteMedia(id: string): Promise<void> {
|
|
await db.delete(media).where(eq(media.id, id));
|
|
}
|
|
|
|
async getPostMedia(postId: string): Promise<PostMedia[]> {
|
|
return await db.select().from(postMedia).where(eq(postMedia.postId, postId)).orderBy(asc(postMedia.displayOrder));
|
|
}
|
|
|
|
// Posts
|
|
async getPosts(userId: string): Promise<Post[]> {
|
|
return await db.select().from(posts).where(eq(posts.userId, userId)).orderBy(desc(posts.createdAt));
|
|
}
|
|
|
|
async getPost(id: string): Promise<Post | undefined> {
|
|
const [post] = await db.select().from(posts).where(eq(posts.id, id));
|
|
return post || undefined;
|
|
}
|
|
|
|
async getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined> {
|
|
const post = await this.getPost(id);
|
|
if (!post) return undefined;
|
|
|
|
const postMediaLinks = await this.getPostMedia(id);
|
|
|
|
// Une seule requête pour tous les médias, puis remise dans l'ordre
|
|
// d'affichage : une requête par média rendait le coût proportionnel au
|
|
// nombre de visuels du post.
|
|
const byId = new Map(
|
|
(await this.getMediaByIds(postMediaLinks.map(link => link.mediaId))).map(item => [item.id, item])
|
|
);
|
|
const mediaItems = postMediaLinks
|
|
.map(link => byId.get(link.mediaId))
|
|
.filter((item): item is Media => item !== undefined);
|
|
|
|
return { post, media: mediaItems };
|
|
}
|
|
|
|
async createPost(post: InsertPost): Promise<Post> {
|
|
const [newPost] = await db.insert(posts).values(post).returning();
|
|
return newPost;
|
|
}
|
|
|
|
async updatePost(id: string, post: Partial<InsertPost>): Promise<Post> {
|
|
const [updated] = await db.update(posts).set({ ...post, updatedAt: new Date() }).where(eq(posts.id, id)).returning();
|
|
return updated;
|
|
}
|
|
|
|
async deletePost(id: string): Promise<void> {
|
|
await db.delete(posts).where(eq(posts.id, id));
|
|
}
|
|
|
|
async updatePostGenerationStatus(
|
|
id: string,
|
|
status: string,
|
|
progress: number,
|
|
error?: string
|
|
): Promise<Post> {
|
|
const updateData: Record<string, unknown> = {
|
|
generationStatus: status,
|
|
generationProgress: progress,
|
|
updatedAt: new Date(),
|
|
};
|
|
if (error !== undefined) {
|
|
updateData.generationError = error;
|
|
}
|
|
const [updated] = await db
|
|
.update(posts)
|
|
.set(updateData)
|
|
.where(eq(posts.id, id))
|
|
.returning();
|
|
return updated;
|
|
}
|
|
|
|
async updatePostMedia(postId: string, mediaIds: string[]): Promise<void> {
|
|
await db.delete(postMedia).where(eq(postMedia.postId, postId));
|
|
|
|
if (mediaIds.length > 0) {
|
|
const postMediaEntries = mediaIds.map((mediaId, index) => ({
|
|
postId,
|
|
mediaId,
|
|
displayOrder: index,
|
|
}));
|
|
await db.insert(postMedia).values(postMediaEntries);
|
|
}
|
|
}
|
|
|
|
// Scheduled Posts
|
|
async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> {
|
|
return this.queryScheduledPosts(eq(posts.userId, userId), startDate, endDate);
|
|
}
|
|
|
|
async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<any[]> {
|
|
if (pageIds.length === 0) {
|
|
return [];
|
|
}
|
|
return this.queryScheduledPosts(inArray(scheduledPosts.pageId, pageIds), startDate, endDate);
|
|
}
|
|
|
|
/**
|
|
* Publications planifiées, avec le post et la page auxquels elles se rapportent.
|
|
*
|
|
* La période est filtrée en SQL : la charger entière pour la filtrer en
|
|
* mémoire revenait à lire tout l'historique à chaque affichage du calendrier.
|
|
*/
|
|
private async queryScheduledPosts(
|
|
scope: SQL,
|
|
startDate?: Date,
|
|
endDate?: Date
|
|
): Promise<any[]> {
|
|
const conditions: SQL[] = [scope];
|
|
if (startDate) conditions.push(gte(scheduledPosts.scheduledAt, startDate));
|
|
if (endDate) conditions.push(lte(scheduledPosts.scheduledAt, endDate));
|
|
|
|
const rows = await db
|
|
.select(SCHEDULED_POST_COLUMNS)
|
|
.from(scheduledPosts)
|
|
.innerJoin(posts, eq(scheduledPosts.postId, posts.id))
|
|
.leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id))
|
|
.where(conditions.length === 1 ? conditions[0] : and(...conditions))
|
|
.orderBy(desc(scheduledPosts.scheduledAt));
|
|
|
|
// La jointure externe renvoie un objet aux champs nuls quand la page a été
|
|
// supprimée ; les appelants attendent `page: null` dans ce cas.
|
|
return rows.map(row => ({ ...row, page: row.page?.id ? row.page : null }));
|
|
}
|
|
|
|
async getScheduledPost(id: string): Promise<ScheduledPost | undefined> {
|
|
const [scheduledPost] = await db.select().from(scheduledPosts).where(eq(scheduledPosts.id, id));
|
|
return scheduledPost || undefined;
|
|
}
|
|
|
|
async getScheduledPostsByPost(postId: string): Promise<ScheduledPost[]> {
|
|
return await db.select().from(scheduledPosts).where(eq(scheduledPosts.postId, postId));
|
|
}
|
|
|
|
async createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost> {
|
|
const [newScheduledPost] = await db.insert(scheduledPosts).values(scheduledPost).returning();
|
|
return newScheduledPost;
|
|
}
|
|
|
|
async updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost> {
|
|
const [updated] = await db.update(scheduledPosts).set(scheduledPost).where(eq(scheduledPosts.id, id)).returning();
|
|
return updated;
|
|
}
|
|
|
|
async deleteScheduledPost(id: string): Promise<void> {
|
|
await db.delete(scheduledPosts).where(eq(scheduledPosts.id, id));
|
|
}
|
|
|
|
async getPendingScheduledPosts(): Promise<ScheduledPost[]> {
|
|
const now = new Date();
|
|
const results = await db
|
|
.select()
|
|
.from(scheduledPosts)
|
|
.where(
|
|
and(
|
|
isNull(scheduledPosts.publishedAt),
|
|
lte(scheduledPosts.scheduledAt, now)
|
|
)
|
|
);
|
|
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)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reels dont la génération est en cours, plus ceux qui ont échoué dans les
|
|
* dernières 24 h (pour que l'utilisateur en voie la cause), avec l'étape
|
|
* courante du rendu. Filtré en SQL : la route est interrogée toutes les
|
|
* trois secondes pendant un rendu.
|
|
*/
|
|
async getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]> {
|
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
const rows = await db
|
|
.select()
|
|
.from(posts)
|
|
.where(
|
|
and(
|
|
eq(posts.userId, userId),
|
|
or(
|
|
inArray(posts.generationStatus, ['processing', 'pending']),
|
|
and(eq(posts.generationStatus, 'failed'), gte(posts.updatedAt, since), isNotNull(posts.generationError)),
|
|
),
|
|
)
|
|
)
|
|
.orderBy(desc(posts.createdAt));
|
|
if (rows.length === 0) return [];
|
|
|
|
const jobs = await db
|
|
.select({ postId: reelJobs.postId, step: reelJobs.step })
|
|
.from(reelJobs)
|
|
.where(inArray(reelJobs.postId, rows.map((p) => p.id)));
|
|
const stepByPost = new Map(jobs.map((j) => [j.postId, j.step]));
|
|
return rows.map((post) => ({ ...post, generationStep: stepByPost.get(post.id) ?? null }));
|
|
}
|
|
|
|
// AI Generations
|
|
async getAiGenerations(userId: string): Promise<AiGeneration[]> {
|
|
return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt));
|
|
}
|
|
|
|
async createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration> {
|
|
const [newGeneration] = await db.insert(aiGenerations).values(generation).returning();
|
|
return newGeneration;
|
|
}
|
|
|
|
// Cloudinary Config
|
|
async getCloudinaryConfig(): Promise<CloudinaryConfig | undefined> {
|
|
const [config] = await db.select().from(cloudinaryConfig).limit(1);
|
|
return config || undefined;
|
|
}
|
|
|
|
async createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig> {
|
|
const [newConfig] = await db.insert(cloudinaryConfig).values(config).returning();
|
|
return newConfig;
|
|
}
|
|
|
|
async updateCloudinaryConfig(config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig> {
|
|
const existingConfig = await this.getCloudinaryConfig();
|
|
if (!existingConfig) throw new Error("Cloudinary config not initialized");
|
|
|
|
const [updated] = await db.update(cloudinaryConfig)
|
|
.set({ ...config, updatedAt: new Date() })
|
|
.where(eq(cloudinaryConfig.id, existingConfig.id))
|
|
.returning();
|
|
return updated;
|
|
}
|
|
|
|
// OpenRouter Config
|
|
async getOpenrouterConfig(userId: string): Promise<OpenrouterConfig | undefined> {
|
|
const [config] = await db.select().from(openrouterConfig).where(eq(openrouterConfig.userId, userId));
|
|
return config || undefined;
|
|
}
|
|
|
|
async getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined> {
|
|
const [config] = await db.select().from(openrouterConfig).orderBy(desc(openrouterConfig.updatedAt)).limit(1);
|
|
return config || undefined;
|
|
}
|
|
|
|
async createOpenrouterConfig(config: InsertOpenrouterConfig): Promise<OpenrouterConfig> {
|
|
const [newConfig] = await db.insert(openrouterConfig).values(config).returning();
|
|
return newConfig;
|
|
}
|
|
|
|
async updateOpenrouterConfig(userId: string, config: Partial<InsertOpenrouterConfig>): Promise<OpenrouterConfig> {
|
|
const [updated] = await db.update(openrouterConfig)
|
|
.set({ ...config, updatedAt: new Date() })
|
|
.where(eq(openrouterConfig.userId, userId))
|
|
.returning();
|
|
return updated;
|
|
}
|
|
|
|
// User Page Permissions
|
|
async getUserPagePermissions(userId: string): Promise<UserPagePermission[]> {
|
|
return await db.select().from(userPagePermissions).where(eq(userPagePermissions.userId, userId));
|
|
}
|
|
|
|
async getPagePermissions(pageId: string): Promise<UserPagePermission[]> {
|
|
return await db.select().from(userPagePermissions).where(eq(userPagePermissions.pageId, pageId));
|
|
}
|
|
|
|
async createPagePermission(permission: InsertUserPagePermission): Promise<UserPagePermission> {
|
|
const [newPermission] = await db.insert(userPagePermissions).values(permission).returning();
|
|
return newPermission;
|
|
}
|
|
|
|
async deletePagePermission(userId: string, pageId: string): Promise<void> {
|
|
await db.delete(userPagePermissions).where(
|
|
and(
|
|
eq(userPagePermissions.userId, userId),
|
|
eq(userPagePermissions.pageId, pageId)
|
|
)
|
|
);
|
|
}
|
|
|
|
async deleteAllUserPagePermissions(userId: string): Promise<void> {
|
|
await db.delete(userPagePermissions).where(eq(userPagePermissions.userId, userId));
|
|
}
|
|
|
|
async getUserAccessiblePages(userId: string): Promise<SocialPage[]> {
|
|
const permissions = await db
|
|
.select()
|
|
.from(userPagePermissions)
|
|
.innerJoin(socialPages, eq(userPagePermissions.pageId, socialPages.id))
|
|
.where(eq(userPagePermissions.userId, userId));
|
|
|
|
return permissions.map(p => p.social_pages);
|
|
}
|
|
|
|
// Music Favorites
|
|
async getMusicFavorites(userId: string): Promise<MusicFavorite[]> {
|
|
return db.select().from(musicFavorites)
|
|
.where(eq(musicFavorites.userId, userId))
|
|
.orderBy(desc(musicFavorites.createdAt));
|
|
}
|
|
|
|
async addMusicFavorite(favorite: InsertMusicFavorite): Promise<MusicFavorite> {
|
|
const [created] = await db.insert(musicFavorites).values(favorite).returning();
|
|
return created;
|
|
}
|
|
|
|
async removeMusicFavorite(userId: string, trackId: string): Promise<void> {
|
|
await db.delete(musicFavorites).where(
|
|
and(
|
|
eq(musicFavorites.userId, userId),
|
|
eq(musicFavorites.trackId, trackId)
|
|
)
|
|
);
|
|
}
|
|
|
|
async isMusicFavorite(userId: string, trackId: string): Promise<boolean> {
|
|
const [fav] = await db.select().from(musicFavorites).where(
|
|
and(
|
|
eq(musicFavorites.userId, userId),
|
|
eq(musicFavorites.trackId, trackId)
|
|
)
|
|
);
|
|
return !!fav;
|
|
}
|
|
|
|
// Audio Tracks
|
|
async getAudioTracks(): Promise<AudioTrack[]> {
|
|
return db.select().from(audioTracks).orderBy(desc(audioTracks.createdAt));
|
|
}
|
|
|
|
async getAudioTrack(id: string): Promise<AudioTrack | undefined> {
|
|
const [track] = await db.select().from(audioTracks).where(eq(audioTracks.id, id));
|
|
return track || undefined;
|
|
}
|
|
|
|
async createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack> {
|
|
const [newTrack] = await db.insert(audioTracks).values(track).returning();
|
|
return newTrack;
|
|
}
|
|
|
|
async deleteAudioTrack(id: string): Promise<void> {
|
|
await db.delete(audioTracks).where(eq(audioTracks.id, id));
|
|
}
|
|
|
|
// App Config
|
|
async getAppConfig(): Promise<AppConfig | undefined> {
|
|
const [config] = await db.select().from(appConfig).limit(1);
|
|
return config || undefined;
|
|
}
|
|
|
|
// Facebook Config (application développeur, globale à l'instance)
|
|
async getFacebookConfig(): Promise<FacebookConfig | undefined> {
|
|
const [config] = await db.select().from(facebookConfig).limit(1);
|
|
if (!config) return undefined;
|
|
return { ...config, appSecret: decrypt(config.appSecret) };
|
|
}
|
|
|
|
async upsertFacebookConfig(data: Partial<InsertFacebookConfig>): Promise<FacebookConfig> {
|
|
const values: Partial<FacebookConfig> = { ...data };
|
|
if (data.appSecret) {
|
|
values.appSecret = encrypt(data.appSecret);
|
|
}
|
|
|
|
const [existing] = await db.select().from(facebookConfig).limit(1);
|
|
if (existing) {
|
|
const [updated] = await db.update(facebookConfig)
|
|
.set({ ...values, updatedAt: new Date() })
|
|
.where(eq(facebookConfig.id, existing.id))
|
|
.returning();
|
|
return { ...updated, appSecret: decrypt(updated.appSecret) };
|
|
}
|
|
|
|
if (!values.appId || !values.appSecret) {
|
|
throw new Error("L'App ID et l'App Secret Facebook sont requis");
|
|
}
|
|
|
|
const [created] = await db.insert(facebookConfig)
|
|
.values({ appId: values.appId, appSecret: values.appSecret })
|
|
.returning();
|
|
return { ...created, appSecret: decrypt(created.appSecret) };
|
|
}
|
|
|
|
// 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) {
|
|
const [updated] = await db.update(appConfig)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(eq(appConfig.id, existing.id))
|
|
.returning();
|
|
return updated;
|
|
}
|
|
const [created] = await db.insert(appConfig).values(data).returning();
|
|
return created;
|
|
}
|
|
}
|
|
|
|
export const storage = new DatabaseStorage();
|