mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-12 01:36:54 +02:00
feat: add external API for publishing posts with image URL and scheduling
- New POST /api/v1/publish endpoint: download image from URL, create post and schedule it per page - New GET /api/v1/pages endpoint: list available pages for external callers - API key auth via X-API-Key header, configurable from admin settings (stored in DB) - New app_config table (migration included) to store the external API key - Admin-only settings section (desktop + mobile) to set/revoke the key - Fallback to EXTERNAL_API_KEY env var if no DB key is configured Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e6fe9fe31
commit
391acfdb83
9 files changed
+539
-2
No files matched your search
@@ -37,3 +37,10 @@ MINIO_PUBLIC_URL=https://media.example.com
|
||||
# Optionnel: Configuration Instagram API
|
||||
# INSTAGRAM_APP_ID=
|
||||
# INSTAGRAM_APP_SECRET=
|
||||
|
||||
# ==========================================
|
||||
# API EXTERNE
|
||||
# ==========================================
|
||||
# Clé d'accès pour l'API externe /api/v1/*
|
||||
# Générer avec: openssl rand -hex 32
|
||||
EXTERNAL_API_KEY=your-secure-random-key-here
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings as SettingsIcon, Bell, Brain, Cloud, Image, Upload, X } from "lucide-react";
|
||||
import { Settings as SettingsIcon, Bell, Brain, Cloud, Image, Upload, X, Plug, Key } from "lucide-react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
@@ -27,6 +27,7 @@ export default function SettingsMobile() {
|
||||
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("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.");
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [externalApiKey, setExternalApiKey] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||
@@ -70,6 +71,36 @@ export default function SettingsMobile() {
|
||||
|
||||
const hasExistingConfig = !!openrouterConfig;
|
||||
|
||||
const { data: externalApiConfig } = useQuery({
|
||||
queryKey: ['/api/settings/external-api'],
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured;
|
||||
|
||||
const saveExternalApiMutation = useMutation({
|
||||
mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
|
||||
setExternalApiKey("");
|
||||
toast({ title: "Clé API sauvegardée", description: "La clé API externe a été enregistrée" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Erreur", description: "Impossible de sauvegarder la clé API", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteExternalApiMutation = useMutation({
|
||||
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
|
||||
toast({ title: "Clé API supprimée", description: "L'accès à l'API externe a été révoqué" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Erreur", description: "Impossible de supprimer la clé API", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const saveCloudinaryMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload: any = {
|
||||
@@ -489,6 +520,61 @@ export default function SettingsMobile() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* API externe */}
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plug className="w-5 h-5" />
|
||||
API externe
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Clé d'accès pour créer et programmer des publications via l'API externe
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-5 pt-0">
|
||||
{hasExistingExternalApiConfig && (
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Clé active</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteExternalApiMutation.mutate()}
|
||||
disabled={deleteExternalApiMutation.isPending}
|
||||
>
|
||||
{deleteExternalApiMutation.isPending ? "..." : "Révoquer"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="externalApiKeyMobile">
|
||||
{hasExistingExternalApiConfig ? "Nouvelle clé (remplace l'actuelle)" : "Clé API"}
|
||||
</Label>
|
||||
<Input
|
||||
id="externalApiKeyMobile"
|
||||
type="password"
|
||||
value={externalApiKey}
|
||||
onChange={(e) => setExternalApiKey(e.target.value)}
|
||||
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé..." : "Entrez une clé sécurisée"}
|
||||
className="min-h-[48px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Header <code className="bg-muted px-1 rounded">X-API-Key</code> dans vos requêtes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveExternalApiMutation.mutate()}
|
||||
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
||||
className="w-full min-h-[48px]"
|
||||
>
|
||||
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video } from "lucide-react";
|
||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug } from "lucide-react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
@@ -29,6 +29,7 @@ export default function Settings() {
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [ffmpegApiUrl, setFfmpegApiUrl] = useState("");
|
||||
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
||||
const [externalApiKey, setExternalApiKey] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||
@@ -54,6 +55,11 @@ export default function Settings() {
|
||||
queryKey: ['/api/ffmpeg/config'],
|
||||
});
|
||||
|
||||
const { data: externalApiConfig } = useQuery({
|
||||
queryKey: ['/api/settings/external-api'],
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (cloudinaryConfig) {
|
||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||
@@ -251,6 +257,31 @@ export default function Settings() {
|
||||
},
|
||||
});
|
||||
|
||||
const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured;
|
||||
|
||||
const saveExternalApiMutation = useMutation({
|
||||
mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
|
||||
setExternalApiKey("");
|
||||
toast({ title: "Clé API sauvegardée", description: "La clé API externe a été enregistrée" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Erreur", description: "Impossible de sauvegarder la clé API", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteExternalApiMutation = useMutation({
|
||||
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
|
||||
toast({ title: "Clé API supprimée", description: "L'accès à l'API externe a été révoqué" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Erreur", description: "Impossible de supprimer la clé API", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const saveFfmpegMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload: any = {
|
||||
@@ -689,6 +720,60 @@ export default function Settings() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plug className="w-5 h-5" />
|
||||
API externe
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Clé d'accès pour créer et programmer des publications via l'API externe (<code className="text-xs bg-muted px-1 rounded">POST /api/v1/publish</code>)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasExistingExternalApiConfig && (
|
||||
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Clé API configurée et active</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteExternalApiMutation.mutate()}
|
||||
disabled={deleteExternalApiMutation.isPending}
|
||||
>
|
||||
{deleteExternalApiMutation.isPending ? "Suppression..." : "Révoquer"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="externalApiKey">
|
||||
{hasExistingExternalApiConfig ? "Nouvelle clé API (remplace l'actuelle)" : "Clé API"}
|
||||
</Label>
|
||||
<Input
|
||||
id="externalApiKey"
|
||||
type="password"
|
||||
value={externalApiKey}
|
||||
onChange={(e) => setExternalApiKey(e.target.value)}
|
||||
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé pour remplacer l'actuelle" : "Générez une clé sécurisée"}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Utilisez cette clé dans le header <code className="bg-muted px-1 rounded">X-API-Key</code> de vos requêtes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveExternalApiMutation.mutate()}
|
||||
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { storage } from "../storage";
|
||||
|
||||
export async function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
const provided = req.headers["x-api-key"];
|
||||
|
||||
if (!provided) {
|
||||
return res.status(401).json({ error: "Clé API manquante (header X-API-Key requis)" });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await storage.getAppConfig();
|
||||
const key = config?.externalApiKey || process.env.EXTERNAL_API_KEY;
|
||||
|
||||
if (!key) {
|
||||
return res.status(503).json({ error: "API externe non configurée" });
|
||||
}
|
||||
|
||||
if (provided !== key) {
|
||||
return res.status(401).json({ error: "Clé API invalide" });
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("[apiKey middleware] Error:", error);
|
||||
return res.status(500).json({ error: "Erreur interne lors de la vérification de la clé API" });
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,16 @@ export async function migrate() {
|
||||
);
|
||||
`);
|
||||
|
||||
// app_config (global application settings)
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS "app_config" (
|
||||
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"external_api_key" text,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
console.log("[Migration] Safe migration completed.");
|
||||
} catch (error) {
|
||||
console.error("[Migration] Error during migration:", error);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { freeSoundService } from "./services/freesound";
|
||||
import { analyticsRouter } from "./routes/analytics";
|
||||
import { reelsRouter } from "./routes/reels";
|
||||
import { remotionRouter } from "./routes/remotion";
|
||||
import { externalRouter } from "./routes/external";
|
||||
import { insertAudioTrackSchema } from "@shared/schema";
|
||||
import * as musicMetadata from "music-metadata";
|
||||
|
||||
@@ -382,6 +383,44 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// External API (clé API, pas de session)
|
||||
app.use("/api/v1", externalRouter);
|
||||
|
||||
// Paramètres de l'API externe (admin seulement)
|
||||
app.get("/api/settings/external-api", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await storage.getAppConfig();
|
||||
res.json({ configured: !!(config?.externalApiKey) });
|
||||
} catch (error) {
|
||||
console.error("Error fetching app config:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la récupération de la configuration" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/settings/external-api", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { apiKey } = req.body;
|
||||
if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") {
|
||||
return res.status(400).json({ error: "La clé API est requise" });
|
||||
}
|
||||
await storage.upsertAppConfig({ externalApiKey: apiKey.trim() });
|
||||
res.json({ success: true, configured: true });
|
||||
} catch (error) {
|
||||
console.error("Error saving app config:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/settings/external-api", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await storage.upsertAppConfig({ externalApiKey: null });
|
||||
res.json({ success: true, configured: false });
|
||||
} catch (error) {
|
||||
console.error("Error deleting app config:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la suppression de la clé" });
|
||||
}
|
||||
});
|
||||
|
||||
// Analytics Routes
|
||||
app.use("/api/analytics", requireAuth, analyticsRouter);
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import path from "path";
|
||||
import { storage } from "../storage";
|
||||
import { db } from "../db";
|
||||
import { postMedia } from "@shared/schema";
|
||||
import { minioService } from "../services/minio";
|
||||
import { requireApiKey } from "../middleware/apiKey";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireApiKey);
|
||||
|
||||
const publishSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu est requis"),
|
||||
imageUrl: z.string().url("URL d'image invalide").optional(),
|
||||
pageIds: z.array(z.string()).min(1, "Au moins une page est requise"),
|
||||
scheduledAt: z.string().datetime({ offset: true }).optional(),
|
||||
postType: z.enum(["feed", "story", "both"]).default("feed"),
|
||||
userId: z.string().optional(),
|
||||
});
|
||||
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
};
|
||||
|
||||
async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string; mimeType: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Impossible de télécharger l'image (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")?.split(";")[0].trim() ?? "";
|
||||
const ext = IMAGE_MIME_TYPES[contentType];
|
||||
|
||||
if (!ext) {
|
||||
throw new Error(`Type MIME non supporté: ${contentType}. Formats acceptés: JPEG, PNG, WebP, GIF`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
if (buffer.length > MAX_SIZE) {
|
||||
throw new Error(`Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(1)} MB, max 10 MB)`);
|
||||
}
|
||||
|
||||
return { buffer, ext, mimeType: contentType };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/publish
|
||||
*
|
||||
* Crée une publication (avec image optionnelle téléchargée depuis une URL)
|
||||
* et la programme pour une ou plusieurs pages Facebook/Instagram.
|
||||
*
|
||||
* Headers:
|
||||
* X-API-Key: <EXTERNAL_API_KEY>
|
||||
*
|
||||
* Body:
|
||||
* content string Texte de la publication
|
||||
* imageUrl string? URL publique de l'image à télécharger
|
||||
* pageIds string[] IDs des pages cibles (social_pages.id)
|
||||
* scheduledAt ISO8601? Date/heure de publication (absent = immédiat)
|
||||
* postType feed|story|both Type de publication (défaut: feed)
|
||||
* userId string? ID utilisateur propriétaire (défaut: premier admin)
|
||||
*/
|
||||
router.post("/publish", async (req, res) => {
|
||||
try {
|
||||
const parsed = publishSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: "Données invalides",
|
||||
details: parsed.error.flatten().fieldErrors,
|
||||
});
|
||||
}
|
||||
|
||||
const { content, imageUrl, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
|
||||
|
||||
// Résoudre l'utilisateur propriétaire
|
||||
let ownerId = bodyUserId;
|
||||
if (!ownerId) {
|
||||
const users = await storage.getAllUsers();
|
||||
const admin = users.find((u) => u.role === "admin");
|
||||
if (!admin) {
|
||||
return res.status(500).json({ error: "Aucun utilisateur admin trouvé" });
|
||||
}
|
||||
ownerId = admin.id;
|
||||
} else {
|
||||
const user = await storage.getUser(ownerId);
|
||||
if (!user) {
|
||||
return res.status(400).json({ error: `Utilisateur introuvable: ${ownerId}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier que toutes les pages existent
|
||||
const resolvedPages: Array<{ id: string; pageName: string }> = [];
|
||||
for (const pageId of pageIds) {
|
||||
const page = await storage.getSocialPage(pageId);
|
||||
if (!page) {
|
||||
return res.status(400).json({ error: `Page introuvable: ${pageId}` });
|
||||
}
|
||||
resolvedPages.push({ id: page.id, pageName: page.pageName });
|
||||
}
|
||||
|
||||
// Validation story → image obligatoire
|
||||
if ((postType === "story" || postType === "both") && !imageUrl) {
|
||||
return res.status(400).json({ error: "Les stories nécessitent une image (imageUrl requis)" });
|
||||
}
|
||||
|
||||
// Télécharger l'image si fournie
|
||||
let mediaRecord: { id: string; originalUrl: string } | null = null;
|
||||
if (imageUrl) {
|
||||
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
|
||||
|
||||
const fileName = `external-${Date.now()}${ext}`;
|
||||
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
|
||||
|
||||
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
|
||||
mediaRecord = await storage.createMedia({
|
||||
userId: ownerId,
|
||||
type: mediaType,
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
fileName,
|
||||
fileSize: String(buffer.length),
|
||||
});
|
||||
}
|
||||
|
||||
// Créer le post
|
||||
const scheduledFor = scheduledAt ? new Date(scheduledAt) : null;
|
||||
|
||||
const post = await storage.createPost({
|
||||
userId: ownerId,
|
||||
content,
|
||||
status: scheduledFor ? "scheduled" : "scheduled",
|
||||
scheduledFor: scheduledFor ?? new Date(),
|
||||
aiGenerated: false,
|
||||
});
|
||||
|
||||
// Lier le média si présent
|
||||
if (mediaRecord) {
|
||||
await db.insert(postMedia).values({
|
||||
postId: post.id,
|
||||
mediaId: mediaRecord.id,
|
||||
displayOrder: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Créer les scheduled_posts par page
|
||||
const scheduledAt_ = scheduledFor ?? new Date();
|
||||
const createdSchedules: Array<{ pageId: string; pageName: string; scheduledPostId: string }> = [];
|
||||
|
||||
for (const page of resolvedPages) {
|
||||
if (postType === "both") {
|
||||
const story = await storage.createScheduledPost({
|
||||
postId: post.id,
|
||||
pageId: page.id,
|
||||
postType: "story",
|
||||
scheduledAt: scheduledAt_,
|
||||
});
|
||||
const feed = await storage.createScheduledPost({
|
||||
postId: post.id,
|
||||
pageId: page.id,
|
||||
postType: "feed",
|
||||
scheduledAt: scheduledAt_,
|
||||
});
|
||||
createdSchedules.push(
|
||||
{ pageId: page.id, pageName: page.pageName, scheduledPostId: story.id },
|
||||
{ pageId: page.id, pageName: page.pageName, scheduledPostId: feed.id }
|
||||
);
|
||||
} else {
|
||||
const sp = await storage.createScheduledPost({
|
||||
postId: post.id,
|
||||
pageId: page.id,
|
||||
postType: postType,
|
||||
scheduledAt: scheduledAt_,
|
||||
});
|
||||
createdSchedules.push({ pageId: page.id, pageName: page.pageName, scheduledPostId: sp.id });
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
post: {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
status: post.status,
|
||||
scheduledAt: scheduledAt_,
|
||||
postType,
|
||||
pages: createdSchedules,
|
||||
media: mediaRecord
|
||||
? { id: mediaRecord.id, url: mediaRecord.originalUrl }
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[external API] Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Erreur interne";
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/pages
|
||||
*
|
||||
* Liste toutes les pages disponibles (id + nom + plateforme).
|
||||
* Utile pour connaître les pageIds à passer à /publish.
|
||||
*/
|
||||
router.get("/pages", async (_req, res) => {
|
||||
try {
|
||||
const users = await storage.getAllUsers();
|
||||
const admin = users.find((u) => u.role === "admin");
|
||||
if (!admin) {
|
||||
return res.status(500).json({ error: "Aucun utilisateur admin trouvé" });
|
||||
}
|
||||
|
||||
const pages = await storage.getSocialPages(admin.id);
|
||||
return res.json(
|
||||
pages.map((p) => ({
|
||||
id: p.id,
|
||||
pageName: p.pageName,
|
||||
platform: p.platform,
|
||||
pageId: p.pageId,
|
||||
tokenStatus: p.tokenStatus,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[external API] Error listing pages:", error);
|
||||
return res.status(500).json({ error: "Erreur interne" });
|
||||
}
|
||||
});
|
||||
|
||||
export { router as externalRouter };
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
audioTracks,
|
||||
type AudioTrack,
|
||||
type InsertAudioTrack,
|
||||
appConfig,
|
||||
type AppConfig,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
|
||||
@@ -126,6 +128,10 @@ export interface IStorage {
|
||||
getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined>;
|
||||
createFreesoundConfig(config: InsertFreesoundConfig): Promise<FreesoundConfig>;
|
||||
updateFreesoundConfig(userId: string, config: Partial<InsertFreesoundConfig>): Promise<FreesoundConfig>;
|
||||
|
||||
// App Config
|
||||
getAppConfig(): Promise<AppConfig | undefined>;
|
||||
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey'>>): Promise<AppConfig>;
|
||||
}
|
||||
|
||||
export class DatabaseStorage implements IStorage {
|
||||
@@ -584,6 +590,25 @@ export class DatabaseStorage implements IStorage {
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
// App Config
|
||||
async getAppConfig(): Promise<AppConfig | undefined> {
|
||||
const [config] = await db.select().from(appConfig).limit(1);
|
||||
return config || undefined;
|
||||
}
|
||||
|
||||
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey'>>): 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();
|
||||
@@ -215,6 +215,13 @@ export const musicFavorites = pgTable("music_favorites", {
|
||||
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"),
|
||||
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
|
||||
@@ -477,6 +484,8 @@ export const updateFreesoundConfigSchema = insertFreesoundConfigSchema.omit({
|
||||
});
|
||||
|
||||
export type FreesoundConfig = typeof freesoundConfig.$inferSelect;
|
||||
|
||||
export type AppConfig = typeof appConfig.$inferSelect;
|
||||
export type InsertFreesoundConfig = z.infer<typeof insertFreesoundConfigSchema>;
|
||||
export type UpdateFreesoundConfig = z.infer<typeof updateFreesoundConfigSchema>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user