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:
EVOTECH IT SRL 2026-07-12 13:36:08 -07:00
parent 8ecc78e729
commit 7e4f23d4c4
22 changed files with 19477 additions and 110 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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",
"+"
]
},

View file

@ -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.
# ----------------------------------------------------------

View file

@ -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

View file

@ -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}

View file

@ -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) });

View file

@ -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,

View file

@ -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

View file

@ -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}

View file

@ -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) {

View file

@ -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');
}

View file

@ -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 };
}
}

View file

@ -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"