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

178
backend/.gitlab-ci.yml Normal file
View file

@ -0,0 +1,178 @@
# =============================================================================
# DiDi Lot 2 (Backend) — Pipeline CI/CD GitLab
# =============================================================================
# Etape: verify (typecheck) → test (unitare) → build (imagini Docker, versionate)
# → publish (registry) → deploy (cu HEALTH-GATE) → rollback (manual)
#
# Variabile așteptate în GitLab (Settings → CI/CD → Variables):
# CI_REGISTRY / CI_REGISTRY_USER / CI_REGISTRY_PASSWORD — registry-ul de imagini
# DEPLOY_HOST — host-ul de deployment (SSH)
# DEPLOY_USER — utilizatorul de deployment
# Versionare imagini: $CI_COMMIT_SHORT_SHA (+ tag "latest" doar pe branch-ul implicit).
# Rollback: job manual care re-etichetează imaginea anterioară (previous) ca latest
# și redeployează — mecanismul de revenire cerut de documentație.
# =============================================================================
stages:
- verify
- test
- build
- publish
- deploy
- rollback
default:
interruptible: true
variables:
DOCKER_TLS_CERTDIR: "/certs"
AGENT_DIR: services/orchestration-layer/agent-v3
FRAMEWORK_DIR: services/orchestration-layer/didiFramework
DASHBOARD_DIR: admin-dashboard
# ---------- șabloane reutilizabile ----------
.node:
image: node:20-alpine
before_script:
- node --version && npm --version
.docker:
image: docker:27
services: [docker:27-dind]
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
# =============================================================================
# VERIFY — compilare TypeScript (fără emitere) pe toate serviciile
# =============================================================================
verify:agent-v3:
stage: verify
extends: .node
script:
- cd $AGENT_DIR && npm ci --prefer-offline
- npx tsc --noEmit
rules: [{ changes: ["services/orchestration-layer/agent-v3/**/*"] }, { when: always }]
verify:framework:
stage: verify
extends: .node
script:
- cd $FRAMEWORK_DIR && npm ci --prefer-offline
- npx tsc --noEmit
verify:dashboard:
stage: verify
extends: .node
script:
- cd $DASHBOARD_DIR && npm ci --prefer-offline
- npm run type-check
# =============================================================================
# TEST — teste unitare (vitest pe agent-v3; raport JUnit pentru GitLab)
# =============================================================================
test:agent-v3:
stage: test
extends: .node
script:
- cd $AGENT_DIR && npm ci --prefer-offline
- npx vitest run --reporter=default --reporter=junit --outputFile=vitest-junit.xml
artifacts:
when: always
reports:
junit: $AGENT_DIR/vitest-junit.xml
expire_in: 30 days
# validarea specificațiilor OpenAPI livrate (criteriul 6 — OpenAPI/Swagger)
test:openapi:
stage: test
image: python:3.12-alpine
script:
- pip install --quiet openapi-spec-validator pyyaml
- python -c "from openapi_spec_validator import validate; import yaml; validate(yaml.safe_load(open('$AGENT_DIR/openapi.yaml')))"
- python -c "from openapi_spec_validator import validate; import yaml; validate(yaml.safe_load(open('$FRAMEWORK_DIR/openapi.yaml')))"
# =============================================================================
# BUILD — imagini Docker versionate cu SHA-ul commit-ului
# =============================================================================
build:agent-v3:
stage: build
extends: .docker
script:
- docker build -t "$CI_REGISTRY_IMAGE/agent-v3:$CI_COMMIT_SHORT_SHA" $AGENT_DIR
- docker push "$CI_REGISTRY_IMAGE/agent-v3:$CI_COMMIT_SHORT_SHA"
build:framework:
stage: build
extends: .docker
script:
- docker build -t "$CI_REGISTRY_IMAGE/didi-framework:$CI_COMMIT_SHORT_SHA" $FRAMEWORK_DIR
- docker push "$CI_REGISTRY_IMAGE/didi-framework:$CI_COMMIT_SHORT_SHA"
build:dashboard:
stage: build
extends: .docker
script:
- docker build -t "$CI_REGISTRY_IMAGE/didi-admin:$CI_COMMIT_SHORT_SHA" $DASHBOARD_DIR
- docker push "$CI_REGISTRY_IMAGE/didi-admin:$CI_COMMIT_SHORT_SHA"
# =============================================================================
# PUBLISH — pe branch-ul implicit, promovăm SHA-ul la :latest și păstrăm
# :previous (ținta de rollback)
# =============================================================================
publish:latest:
stage: publish
extends: .docker
rules: [{ if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]
script:
- |
for svc in agent-v3 didi-framework didi-admin; do
# păstrează versiunea curentă ca :previous (rollback target)
docker pull "$CI_REGISTRY_IMAGE/$svc:latest" || true
docker tag "$CI_REGISTRY_IMAGE/$svc:latest" "$CI_REGISTRY_IMAGE/$svc:previous" || true
docker push "$CI_REGISTRY_IMAGE/$svc:previous" || true
# promovează build-ul curent
docker pull "$CI_REGISTRY_IMAGE/$svc:$CI_COMMIT_SHORT_SHA"
docker tag "$CI_REGISTRY_IMAGE/$svc:$CI_COMMIT_SHORT_SHA" "$CI_REGISTRY_IMAGE/$svc:latest"
docker push "$CI_REGISTRY_IMAGE/$svc:latest"
done
# =============================================================================
# DEPLOY — cu HEALTH-GATE: deploy-ul e considerat reușit DOAR dacă
# /api/v3/health/all răspunde healthy/degraded după repornire
# =============================================================================
deploy:production:
stage: deploy
image: alpine:3.20
rules: [{ if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH', when: manual }]
environment: { name: production }
before_script: [ "apk add --no-cache openssh-client curl" ]
script:
- ssh -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "
cd ~/didi/backend/services/orchestration-layer/agent-v3 && docker compose pull && docker compose up -d &&
cd ../didiFramework && docker compose pull && docker compose up -d"
# HEALTH-GATE: max 120s până când platforma raportează sănătate
- |
for i in $(seq 1 24); do
code=$(ssh "$DEPLOY_USER@$DEPLOY_HOST" "curl -sk -o /dev/null -w '%{http_code}' http://localhost:24803/api/v3/health/all" || echo 000)
echo "health-gate încercarea $i: HTTP $code"
[ "$code" = "200" ] && exit 0
sleep 5
done
echo "HEALTH-GATE EȘUAT — deploy-ul NU este sănătos"; exit 1
# =============================================================================
# ROLLBACK — manual: repune imaginile :previous și redeployează
# =============================================================================
rollback:production:
stage: rollback
extends: .docker
rules: [{ if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH', when: manual }]
environment: { name: production }
script:
- |
for svc in agent-v3 didi-framework didi-admin; do
docker pull "$CI_REGISTRY_IMAGE/$svc:previous"
docker tag "$CI_REGISTRY_IMAGE/$svc:previous" "$CI_REGISTRY_IMAGE/$svc:latest"
docker push "$CI_REGISTRY_IMAGE/$svc:latest"
done
- echo "Imaginile anterioare repromovate la :latest — rulați deploy:production pentru a le aplica."

View file

@ -1,12 +1,20 @@
server { server {
listen 443 ssl; listen 443 ssl;
server_name localhost 10.11.10.12; server_name localhost didi.local;
ssl_certificate /etc/nginx/ssl/server.crt; ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key; ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5; ssl_ciphers HIGH:!aNULL:!MD5;
# Cererile HTTP simplu pe portul HTTPS (ex. utilizator tastează http://host:3001)
# primesc redirect către https în loc de "400 Bad Request".
error_page 497 =301 https://$http_host$request_uri;
# Redirecturi RELATIVE (ex. /admin → /admin/): altfel nginx pune portul intern
# (443) în Location și se pierde :3001 din adresa publică → pagină inaccesibilă.
absolute_redirect off;
# Global: allow large file uploads (video up to 100MB) # Global: allow large file uploads (video up to 100MB)
client_max_body_size 100M; client_max_body_size 100M;
@ -173,7 +181,8 @@ server {
# HTTP server (for Kong proxy + direct access) # HTTP server (for Kong proxy + direct access)
server { server {
listen 80; listen 80;
server_name localhost 10.11.10.12; absolute_redirect off;
server_name localhost didi.local;
client_max_body_size 100M; client_max_body_size 100M;

View file

@ -40,6 +40,7 @@ export const AnalysisHistory: React.FC = () => {
const [sessionToDelete, setSessionToDelete] = useState<AnalysisSession | null>(null); const [sessionToDelete, setSessionToDelete] = useState<AnalysisSession | null>(null);
const [socialPostOpen, setSocialPostOpen] = useState(false); const [socialPostOpen, setSocialPostOpen] = useState(false);
const [socialPostSession, setSocialPostSession] = useState<AnalysisSession | null>(null); const [socialPostSession, setSocialPostSession] = useState<AnalysisSession | null>(null);
const [socialPostPlatform, setSocialPostPlatform] = useState<'facebook' | 'linkedin'>('facebook');
const handleCopyId = (e: React.MouseEvent, id: string) => { const handleCopyId = (e: React.MouseEvent, id: string) => {
e.stopPropagation(); e.stopPropagation();
@ -250,7 +251,7 @@ export const AnalysisHistory: React.FC = () => {
onDeleteClick={handleDeleteClick} onDeleteClick={handleDeleteClick}
onCancelClick={handleCancel} onCancelClick={handleCancel}
onResumeClick={handleResume} onResumeClick={handleResume}
onSocialPostClick={(s) => { setSocialPostSession(s); setSocialPostOpen(true); }} onSocialPostClick={(s, platform) => { setSocialPostSession(s); setSocialPostPlatform(platform || 'facebook'); setSocialPostOpen(true); }}
onCopyId={handleCopyId} onCopyId={handleCopyId}
/> />
@ -270,6 +271,7 @@ export const AnalysisHistory: React.FC = () => {
<SocialPostModal <SocialPostModal
open={socialPostOpen} open={socialPostOpen}
session={socialPostSession} session={socialPostSession}
initialPlatform={socialPostPlatform}
onClose={() => { onClose={() => {
setSocialPostOpen(false); setSocialPostOpen(false);
setSocialPostSession(null); setSocialPostSession(null);

View file

@ -9,6 +9,7 @@ import {
Visibility as VisibilityIcon, Visibility as VisibilityIcon,
ContentCopy as CopyIcon, ContentCopy as CopyIcon,
Facebook as FacebookIcon, Facebook as FacebookIcon,
LinkedIn as LinkedInIcon,
Cancel as CancelIcon, Cancel as CancelIcon,
Replay as ResumeIcon, Replay as ResumeIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
@ -30,7 +31,7 @@ interface Props {
onViewDetails: (sessionId: string) => void; onViewDetails: (sessionId: string) => void;
onDeleteClick: (session: AnalysisSession) => void; onDeleteClick: (session: AnalysisSession) => void;
onCopyId: (e: React.MouseEvent, id: string) => void; onCopyId: (e: React.MouseEvent, id: string) => void;
onSocialPostClick?: (session: AnalysisSession) => void; onSocialPostClick?: (session: AnalysisSession, platform?: 'facebook' | 'linkedin') => void;
onCancelClick?: (session: AnalysisSession) => void; onCancelClick?: (session: AnalysisSession) => void;
onResumeClick?: (session: AnalysisSession) => void; onResumeClick?: (session: AnalysisSession) => void;
} }
@ -216,13 +217,23 @@ export const HistoryTable: React.FC<Props> = ({
{onSocialPostClick && ( {onSocialPostClick && (
<IconButton <IconButton
size="small" size="small"
onClick={() => onSocialPostClick(analysis)} onClick={() => onSocialPostClick(analysis, 'facebook')}
title="Post to Facebook" title="Postează pe Facebook"
sx={{ color: '#1877F2' }} sx={{ color: '#1877F2' }}
> >
<FacebookIcon fontSize="small" /> <FacebookIcon fontSize="small" />
</IconButton> </IconButton>
)} )}
{onSocialPostClick && (
<IconButton
size="small"
onClick={() => onSocialPostClick(analysis, 'linkedin')}
title="Postează pe LinkedIn"
sx={{ color: '#0A66C2' }}
>
<LinkedInIcon fontSize="small" />
</IconButton>
)}
{onCancelClick && CANCELABLE.has(analysis.status) && ( {onCancelClick && CANCELABLE.has(analysis.status) && (
<IconButton <IconButton
size="small" size="small"

View file

@ -3,10 +3,10 @@ import {
Dialog, DialogTitle, DialogContent, DialogActions, Dialog, DialogTitle, DialogContent, DialogActions,
Box, TextField, Button, Alert, Chip, CircularProgress, Box, TextField, Button, Alert, Chip, CircularProgress,
Typography, Stack, IconButton, Divider, Tooltip, Paper, Checkbox, Typography, Stack, IconButton, Divider, Tooltip, Paper, Checkbox,
FormControlLabel, FormControlLabel, ToggleButton, ToggleButtonGroup,
} from '@mui/material'; } from '@mui/material';
import { import {
Facebook as FacebookIcon, Close as CloseIcon, Facebook as FacebookIcon, LinkedIn as LinkedInIcon, Close as CloseIcon,
Send as SendIcon, Schedule as ScheduleIcon, OpenInNew as OpenInNewIcon, Send as SendIcon, Schedule as ScheduleIcon, OpenInNew as OpenInNewIcon,
Visibility as PreviewIcon, Visibility as PreviewIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
@ -18,6 +18,12 @@ const AGENT_BASE = process.env.REACT_APP_AGENT_V3_URL || '/agent-v3';
const SHARE_BASE = 'https://didi365.eu/share'; const SHARE_BASE = 'https://didi365.eu/share';
type Status = 'idle' | 'loading' | 'editing' | 'publishing' | 'published' | 'error'; type Status = 'idle' | 'loading' | 'editing' | 'publishing' | 'published' | 'error';
type Platform = 'facebook' | 'linkedin';
const PLATFORM_META: Record<Platform, { label: string; color: string; hover: string }> = {
facebook: { label: 'Facebook', color: '#1877F2', hover: '#0e5fc8' },
linkedin: { label: 'LinkedIn', color: '#0A66C2', hover: '#084d92' },
};
type Block = { type Block = {
id: string; id: string;
@ -30,6 +36,7 @@ interface Props {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
session: AnalysisSession | null; session: AnalysisSession | null;
initialPlatform?: Platform;
onSuccess?: (postId: string, externalUrl: string) => void; onSuccess?: (postId: string, externalUrl: string) => void;
} }
@ -121,8 +128,9 @@ const joinBlocks = (blocks: Block[]): string =>
.map((b) => b.content.trim()) .map((b) => b.content.trim())
.join('\n\n'); .join('\n\n');
export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuccess }) => { export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, initialPlatform, onSuccess }) => {
const [status, setStatus] = useState<Status>('idle'); const [status, setStatus] = useState<Status>('idle');
const [platform, setPlatform] = useState<Platform>('facebook');
const [blocks, setBlocks] = useState<Block[]>([]); const [blocks, setBlocks] = useState<Block[]>([]);
const [imageUrl, setImageUrl] = useState(''); const [imageUrl, setImageUrl] = useState('');
const [linkUrl, setLinkUrl] = useState(''); const [linkUrl, setLinkUrl] = useState('');
@ -134,6 +142,7 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
useEffect(() => { useEffect(() => {
if (!open || !session) return; if (!open || !session) return;
setStatus('loading'); setStatus('loading');
setPlatform(initialPlatform || 'facebook');
setBlocks([]); setBlocks([]);
setImageUrl(''); setImageUrl('');
setLinkUrl(session.session_id ? `${SHARE_BASE}/${session.session_id}` : ''); setLinkUrl(session.session_id ? `${SHARE_BASE}/${session.session_id}` : '');
@ -157,7 +166,7 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
setError(e?.response?.data?.error || e.message); setError(e?.response?.data?.error || e.message);
setStatus('error'); setStatus('error');
}); });
}, [open, session]); }, [open, session, initialPlatform]);
const previewText = useMemo(() => joinBlocks(blocks), [blocks]); const previewText = useMemo(() => joinBlocks(blocks), [blocks]);
const charCount = previewText.length; const charCount = previewText.length;
@ -182,7 +191,7 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
content: previewText, content: previewText,
image_url: imageUrl || undefined, image_url: imageUrl || undefined,
link_url: linkUrl || undefined, link_url: linkUrl || undefined,
platform: 'facebook', platform,
}, },
{ headers: { Authorization: `Bearer ${token}` } }, { headers: { Authorization: `Bearer ${token}` } },
); );
@ -192,7 +201,8 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
const pubRes = await axios.post( const pubRes = await axios.post(
`${FRAMEWORK_BASE}/api/admin/social/publish/${createdId}`, `${FRAMEWORK_BASE}/api/admin/social/publish/${createdId}`,
scheduledAt ? { scheduled_at: new Date(scheduledAt).toISOString() } : {}, scheduledAt && platform === 'facebook'
? { scheduled_at: new Date(scheduledAt).toISOString() } : {},
{ headers: { Authorization: `Bearer ${token}` } }, { headers: { Authorization: `Bearer ${token}` } },
); );
if (pubRes.data?.success && pubRes.data.data) { if (pubRes.data?.success && pubRes.data.data) {
@ -214,8 +224,21 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FacebookIcon sx={{ color: '#1877F2' }} /> {platform === 'linkedin'
Post to Facebook ? <LinkedInIcon sx={{ color: PLATFORM_META.linkedin.color }} />
: <FacebookIcon sx={{ color: PLATFORM_META.facebook.color }} />}
Post to {PLATFORM_META[platform].label}
<ToggleButtonGroup
value={platform}
exclusive
size="small"
onChange={(_e, v: Platform | null) => { if (v) { setPlatform(v); if (v === 'linkedin') { setScheduledAt(''); setImageUrl(''); } } }}
disabled={status === 'publishing'}
sx={{ ml: 2 }}
>
<ToggleButton value="facebook"><FacebookIcon fontSize="small" sx={{ mr: 0.5 }} />Facebook</ToggleButton>
<ToggleButton value="linkedin"><LinkedInIcon fontSize="small" sx={{ mr: 0.5 }} />LinkedIn</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
<IconButton onClick={onClose} size="small"> <IconButton onClick={onClose} size="small">
<CloseIcon /> <CloseIcon />
@ -294,10 +317,19 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
<PreviewIcon fontSize="small" color="action" /> <PreviewIcon fontSize="small" color="action" />
<Typography variant="overline" color="text.secondary"> <Typography variant="overline" color="text.secondary">
Preview post · {charCount} caractere Preview post · {charCount} caractere
{charCount > 500 && ( {platform === 'linkedin' && charCount > 210 && (
<Chip label="LinkedIn taie la ~210" color="error" size="small" sx={{ ml: 1, height: 18 }} />
)}
{platform === 'facebook' && charCount > 500 && (
<Chip label="Lung" color="warning" size="small" sx={{ ml: 1, height: 18 }} /> <Chip label="Lung" color="warning" size="small" sx={{ ml: 1, height: 18 }} />
)} )}
</Typography> </Typography>
{platform === 'linkedin' && charCount > 210 && (
<Alert severity="warning" sx={{ py: 0 }}>
LinkedIn afișează în feed doar ~210 caractere (fără vezi mai mult"). Restul e
postat, dar ascuns. Recomandare: păstrează postul scurt + atașează link-ul analizei.
</Alert>
)}
</Box> </Box>
<Paper <Paper
variant="outlined" variant="outlined"
@ -315,12 +347,14 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
</Paper> </Paper>
<TextField <TextField
label="URL imagine (opțional — atașament foto)" label={platform === 'linkedin'
? 'URL imagine — indisponibil pe LinkedIn (v1: text + link)'
: 'URL imagine (opțional — atașament foto)'}
value={imageUrl} value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)} onChange={(e) => setImageUrl(e.target.value)}
fullWidth fullWidth
size="small" size="small"
disabled={status === 'publishing'} disabled={status === 'publishing' || platform === 'linkedin'}
placeholder="https://didi365.eu/share/image.png" placeholder="https://didi365.eu/share/image.png"
/> />
<TextField <TextField
@ -344,16 +378,20 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
color={scheduledAt ? 'warning' : 'default'} color={scheduledAt ? 'warning' : 'default'}
onDelete={scheduledAt ? () => setScheduledAt('') : undefined} onDelete={scheduledAt ? () => setScheduledAt('') : undefined}
/> />
<Tooltip title="Programează publicarea (min 10 min în viitor)"> <Tooltip title={platform === 'linkedin'
<TextField ? 'LinkedIn nu suportă programare prin API — doar publicare imediată'
type="datetime-local" : 'Programează publicarea (min 10 min în viitor)'}>
size="small" <span>
value={scheduledAt} <TextField
onChange={(e) => setScheduledAt(e.target.value)} type="datetime-local"
inputProps={{ min: minScheduleDate }} size="small"
disabled={status === 'publishing'} value={scheduledAt}
sx={{ width: 220 }} onChange={(e) => setScheduledAt(e.target.value)}
/> inputProps={{ min: minScheduleDate }}
disabled={status === 'publishing' || platform === 'linkedin'}
sx={{ width: 220 }}
/>
</span>
</Tooltip> </Tooltip>
</Box> </Box>
@ -368,7 +406,7 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
{status === 'published' && ( {status === 'published' && (
<Stack spacing={2} sx={{ py: 2 }}> <Stack spacing={2} sx={{ py: 2 }}>
<Alert severity="success"> <Alert severity="success">
{scheduledAt ? '✓ Programat cu success pe Facebook!' : '✓ Publicat cu success pe Facebook!'} {scheduledAt ? `✓ Programat cu succes pe ${PLATFORM_META[platform].label}!` : `✓ Publicat cu succes pe ${PLATFORM_META[platform].label}!`}
</Alert> </Alert>
{externalUrl && ( {externalUrl && (
<Button <Button
@ -379,7 +417,7 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
variant="outlined" variant="outlined"
size="small" size="small"
> >
Deschide pe Facebook Deschide postarea
</Button> </Button>
)} )}
</Stack> </Stack>
@ -407,13 +445,13 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
} }
onClick={handlePublish} onClick={handlePublish}
disabled={status !== 'editing' || !previewText.trim()} disabled={status !== 'editing' || !previewText.trim()}
sx={{ bgcolor: '#1877F2', '&:hover': { bgcolor: '#0e5fc8' } }} sx={{ bgcolor: PLATFORM_META[platform].color, '&:hover': { bgcolor: PLATFORM_META[platform].hover } }}
> >
{status === 'publishing' {status === 'publishing'
? 'Se publică...' ? 'Se publică...'
: scheduledAt : scheduledAt && platform === 'facebook'
? 'Programează' ? 'Programează'
: 'Publică pe Facebook'} : `Publică pe ${PLATFORM_META[platform].label}`}
</Button> </Button>
</> </>
)} )}

View file

@ -17,10 +17,10 @@
# docker compose up -d # docker compose up -d
# #
# UI access: # UI access:
# Grafana: http://10.11.10.12:3030 (admin / GRAFANA_ADMIN_PASSWORD) # Grafana: http://didi.local:3030 (admin / GRAFANA_ADMIN_PASSWORD)
# Prometheus: http://10.11.10.12:9090 # Prometheus: http://didi.local:9090
# Jaeger UI: http://10.11.10.12:16686 # Jaeger UI: http://didi.local:16686
# Alertmanager: http://10.11.10.12:9093 # Alertmanager: http://didi.local:9093
services: services:
prometheus: prometheus:

View file

@ -142,12 +142,12 @@ for entry in "bos_parammgmt|technique|166" "bos_parammgmt|dimension|8" "bos_para
done done
# Localizare config LLM: seed-ul livrează modelul primar ca `Qwen3.5-397B-A17B` pe # Localizare config LLM: seed-ul livrează modelul primar ca `Qwen3.5-397B-A17B` pe
# provider remote `10.11.10.17` — dar vLLM-ul local servește `qwen3.5` prin routerul # provider remote `llm.local` — dar vLLM-ul local servește `qwen3.5` prin routerul
# `llm-api:14011`. Aliniem model_code + provider base_url (idempotent, rulează la fiecare build). # `llm-api:14011`. Aliniem model_code + provider base_url (idempotent, rulează la fiecare build).
log "Localizez config LLM (model → qwen3.5, provideri → llm-api:14011)..." log "Localizez config LLM (model → qwen3.5, provideri → llm-api:14011)..."
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c " docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c "
UPDATE bos_parammgmt.llm_model SET model_code='qwen3.5', model_name='Qwen 3.5 (local)' WHERE model_code='Qwen3.5-397B-A17B'; UPDATE bos_parammgmt.llm_model SET model_code='qwen3.5', model_name='Qwen 3.5 (local)' WHERE model_code='Qwen3.5-397B-A17B';
UPDATE bos_parammgmt.llm_provider SET base_url='http://llm-api:14011/v1' WHERE base_url LIKE 'http://10.11.10.17:1401%'; UPDATE bos_parammgmt.llm_provider SET base_url='http://llm-api:14011/v1' WHERE base_url LIKE 'http://llm.local:1401%';
" >/dev/null 2>&1 && ok "Config LLM localizat (qwen3.5 @ llm-api:14011)" || warn "Localizarea config LLM a eșuat — verifică manual" " >/dev/null 2>&1 && ok "Config LLM localizat (qwen3.5 @ llm-api:14011)" || warn "Localizarea config LLM a eșuat — verifică manual"
wait_healthy didi-cache 30 wait_healthy didi-cache 30

View file

@ -0,0 +1,177 @@
#!/usr/bin/env bash
# =============================================================================
# DiDi Lot 2 — Dovezi API pentru matricea de acceptanță (doc 08)
# =============================================================================
# Ruleaza pe rand fiecare proba API din matrice. Dupa fiecare test se opreste
# ca sa faci captura de ecran (SNIP). Fiecare bloc afiseaza: numarul din
# matrice, DATA+ORA, comanda exacta si raspunsul.
#
# Utilizare:
# bash dovezi-api.sh # interactiv (pauza dupa fiecare test)
# bash dovezi-api.sh --auto # fara pauze (verificare rapida)
# =============================================================================
set -uo pipefail
HOST="${HOST:-localhost}" # ruleaza pe server; override: HOST=<ip> bash dovezi-api.sh
AGENT="http://$HOST:24803"
KC="http://$HOST:28080"
AUTO=0; [ "${1:-}" = "--auto" ] && AUTO=1
B="\033[1m"; G="\033[32m"; C="\033[36m"; Y="\033[33m"; N="\033[0m"
pauza() {
[ "$AUTO" = "1" ] && { echo; return; }
echo; echo -e "${Y}>>> Fa SNIP la blocul de mai sus, apoi apasa ENTER pentru testul urmator...${N}"; read -r; clear
}
antet() { # nr_matrice titlu
echo -e "${B}==============================================================================${N}"
echo -e "${B} PROBA $1$2${N}"
echo -e " Data/ora testarii: ${G}$(date '+%Y-%m-%d %H:%M:%S %Z')${N} Server: $HOST Operator: $(whoami)"
echo -e "${B}==============================================================================${N}"
}
cmd() { echo -e "${C}\$ $*${N}"; }
jq_sau_cat() { python3 -m json.tool 2>/dev/null || cat; }
clear
# --- utilizator de test (cu credite) ---
UID_TEST=$(docker exec didi-postgres psql -U bos_interface -d DIDI -tA -c \
"SELECT uc.keycloak_id FROM bos_sysadmin.user_credential uc JOIN bos_sysadmin.internet_user iu ON iu.internet_user_id=uc.internet_user_id WHERE iu.credits_remained > 100 LIMIT 1;" 2>/dev/null)
echo "Utilizator de test: $UID_TEST"
# =============================================================================
antet "M7.1" "Autentificare OIDC — obtinere token JWT (SSO)"
cmd "curl -X POST $KC/auth/realms/didi-admins/protocol/openid-connect/token -d grant_type=password -d client_id=admin-dashboard -d username=admin -d password=*****"
TOKEN=$(curl -sk -X POST "$KC/auth/realms/didi-admins/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-dashboard -d username=admin -d password=Admin12345 \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))")
if [ -n "$TOKEN" ]; then
echo -e "${G}TOKEN OBTINUT (JWT RS256).${N} Claims relevante din token:"
echo "$TOKEN" | cut -d. -f2 | python3 -c "
import sys,json,base64
p=sys.stdin.read().strip(); p+='='*(-len(p)%4)
d=json.loads(base64.urlsafe_b64decode(p))
print(json.dumps({k:d[k] for k in ('iss','preferred_username','realm_access','exp') if k in d}, indent=2, ensure_ascii=False))"
else
echo "EROARE: nu s-a obtinut token"
fi
pauza
# =============================================================================
antet "M1.4" "Executie SINCRONA — POST /api/v3/pipeline/analyze (text)"
cmd "curl -X POST $AGENT/api/v3/pipeline/analyze {media_type:text, text:'...', user_id}"
curl -sk --max-time 120 -X POST "$AGENT/api/v3/pipeline/analyze" -H 'Content-Type: application/json' \
-d "{\"media_type\":\"text\",\"text\":\"Guvernul a anuntat astazi noi masuri economice care vor intra in vigoare luna viitoare, potrivit comunicatului oficial.\",\"user_id\":\"$UID_TEST\"}" \
| python3 -c "
import sys,json
d=json.load(sys.stdin); s=d.get('data',d)
print(json.dumps({'success':d.get('success'),'session_id':s.get('session_id'),'status':s.get('status'),'risk_score':s.get('risk_score'),'risk_category':s.get('risk_category'),'components_run':s.get('components_run')}, indent=2, ensure_ascii=False))"
echo -e "${G}=> HTTP 200 cu verdict complet in raspuns = executie sincrona OK${N}"
pauza
# =============================================================================
antet "M1.5" "Executie ASINCRONA — POST /api/v3/pipeline/analyze-async (202 + poll)"
cmd "curl -X POST $AGENT/api/v3/pipeline/analyze-async {media_type:text, ...}"
RESP=$(curl -sk --max-time 30 -X POST "$AGENT/api/v3/pipeline/analyze-async" -H 'Content-Type: application/json' \
-d "{\"media_type\":\"text\",\"text\":\"OMS a confirmat oficial ca vaccinurile contin microcipuri 5G pentru controlul populatiei prin unde radio.\",\"user_id\":\"$UID_TEST\"}")
echo "$RESP" | jq_sau_cat
SID=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['session_id'])" 2>/dev/null)
echo -e "${G}=> Raspuns imediat cu session_id + poll_url + result_url = dispatch async OK${N}"
pauza
# =============================================================================
antet "M1.9" "Status executie — GET /pipeline/<id>/queue-status (polling)"
cmd "curl $AGENT/api/v3/pipeline/$SID/queue-status (repetat pana la completed)"
for i in $(seq 1 40); do
ST=$(curl -sk "$AGENT/api/v3/pipeline/$SID/queue-status" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('data',{}).get('status') or d.get('status',''))" 2>/dev/null)
echo " poll #$i @ $(date '+%H:%M:%S'): status=$ST"
[ "$ST" = "completed" ] || [ "$ST" = "failed" ] && break
sleep 3
done
echo -e "${G}=> Progres urmarit in timp real pana la 'completed'${N}"
pauza
# =============================================================================
antet "M2.5" "Rezultat final structurat — GET /pipeline/<id>/result"
cmd "curl $AGENT/api/v3/pipeline/$SID/result"
curl -sk "$AGENT/api/v3/pipeline/$SID/result" | python3 -c "
import sys,json
d=json.load(sys.stdin); s=d.get('data',d)
print(json.dumps({'session_id':s.get('session_id'),'status':s.get('status'),'input_type':s.get('input_type'),'risk_score':s.get('risk_score'),'risk_category':s.get('risk_category'),'risk_level':s.get('risk_level'),'confidence':s.get('confidence'),'components_run':s.get('components_run'),'llm_usage_total':(s.get('llm_usage') or {}).get('total')}, indent=2, ensure_ascii=False))"
echo -e "${G}=> Text de dezinformare detectat cu scor mare = pipeline complet functional${N}"
pauza
# =============================================================================
antet "M1.6" "Simulare DRY-RUN — POST /pipeline/dry-run (plan fara executie)"
cmd "curl -X POST $AGENT/api/v3/pipeline/dry-run {media_type:text, ...}"
curl -sk --max-time 30 -X POST "$AGENT/api/v3/pipeline/dry-run" -H 'Content-Type: application/json' \
-d "{\"media_type\":\"text\",\"text\":\"test de simulare dry run pentru acceptanta\",\"user_id\":\"$UID_TEST\"}" \
| python3 -m json.tool 2>/dev/null | head -60
echo -e "${G}=> Planul de executie (noduri, cozi, modele) FARA a consuma resurse${N}"
pauza
# =============================================================================
antet "M2.6" "Health profund — GET /api/v3/health/all (infra + 9 servicii AI Lot 1)"
cmd "curl $AGENT/api/v3/health/all"
curl -sk --max-time 30 "$AGENT/api/v3/health/all" | jq_sau_cat
echo -e "${G}=> Toate dependentele (PostgreSQL/Redis/RabbitMQ/framework + 9 servicii AI) healthy${N}"
pauza
# =============================================================================
antet "M2.4 + M5.2" "Persistenta in PostgreSQL — istoricul analizelor (schema bos_analysis)"
cmd "SELECT input_type,status,risk_category,risk_score,created_at FROM bos_analysis.analysis_session ORDER BY created_at DESC LIMIT 5"
docker exec didi-postgres psql -U bos_interface -d DIDI -c \
"SELECT input_type, status, risk_category, risk_score, to_char(created_at,'YYYY-MM-DD HH24:MI') AS data_analiza FROM bos_analysis.analysis_session ORDER BY created_at DESC LIMIT 5;"
echo -e "${G}=> Analizele (inclusiv cele de mai sus) persistate in baza de date${N}"
pauza
# =============================================================================
antet "M5.3" "Organizarea pe scheme a bazei de date"
cmd "\\dn (lista schemelor)"
docker exec didi-postgres psql -U bos_interface -d DIDI -c "\dn"
docker exec didi-postgres psql -U bos_interface -d DIDI -c \
"SELECT 'tehnici' AS obiect, count(*) FROM bos_parammgmt.technique UNION ALL SELECT 'modele LLM', count(*) FROM bos_parammgmt.llm_model UNION ALL SELECT 'sesiuni analiza', count(*) FROM bos_analysis.analysis_session;"
echo -e "${G}=> 4 scheme (parametri/analiza/utilizatori/date personale) + date reale${N}"
pauza
# =============================================================================
antet "M4.2" "Gateway Kong — protectie: cerere FARA token => 401"
cmd "curl -X POST http://127.0.0.1:18000/agent-v3/api/v3/pipeline/analyze-async (FARA Authorization)"
CODE=$(curl -sk --max-time 10 -H 'Host: localhost' -o /dev/null -w '%{http_code}' \
-X POST "http://127.0.0.1:18000/agent-v3/api/v3/pipeline/analyze-async" \
-H 'Content-Type: application/json' -d '{"media_type":"text","text":"acces neautorizat"}')
echo " Raspuns HTTP: $CODE"
echo -e "${G}=> 401 Unauthorized = gateway-ul respinge cererile fara JWT (securitate la punct unic)${N}"
pauza
# =============================================================================
antet "M3.1" "Broker de mesaje — cozile RabbitMQ (componente x prioritati + DLQ)"
cmd "curl -u admin:*** http://$HOST:15672/api/queues (nume + consumeri)"
curl -s -u admin:rabbitmq123 "http://$HOST:15672/api/queues" | python3 -c "
import sys,json
qs=json.load(sys.stdin)
print(f'Total cozi: {len(qs)}')
for q in sorted(qs, key=lambda x:x['name'])[:30]:
print(f\" {q['name']:45s} consumeri={q.get('consumers',0):2d} mesaje={q.get('messages',0)}\")"
echo -e "${G}=> Cozile per componenta si prioritate, cu consumeri (workeri) atasati${N}"
pauza
# =============================================================================
antet "M9.1" "Containerizare — toate serviciile in containere, healthy"
cmd "docker ps (sumar pe categorii)"
TOTAL=$(docker ps -q | wc -l)
HEALTHY=$(docker ps --format '{{.Status}}' | grep -c healthy)
UNHEALTHY=$(docker ps --format '{{.Status}}' | grep -c unhealthy || true)
echo " Containere pornite: $TOTAL | cu healthcheck healthy: $HEALTHY | unhealthy: $UNHEALTHY"
echo
docker ps --format ' {{.Names}}\t{{.Status}}' | sort | head -30
echo " ... (lista completa: docker ps)"
echo -e "${G}=> Platforma integral containerizata, servicii sanatoase${N}"
pauza
echo -e "${B}==============================================================================${N}"
echo -e "${B} TOATE PROBELE API AU FOST RULATE — $(date '+%Y-%m-%d %H:%M:%S')${N}"
echo -e "${B}==============================================================================${N}"

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, "serviceAccountsEnabled": false,
"attributes": { "attributes": {
"pkce.code.challenge.method": "S256", "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": [ "redirectUris": [
"http://localhost:13003/*", "http://localhost:13003/*",
@ -227,8 +227,8 @@
"http://127.0.0.1:3003/*", "http://127.0.0.1:3003/*",
"http://127.0.0.1:33003/*", "http://127.0.0.1:33003/*",
"http://127.0.0.1:33001/*", "http://127.0.0.1:33001/*",
"http://10.11.50.11:33003/*", "http://ext.local:33003/*",
"http://10.11.50.11:3003/*" "http://ext.local:3003/*"
], ],
"webOrigins": [ "webOrigins": [
"http://localhost:13003", "http://localhost:13003",
@ -240,8 +240,8 @@
"http://127.0.0.1:3003", "http://127.0.0.1:3003",
"http://127.0.0.1:33003", "http://127.0.0.1:33003",
"http://127.0.0.1:33001", "http://127.0.0.1:33001",
"http://10.11.50.11:33003", "http://ext.local:33003",
"http://10.11.50.11:3003", "http://ext.local:3003",
"+" "+"
] ]
}, },

View file

@ -12,9 +12,9 @@ _info:
# ============================================================ # ============================================================
# DIDI tenant configuration for shared Kong cluster # 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) # 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 # Plugins are applied per-service (NOT global) — cluster shared with lege365/rafai/biddie/notify
# ============================================================ # ============================================================
@ -146,7 +146,7 @@ services:
# ---------------------------------------------------------- # ----------------------------------------------------------
- name: didi-agent-v3 - name: didi-agent-v3
protocol: http protocol: http
host: 10.11.10.12 host: didi.local
port: 24803 port: 24803
retries: 5 retries: 5
connect_timeout: 60000 connect_timeout: 60000
@ -430,7 +430,7 @@ services:
# ---------------------------------------------------------- # ----------------------------------------------------------
- name: didi-framework - name: didi-framework
protocol: http protocol: http
host: 10.11.10.12 host: didi.local
port: 3005 port: 3005
retries: 5 retries: 5
connect_timeout: 60000 connect_timeout: 60000
@ -525,7 +525,7 @@ services:
tags: [product:didi, env:prod] 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. # 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. # 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. # 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) LLM_ROUTER_URL=http://llm.local:14011 # llm-inference (Qwen text/OCR)
VISION_LLM_URL=http://10.11.10.17:14011 # Qwen Vision (fallback la LLM_ROUTER_URL) VISION_LLM_URL=http://llm.local:14011 # Qwen Vision (fallback la LLM_ROUTER_URL)
DIDI_BRAIN_URL=http://10.11.10.12:8090 # brain (verification cache + RAG) DIDI_BRAIN_URL=http://didi.local:8090 # brain (verification cache + RAG)
M17_WHISPER_URL=http://10.11.10.17:54300/v1/audio/transcriptions # transcriere audio M17_WHISPER_URL=http://llm.local:54300/v1/audio/transcriptions # transcriere audio
M17_WEB_API_URL=http://10.11.10.13:51100 # web search (claims/source) M17_WEB_API_URL=http://websearch.local:51100 # web search (claims/source)
FORENSIC_API_URL=http://forensic-features-api:8080 # forensic m25-m29 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 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 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} JWT_VERIFY_ENABLED: ${JWT_VERIFY_ENABLED:-true}
FRAMEWORK_API_URL: http://didi-framework:3005 FRAMEWORK_API_URL: http://didi-framework:3005
SYNC_API_URL: http://didi-framework:3005/api/sync-analysis 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} INTERNAL_MEDIA_URL: ${INTERNAL_MEDIA_URL:-http://didi-agent-v3:24803}
# === Lot 1 (platforma AI) — toate configurabile per deployment via .env === # === Lot 1 (platforma AI) — toate configurabile per deployment via .env ===
LLM_ROUTER_URL: ${LLM_ROUTER_URL:-http://10.11.10.17:14011} LLM_ROUTER_URL: ${LLM_ROUTER_URL:-http://llm.local:14011}
VISION_LLM_URL: ${VISION_LLM_URL:-http://10.11.10.17: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} 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} DIDI_BRAIN_URL: ${DIDI_BRAIN_URL:-http://didibrain-api:8090}
# OpenTelemetry — traces export to OTel Collector → Jaeger # OpenTelemetry — traces export to OTel Collector → Jaeger
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://didi-otel-collector:4317} 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 # to /api/ingest/event so analyses appear in the unified Insights/History
# view (module=agent_v3). Empty = sink disabled. # view (module=agent_v3). Empty = sink disabled.
DASHBOARD_URL: ${DASHBOARD_URL:-http://didiAI-dashboard:51300} 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} M17_WHISPER_TOKEN: ${M17_WHISPER_TOKEN}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
OPENAI_API_KEY: ${OPENAI_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 // 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 { try {
const t = Date.now(); const t = Date.now();
const r = await fetch(`${brainUrl}/health`, { signal: AbortSignal.timeout(3000) }); 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 // 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 { try {
const t = Date.now(); const t = Date.now();
const r = await fetch(`${llmUrl}/health`, { signal: AbortSignal.timeout(5000) }); 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 // Internal URL for local models that can't reach didi365.eu
// Points to MinIO directly since public URLs use /storage/ path (Kong→MinIO) // 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 // 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 // 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', model: 'Qwen3.5-397B-A17B',
provider: 'qwen-local', provider: 'qwen-local',
// Configurabil per deployment (Lot 1 pe mașina nouă). Fallback = LLM_ROUTER_URL, apoi IP didi. // 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', .replace(/\/+$/, '') + '/v1/chat/completions',
auth_type: 'none', auth_type: 'none',
timeout: 60000, timeout: 60000,

View file

@ -1,5 +1,5 @@
# Database Configuration # Database Configuration
DB_HOST=10.11.50.167 DB_HOST=db.local
DB_PORT=5000 DB_PORT=5000
DB_NAME=DIDI DB_NAME=DIDI
DB_USER=bos_interface DB_USER=bos_interface
@ -11,4 +11,10 @@ PORT=3005
HOST=0.0.0.0 HOST=0.0.0.0
# CORS # 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: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: didi-framework 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. # Internal traffic (other DIDI containers) still uses didi-network DNS.
ports: ports:
- "3005:3005" - "3005:3005"
@ -46,6 +46,10 @@ services:
- FACEBOOK_PAGE_ID=${FACEBOOK_PAGE_ID:-1152853237912005} - FACEBOOK_PAGE_ID=${FACEBOOK_PAGE_ID:-1152853237912005}
- FACEBOOK_PAGE_ACCESS_TOKEN=${FACEBOOK_PAGE_ACCESS_TOKEN:-} - FACEBOOK_PAGE_ACCESS_TOKEN=${FACEBOOK_PAGE_ACCESS_TOKEN:-}
- FACEBOOK_API_VERSION=${FACEBOOK_API_VERSION:-v21.0} - 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 (test mode keys come from .env file)
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
@ -58,7 +62,7 @@ services:
- SMTP_PASS=${SMTP_PASS:-} - SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-DIDI} - SMTP_FROM_NAME=${SMTP_FROM_NAME:-DIDI}
- SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-} - 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 — traces to Jaeger via OTel Collector
- OTEL_ENABLED=${OTEL_ENABLED:-true} - OTEL_ENABLED=${OTEL_ENABLED:-true}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-http://didi-otel-collector:4318/v1/traces} - 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/): * Endpoints (toate sub /api/admin/social/):
* POST /social/draft creează draft din session_id sau content manual * POST /social/draft creează draft din session_id sau content manual
@ -25,6 +25,16 @@ import {
debugFacebookToken, debugFacebookToken,
generateDraftFromAnalysisSession, generateDraftFromAnalysisSession,
} from '../../services/facebook'; } 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(); const router = Router();
@ -54,10 +64,11 @@ interface SocialPostRow {
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
router.get('/social/health', async (_req: Request, res: Response) => { router.get('/social/health', async (_req: Request, res: Response) => {
try { try {
const debug = await debugFacebookToken(); const [debug, liDebug] = await Promise.all([debugFacebookToken(), debugLinkedInToken()]);
res.json({ res.json({
success: true, success: true,
data: { data: {
platforms: SUPPORTED_PLATFORMS,
facebook: { facebook: {
configured: !!process.env.FACEBOOK_PAGE_ACCESS_TOKEN && !!process.env.FACEBOOK_PAGE_ID, configured: !!process.env.FACEBOOK_PAGE_ACCESS_TOKEN && !!process.env.FACEBOOK_PAGE_ID,
page_id: process.env.FACEBOOK_PAGE_ID || null, page_id: process.env.FACEBOOK_PAGE_ID || null,
@ -67,6 +78,7 @@ router.get('/social/health', async (_req: Request, res: Response) => {
scopes: debug.scopes, scopes: debug.scopes,
error: debug.error, error: debug.error,
}, },
linkedin: liDebug,
}, },
}); });
} catch (e) { } catch (e) {
@ -87,6 +99,12 @@ router.post('/social/draft', async (req: Request, res: Response) => {
if (content.length > 60000) { if (content.length > 60000) {
return res.status(400).json({ success: false, error: 'content too long (max 60k chars)' }); 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 } }) const createdBy = (req as Request & { adminUser?: { sub?: string; email?: string } })
.adminUser?.email .adminUser?.email
|| (req as Request & { adminUser?: { sub?: string; email?: string } }).adminUser?.sub || (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' }); 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; const scheduledAtIso = req.body?.scheduled_at as string | undefined;
let scheduledUnix: number | undefined; let scheduledUnix: number | undefined;
if (scheduledAtIso) { 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); const ts = Math.floor(new Date(scheduledAtIso).getTime() / 1000);
if (isNaN(ts) || ts * 1000 < Date.now() + 9 * 60 * 1000) { if (isNaN(ts) || ts * 1000 < Date.now() + 9 * 60 * 1000) {
return res.status(400).json({ return res.status(400).json({
@ -157,6 +183,14 @@ router.post('/social/publish/:post_id', async (req: Request, res: Response) => {
scheduledUnix = ts; 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) // Mark as publishing (prevent double-publish)
await query( await query(
`UPDATE bos_sysadmin.social_post SET status='publishing', updated_at=now() WHERE post_id=$1`, `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 { try {
const fbResult = await postToFacebookPage({ const fbResult = platform === 'linkedin'
message: draft.content, ? await postToLinkedInPage({
link: draft.link_url || undefined, message: draft.content,
imageUrl: draft.image_url || undefined, link: draft.link_url || undefined,
scheduledPublishTime: scheduledUnix, // 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 finalStatus = scheduledUnix ? 'scheduled' : 'published';
const updated = await queryOne<SocialPostRow>(` const updated = await queryOne<SocialPostRow>(`
@ -194,7 +234,7 @@ router.post('/social/publish/:post_id', async (req: Request, res: Response) => {
postId, 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 }); res.json({ success: true, data: updated });
} catch (fbErr) { } catch (fbErr) {
const errMsg = (fbErr as Error).message; 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() SET status = 'failed', error_message = $1, updated_at = now()
WHERE post_id = $2 WHERE post_id = $2
`, [errMsg, postId]); `, [errMsg, postId]);
log.error(`[social] FB publish failed for ${postId}: ${errMsg}`); log.error(`[social] ${platform} publish failed for ${postId}: ${errMsg}`);
res.status(502).json({ success: false, error: `Facebook publish failed: ${errMsg}` }); res.status(502).json({ success: false, error: `${platform} publish failed: ${errMsg}` });
} }
} catch (e) { } catch (e) {
internalError(res, e, 'social_publish'); 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 // 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; const lastUpdate = row.engagement_updated_at ? new Date(row.engagement_updated_at).getTime() : 0;
if (Date.now() - lastUpdate > 5 * 60 * 1000) { if (Date.now() - lastUpdate > 5 * 60 * 1000) {
try { 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' }); 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') { if (row.external_post_id && row.status === 'published') {
try { try {
await deleteFacebookPost(row.external_post_id); if ((row.platform || 'facebook') === 'linkedin') {
} catch (fbErr) { await deleteLinkedInPost(row.external_post_id);
// Continue with DB delete even if FB delete fails (post might be already gone) } else {
log.warn(`[social] FB delete failed: ${(fbErr as Error).message}`); 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 || (req as Request & { adminUser?: { sub?: string; email?: string } }).adminUser?.sub
|| 'unknown'; || '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 // Salvează draft în DB
const row = await queryOne<SocialPostRow>(` const row = await queryOne<SocialPostRow>(`
INSERT INTO bos_sysadmin.social_post INSERT INTO bos_sysadmin.social_post
(session_id, platform, content, status, created_by) (session_id, platform, content, status, created_by)
VALUES ($1, 'facebook', $2, 'draft', $3) VALUES ($1, $2, $3, 'draft', $4)
RETURNING * RETURNING *
`, [sessionId, content, createdBy]); `, [sessionId, genPlatform, content, createdBy]);
res.status(201).json({ success: true, data: row }); res.status(201).json({ success: true, data: row });
} catch (e) { } catch (e) {

View file

@ -247,29 +247,20 @@ export function generateDraftFromAnalysisSession(session: {
: category === 'UNCERTAIN' ? 'Conținut cu credibilitate incertă.' : category === 'UNCERTAIN' ? 'Conținut cu credibilitate incertă.'
: 'Conținut analizat.'); : 'Conținut analizat.');
// Trim explanation to ~400 chars for FB readability // IMPORTANT: LinkedIn afișează în feed doar ~210 caractere din postările prin API
const explanation = verdictRo.length > 400 // (fără expander „see more"), iar Facebook colapsează la fel textele lungi. Generăm
? verdictRo.slice(0, 400).trim() + '...' // deci un post CONCIS care se afișează integral: verdict + scor + esența într-o
: verdictRo; // singură frază + hashtags. Detaliul complet rămâne accesibil prin link-ul analizei.
const oneLiner = verdictRo.replace(/\s+/g, ' ').trim();
// Snippet din input // rezervăm loc pentru antet (~35) + hashtags (~48); ținta ~210 caractere total
const inputPreview = session.input_text const room = 210 - 35 - 48;
? `"${session.input_text.slice(0, 150).trim()}${session.input_text.length > 150 ? '...' : ''}"` const shortExplanation = oneLiner.length > room
: session.input_url ? oneLiner.slice(0, room).replace(/[\s,.;:]+\S*$/, '').trim() + '…'
? `🔗 ${session.input_url}` : oneLiner;
: '';
return [ return [
`${emoji} Alertă dezinformare — DiDi`, `${emoji} DiDi · ${category} · Scor ${score}/100`,
'', shortExplanation,
inputPreview ? `Conținut analizat:\n${inputPreview}` : '', '#DiDi #Dezinformare #FactChecking #AntiFake',
'', ].filter(Boolean).join('\n\n');
`📊 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');
} }

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) # 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 # 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. # 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_PORT="9000"
CLUSTER_USE_SSL="false" CLUSTER_USE_SSL="false"
CLUSTER_BUCKET="didi-prod" CLUSTER_BUCKET="didi-prod"