Logbook

Daily reflections, progress, and lessons learned

May 24, 2026 Project: #Portfolio
determined

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.

Lesson

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.

Read More →
May 23, 2026 Project: #Portfolio
focused

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.

Lesson

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.

Read More →
May 22, 2026 Project: #Portfolio
productive

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.

Lesson

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.

Read More →
May 21, 2026 Project: #Portfolio
shipped

Deployment.

Today was about getting the platform to production cleanly. Most of it was CI/CD plumbing.Replaced the appleboy/ssh-action step with a native SSH heredoc — fewer moving parts, easier to debug when the deploy fails. Worked through a chain of fixes: the deploy path needed to be ~/marufsheikh, then $HOME/marufsheikh because the absolute path was wrong, then realised the deploy path was being interpolated through script instead of envs (YAML structure was wrong). Added script_stop and pwd to surface failures earlier.Then git fetch was not updating origin/master tracking ref — the deploy would silently use a stale commit. Switched from git pull to git fetch + git reset --hard. Added git clean after the reset to drop any leftover files. Logged the deployed commit so I can see exactly what is live.Two product changes today: contact page info is now dynamic (admin can edit address, hours, phone from settings) and a small SQLite fix — dropColumn is skipped in migration down() on SQLite3 because SQLite cannot drop columns in older versions.

Lesson

A silent deploy is worse than a loud failure. Always log the deployed commit.

Read More →
May 20, 2026 Project: #Portfolio
accomplished

Twelve languages.

Today the platform learns to speak. Expanded from EN/AR to twelve languages: English, Chinese (Simplified), Hindi, Spanish, Arabic (RTL), French, Portuguese (Brazil), Bengali, Russian, Indonesian, German, Japanese. Wrote ten new language files — every UI section translated.The interesting design decision: I did not build separate translation tables. Each translatable field gets eleven sibling columns named _{suffix} — title_zh_hans, bio_ar, content_de. Schema changes are local to each table, queries stay simple, indexing is straightforward.On admin save, TranslationService::translateForStore() auto-fills empty translation columns via batched Google Translate API calls. Built a reusable admin partial that drops into all five edit forms — per-field and per-language Re-translate buttons with a fill-count badge.Added an AJAX translate endpoint for single-field re-translation. CSRF was the gotcha: the panel JS was getting 403 on the second click because tokens rotate on every request. Returned the refreshed csrf_hash in every response and made the panel JS consume it before the next call.While I was reworking URLs anyway: switched works, posts, and logbook to slug-based public URLs.Tests grew from 63 to 97. One small bug along the way: the "Re-translate all" button label was always showing the Japanese flag because $info was being set to the last foreach value. Classic PHP foreach-by-reference trap. Fixed.

Lesson

Sibling columns beat translation tables when fields are few and queries matter.

Read More →
Prev 1 2 3 4 5 Next