Logbook
Daily reflections, progress, and lessons learned
Profile and footer translations.
The profile page was leaking English. job_title, skills, experience, education were rendering raw $profile['field'] instead of going through lang_field(). Fixed the frontend, then added these four fields as translatable — 44 new columns (4 fields × 11 lang suffixes).Same treatment for the footer: footer_text in settings now has eleven translated variants. Added a translations panel to the Settings admin form and lang_field($settings, 'footer_text') in footer.php.The Summernote bug took me longer than it should have. When the profile translations panel opened, only the bio field would init as a rich editor — skills, experience, and education stayed as plain textareas. Root cause: multiple Summernote instances trying to initialize on the same tick caused DOM collisions. Extracted initOneField() and staggered each init by i * 40 ms.
Stagger DOM-heavy widget inits — they collide on the same tick.
Vector embeddings and real analytics.
The RAG search has been keyword-only this whole time. Today I made it semantic.Built EmbeddingService — calls OpenAI text-embedding-3-small, gets a 1536-dimensional vector, stores it as JSON in the existing embedding_vector column. Status tracked in embedding_status (pending → processing → completed/failed). Truncates input at 8000 multibyte chars using mb_strlen/mb_substr to avoid mid-codepoint cuts.Then rewrote RAGService::retrieveDocuments() to be hybrid: keyword LIKE pass first (broad recall), then cosine similarity re-rank on documents that have embeddings. Final score is 0.4 × keyword + 0.6 × cosine. Falls back gracefully to keyword-only when embeddings are absent or when dimensions do not match.The KB embed button kept reporting "Request failed" — turned out native fetch() does not send X-Requested-With: XMLHttpRequest, so isAJAX() returned false, the controller fell through to a redirect, and the HTML response could not be parsed as JSON.AnalyticsService had three methods that returned hardcoded 4.5 or rand() values — leftover stubs. Replaced all three with real DB queries. Discovered AnalyticsModel::$allowedFields was empty (model was blocking all inserts) and that MySQL and SQLite return different key names for un-aliased AVG() — switched to explicit aliasing.The Workflow controller had a production-silent bug. CI4 4.4+ getMethod() returns uppercase 'POST', but create() and edit() were doing === 'post' — so every form submission silently rendered the GET view. Fixed.Then the test push. Added 74 new tests covering previously untested code. Suite is now 171 passing, was 97.
Hybrid search beats pure vector or pure keyword. Recall + precision, not either-or.
Auto-embed on save.
One more thing. Realised KB documents created via the admin were not getting embeddings until I manually clicked the button. Added beforeInsert / beforeUpdate callbacks to KnowledgeBaseModel: new docs are stamped with embedding_status = 'pending'; updates to title, content, keywords, or tags reset the embedding to pending and clear the stored vector so stale embeddings re-enter the queue.Cleaned up the gap/next-steps list in the project status docs. Three items I had been carrying — auto-trigger embeddings, translate categories, My Account tests — are all done.
Model hooks remove an entire class of "I forgot to click the button" bugs.
Security day. The first hard one.
This was the biggest day so far. Took a security audit of the codebase and found seven serious issues. Fixed all of them in the same session.The worst one was command injection in AutomationEngine — it was executing spark commands stored in the database via exec() with no sanitization. Wrote sanitizeSparkCommand() to strip metacharacters and added escapeshellarg() on every call. Then went through the file upload paths in Admin/Settings (logo, dark logo, favicon, OG image) and added MIME-type allowlist validation everywhere — extension alone was not enough. Added rate limiting on the contact form (5 submissions per 10 minutes per IP).While I was in there: fixed a silent bug where getMethod() === 'post' always failed in CI4 4.x because the method now returns uppercase 'POST' — the contact form had been falling through to the GET branch in production without anyone noticing.Migration housekeeping: replaced MySQL-only SHOW TABLES/SHOW COLUMNS calls in one migration with driver-agnostic tableExists()/fieldExists() — that single change fixed 10 failing SQLite tests. Also went through 27 createTable migrations and added PRIMARY KEY to columns that had AUTO_INCREMENT without it.Test suite went from 10 errors to 29/29 passing. Then the infrastructure work: wrote .github/workflows/ci.yml, generated phpstan-baseline.neon, git init, first commit, committed vendor/ for shared hosting deployment.Long day. The codebase is now in a much more defensible state.
A 30-minute audit can save weeks of cleanup. Schedule them.
Password reset and My Account.
Built the password reset flow end to end. Forgot-password form, tokenised email link, reset form. Tokens are 64-byte random, stored as SHA-256 hashes in the database, expire after one hour. Anti-enumeration is built in — the success message is the same whether the email exists or not.Then the My Account page. Logged-in users can update name, email, avatar (JPEG/PNG/GIF/WebP, 2 MB cap), and change their password (current + new + confirm) on the same page. Added a requireLogin() guard and explicit null checks on $user to prevent fatal errors when a session user_id is stale.Some sharp edges along the way. The reset URL originally embedded the user's email — the CI4 router rejects @ in URL segments with HTTP 400. Pulled the email out and let the controller look it up from the token. The contact throttler crashed on IPv6 addresses because PSR-6 cache keys cannot contain colons — switched the key to md5($ip).SMTP was the rabbit hole. Started on port 465 with SMTPCrypto=ssl (implicit SSL via fsockopen('ssl://...')) — it fails under Apache on XAMPP because the SSL context is different. Switched to port 587 with SMTPCrypto=tls (STARTTLS via stream_socket_enable_crypto()) and raised the timeout from 5 s to 15 s. Emails send.Also wrote a tiny dev helper: php spark email:test <addr> for on-demand SMTP smoke tests.
When in doubt, never put email addresses in URL paths. And always test SMTP under the actual production server.