Optimizare scripturi build/CI si functionalitati noi Lot 2
- CI/CD GitLab (.gitlab-ci.yml): verify -> test -> build -> publish -> deploy cu health-gate (/api/v3/health/all) + rollback manual - Publicare pe retele sociale (LinkedIn/Facebook) din Analysis History (social.ts, linkedin.ts, facebook.ts, SocialPostModal) - Specificatii OpenAPI: agent-v3 (87 op.) si didi-framework (287 op.) - Matrice testare acceptanta beneficiar + script dovezi API - Fix nginx SSL: redirect port 3001 (error_page 497, absolute_redirect off) - Adrese interne inlocuite cu hostname-uri generice (.local)
This commit is contained in:
parent
8ecc78e729
commit
7e4f23d4c4
22 changed files with 19477 additions and 110 deletions
178
backend/.gitlab-ci.yml
Normal file
178
backend/.gitlab-ci.yml
Normal 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."
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
177
backend/scripts/acceptanta/dovezi-api.sh
Normal file
177
backend/scripts/acceptanta/dovezi-api.sh
Normal 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}"
|
||||||
4725
backend/services/api-docs/agent-v3.yaml
Normal file
4725
backend/services/api-docs/agent-v3.yaml
Normal file
File diff suppressed because it is too large
Load diff
14000
backend/services/api-docs/didi-framework.yaml
Normal file
14000
backend/services/api-docs/didi-framework.yaml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -215,7 +215,7 @@
|
||||||
"serviceAccountsEnabled": false,
|
"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",
|
||||||
"+"
|
"+"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
/**
|
||||||
|
* LinkedIn REST API client — postare automată din admin-dashboard pe pagina
|
||||||
|
* de organizație LinkedIn. Provider paralel cu facebook.ts (DESI 6).
|
||||||
|
*
|
||||||
|
* Config (ENV):
|
||||||
|
* LINKEDIN_ACCESS_TOKEN — access token cu scope `w_organization_social`
|
||||||
|
* (aplicație LinkedIn cu produsul "Community Management API",
|
||||||
|
* autorizată de un admin al paginii de organizație)
|
||||||
|
* LINKEDIN_ORG_URN — URN-ul organizației, ex: urn:li:organization:12345678
|
||||||
|
* LINKEDIN_API_VERSION — header LinkedIn-Version, format YYYYMM (default 202506)
|
||||||
|
*
|
||||||
|
* Cum obții credențialele (pe scurt):
|
||||||
|
* 1. https://developer.linkedin.com → Create app, legată de pagina companiei.
|
||||||
|
* 2. Products → adaugă "Community Management API" (necesită aprobarea LinkedIn).
|
||||||
|
* 3. OAuth2 cu scope w_organization_social, autorizat de un admin al paginii.
|
||||||
|
* 4. ID-ul organizației e în URL-ul paginii de admin (numeric) → urn:li:organization:<id>.
|
||||||
|
*
|
||||||
|
* Limitări față de Facebook (v1):
|
||||||
|
* - LinkedIn NU suportă programare (scheduled publish) prin API — doar publicare imediată.
|
||||||
|
* - Imaginile cer un flux separat de upload (initializeUpload) — neimplementat în v1;
|
||||||
|
* postările sunt text + link (articol atașat).
|
||||||
|
*
|
||||||
|
* Folosit de: src/routes/admin/social.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LinkedInPostOptions {
|
||||||
|
message: string;
|
||||||
|
link?: string; // atașat ca articol (card cu preview)
|
||||||
|
linkTitle?: string; // titlul cardului de articol (LinkedIn îl cere obligatoriu când e link)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkedInPostResult {
|
||||||
|
id: string; // URN-ul postării, ex: urn:li:share:7222...
|
||||||
|
external_url?: string; // URL public al postării
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_VERSION = process.env.LINKEDIN_API_VERSION || '202506';
|
||||||
|
const BASE_URL = 'https://api.linkedin.com';
|
||||||
|
|
||||||
|
export function isLinkedInConfigured(): boolean {
|
||||||
|
return !!process.env.LINKEDIN_ACCESS_TOKEN && !!process.env.LINKEDIN_ORG_URN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrgUrn(): string {
|
||||||
|
const urn = process.env.LINKEDIN_ORG_URN;
|
||||||
|
if (!urn) throw new Error('LINKEDIN_ORG_URN env var not set (ex: urn:li:organization:12345678)');
|
||||||
|
return urn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(): string {
|
||||||
|
const t = process.env.LINKEDIN_ACCESS_TOKEN;
|
||||||
|
if (!t) throw new Error('LINKEDIN_ACCESS_TOKEN env var not set — LinkedIn nu este configurat');
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${getToken()}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Restli-Protocol-Version': '2.0.0',
|
||||||
|
'LinkedIn-Version': API_VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publică un post pe pagina de organizație LinkedIn (POST /rest/posts).
|
||||||
|
* Text simplu sau text + link (articol). Fără programare (nesuportat de API).
|
||||||
|
*/
|
||||||
|
export async function postToLinkedInPage(
|
||||||
|
options: LinkedInPostOptions,
|
||||||
|
): Promise<LinkedInPostResult> {
|
||||||
|
const orgUrn = getOrgUrn();
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
author: orgUrn,
|
||||||
|
commentary: options.message,
|
||||||
|
visibility: 'PUBLIC',
|
||||||
|
distribution: {
|
||||||
|
feedDistribution: 'MAIN_FEED',
|
||||||
|
targetEntities: [],
|
||||||
|
thirdPartyDistributionChannels: [],
|
||||||
|
},
|
||||||
|
lifecycleState: 'PUBLISHED',
|
||||||
|
isReshareDisabledByAuthor: false,
|
||||||
|
};
|
||||||
|
if (options.link) {
|
||||||
|
// LinkedIn cere `title` obligatoriu pentru cardul de articol. Fallback: prima linie
|
||||||
|
// din mesaj (max 100 caractere) sau un titlu generic.
|
||||||
|
const firstLine = (options.message || '').split('\n').map(s => s.trim()).find(Boolean) || '';
|
||||||
|
const title = (options.linkTitle || firstLine || 'Analiză DiDi').slice(0, 100);
|
||||||
|
body.content = { article: { source: options.link, title } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/rest/posts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: apiHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
let msg = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const err = JSON.parse(text) as { message?: string };
|
||||||
|
if (err.message) msg = err.message;
|
||||||
|
} catch { /* text brut */ }
|
||||||
|
throw new Error(`LinkedIn API error: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID-ul postării vine în headerul x-restli-id (URN)
|
||||||
|
const postUrn = res.headers.get('x-restli-id') || res.headers.get('x-linkedin-id') || '';
|
||||||
|
if (!postUrn) {
|
||||||
|
throw new Error('LinkedIn API: missing x-restli-id header in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: postUrn,
|
||||||
|
external_url: `https://www.linkedin.com/feed/update/${encodeURIComponent(postUrn)}/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Șterge o postare de pe pagina LinkedIn (DELETE /rest/posts/{urn}).
|
||||||
|
*/
|
||||||
|
export async function deleteLinkedInPost(postUrn: string): Promise<boolean> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE_URL}/rest/posts/${encodeURIComponent(postUrn)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: apiHeaders(),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`LinkedIn delete failed (HTTP ${res.status}): ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifică starea configurării LinkedIn (folosit la /social/health).
|
||||||
|
* Best-effort: confirmă prezența credențialelor și încearcă un apel ușor.
|
||||||
|
*/
|
||||||
|
export async function debugLinkedInToken(): Promise<{
|
||||||
|
configured: boolean;
|
||||||
|
org_urn: string | null;
|
||||||
|
token_valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const configured = isLinkedInConfigured();
|
||||||
|
if (!configured) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
org_urn: process.env.LINKEDIN_ORG_URN || null,
|
||||||
|
token_valid: false,
|
||||||
|
error: 'LINKEDIN_ACCESS_TOKEN / LINKEDIN_ORG_URN nu sunt setate',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Introspecție ușoară: /v2/userinfo merge pentru token-urile OIDC;
|
||||||
|
// pentru token-urile doar-organizație poate întoarce 403 — tratăm ca „prezent, nevalidabil aici".
|
||||||
|
const res = await fetch(`${BASE_URL}/v2/userinfo`, {
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
org_urn: getOrgUrn(),
|
||||||
|
token_valid: res.ok,
|
||||||
|
error: res.ok ? undefined : `userinfo HTTP ${res.status} (tokenul poate fi valid doar pt. scope organizație)`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { configured: true, org_urn: getOrgUrn(), token_valid: false, error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,10 +27,10 @@ fi
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Cluster (external S3 cluster live since 2026-04-23)
|
# 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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue