feat: persist Facebook posts in DB for instant page load
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

- Migration 071: Add cached_posts (JSONB) and posts_cached_at to social_media_config
- Service: get_cached_posts() and save_all_posts_to_cache() methods
- Route: New POST endpoint to save posts cache, pass cached data to template
- Template: Render cached posts+charts instantly on page load from DB,
  save to DB after "Load all" or "Refresh", remove AJAX auto-load

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-20 08:35:25 +01:00
parent 0d3c925338
commit 63ee509e1e
5 changed files with 110 additions and 13 deletions

View File

@ -114,6 +114,13 @@ def social_publisher_list():
for fp in fb_profiles:
fb_stats[fp.company_id] = fp
# Load cached FB posts from DB for instant rendering
cached_fb_posts = {}
for cid in (user_company_ids or []):
cached = social_publisher.get_cached_posts(cid)
if cached:
cached_fb_posts[cid] = cached
return render_template('admin/social_publisher.html',
posts=posts,
stats=stats,
@ -122,7 +129,8 @@ def social_publisher_list():
type_filter=type_filter,
company_filter=company_filter,
configured_companies=configured_companies,
fb_stats=fb_stats)
fb_stats=fb_stats,
cached_fb_posts=cached_fb_posts)
finally:
db.close()
@ -393,6 +401,27 @@ def social_publisher_fb_posts(company_id):
return jsonify(result)
@bp.route('/social-publisher/fb-posts-cache/<int:company_id>', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_save_posts_cache(company_id):
"""Save all loaded FB posts to DB cache for instant page load (AJAX)."""
from services.social_publisher_service import social_publisher
db = SessionLocal()
try:
if not _user_can_access_company(db, company_id):
return jsonify({'success': False, 'error': 'Brak uprawnień.'}), 403
finally:
db.close()
data = request.get_json()
posts = data.get('posts', []) if data else []
if posts:
social_publisher.save_all_posts_to_cache(company_id, posts)
return jsonify({'success': True, 'saved': len(posts)})
@bp.route('/social-publisher/fb-post-insights/<int:company_id>/<path:post_id>')
@login_required
@role_required(SystemRole.MANAGER)

View File

@ -5424,6 +5424,8 @@ class SocialMediaConfig(Base):
is_active = Column(Boolean, default=True)
debug_mode = Column(Boolean, default=True)
config_data = Column(JSONBType)
cached_posts = Column(JSONBType)
posts_cached_at = Column(DateTime)
updated_by = Column(Integer, ForeignKey('users.id'))
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)

View File

@ -0,0 +1,8 @@
-- Add cached Facebook posts storage to social_media_config
-- Posts are cached in DB so they render instantly on page load
ALTER TABLE social_media_config ADD COLUMN IF NOT EXISTS cached_posts JSONB;
ALTER TABLE social_media_config ADD COLUMN IF NOT EXISTS posts_cached_at TIMESTAMP;
-- Grant permissions
GRANT ALL ON TABLE social_media_config TO nordabiz_app;

View File

