Optimizare scripturi build/CI si functionalitati noi Lot 2

- CI/CD GitLab (.gitlab-ci.yml): verify -> test -> build -> publish ->
  deploy cu health-gate (/api/v3/health/all) + rollback manual
- Publicare pe retele sociale (LinkedIn/Facebook) din Analysis History
  (social.ts, linkedin.ts, facebook.ts, SocialPostModal)
- Specificatii OpenAPI: agent-v3 (87 op.) si didi-framework (287 op.)
- Matrice testare acceptanta beneficiar + script dovezi API
- Fix nginx SSL: redirect port 3001 (error_page 497, absolute_redirect off)
- Adrese interne inlocuite cu hostname-uri generice (.local)
This commit is contained in:
EVOTECH IT SRL 2026-07-12 13:36:08 -07:00
parent 8ecc78e729
commit 7e4f23d4c4
22 changed files with 19477 additions and 110 deletions

View file

@ -1,12 +1,20 @@
server {
listen 443 ssl;
server_name localhost 10.11.10.12;
server_name localhost didi.local;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
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)
client_max_body_size 100M;
@ -173,7 +181,8 @@ server {
# HTTP server (for Kong proxy + direct access)
server {
listen 80;
server_name localhost 10.11.10.12;
absolute_redirect off;
server_name localhost didi.local;
client_max_body_size 100M;

View file

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

View file

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

View file

@ -3,10 +3,10 @@ import {
Dialog, DialogTitle, DialogContent, DialogActions,
Box, TextField, Button, Alert, Chip, CircularProgress,
Typography, Stack, IconButton, Divider, Tooltip, Paper, Checkbox,
FormControlLabel,
FormControlLabel, ToggleButton, ToggleButtonGroup,
} from '@mui/material';
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,
Visibility as PreviewIcon,
} 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';
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 = {
id: string;
@ -30,6 +36,7 @@ interface Props {
open: boolean;
onClose: () => void;
session: AnalysisSession | null;
initialPlatform?: Platform;
onSuccess?: (postId: string, externalUrl: string) => void;
}
@ -121,8 +128,9 @@ const joinBlocks = (blocks: Block[]): string =>
.map((b) => b.content.trim())
.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 [platform, setPlatform] = useState<Platform>('facebook');
const [blocks, setBlocks] = useState<Block[]>([]);
const [imageUrl, setImageUrl] = useState('');
const [linkUrl, setLinkUrl] = useState('');
@ -134,6 +142,7 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
useEffect(() => {
if (!open || !session) return;
setStatus('loading');
setPlatform(initialPlatform || 'facebook');
setBlocks([]);
setImageUrl('');
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);
setStatus('error');
});
}, [open, session]);
}, [open, session, initialPlatform]);
const previewText = useMemo(() => joinBlocks(blocks), [blocks]);
const charCount = previewText.length;
@ -182,7 +191,7 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
content: previewText,
image_url: imageUrl || undefined,
link_url: linkUrl || undefined,
platform: 'facebook',
platform,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
@ -192,7 +201,8 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
const pubRes = await axios.post(
`${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}` } },
);
if (pubRes.data?.success && pubRes.data.data) {
@ -214,8 +224,21 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FacebookIcon sx={{ color: '#1877F2' }} />
Post to Facebook
{platform === 'linkedin'
? <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 }} />
<IconButton onClick={onClose} size="small">
<CloseIcon />
@ -294,10 +317,19 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
<PreviewIcon fontSize="small" color="action" />
<Typography variant="overline" color="text.secondary">
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 }} />
)}
</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>
<Paper
variant="outlined"
@ -315,12 +347,14 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
</Paper>
<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}
onChange={(e) => setImageUrl(e.target.value)}
fullWidth
size="small"
disabled={status === 'publishing'}
disabled={status === 'publishing' || platform === 'linkedin'}
placeholder="https://didi365.eu/share/image.png"
/>
<TextField
@ -344,16 +378,20 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
color={scheduledAt ? 'warning' : 'default'}
onDelete={scheduledAt ? () => setScheduledAt('') : undefined}
/>
<Tooltip title="Programează publicarea (min 10 min în viitor)">
<TextField
type="datetime-local"
size="small"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
inputProps={{ min: minScheduleDate }}
disabled={status === 'publishing'}
sx={{ width: 220 }}
/>
<Tooltip title={platform === 'linkedin'
? 'LinkedIn nu suportă programare prin API — doar publicare imediată'
: 'Programează publicarea (min 10 min în viitor)'}>
<span>
<TextField
type="datetime-local"
size="small"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
inputProps={{ min: minScheduleDate }}
disabled={status === 'publishing' || platform === 'linkedin'}
sx={{ width: 220 }}
/>
</span>
</Tooltip>
</Box>
@ -368,7 +406,7 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
{status === 'published' && (
<Stack spacing={2} sx={{ py: 2 }}>
<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>
{externalUrl && (
<Button
@ -379,7 +417,7 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
variant="outlined"
size="small"
>
Deschide pe Facebook
Deschide postarea
</Button>
)}
</Stack>
@ -407,13 +445,13 @@ export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuc
}
onClick={handlePublish}
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'
? 'Se publică...'
: scheduledAt
: scheduledAt && platform === 'facebook'
? 'Programează'
: 'Publică pe Facebook'}
: `Publică pe ${PLATFORM_META[platform].label}`}
</Button>
</>
)}