Add Docker support and installation guide for easy server deployment

Includes a Dockerfile for building the application image and an INSTALLATION.md file detailing the setup process, prerequisites, and helpful commands for managing the application on a private server.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/ds3R7vP
This commit is contained in:
michaelschal committed 2025-10-01 09:52:59 +00:00
1 parent bbad954935
commit f881f668a6
89 files changed
+17095

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import sharp from "sharp";
import { randomUUID } from "crypto";
import path from "path";
import fs from "fs/promises";
interface CropDimensions {
width: number;
height: number;
}
interface ProcessedImages {
facebookFeed: string;
instagramFeed: string;
instagramStory: string;
}
const CROP_FORMATS = {
facebookFeed: { width: 1200, height: 630 } as CropDimensions,
instagramFeed: { width: 1080, height: 1080 } as CropDimensions,
instagramStory: { width: 1080, height: 1920 } as CropDimensions,
};
export class ImageProcessor {
private uploadsDir: string;
constructor() {
this.uploadsDir = path.join(process.cwd(), "uploads");
this.ensureUploadsDirExists();
}
private async ensureUploadsDirExists() {
try {
await fs.access(this.uploadsDir);
} catch {
await fs.mkdir(this.uploadsDir, { recursive: true });
}
}
async processImage(buffer: Buffer, originalFilename: string): Promise<ProcessedImages> {
const fileExt = path.extname(originalFilename);
const baseId = randomUUID();
const processedImages: ProcessedImages = {
facebookFeed: "",
instagramFeed: "",
instagramStory: "",
};
// Process Facebook Feed (1200x630)
const fbFeedFilename = `${baseId}-fb-feed${fileExt}`;
const fbFeedPath = path.join(this.uploadsDir, fbFeedFilename);
await sharp(buffer)
.resize(CROP_FORMATS.facebookFeed.width, CROP_FORMATS.facebookFeed.height, {
fit: "cover",
position: "center",
})
.toFile(fbFeedPath);
processedImages.facebookFeed = `/uploads/${fbFeedFilename}`;
// Process Instagram Feed (1080x1080)
const igFeedFilename = `${baseId}-ig-feed${fileExt}`;
const igFeedPath = path.join(this.uploadsDir, igFeedFilename);
await sharp(buffer)
.resize(CROP_FORMATS.instagramFeed.width, CROP_FORMATS.instagramFeed.height, {
fit: "cover",
position: "center",
})
.toFile(igFeedPath);
processedImages.instagramFeed = `/uploads/${igFeedFilename}`;
// Process Instagram Story (1080x1920)
const igStoryFilename = `${baseId}-ig-story${fileExt}`;
const igStoryPath = path.join(this.uploadsDir, igStoryFilename);
await sharp(buffer)
.resize(CROP_FORMATS.instagramStory.width, CROP_FORMATS.instagramStory.height, {
fit: "cover",
position: "center",
})
.toFile(igStoryPath);
processedImages.instagramStory = `/uploads/${igStoryFilename}`;
return processedImages;
}
async saveOriginalFile(buffer: Buffer, filename: string): Promise<string> {
const uniqueFilename = `${randomUUID()}-${filename}`;
const filePath = path.join(this.uploadsDir, uniqueFilename);
await fs.writeFile(filePath, buffer);
return `/uploads/${uniqueFilename}`;
}
async deleteFile(filePath: string): Promise<void> {
try {
const fullPath = path.join(process.cwd(), filePath);
await fs.unlink(fullPath);
} catch (error) {
console.error("Error deleting file:", error);
}
}
}
export const imageProcessor = new ImageProcessor();
+148
View File
@@ -0,0 +1,148 @@
interface ProductInfo {
name: string;
description?: string;
price?: string;
features?: string[];
[key: string]: any;
}
interface GeneratedText {
variant: string;
text: string;
characterCount: number;
}
export class OpenRouterService {
private apiKey: string;
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
constructor() {
this.apiKey = process.env.OPENROUTER_API_KEY || "";
if (!this.apiKey) {
console.warn("OPENROUTER_API_KEY not set. AI text generation will not work.");
}
}
async generatePostText(productInfo: ProductInfo): Promise<GeneratedText[]> {
if (!this.apiKey) {
throw new Error("OpenRouter API key not configured");
}
const prompt = this.buildPrompt(productInfo);
try {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": process.env.APP_URL || "http://localhost:5000",
"X-Title": "Social Flow"
},
body: JSON.stringify({
model: "anthropic/claude-3.5-sonnet",
messages: [
{
role: "user",
content: prompt
}
],
temperature: 0.8,
max_tokens: 1000
})
});
if (!response.ok) {
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content || "";
return this.parseGeneratedText(content);
} catch (error) {
console.error("Error generating text with OpenRouter:", error);
throw error;
}
}
private buildPrompt(productInfo: ProductInfo): string {
const features = productInfo.features?.join(", ") || "";
return `Tu es un expert en marketing sur les réseaux sociaux. Génère 3 variantes de texte pour une publication Facebook/Instagram pour ce produit:
Nom: ${productInfo.name}
${productInfo.description ? `Description: ${productInfo.description}` : ''}
${productInfo.price ? `Prix: ${productInfo.price}` : ''}
${features ? `Caractéristiques: ${features}` : ''}
Crée 3 versions différentes:
1. Version Dynamique - Énergique et engageante avec des emojis
2. Version Informative - Factuelle et professionnelle
3. Version Émotionnelle - Axée sur les bénéfices émotionnels
Chaque version doit:
- Être en français
- Faire entre 150-250 caractères
- Être optimisée pour les réseaux sociaux
- Inclure des emojis pertinents (sauf la version informative)
Format de réponse:
VERSION 1 - DYNAMIQUE:
[texte]
VERSION 2 - INFORMATIVE:
[texte]
VERSION 3 - ÉMOTIONNELLE:
[texte]`;
}
private parseGeneratedText(content: string): GeneratedText[] {
const variants: GeneratedText[] = [];
const version1Match = content.match(/VERSION 1 - DYNAMIQUE:\s*\n([\s\S]*?)(?=VERSION 2|$)/i);
const version2Match = content.match(/VERSION 2 - INFORMATIVE:\s*\n([\s\S]*?)(?=VERSION 3|$)/i);
const version3Match = content.match(/VERSION 3 - ÉMOTIONNELLE:\s*\n([\s\S]*?)$/i);
if (version1Match) {
const text = version1Match[1].trim();
variants.push({
variant: "Version 1 - Dynamique",
text,
characterCount: text.length
});
}
if (version2Match) {
const text = version2Match[1].trim();
variants.push({
variant: "Version 2 - Informative",
text,
characterCount: text.length
});
}
if (version3Match) {
const text = version3Match[1].trim();
variants.push({
variant: "Version 3 - Émotionnelle",
text,
characterCount: text.length
});
}
// Fallback if parsing fails
if (variants.length === 0) {
variants.push({
variant: "Généré par IA",
text: content.trim(),
characterCount: content.trim().length
});
}
return variants;
}
}
export const openRouterService = new OpenRouterService();
+69
View File
@@ -0,0 +1,69 @@
import cron, { ScheduledTask } from "node-cron";
import { storage } from "../storage";
export class SchedulerService {
private task: ScheduledTask | null = null;
start() {
// Run every minute to check for pending posts
this.task = cron.schedule("* * * * *", async () => {
await this.processPendingPosts();
});
console.log("Scheduler service started");
}
stop() {
if (this.task) {
this.task.stop();
console.log("Scheduler service stopped");
}
}
private async processPendingPosts() {
try {
const pendingPosts = await storage.getPendingScheduledPosts();
for (const scheduledPost of pendingPosts) {
try {
await this.publishPost(scheduledPost);
} catch (error) {
console.error(`Error publishing post ${scheduledPost.id}:`, error);
await storage.updateScheduledPost(scheduledPost.id, {
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
} catch (error) {
console.error("Error processing pending posts:", error);
}
}
private async publishPost(scheduledPost: any) {
const post = await storage.getPost(scheduledPost.postId);
const page = await storage.getSocialPage(scheduledPost.pageId);
if (!post || !page) {
throw new Error("Post or page not found");
}
// In a real implementation, this would call Facebook/Instagram Graph API
// For now, we'll simulate the publishing
console.log(`Publishing post ${post.id} to ${page.platform} page ${page.pageName}`);
// Update the scheduled post as published
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: `simulated-${Date.now()}`, // In real app, this would be the ID from FB/IG
});
// Update post status
await storage.updatePost(post.id, {
status: "published",
});
console.log(`Successfully published post ${post.id}`);
}
}
export const schedulerService = new SchedulerService();