Logbook
Daily reflections, progress, and lessons learned
Platform-Aware Content Plan, Image Crop, AI Image Generation, Security Hardening
Two patches today, both substantial. Platform-Aware Content Plan The 30-day content plan from yesterday got a major upgrade. GenerateContentPlanSkill now receives the selected account platforms and generates separate, platform-optimised captions per day. Each platform has its own style guide baked into the AI prompt: Facebook gets conversational copy at 100–250 words with max two emojis and a question at the end; LinkedIn gets professional thought leadership at 150–400 words with zero or one emoji and a bold opening insight; Instagram gets a hook in the first line, 50–150 words, 3–6 emojis, 8–15 hashtags. The preview shows a Bootstrap tab UI — Facebook, LinkedIn, Instagram tabs with editable text areas. storePlan() saves the correct platform caption to each account's post record. Token limit was raised from 4,000 to 8,000 and timeout from 60 to 120 seconds — 30 days of full captions across three platforms simply cannot fit in 4,000 tokens. The GeminiProvider was also ignoring the caller's timeout option and always using 60 seconds hardcoded; fixed to read $options['timeout']. AIManager was dropping the timeout key through array_filter — fixed to forward it correctly. Fixed BaseSkill::generate() always returning success=true regardless of the AI response. API failures (401, quota exceeded, network error) were silently producing empty content and the controller showed "No content returned" with no indication of the real problem. Now propagates success=false with the actual error message. Split "No content returned" into two distinct messages: real AI errors surface directly, parse failures show the first 300 characters of raw AI output so format mismatches are visible immediately. Image Crop Tool and AI Image Generation Cropper.js 1.6.2 integrated on create/edit post. Images are cropped client-side before upload using preset aspect ratios: 4:5 Portrait for Instagram, 1:1 Square, 1.91:1 Landscape, 1200×630 for Facebook and LinkedIn, and Free. Cropped canvas exported at 1080 px wide as JPEG before sending to the server — consistent dimensions regardless of original file size. AI image generation added as a collapsible card on create/edit post. Prompt input calls POST /social-media/generate-image which routes to Gemini's image generation model, saves the base64 result to uploads/social/, and returns the URL alongside a refreshed CSRF token. Preview shown immediately; "Add to Post" attaches the image to the upload grid. The content plan carries image concepts through the plan preview into the edit post page — if a plan day had an image concept, the edit form pre-fills the AI image prompt and auto-expands the generation panel. Platform image hints now shown dynamically as account checkboxes are toggled: Instagram shows the aspect ratio warning (0.8–1.91), Facebook recommends 1200×630, LinkedIn recommends 1200×627. Security Hardening and DB Cleanup CSRF exception in the filter narrowed from the api/* wildcard down to the four specific POST routes that are legitimately session-free: contact form, Biznetrix ack endpoint, RAG query, and RAG documents. Every other API route is now CSRF-protected by default. The wildcard was always a temporary measure and this closes it. EmbeddingService now takes a constructor-injected CURLRequest instance shared for the lifetime of the service, instead of creating a new HTTP connection inside every generate() call. In batch embedding workflows this was allocating a new connection per document — fixed. Same pattern applied to TranslationService, which was using raw curl_init/curl_exec/curl_close; replaced with the CI4 CURLRequest for consistency and test-injectability. Database cleanup migration: sets cover_image = NULL on any posts row still carrying the old placeholder avatar path. The file_exists() guard was already handling these correctly at render time, but having the placeholder value in the database was misleading when reading records directly. PHPStan at 0 errors on 403 files. PHPUnit 171/171 passing.
Three things stood out today. The BaseSkill always-returning-success bug: any function that communicates failure should never lie. Silent success on AI failure is worse than a thrown exception because it produces wrong content with no signal that anything went wrong. Constructor-injected HTTP clients vs per-call creation: the per-call pattern in EmbeddingService was invisible in single-document flows but became a problem in batch processing. Inject shared resources at construction time. And the CSRF wildcard: api/* is never the right long-term answer, even if it is the right quick fix. Always narrow it to the specific routes that actually need it.
Instagram OAuth Fixed, Media Uploads, 30-Day AI Content Plan
One major patch today covering three features. Instagram "Cannot Parse Access Token" — Dual-Flow Fix Instagram connection was failing with "Cannot parse access token" and I finally understood why. The flow was taking a Facebook user token (obtained via Facebook Login with Instagram Business permissions) and sending it to graph.instagram.com. That endpoint rejects Facebook tokens — it only accepts tokens from Instagram's own Basic Display API, which is a different product entirely. Instagram Business accounts are linked to Facebook Pages and use the Facebook Graph API, not the Instagram endpoint. Fixed with a dual-flow fallback: tries graph.instagram.com/me first (correct path for Instagram Login), then falls back to graph.facebook.com/me/accounts (Facebook Business API path) when the first attempt returns no account ID. The fallback is what actually works for Business accounts. Instagram now connects successfully. Media Uploads on Create and Edit Post Social post create and edit both now have a drag-drop upload zone with URL input and removable thumbnail preview grid. Files save to public/uploads/social/, URLs are accepted directly. On edit, existing media shows as removable thumbnails; keep_media[] inputs preserve existing URLs through the save so you do not lose previous media by only uploading one new file. 30-Day AI Content Plan New page at /admin/social-media/plan — generates a day-by-day content calendar using a GenerateContentPlanSkill. Supports 7, 14, and 30-day plans with brand, audience, goal, and tone inputs. The preview page shows all days as editable text areas so you can adjust any caption before committing. Toggle individual days on or off before bulk-saving. Approved days save as draft posts in the queue, ready for scheduling.
The Instagram "cannot parse access token" error is one of the most misleading errors in the Meta ecosystem. The token is valid — it is just being sent to the wrong endpoint. Instagram Business and Instagram Basic Display are separate products with separate token flows, even though they appear connected in the Facebook developer console. Always check which endpoint expects which token type.
Cron Debugging, Facebook Page Tokens, LinkedIn OAuth, Timezone Fix
Three patches across the day. A lot of debugging, very satisfying when everything finally clicked into place. Cron Chain Fixes RunScheduler was crashing on startup. The command was reading $task['job_class'] but the live DB table only has a command column — the migration that would have added job_class was silently skipped because the table already existed when it ran. Rewrote the scheduler to use command and execute via exec(PHP_BINARY . spark …) directly. The publish command (automation:publish-posts) was never running at all — cron.sh was missing the run_posts() call in the all case. The PHP binary issue: cron.sh was defaulting to the system php which is an older version on this server. CloudPanel does not set environment variables for cron processes, so PHP_BIN was always empty. Hardcoded the default to the correct versioned binary path. Timezone bug: the app timezone was set to UTC but the admin form submits browser-local datetime-local values (Bangladesh, UTC+6). Posts scheduled at 6 PM were being stored as 18:00 UTC, meaning the cron never matched them against NOW() which was 12:00 UTC at that time. Changed app timezone to Asia/Dhaka. Posts now schedule and publish correctly. Facebook Page Token Fix This was the one that had me stumped for a while. The OAuth flow was storing the user's long-lived access token and user ID — but posting to a Facebook Page requires a page access token and page ID, not the user token. A user token does not have permission to post on behalf of a Page. Added fetchFacebookPageToken() (calls /me/accounts) and fetchFacebookPageInfo() to extract and store the correct page token at OAuth connect time. DMs were also returning empty because fetchFacebookDms() was filtering out conversations with unread_count = 0. Messages already read in Messenger were being silently skipped. Removed the filter — deduplication by message_id is sufficient and correct. LinkedIn OAuth and PHPStan Level 5 LinkedIn account was not saving after OAuth because the /v2/userinfo endpoint returns sub as the user ID, not id. The account_id was always empty, causing a silent model validation failure and a false success flash. Mapped sub → id and email → username. Same issue with Twitter: the /2/users/me endpoint wraps the user object under a data key, fields were not being read. Fixed a false success on insert failure — exchangeCode() was calling insert() but never checking the return value. A validation failure silently returned without saving. Now throws a RuntimeException with the model's actual error messages so the callback shows a real error instead of a success banner. OAuth callback was 404-ing because the callback route was only registered at one path but the migration seeded a different path in the default OAuth app config. Added the aliased route. Facebook Login for Business: when a config_id is set, the scope parameter must not be sent — Facebook rejects it with "Invalid Scopes". Removed scope from the auth URL params when config_id is detected. PHPStan raised to level 5 and brought to 0 errors: strict null checks throughout, removed redundant is_array on already-typed arrays, fixed curl_errno int used in boolean OR. LinkedIn End-to-End Confirmed By end of day: LinkedIn OAuth connects and saves correctly, auto-posting to LinkedIn via UGC Posts API confirmed working, comment fetch and AI reply working. LinkedIn DMs are intentionally not supported — LinkedIn removed DM API access from the Marketing API in 2023. The DM_PLATFORMS constant reflects that by design.
Three lessons from today. (1) Silent insert failures are worse than exceptions — always check the model return value and surface the actual error. A false success flash that hides a failed DB write can go undetected for weeks. (2) Page tokens and user tokens are not interchangeable on Facebook. The Graph API will accept a user token on some endpoints and silently fail on others; always fetch the page token at connect time. (3) Cron PHP binary resolution must be explicit on shared hosting. Environment variables are stripped from cron processes, so PHP_BIN defaults do not work.
Six Patches: SEO Overhaul, Social Automation, Authority Pages, Session Timeout
Six patches across one day. This was a long one. Session Idle Timeout + Admin Bug Fixes Admin idle timeout is now enforced at 30 minutes via a last_activity session key in AdminAuthFilter. Found a subtle bug with the destroy-before-flash pattern: session()->destroy() wipes the session before the flash message can be stored, so the timeout redirect was showing a blank login page with no explanation. Fixed to session()->remove([...auth keys...]) to keep the session alive long enough to carry the flash to the login page. Settings page was redirecting to the homepage on the live site. Root cause: echo view() instead of return view() — the response was being echoed directly, bypassing CI4's response pipeline. About page canonical was pointing to /profile instead of /about. Both fixed. SEO Overhaul Per-page Open Graph tags across Posts, Works, Logbook, and Profile — each page now passes its own og_title, og_description, og_type, and og_image to the layout. og:url was always emitting the homepage URL regardless of the current page — now uses the per-page canonical. JSON-LD type is dynamic: WebSite on homepage, Article on detail pages, Person on About. JSON_HEX_TAG added to prevent a </script> substring in JSON-LD from breaking out of the script block. Profile meta now uses a clean bio (strip_tags before use as og:description). Added public/llms.txt following the llmstxt.org spec for AI crawler guidance. Dynamic llms.txt and hreflang Static llms.txt replaced with a Llms controller pulling all published works and featured posts from the database. New is_llms_featured column on the posts table — "Feature in llms.txt" checkbox in admin Blog. Only flagged posts appear. hreflang x-default now points to the bare domain; language alternates use ?lang={code} on the current page URL so search engine crawlers get correctly translated content without needing a session cookie. get_current_language() reads ?lang= first and persists to session. Cover Image, OG Fallback Chain, Share Buttons Admin Blog create/edit now includes a cover image upload with live preview and remove-checkbox. The OG image fallback chain throughout the frontend: uploaded cover image → global OG image from settings → gradient placeholder. file_exists() guard on cover image paths so stale database values fall through to the next fallback instead of rendering a broken img tag. Share buttons (X/Twitter, LinkedIn, Copy Link) added to post and work detail pages — no external scripts, brand colours, translated labels in all 12 language files. Authority Pages: /now, /stack, /ai-lab Three new public pages backed by admin-editable content. /now shows what I'm working on right now (single-row upsert, Summernote editor, auto-translated). /stack is a technology stack grouped by category with sort order. /ai-lab lists AI experiments and tools with status badges and demo links. Full CRUD in the admin via a MetaPages controller. Footer redesigned as a five-column inline grid with a double-width intro column, proper mobile collapse. Stack and AI Lab sections appended to /llms.txt. cron.sh added at project root for scheduler and queue targets with logging to writable/logs/cron.log. Full Social Automation: Real Posts + RAG Comment and DM Replies The demo stubs are gone. PublishSocialPosts rewritten with live API calls for all four platforms: Facebook Graph API v25.0, LinkedIn UGC Posts API, Twitter API v2 with 280-char truncation, Instagram two-step container and publish flow. CheckSocialComments command now polls all published posts for new comments across Facebook, Instagram, Twitter mentions, and LinkedIn. Deduplicates by platform comment ID, classifies with RAG, auto-posts reply, saves leads scoring 40 or above to social_leads. Same pipeline for DMs via CheckSocialDms across Facebook Messenger, Instagram DMs, and Twitter DMs. The SocialRagService pipeline: embed incoming message → cosine-score KB documents → top-5 by similarity → feed context + message to AI → parse JSON response (classification, lead score 0–100, suggested reply). Falls back to keyword search when embeddings are not configured. social_dms table added with a unique index on message_id to prevent duplicate processing. Social Post Edit and Reschedule Any draft, scheduled, or failed post can now be edited — content, account, scheduled time, link URL. Saving with a date moves it to scheduled; without a date it stays draft. Failed posts get a dedicated reschedule action that resets retry count, clears the error message, and queues it for the next cron run. The failed section in the queue view shows last error message and retry badge count prominently at the top. Guards added: editing a published post is rejected, rescheduling a non-failed post is rejected.
The session destroy-before-flash bug is a CI4 footgun: destroy() wipes everything including queued flash data. Always use remove() with specific keys when you want the session to survive to carry a flash message. The og:url-always-homepage bug is the kind of thing that passes visual testing because the content looks right — only visible when you inspect the source or check sharing previews.
Version System, Admin Settings Expansion, Logbook Live Search
Two sessions today. The morning one was focused on the admin layer, the evening one on the frontend logbook. Admin Settings Expansion Added a proper version system — APP_VERSION constant in Config, displayed in the admin footer alongside the site name pulled from the database instead of hardcoded text. Small thing but it means I can see at a glance whether the live server is running the latest deploy. Three new admin settings pages wired up. Email settings now manages SMTP configuration (host, port, encryption, credentials, from address, notification recipient) through a dedicated /admin/email page — password field shows a set/not-set indicator without exposing the value. Biznetrix integration settings moved to /admin/biznetrix/settings with the API URL visible and the API key masked. Google Translate key gets its own field in the main settings with the same set/not-set pattern. All three write to .env via a shared writeEnvKey() helper extracted into BaseController — previously each controller had its own copy of that logic. DRY. Admin layout housekeeping: seven dashboard-style pages and six social media sub-pages had redundant inner content-header and section.content wrappers duplicating what the master layout already provides. Stripped them all. Action buttons (Add Provider, New Category, etc.) moved to float-right inside card headers, consistent with every other CRUD page on the platform. Logbook Live Search and Pagination The logbook frontend now supports clean query params (?q= and ?page=), paginates at five entries per page, and updates results via debounced AJAX while typing — searches across date and content. Centered header, simplified search UI, better input padding. Fixed a pagination JS bug where clicking a page link was injecting the full page HTML (including the footer) into the results list, causing duplicated footer rendering. The interceptor now only captures pagination links, not every anchor on the page. Auto-translate on update is now "fill missing only" for Blog and Works — when Google Translate is configured, update saves fill any translation column that is still empty, but never overwrite an existing manual translation. Right behaviour: manual edits should not get silently overwritten by the API.
The "set/not-set indicator without exposing the value" pattern for sensitive fields (SMTP password, API keys) is the correct admin UX. Never render credential values back into HTML, even masked — a view-source reveals them. Store, compare, indicate.