feat: sync Facebook OAuth stats to company social media profiles
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

After connecting a FB page via OAuth, automatically fetch page stats
(followers, engagement, bio) from Graph API and persist to
CompanySocialMedia table. Adds manual refresh endpoint and UI badge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-19 13:54:57 +01:00
parent 1cbbb6fc80
commit 592ceff30d
4 changed files with 242 additions and 1 deletions

View File

@ -380,6 +380,17 @@ def oauth_select_fb_page():
db.commit()
logger.info(f"FB page selected: {page_name} (ID: {page_id}) for company {company_id}")
# Sync Facebook stats to CompanySocialMedia (non-blocking)
sync_data = None
try:
from facebook_graph_service import sync_facebook_to_social_media
sync_result = sync_facebook_to_social_media(db, company_id)
logger.info(f"FB sync after page select: {sync_result}")
if sync_result.get('success'):
sync_data = sync_result.get('data')
except Exception as e:
logger.error(f"FB sync error (non-blocking): {e}")
return jsonify({
'success': True,
'message': f'Strona "{page_name}" połączona',
@ -387,7 +398,8 @@ def oauth_select_fb_page():
'id': page_id,
'name': page_name,
'category': selected.get('category'),
}
},
'sync': sync_data,
})
except Exception as e:
db.rollback()
@ -395,3 +407,36 @@ def oauth_select_fb_page():
return jsonify({'success': False, 'error': 'Błąd zapisu konfiguracji strony'}), 500
finally:
db.close()
@bp.route('/oauth/meta/sync-facebook', methods=['POST'])
@login_required
def oauth_sync_facebook_data():
"""Manually refresh Facebook page stats for a company.
POST /api/oauth/meta/sync-facebook
Body: {"company_id": 123}
"""
data = request.get_json()
if not data or not data.get('company_id'):
return jsonify({'success': False, 'error': 'company_id jest wymagany'}), 400
company_id = data['company_id']
# Permission check: user must be able to edit this company
if not current_user.can_edit_company(company_id):
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
from facebook_graph_service import sync_facebook_to_social_media
result = sync_facebook_to_social_media(db, company_id)
if result.get('success'):
return jsonify(result)
else:
return jsonify(result), 400
except Exception as e:
logger.error(f"FB manual sync error: {e}")
return jsonify({'success': False, 'error': 'Błąd synchronizacji danych Facebook'}), 500
finally:
db.close()

View File

@ -284,3 +284,129 @@ class FacebookGraphService:
"""
result = self._delete(post_id)
return bool(result and result.get('success'))
# ============================================================
# SYNC: Facebook Page → CompanySocialMedia
# ============================================================
def sync_facebook_to_social_media(db, company_id: int) -> dict:
"""Fetch Facebook page stats via Graph API and upsert into CompanySocialMedia.
Requires an active OAuth token and SocialMediaConfig for the company.
Called automatically after page selection and manually via sync endpoint.
Args:
db: SQLAlchemy session
company_id: Company ID to sync data for
Returns:
dict with 'success' bool and either 'data' or 'error'
"""
from oauth_service import OAuthService
from database import SocialMediaConfig, CompanySocialMedia
# 1. Get valid page access token
oauth = OAuthService()
token = oauth.get_valid_token(db, company_id, 'meta', 'facebook')
if not token:
return {'success': False, 'error': 'no_token', 'message': 'Brak aktywnego tokenu Facebook'}
# 2. Get page_id from SocialMediaConfig
config = db.query(SocialMediaConfig).filter(
SocialMediaConfig.platform == 'facebook',
SocialMediaConfig.company_id == company_id,
SocialMediaConfig.is_active == True,
).first()
if not config or not config.page_id:
return {'success': False, 'error': 'no_page', 'message': 'Brak skonfigurowanej strony Facebook'}
page_id = config.page_id
# 3. Fetch page info from Graph API
fb = FacebookGraphService(token)
page_info = fb.get_page_info(page_id)
if not page_info:
return {'success': False, 'error': 'api_failed', 'message': 'Nie udało się pobrać danych strony z Facebook API'}
# 4. Fetch page insights (best-effort, may be empty)
insights = fb.get_page_insights(page_id, 28)
# 5. Calculate metrics
followers = page_info.get('followers_count') or page_info.get('fan_count') or 0
engaged_users = insights.get('page_engaged_users', 0)
engagement_rate = None
if followers > 0 and engaged_users > 0:
engagement_rate = round((engaged_users / followers) * 100, 2)
# Profile completeness: 25 points each for about, website, phone, address
completeness = 0
if page_info.get('about'):
completeness += 25
if page_info.get('website'):
completeness += 25
if page_info.get('phone'):
completeness += 25
if page_info.get('single_line_address'):
completeness += 25
page_url = page_info.get('link') or f"https://facebook.com/{page_id}"
# 6. Upsert CompanySocialMedia record
# Look for existing Facebook record for this company (any URL)
existing = db.query(CompanySocialMedia).filter(
CompanySocialMedia.company_id == company_id,
CompanySocialMedia.platform == 'facebook',
).first()
now = datetime.now()
if existing:
csm = existing
csm.url = page_url
else:
csm = CompanySocialMedia(
company_id=company_id,
platform='facebook',
url=page_url,
)
db.add(csm)
csm.source = 'facebook_api'
csm.is_valid = True
csm.check_status = 'ok'
csm.page_name = page_info.get('name', '')
csm.followers_count = followers if followers > 0 else csm.followers_count
csm.has_bio = bool(page_info.get('about'))
csm.profile_description = (page_info.get('about') or '')[:500]
if engagement_rate is not None:
csm.engagement_rate = engagement_rate
csm.profile_completeness_score = completeness
csm.verified_at = now
csm.last_checked_at = now
# Append to followers_history
history = csm.followers_history or []
if followers > 0:
today_str = now.strftime('%Y-%m-%d')
# Don't duplicate entries for the same day
if not history or history[-1].get('date') != today_str:
history.append({'date': today_str, 'count': followers})
csm.followers_history = history
db.commit()
logger.info(f"FB sync OK for company {company_id}: {followers} followers, engagement={engagement_rate}")
return {
'success': True,
'data': {
'page_name': csm.page_name,
'followers_count': followers,
'engagement_rate': engagement_rate,
'profile_completeness_score': completeness,
'source': 'facebook_api',
}
}

View File

@ -2582,6 +2582,12 @@
<svg width="14" height="14" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
Aktywny profil
</div>
{% if sm.source == 'facebook_api' %}
<div style="color: #1877f2; font-size: var(--font-size-xs); display: flex; align-items: center; gap: 4px; margin-top: 2px;">
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
Dane z Facebook API
</div>
{% endif %}
{% if platform == 'linkedin' and (can_edit_profile or is_admin) %}
{% if '/in/' in sm.url %}
<div style="color: #d97706; font-size: var(--font-size-xs); display: flex; align-items: center; gap: 4px; margin-top: 2px;">
@ -2631,6 +2637,14 @@
{% if platform == 'youtube' %}Subskrybentów{% else %}Obserwujących{% endif %}
</div>
</div>
{% if sm.engagement_rate %}
<div style="text-align: center;">
<div style="font-size: var(--font-size-xl); font-weight: 700; color: #10b981;">
{{ sm.engagement_rate }}%
</div>
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Engagement</div>
</div>
{% endif %}
{% if sm.total_posts %}
<div style="text-align: center;">
<div style="font-size: var(--font-size-xl); font-weight: 700; color: var(--text-primary);">
@ -2645,6 +2659,17 @@
{% endif %}
</div>
<!-- Refresh button for Facebook API connected profiles -->
{% if sm.source == 'facebook_api' and (can_edit_profile or is_admin) %}
<button onclick="syncFacebookData({{ company.id }}, this)" style="display: block; width: 100%; text-align: center; padding: var(--spacing-xs) var(--spacing-md);
background: transparent; color: #1877f2; border: 1px solid #1877f2; border-radius: var(--radius);
font-size: var(--font-size-xs); cursor: pointer; margin-bottom: var(--spacing-xs);
transition: background 0.2s;">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 20 20" style="vertical-align: middle; margin-right: 4px;"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
Odśwież dane z API
</button>
{% endif %}
<!-- Visit Button -->
<a href="{{ sm.url }}" target="_blank" rel="noopener noreferrer"
style="display: block; text-align: center; padding: var(--spacing-sm) var(--spacing-md);
@ -4840,4 +4865,41 @@ function updateLogoStep(stepId, status, message) {
})();
</script>
<script>
function syncFacebookData(companyId, btn) {
btn.disabled = true;
btn.textContent = 'Synchronizuję...';
fetch('/api/oauth/meta/sync-facebook', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''},
body: JSON.stringify({company_id: companyId})
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.textContent = 'Zaktualizowano!';
btn.style.background = '#10b981';
btn.style.color = 'white';
btn.style.borderColor = '#10b981';
setTimeout(() => location.reload(), 1000);
} else {
btn.textContent = data.message || 'Błąd synchronizacji';
btn.style.borderColor = '#dc2626';
btn.style.color = '#dc2626';
setTimeout(() => {
btn.textContent = 'Odśwież dane z API';
btn.disabled = false;
btn.style.borderColor = '#1877f2';
btn.style.color = '#1877f2';
}, 3000);
}
})
.catch(() => {
btn.textContent = 'Błąd połączenia';
btn.disabled = false;
setTimeout(() => { btn.textContent = 'Odśwież dane z API'; }, 2000);
});
}
</script>
{% endblock %}

View File

@ -860,6 +860,14 @@
</span>
</div>
{% endif %}
{% if profile.source == 'facebook_api' %}
<div style="margin: 6px 0; padding: 8px 12px; background: rgba(24, 119, 242, 0.08); border-left: 3px solid #1877f2; border-radius: 4px; font-size: 12px; color: #1e40af;">
<svg width="14" height="14" fill="#1877f2" viewBox="0 0 20 20" style="vertical-align: middle; margin-right: 4px;"><path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
<strong>Dane z Facebook API</strong>
{% if profile.engagement_rate is not none %} &middot; Engagement: {{ '%.1f'|format(profile.engagement_rate) }}%{% endif %}
{% if profile.profile_completeness_score %} &middot; Kompletność profilu: {{ profile.profile_completeness_score }}%{% endif %}
</div>
{% endif %}
<div class="platform-meta">
{% if profile.page_name %}
<div class="platform-meta-item">