@ -603,11 +603,46 @@ class SocialPublisherService:
# ---- Facebook Page Posts (read from API) ----
def get_cached_posts(self, company_id: int) -> Dict:
"""Return DB-cached posts for instant page load. No API call."""
db = SessionLocal()
try:
config = db.query(SocialMediaConfig).filter_by(
company_id=company_id, platform='facebook'
).first()
if not config or not config.cached_posts:
return None
return {
'posts': config.cached_posts.get('posts', []),
'cached_at': config.posts_cached_at,
'total_count': config.cached_posts.get('total_count', 0),
}
finally:
db.close()
def _save_posts_to_db(self, company_id: int, posts: list):
"""Save posts to DB cache for instant page load."""
db = SessionLocal()
try:
config = db.query(SocialMediaConfig).filter_by(
company_id=company_id, platform='facebook'
).first()
if config:
config.cached_posts = {'posts': posts, 'total_count': len(posts)}
config.posts_cached_at = datetime.now()
db.commit()
except Exception as e:
db.rollback()
logger.error(f"Failed to save posts cache for company {company_id}: {e}")
finally:
db.close()
def get_page_recent_posts(self, company_id: int, limit: int = 10,
after: str = None) -> Dict:
"""Fetch recent posts from company's Facebook page with engagement metrics.
Uses in-memory cache with 5-minute TTL (first page only).
Saves first page to DB for instant page load.
"""
db = SessionLocal()
try:
@ -617,7 +652,7 @@ class SocialPublisherService:
page_id = config.page_id
# Cache only first page (no cursor)
# In-memory cache only for first page (no cursor)
if not after:
cache_key = (company_id, page_id)
cached = _posts_cache.get(cache_key)
@ -640,7 +675,7 @@ class SocialPublisherService:
posts = result['posts']
next_cursor = result.get('next_cursor')
# Update cache for first page only
# Update in-memory cache for first page only
if not after:
cache_key = (company_id, page_id)
_posts_cache[cache_key] = {
@ -657,6 +692,10 @@ class SocialPublisherService:
finally:
db.close()
def save_all_posts_to_cache(self, company_id: int, posts: list):
"""Public method to save all loaded posts to DB cache."""
self._save_posts_to_db(company_id, posts)
def get_post_insights_detail(self, company_id: int, post_id: str) -> Dict:
"""Fetch detailed insights for a specific post (impressions, reach, clicks)."""
db = SessionLocal()

View File

@ -447,10 +447,16 @@
{% for company_id_key, fb in fb_stats.items() %}
<div class="fb-posts-section" id="fbPostsSection-{{ company_id_key }}">
<div class="fb-posts-header">
<h3>Ostatnie posty na Facebook</h3>
<div style="display: flex; gap: var(--spacing-xs);">
<button class="btn btn-secondary btn-small" onclick="loadFbPosts({{ company_id_key }}, this)">Zaladuj posty</button>
<button class="btn btn-primary btn-small" onclick="loadAllFbPosts({{ company_id_key }}, this)">Zaladuj wszystkie + wykresy</button>
<h3>Posty na Facebook
{% if cached_fb_posts.get(company_id_key) %}
<span style="font-size: var(--font-size-xs); color: var(--text-secondary); font-weight: 400;">
({{ cached_fb_posts[company_id_key].total_count }} postow, cache z {{ cached_fb_posts[company_id_key].cached_at.strftime('%d.%m %H:%M') }})
</span>
{% endif %}
</h3>
<div style="display: flex; gap: var(--spacing-xs); align-items: center;">
<button class="btn btn-secondary btn-small" onclick="loadFbPosts({{ company_id_key }}, this)">Odswiez posty</button>
<button class="btn btn-primary btn-small" onclick="loadAllFbPosts({{ company_id_key }}, this)">Wszystkie + wykresy</button>
</div>
</div>
<div id="fbChartsSection-{{ company_id_key }}" style="display:none;">
@ -817,6 +823,7 @@
return;
}
renderFbPosts(companyId, data.posts, data.next_cursor, isAppend);
if (!isAppend) saveFbPostsToCache(companyId, data.posts);
})
.catch(function(err) {
btn.textContent = origText;
@ -919,6 +926,7 @@
container.insertAdjacentHTML('beforeend',
'<div style="text-align:center;margin-top:var(--spacing-md);color:var(--text-secondary);font-size:var(--font-size-sm);">Zaladowano wszystkie posty (' + allPosts.length + ')</div>');
renderFbCharts(companyId, allPosts);
saveFbPostsToCache(companyId, allPosts);
} catch (err) {
btn.textContent = origText;
btn.disabled = false;
@ -1215,14 +1223,25 @@
});
}
// Auto-load first page of posts on page load
function saveFbPostsToCache(companyId, posts) {
fetch('/admin/social-publisher/fb-posts-cache/' + companyId, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''},
body: JSON.stringify({posts: posts})
}).catch(function() {});
}
// Render cached posts from DB on page load (instant, no API call)
document.addEventListener('DOMContentLoaded', function() {
{% if fb_stats %}
{% for company_id_key, fb in fb_stats.items() %}
{% if cached_fb_posts %}
{% for cid, cache_data in cached_fb_posts.items() %}
(function() {
var companyId = {{ company_id_key }};
var btn = document.querySelector('#fbPostsSection-' + companyId + ' .btn-secondary');
if (btn) loadFbPosts(companyId, btn);
var companyId = {{ cid }};
var cachedPosts = {{ cache_data.posts | tojson }};
if (cachedPosts && cachedPosts.length > 0) {
renderFbPosts(companyId, cachedPosts, null, false);
renderFbCharts(companyId, cachedPosts);
}
})();
{% endfor %}
{% endif %}