Optimizare scripturi build/CI si functionalitati noi Lot 2
- CI/CD GitLab (.gitlab-ci.yml): verify -> test -> build -> publish -> deploy cu health-gate (/api/v3/health/all) + rollback manual - Publicare pe retele sociale (LinkedIn/Facebook) din Analysis History (social.ts, linkedin.ts, facebook.ts, SocialPostModal) - Specificatii OpenAPI: agent-v3 (87 op.) si didi-framework (287 op.) - Matrice testare acceptanta beneficiar + script dovezi API - Fix nginx SSL: redirect port 3001 (error_page 497, absolute_redirect off) - Adrese interne inlocuite cu hostname-uri generice (.local)
This commit is contained in:
parent
8ecc78e729
commit
7e4f23d4c4
22 changed files with 19477 additions and 110 deletions
4725
backend/services/api-docs/agent-v3.yaml
Normal file
4725
backend/services/api-docs/agent-v3.yaml
Normal file
File diff suppressed because it is too large
Load diff
14000
backend/services/api-docs/didi-framework.yaml
Normal file
14000
backend/services/api-docs/didi-framework.yaml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -215,7 +215,7 @@
|
|||
"serviceAccountsEnabled": false,
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256",
|
||||
"post.logout.redirect.uris": "http://localhost:13003/* http://localhost:3003/* http://localhost:33003/* http://10.11.50.11:33003/*"
|
||||
"post.logout.redirect.uris": "http://localhost:13003/* http://localhost:3003/* http://localhost:33003/* http://ext.local:33003/*"
|
||||
},
|
||||
"redirectUris": [
|
||||
"http://localhost:13003/*",
|
||||
|
|
@ -227,8 +227,8 @@
|
|||
"http://127.0.0.1:3003/*",
|
||||
"http://127.0.0.1:33003/*",
|
||||
"http://127.0.0.1:33001/*",
|
||||
"http://10.11.50.11:33003/*",
|
||||
"http://10.11.50.11:3003/*"
|
||||
"http://ext.local:33003/*",
|
||||
"http://ext.local:3003/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:13003",
|
||||
|
|
@ -240,8 +240,8 @@
|
|||
"http://127.0.0.1:3003",
|
||||
"http://127.0.0.1:33003",
|
||||
"http://127.0.0.1:33001",
|
||||
"http://10.11.50.11:33003",
|
||||
"http://10.11.50.11:3003",
|
||||
"http://ext.local:33003",
|
||||
"http://ext.local:3003",
|
||||
"+"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ _info:
|
|||
|
||||
# ============================================================
|
||||
# DIDI tenant configuration for shared Kong cluster
|
||||
# Cluster CP: 10.11.10.176:8001 | DP1: 10.11.10.177 | DP2: 10.11.10.178 | LB: 10.11.10.175
|
||||
# Cluster CP: kong-cp.local:8001 | DP1: kong-dp1.local | DP2: kong-dp2.local | LB: kong-lb.local
|
||||
# Hosts: didi365.eu (public) + www.didi365.eu + localhost (internal alias)
|
||||
# Upstreams: 10.11.10.12 (DIDI host) on exposed ports
|
||||
# Upstreams: didi.local (DIDI host) on exposed ports
|
||||
# Plugins are applied per-service (NOT global) — cluster shared with lege365/rafai/biddie/notify
|
||||
# ============================================================
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ services:
|
|||
# ----------------------------------------------------------
|
||||
- name: didi-agent-v3
|
||||
protocol: http
|
||||
host: 10.11.10.12
|
||||
host: didi.local
|
||||
port: 24803
|
||||
retries: 5
|
||||
connect_timeout: 60000
|
||||
|
|
@ -430,7 +430,7 @@ services:
|
|||
# ----------------------------------------------------------
|
||||
- name: didi-framework
|
||||
protocol: http
|
||||
host: 10.11.10.12
|
||||
host: didi.local
|
||||
port: 3005
|
||||
retries: 5
|
||||
connect_timeout: 60000
|
||||
|
|
@ -525,7 +525,7 @@ services:
|
|||
tags: [product:didi, env:prod]
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# NOTE: didi-admin NOT migrated — internal-only (VPN access to 10.11.10.12 directly).
|
||||
# NOTE: didi-admin NOT migrated — internal-only (VPN access to didi.local directly).
|
||||
# Will be revisited after admin nginx is replaced with simpler setup.
|
||||
# ----------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -33,11 +33,11 @@ EXTRACTORS_URL=http://<HOST_IP>:54400
|
|||
# Lot 1 — Platforma AI: TOATE URL-urile serviciilor AI, configurabile per deployment.
|
||||
# Pe o mașină nouă cu Lot 1 propriu, schimbă doar host-urile de mai jos.
|
||||
# ============================================================================
|
||||
LLM_ROUTER_URL=http://10.11.10.17:14011 # llm-inference (Qwen text/OCR)
|
||||
VISION_LLM_URL=http://10.11.10.17:14011 # Qwen Vision (fallback la LLM_ROUTER_URL)
|
||||
DIDI_BRAIN_URL=http://10.11.10.12:8090 # brain (verification cache + RAG)
|
||||
M17_WHISPER_URL=http://10.11.10.17:54300/v1/audio/transcriptions # transcriere audio
|
||||
M17_WEB_API_URL=http://10.11.10.13:51100 # web search (claims/source)
|
||||
LLM_ROUTER_URL=http://llm.local:14011 # llm-inference (Qwen text/OCR)
|
||||
VISION_LLM_URL=http://llm.local:14011 # Qwen Vision (fallback la LLM_ROUTER_URL)
|
||||
DIDI_BRAIN_URL=http://didi.local:8090 # brain (verification cache + RAG)
|
||||
M17_WHISPER_URL=http://llm.local:54300/v1/audio/transcriptions # transcriere audio
|
||||
M17_WEB_API_URL=http://websearch.local:51100 # web search (claims/source)
|
||||
FORENSIC_API_URL=http://forensic-features-api:8080 # forensic m25-m29
|
||||
DOMAIN_CHECK_API_URL=http://domain-check-api:11000/api/v1/check/check # domain check
|
||||
INTERNAL_MEDIA_URL=http://didi-agent-v3:24803 # URL intern media pt modelele locale
|
||||
|
|
|
|||
|
|
@ -32,13 +32,13 @@ x-worker-env: &worker-env
|
|||
JWT_VERIFY_ENABLED: ${JWT_VERIFY_ENABLED:-true}
|
||||
FRAMEWORK_API_URL: http://didi-framework:3005
|
||||
SYNC_API_URL: http://didi-framework:3005/api/sync-analysis
|
||||
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://10.11.10.11:8443}
|
||||
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://api.local:8443}
|
||||
INTERNAL_MEDIA_URL: ${INTERNAL_MEDIA_URL:-http://didi-agent-v3:24803}
|
||||
# === Lot 1 (platforma AI) — toate configurabile per deployment via .env ===
|
||||
LLM_ROUTER_URL: ${LLM_ROUTER_URL:-http://10.11.10.17:14011}
|
||||
VISION_LLM_URL: ${VISION_LLM_URL:-http://10.11.10.17:14011}
|
||||
LLM_ROUTER_URL: ${LLM_ROUTER_URL:-http://llm.local:14011}
|
||||
VISION_LLM_URL: ${VISION_LLM_URL:-http://llm.local:14011}
|
||||
DOMAIN_CHECK_API_URL: ${DOMAIN_CHECK_API_URL:-http://domain-check-api:11000/api/v1/check/check}
|
||||
M17_WEB_API_URL: ${M17_WEB_API_URL:-http://10.11.10.13:51100}
|
||||
M17_WEB_API_URL: ${M17_WEB_API_URL:-http://websearch.local:51100}
|
||||
DIDI_BRAIN_URL: ${DIDI_BRAIN_URL:-http://didibrain-api:8090}
|
||||
# OpenTelemetry — traces export to OTel Collector → Jaeger
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://didi-otel-collector:4317}
|
||||
|
|
@ -47,7 +47,7 @@ x-worker-env: &worker-env
|
|||
# to /api/ingest/event so analyses appear in the unified Insights/History
|
||||
# view (module=agent_v3). Empty = sink disabled.
|
||||
DASHBOARD_URL: ${DASHBOARD_URL:-http://didiAI-dashboard:51300}
|
||||
M17_WHISPER_URL: ${M17_WHISPER_URL:-http://10.11.10.17:54300/v1/audio/transcriptions}
|
||||
M17_WHISPER_URL: ${M17_WHISPER_URL:-http://llm.local:54300/v1/audio/transcriptions}
|
||||
M17_WHISPER_TOKEN: ${M17_WHISPER_TOKEN}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ router.get('/health/all', async (req: Request, res: Response) => {
|
|||
}
|
||||
|
||||
// Brain (didi_brain) — fail-open dependency
|
||||
const brainUrl = process.env.DIDI_BRAIN_URL || 'http://10.11.10.12:8090';
|
||||
const brainUrl = process.env.DIDI_BRAIN_URL || 'http://didi.local:8090';
|
||||
try {
|
||||
const t = Date.now();
|
||||
const r = await fetch(`${brainUrl}/health`, { signal: AbortSignal.timeout(3000) });
|
||||
|
|
@ -93,7 +93,7 @@ router.get('/health/all', async (req: Request, res: Response) => {
|
|||
}
|
||||
|
||||
// LLM router
|
||||
const llmUrl = process.env.LLM_ROUTER_URL || 'http://10.11.10.17:14011';
|
||||
const llmUrl = process.env.LLM_ROUTER_URL || 'http://llm.local:14011';
|
||||
try {
|
||||
const t = Date.now();
|
||||
const r = await fetch(`${llmUrl}/health`, { signal: AbortSignal.timeout(5000) });
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { log } from '../logger';
|
|||
|
||||
// Internal URL for local models that can't reach didi365.eu
|
||||
// Points to MinIO directly since public URLs use /storage/ path (Kong→MinIO)
|
||||
const INTERNAL_MEDIA_BASE = process.env.INTERNAL_MEDIA_URL || 'http://10.11.10.12:9000';
|
||||
const INTERNAL_MEDIA_BASE = process.env.INTERNAL_MEDIA_URL || 'http://didi.local:9000';
|
||||
|
||||
// Vision LLMs may have training cutoffs; without an explicit current date they
|
||||
// can flag content dated e.g. "September 2025" as a "future date" while the
|
||||
|
|
@ -79,7 +79,7 @@ const DEFAULT_VISION_MODELS: VisionModel[] = [
|
|||
model: 'Qwen3.5-397B-A17B',
|
||||
provider: 'qwen-local',
|
||||
// Configurabil per deployment (Lot 1 pe mașina nouă). Fallback = LLM_ROUTER_URL, apoi IP didi.
|
||||
endpoint: (process.env.VISION_LLM_URL || process.env.LLM_ROUTER_URL || 'http://10.11.10.17:14011')
|
||||
endpoint: (process.env.VISION_LLM_URL || process.env.LLM_ROUTER_URL || 'http://llm.local:14011')
|
||||
.replace(/\/+$/, '') + '/v1/chat/completions',
|
||||
auth_type: 'none',
|
||||
timeout: 60000,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Database Configuration
|
||||
DB_HOST=10.11.50.167
|
||||
DB_HOST=db.local
|
||||
DB_PORT=5000
|
||||
DB_NAME=DIDI
|
||||
DB_USER=bos_interface
|
||||
|
|
@ -11,4 +11,10 @@ PORT=3005
|
|||
HOST=0.0.0.0
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
# ── LinkedIn (postare automată pe pagina de organizație — provider paralel cu Facebook) ──
|
||||
# App LinkedIn cu produsul "Community Management API" + OAuth2 scope w_organization_social,
|
||||
# autorizat de un admin al paginii. Detalii: src/services/linkedin.ts
|
||||
LINKEDIN_ACCESS_TOKEN=CHANGE_ME
|
||||
LINKEDIN_ORG_URN=urn:li:organization:CHANGE_ME
|
||||
LINKEDIN_API_VERSION=202506
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ services:
|
|||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: didi-framework
|
||||
# Bound on VLAN 10 IP for Kong cluster (10.11.10.176/177/178) reachability.
|
||||
# Bound on VLAN 10 IP for Kong cluster (kong-cp.local/177/178) reachability.
|
||||
# Internal traffic (other DIDI containers) still uses didi-network DNS.
|
||||
ports:
|
||||
- "3005:3005"
|
||||
|
|
@ -46,6 +46,10 @@ services:
|
|||
- FACEBOOK_PAGE_ID=${FACEBOOK_PAGE_ID:-1152853237912005}
|
||||
- FACEBOOK_PAGE_ACCESS_TOKEN=${FACEBOOK_PAGE_ACCESS_TOKEN:-}
|
||||
- FACEBOOK_API_VERSION=${FACEBOOK_API_VERSION:-v21.0}
|
||||
# LinkedIn (postare automată pe profil/pagină — provider paralel cu Facebook)
|
||||
- LINKEDIN_ACCESS_TOKEN=${LINKEDIN_ACCESS_TOKEN:-}
|
||||
- LINKEDIN_ORG_URN=${LINKEDIN_ORG_URN:-}
|
||||
- LINKEDIN_API_VERSION=${LINKEDIN_API_VERSION:-202506}
|
||||
# Stripe (test mode keys come from .env file)
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||
|
|
@ -58,7 +62,7 @@ services:
|
|||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-DIDI}
|
||||
- SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-}
|
||||
- PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://10.11.10.11:8443}
|
||||
- PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://api.local:8443}
|
||||
# OTel — traces to Jaeger via OTel Collector
|
||||
- OTEL_ENABLED=${OTEL_ENABLED:-true}
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-http://didi-otel-collector:4318/v1/traces}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Admin SOCIAL POSTS routes — postare automată pe Facebook (DESI 6).
|
||||
* Admin SOCIAL POSTS routes — postare automată pe Facebook + LinkedIn (DESI 6).
|
||||
*
|
||||
* Endpoints (toate sub /api/admin/social/):
|
||||
* POST /social/draft — creează draft din session_id sau content manual
|
||||
|
|
@ -25,6 +25,16 @@ import {
|
|||
debugFacebookToken,
|
||||
generateDraftFromAnalysisSession,
|
||||
} from '../../services/facebook';
|
||||
import {
|
||||
postToLinkedInPage,
|
||||
deleteLinkedInPost,
|
||||
debugLinkedInToken,
|
||||
isLinkedInConfigured,
|
||||
} from '../../services/linkedin';
|
||||
|
||||
// Platformele suportate pentru postare (extensibil — adaugă provider + ramură în publish/delete)
|
||||
const SUPPORTED_PLATFORMS = ['facebook', 'linkedin'] as const;
|
||||
type Platform = typeof SUPPORTED_PLATFORMS[number];
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -54,10 +64,11 @@ interface SocialPostRow {
|
|||
// ─────────────────────────────────────────────────────────────────────────
|
||||
router.get('/social/health', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const debug = await debugFacebookToken();
|
||||
const [debug, liDebug] = await Promise.all([debugFacebookToken(), debugLinkedInToken()]);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
platforms: SUPPORTED_PLATFORMS,
|
||||
facebook: {
|
||||
configured: !!process.env.FACEBOOK_PAGE_ACCESS_TOKEN && !!process.env.FACEBOOK_PAGE_ID,
|
||||
page_id: process.env.FACEBOOK_PAGE_ID || null,
|
||||
|
|
@ -67,6 +78,7 @@ router.get('/social/health', async (_req: Request, res: Response) => {
|
|||
scopes: debug.scopes,
|
||||
error: debug.error,
|
||||
},
|
||||
linkedin: liDebug,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
@ -87,6 +99,12 @@ router.post('/social/draft', async (req: Request, res: Response) => {
|
|||
if (content.length > 60000) {
|
||||
return res.status(400).json({ success: false, error: 'content too long (max 60k chars)' });
|
||||
}
|
||||
if (platform && !SUPPORTED_PLATFORMS.includes(platform)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `platform invalid: '${platform}'. Platforme suportate: ${SUPPORTED_PLATFORMS.join(', ')}`,
|
||||
});
|
||||
}
|
||||
const createdBy = (req as Request & { adminUser?: { sub?: string; email?: string } })
|
||||
.adminUser?.email
|
||||
|| (req as Request & { adminUser?: { sub?: string; email?: string } }).adminUser?.sub
|
||||
|
|
@ -144,9 +162,17 @@ router.post('/social/publish/:post_id', async (req: Request, res: Response) => {
|
|||
return res.status(409).json({ success: false, error: 'Already published' });
|
||||
}
|
||||
|
||||
const platform = (draft.platform || 'facebook') as Platform;
|
||||
|
||||
const scheduledAtIso = req.body?.scheduled_at as string | undefined;
|
||||
let scheduledUnix: number | undefined;
|
||||
if (scheduledAtIso) {
|
||||
if (platform === 'linkedin') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'LinkedIn nu suportă programare (scheduled publish) prin API — doar publicare imediată',
|
||||
});
|
||||
}
|
||||
const ts = Math.floor(new Date(scheduledAtIso).getTime() / 1000);
|
||||
if (isNaN(ts) || ts * 1000 < Date.now() + 9 * 60 * 1000) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -157,6 +183,14 @@ router.post('/social/publish/:post_id', async (req: Request, res: Response) => {
|
|||
scheduledUnix = ts;
|
||||
}
|
||||
|
||||
// Ghid clar înainte de a marca 'publishing': platforma trebuie configurată
|
||||
if (platform === 'linkedin' && !isLinkedInConfigured()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'LinkedIn nu este configurat — setează LINKEDIN_ACCESS_TOKEN și LINKEDIN_ORG_URN în .env (vezi services/linkedin.ts)',
|
||||
});
|
||||
}
|
||||
|
||||
// Mark as publishing (prevent double-publish)
|
||||
await query(
|
||||
`UPDATE bos_sysadmin.social_post SET status='publishing', updated_at=now() WHERE post_id=$1`,
|
||||
|
|
@ -164,12 +198,18 @@ router.post('/social/publish/:post_id', async (req: Request, res: Response) => {
|
|||
);
|
||||
|
||||
try {
|
||||
const fbResult = await postToFacebookPage({
|
||||
message: draft.content,
|
||||
link: draft.link_url || undefined,
|
||||
imageUrl: draft.image_url || undefined,
|
||||
scheduledPublishTime: scheduledUnix,
|
||||
});
|
||||
const fbResult = platform === 'linkedin'
|
||||
? await postToLinkedInPage({
|
||||
message: draft.content,
|
||||
link: draft.link_url || undefined,
|
||||
// imaginile pe LinkedIn cer flux separat de upload — neimplementat în v1
|
||||
})
|
||||
: await postToFacebookPage({
|
||||
message: draft.content,
|
||||
link: draft.link_url || undefined,
|
||||
imageUrl: draft.image_url || undefined,
|
||||
scheduledPublishTime: scheduledUnix,
|
||||
});
|
||||
|
||||
const finalStatus = scheduledUnix ? 'scheduled' : 'published';
|
||||
const updated = await queryOne<SocialPostRow>(`
|
||||
|
|
@ -194,7 +234,7 @@ router.post('/social/publish/:post_id', async (req: Request, res: Response) => {
|
|||
postId,
|
||||
]);
|
||||
|
||||
log.info(`[social] Post ${postId} → FB ${fbResult.id} (${finalStatus})`);
|
||||
log.info(`[social] Post ${postId} → ${platform} ${fbResult.id} (${finalStatus})`);
|
||||
res.json({ success: true, data: updated });
|
||||
} catch (fbErr) {
|
||||
const errMsg = (fbErr as Error).message;
|
||||
|
|
@ -203,8 +243,8 @@ router.post('/social/publish/:post_id', async (req: Request, res: Response) => {
|
|||
SET status = 'failed', error_message = $1, updated_at = now()
|
||||
WHERE post_id = $2
|
||||
`, [errMsg, postId]);
|
||||
log.error(`[social] FB publish failed for ${postId}: ${errMsg}`);
|
||||
res.status(502).json({ success: false, error: `Facebook publish failed: ${errMsg}` });
|
||||
log.error(`[social] ${platform} publish failed for ${postId}: ${errMsg}`);
|
||||
res.status(502).json({ success: false, error: `${platform} publish failed: ${errMsg}` });
|
||||
}
|
||||
} catch (e) {
|
||||
internalError(res, e, 'social_publish');
|
||||
|
|
@ -270,7 +310,7 @@ router.get('/social/:post_id', async (req: Request, res: Response) => {
|
|||
}
|
||||
|
||||
// Refresh engagement dacă published și mai vechi de 5 min
|
||||
if (row.status === 'published' && row.external_post_id) {
|
||||
if (row.status === 'published' && row.external_post_id && (row.platform || 'facebook') === 'facebook') {
|
||||
const lastUpdate = row.engagement_updated_at ? new Date(row.engagement_updated_at).getTime() : 0;
|
||||
if (Date.now() - lastUpdate > 5 * 60 * 1000) {
|
||||
try {
|
||||
|
|
@ -307,13 +347,17 @@ router.delete('/social/:post_id', async (req: Request, res: Response) => {
|
|||
return res.status(404).json({ success: false, error: 'Not found' });
|
||||
}
|
||||
|
||||
// Delete from FB only if published
|
||||
// Delete de pe platforma externa doar daca e publicat
|
||||
if (row.external_post_id && row.status === 'published') {
|
||||
try {
|
||||
await deleteFacebookPost(row.external_post_id);
|
||||
} catch (fbErr) {
|
||||
// Continue with DB delete even if FB delete fails (post might be already gone)
|
||||
log.warn(`[social] FB delete failed: ${(fbErr as Error).message}`);
|
||||
if ((row.platform || 'facebook') === 'linkedin') {
|
||||
await deleteLinkedInPost(row.external_post_id);
|
||||
} else {
|
||||
await deleteFacebookPost(row.external_post_id);
|
||||
}
|
||||
} catch (extErr) {
|
||||
// Continuam cu soft-delete in DB chiar daca delete-ul extern esueaza
|
||||
log.warn(`[social] ${row.platform} delete failed: ${(extErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,13 +423,18 @@ router.post('/social/generate-from-session/:session_id', async (req: Request, re
|
|||
|| (req as Request & { adminUser?: { sub?: string; email?: string } }).adminUser?.sub
|
||||
|| 'unknown';
|
||||
|
||||
const genPlatform = (req.body?.platform as string) || 'facebook';
|
||||
if (!SUPPORTED_PLATFORMS.includes(genPlatform as Platform)) {
|
||||
return res.status(400).json({ success: false, error: `platform invalid: '${genPlatform}'` });
|
||||
}
|
||||
|
||||
// Salvează draft în DB
|
||||
const row = await queryOne<SocialPostRow>(`
|
||||
INSERT INTO bos_sysadmin.social_post
|
||||
(session_id, platform, content, status, created_by)
|
||||
VALUES ($1, 'facebook', $2, 'draft', $3)
|
||||
VALUES ($1, $2, $3, 'draft', $4)
|
||||
RETURNING *
|
||||
`, [sessionId, content, createdBy]);
|
||||
`, [sessionId, genPlatform, content, createdBy]);
|
||||
|
||||
res.status(201).json({ success: true, data: row });
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -247,29 +247,20 @@ export function generateDraftFromAnalysisSession(session: {
|
|||
: category === 'UNCERTAIN' ? 'Conținut cu credibilitate incertă.'
|
||||
: 'Conținut analizat.');
|
||||
|
||||
// Trim explanation to ~400 chars for FB readability
|
||||
const explanation = verdictRo.length > 400
|
||||
? verdictRo.slice(0, 400).trim() + '...'
|
||||
: verdictRo;
|
||||
|
||||
// Snippet din input
|
||||
const inputPreview = session.input_text
|
||||
? `"${session.input_text.slice(0, 150).trim()}${session.input_text.length > 150 ? '...' : ''}"`
|
||||
: session.input_url
|
||||
? `🔗 ${session.input_url}`
|
||||
: '';
|
||||
// IMPORTANT: LinkedIn afișează în feed doar ~210 caractere din postările prin API
|
||||
// (fără expander „see more"), iar Facebook colapsează la fel textele lungi. Generăm
|
||||
// deci un post CONCIS care se afișează integral: verdict + scor + esența într-o
|
||||
// singură frază + hashtags. Detaliul complet rămâne accesibil prin link-ul analizei.
|
||||
const oneLiner = verdictRo.replace(/\s+/g, ' ').trim();
|
||||
// rezervăm loc pentru antet (~35) + hashtags (~48); ținta ~210 caractere total
|
||||
const room = 210 - 35 - 48;
|
||||
const shortExplanation = oneLiner.length > room
|
||||
? oneLiner.slice(0, room).replace(/[\s,.;:]+\S*$/, '').trim() + '…'
|
||||
: oneLiner;
|
||||
|
||||
return [
|
||||
`${emoji} Alertă dezinformare — DiDi`,
|
||||
'',
|
||||
inputPreview ? `Conținut analizat:\n${inputPreview}` : '',
|
||||
'',
|
||||
`📊 Scor risc: ${score}/100 (${category})`,
|
||||
'',
|
||||
explanation,
|
||||
'',
|
||||
'🔍 Analiza completă prin platforma DiDi — detecție automată tehnici de manipulare, AI-generated content, verificare claims și evaluare surse.',
|
||||
'',
|
||||
'#DiDi #Dezinformare #FactChecking #AI #Clossers',
|
||||
].filter(Boolean).join('\n');
|
||||
`${emoji} DiDi · ${category} · Scor ${score}/100`,
|
||||
shortExplanation,
|
||||
'#DiDi #Dezinformare #FactChecking #AntiFake',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* LinkedIn REST API client — postare automată din admin-dashboard pe pagina
|
||||
* de organizație LinkedIn. Provider paralel cu facebook.ts (DESI 6).
|
||||
*
|
||||
* Config (ENV):
|
||||
* LINKEDIN_ACCESS_TOKEN — access token cu scope `w_organization_social`
|
||||
* (aplicație LinkedIn cu produsul "Community Management API",
|
||||
* autorizată de un admin al paginii de organizație)
|
||||
* LINKEDIN_ORG_URN — URN-ul organizației, ex: urn:li:organization:12345678
|
||||
* LINKEDIN_API_VERSION — header LinkedIn-Version, format YYYYMM (default 202506)
|
||||
*
|
||||
* Cum obții credențialele (pe scurt):
|
||||
* 1. https://developer.linkedin.com → Create app, legată de pagina companiei.
|
||||
* 2. Products → adaugă "Community Management API" (necesită aprobarea LinkedIn).
|
||||
* 3. OAuth2 cu scope w_organization_social, autorizat de un admin al paginii.
|
||||
* 4. ID-ul organizației e în URL-ul paginii de admin (numeric) → urn:li:organization:<id>.
|
||||
*
|
||||
* Limitări față de Facebook (v1):
|
||||
* - LinkedIn NU suportă programare (scheduled publish) prin API — doar publicare imediată.
|
||||
* - Imaginile cer un flux separat de upload (initializeUpload) — neimplementat în v1;
|
||||
* postările sunt text + link (articol atașat).
|
||||
*
|
||||
* Folosit de: src/routes/admin/social.ts
|
||||
*/
|
||||
|
||||
export interface LinkedInPostOptions {
|
||||
message: string;
|
||||
link?: string; // atașat ca articol (card cu preview)
|
||||
linkTitle?: string; // titlul cardului de articol (LinkedIn îl cere obligatoriu când e link)
|
||||
}
|
||||
|
||||
export interface LinkedInPostResult {
|
||||
id: string; // URN-ul postării, ex: urn:li:share:7222...
|
||||
external_url?: string; // URL public al postării
|
||||
}
|
||||
|
||||
const API_VERSION = process.env.LINKEDIN_API_VERSION || '202506';
|
||||
const BASE_URL = 'https://api.linkedin.com';
|
||||
|
||||
export function isLinkedInConfigured(): boolean {
|
||||
return !!process.env.LINKEDIN_ACCESS_TOKEN && !!process.env.LINKEDIN_ORG_URN;
|
||||
}
|
||||
|
||||
function getOrgUrn(): string {
|
||||
const urn = process.env.LINKEDIN_ORG_URN;
|
||||
if (!urn) throw new Error('LINKEDIN_ORG_URN env var not set (ex: urn:li:organization:12345678)');
|
||||
return urn;
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
const t = process.env.LINKEDIN_ACCESS_TOKEN;
|
||||
if (!t) throw new Error('LINKEDIN_ACCESS_TOKEN env var not set — LinkedIn nu este configurat');
|
||||
return t;
|
||||
}
|
||||
|
||||
function apiHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `Bearer ${getToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Restli-Protocol-Version': '2.0.0',
|
||||
'LinkedIn-Version': API_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publică un post pe pagina de organizație LinkedIn (POST /rest/posts).
|
||||
* Text simplu sau text + link (articol). Fără programare (nesuportat de API).
|
||||
*/
|
||||
export async function postToLinkedInPage(
|
||||
options: LinkedInPostOptions,
|
||||
): Promise<LinkedInPostResult> {
|
||||
const orgUrn = getOrgUrn();
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
author: orgUrn,
|
||||
commentary: options.message,
|
||||
visibility: 'PUBLIC',
|
||||
distribution: {
|
||||
feedDistribution: 'MAIN_FEED',
|
||||
targetEntities: [],
|
||||
thirdPartyDistributionChannels: [],
|
||||
},
|
||||
lifecycleState: 'PUBLISHED',
|
||||
isReshareDisabledByAuthor: false,
|
||||
};
|
||||
if (options.link) {
|
||||
// LinkedIn cere `title` obligatoriu pentru cardul de articol. Fallback: prima linie
|
||||
// din mesaj (max 100 caractere) sau un titlu generic.
|
||||
const firstLine = (options.message || '').split('\n').map(s => s.trim()).find(Boolean) || '';
|
||||
const title = (options.linkTitle || firstLine || 'Analiză DiDi').slice(0, 100);
|
||||
body.content = { article: { source: options.link, title } };
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}/rest/posts`, {
|
||||
method: 'POST',
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const err = JSON.parse(text) as { message?: string };
|
||||
if (err.message) msg = err.message;
|
||||
} catch { /* text brut */ }
|
||||
throw new Error(`LinkedIn API error: ${msg}`);
|
||||
}
|
||||
|
||||
// ID-ul postării vine în headerul x-restli-id (URN)
|
||||
const postUrn = res.headers.get('x-restli-id') || res.headers.get('x-linkedin-id') || '';
|
||||
if (!postUrn) {
|
||||
throw new Error('LinkedIn API: missing x-restli-id header in response');
|
||||
}
|
||||
|
||||
return {
|
||||
id: postUrn,
|
||||
external_url: `https://www.linkedin.com/feed/update/${encodeURIComponent(postUrn)}/`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Șterge o postare de pe pagina LinkedIn (DELETE /rest/posts/{urn}).
|
||||
*/
|
||||
export async function deleteLinkedInPost(postUrn: string): Promise<boolean> {
|
||||
const res = await fetch(
|
||||
`${BASE_URL}/rest/posts/${encodeURIComponent(postUrn)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
},
|
||||
);
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`LinkedIn delete failed (HTTP ${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifică starea configurării LinkedIn (folosit la /social/health).
|
||||
* Best-effort: confirmă prezența credențialelor și încearcă un apel ușor.
|
||||
*/
|
||||
export async function debugLinkedInToken(): Promise<{
|
||||
configured: boolean;
|
||||
org_urn: string | null;
|
||||
token_valid: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const configured = isLinkedInConfigured();
|
||||
if (!configured) {
|
||||
return {
|
||||
configured: false,
|
||||
org_urn: process.env.LINKEDIN_ORG_URN || null,
|
||||
token_valid: false,
|
||||
error: 'LINKEDIN_ACCESS_TOKEN / LINKEDIN_ORG_URN nu sunt setate',
|
||||
};
|
||||
}
|
||||
try {
|
||||
// Introspecție ușoară: /v2/userinfo merge pentru token-urile OIDC;
|
||||
// pentru token-urile doar-organizație poate întoarce 403 — tratăm ca „prezent, nevalidabil aici".
|
||||
const res = await fetch(`${BASE_URL}/v2/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
return {
|
||||
configured: true,
|
||||
org_urn: getOrgUrn(),
|
||||
token_valid: res.ok,
|
||||
error: res.ok ? undefined : `userinfo HTTP ${res.status} (tokenul poate fi valid doar pt. scope organizație)`,
|
||||
};
|
||||
} catch (e) {
|
||||
return { configured: true, org_urn: getOrgUrn(), token_valid: false, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
|
@ -27,10 +27,10 @@ fi
|
|||
# -------------------------------------------------------------------------
|
||||
# Cluster (external S3 cluster live since 2026-04-23)
|
||||
# -------------------------------------------------------------------------
|
||||
# NOTE: Using VIP IP directly (10.11.10.128) instead of DNS <minio-host> —
|
||||
# NOTE: Using VIP IP directly (minio-cluster.local) instead of DNS <minio-host> —
|
||||
# Docker containers don't resolve internal pfSense DNS (they use systemd-resolved
|
||||
# at 127.0.0.53 which doesn't see minio-host zone). VIP IP is stable.
|
||||
CLUSTER_ENDPOINT="10.11.10.128"
|
||||
CLUSTER_ENDPOINT="minio-cluster.local"
|
||||
CLUSTER_PORT="9000"
|
||||
CLUSTER_USE_SSL="false"
|
||||
CLUSTER_BUCKET="didi-prod"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue