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
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue