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

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