From c2452c0a45c9b26679e61e940fc395cfcd5d577f Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Mon, 29 Dec 2025 11:13:47 +0300 Subject: [PATCH] fix: Normalize USERS table timestamps before dm:save to prevent PostgreSQL type errors - Add format-timestamp-for-postgres and normalize-user-timestamps functions in database.lisp - data-model-save now normalizes timestamp fields for USERS table before saving - Fix authenticate-user to use data-model-save instead of dm:save directly - Add global auth state in auth-ui.lisp for other scripts to check login status - Add auth checks in front-page.lisp and stream-player.lisp to skip API calls when not logged in - Fix ParenScript parentheses structure in stream-player.lisp DOMContentLoaded handler Fixes password reset, role update, and last-login update failures caused by integer timestamps being sent to PostgreSQL TIMESTAMP columns. --- database.lisp | 33 ++++++++++++++++++++++++++++ parenscript/auth-ui.lisp | 5 +++++ parenscript/front-page.lisp | 26 ++++++++++++++--------- parenscript/stream-player.lisp | 39 ++++++++++++++++++++-------------- user-management.lisp | 3 ++- 5 files changed, 79 insertions(+), 27 deletions(-) diff --git a/database.lisp b/database.lisp index 7add19b..341c15b 100644 --- a/database.lisp +++ b/database.lisp @@ -98,6 +98,37 @@ (string= (string-upcase (package-name (db:implementation))) "I-LAMBDALITE")) +(defun format-timestamp-for-postgres (value) + "Convert a timestamp value to ISO 8601 format for PostgreSQL. +Handles: integers (Unix epoch), local-time timestamps, strings, and NIL." + (cond + ((null value) nil) + ((stringp value) value) ; Already a string, assume it's formatted + ((integerp value) + ;; Convert Unix epoch to ISO 8601 string + (local-time:format-timestring nil (local-time:unix-to-timestamp value) + :format '(:year "-" (:month 2) "-" (:day 2) " " + (:hour 2) ":" (:min 2) ":" (:sec 2)) + :timezone local-time:+utc-zone+)) + ((typep value 'local-time:timestamp) + (local-time:format-timestring nil value + :format '(:year "-" (:month 2) "-" (:day 2) " " + (:hour 2) ":" (:min 2) ":" (:sec 2)) + :timezone local-time:+utc-zone+)) + (t (format nil "~a" value)))) ; Fallback: convert to string + +(defun normalize-user-timestamps (data-model) + "Ensure USERS table timestamp fields are properly formatted for PostgreSQL." + (when (string-equal (dm:collection data-model) "USERS") + (let ((created-date (dm:field data-model "created-date")) + (last-login (dm:field data-model "last-login"))) + (when created-date + (setf (dm:field data-model "created-date") + (format-timestamp-for-postgres created-date))) + (when last-login + (setf (dm:field data-model "last-login") + (format-timestamp-for-postgres last-login)))))) + (defun data-model-save (data-model) "Wrapper on data-model save method to bypass error using dm:save on lambdalite. It uses the same approach as dm:save under the hood through db:save." @@ -109,4 +140,6 @@ It uses the same approach as dm:save under the hood through db:save." (dm:field-table data-model))) (progn (format t "Updating database table '~a'~%" (dm:collection data-model)) + ;; Normalize timestamp fields before saving to PostgreSQL + (normalize-user-timestamps data-model) (dm:save data-model)))) diff --git a/parenscript/auth-ui.lisp b/parenscript/auth-ui.lisp index f56ede3..53f841c 100644 --- a/parenscript/auth-ui.lisp +++ b/parenscript/auth-ui.lisp @@ -7,6 +7,9 @@ (ps:ps* '(progn + ;; Global auth state - accessible by other scripts + (defvar *auth-state* (ps:create :logged-in false :is-admin false :user-id nil)) + ;; Check if user is logged in by calling the API (defun check-auth-status () (ps:chain @@ -16,6 +19,8 @@ (then (lambda (result) ;; api-output wraps response in {status, message, data} (let ((data (or (ps:@ result data) result))) + ;; Store auth state globally for other scripts to use + (setf *auth-state* data) data))) (catch (lambda (error) (ps:chain console (error "Error checking auth status:" error)) diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index 3492e0f..cd92963 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -171,14 +171,17 @@ ;; Cache of user's favorite track titles for quick lookup (defvar *user-favorites-cache* (array)) - ;; Load user's favorites into cache + ;; Load user's favorites into cache (only if logged in) (defun load-favorites-cache () - (ps:chain - (fetch "/api/asteroid/user/favorites") - (then (lambda (response) - (if (ps:@ response ok) - (ps:chain response (json)) - nil))) + ;; Check global auth state - only call API if logged in + (when (and (not (= (typeof *auth-state*) "undefined")) + (ps:@ *auth-state* logged-in)) + (ps:chain + (fetch "/api/asteroid/user/favorites") + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + nil))) (then (lambda (data) (when (and data (ps:@ data data) (ps:@ data data favorites)) (setf *user-favorites-cache* @@ -186,7 +189,7 @@ (map (lambda (f) (ps:@ f title))))) ;; Update UI after cache is loaded (check-favorite-status)))) - (catch (lambda (error) nil)))) + (catch (lambda (error) nil))))) ;; Check if current track is in favorites and update UI (defun check-favorite-status () @@ -205,7 +208,10 @@ ;; Record track to listening history (only if logged in) (defun record-track-listen-main (title) - (when (and title (not (= title "")) (not (= title "Loading...")) + ;; Check global auth state - only call API if logged in + (when (and (not (= (typeof *auth-state*) "undefined")) + (ps:@ *auth-state* logged-in) + title (not (= title "")) (not (= title "Loading...")) (not (= title "NA")) (not (= title *last-recorded-title-main*))) (setf *last-recorded-title-main* title) (ps:chain @@ -214,7 +220,7 @@ (then (lambda (response) (ps:@ response ok))) (catch (lambda (error) - ;; Silently fail - user might not be logged in + ;; Silently fail nil))))) ;; Update now playing info from API diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index 6dde88c..0f01110 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -204,14 +204,17 @@ ;; Cache of user's favorite track titles for quick lookup (mini player) (defvar *user-favorites-cache-mini* (array)) - ;; Load user's favorites into cache (mini player) + ;; Load user's favorites into cache (mini player - only if logged in) (defun load-favorites-cache-mini () - (ps:chain - (fetch "/api/asteroid/user/favorites" (ps:create :credentials "include")) - (then (lambda (response) - (if (ps:@ response ok) - (ps:chain response (json)) - nil))) + ;; Check global auth state - only call API if logged in + (when (and (not (= (typeof *auth-state*) "undefined")) + (ps:@ *auth-state* logged-in)) + (ps:chain + (fetch "/api/asteroid/user/favorites" (ps:create :credentials "include")) + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + nil))) (then (lambda (data) (when data ;; Handle both wrapped (data.data.favorites) and unwrapped (data.favorites) responses @@ -222,7 +225,7 @@ (ps:chain favorites (map (lambda (f) (ps:@ f title))))) ;; Update UI after cache is loaded (check-favorite-status-mini)))))) - (catch (lambda (error) nil)))) + (catch (lambda (error) nil))))) ;; Check if current track is in favorites and update mini player UI (defun check-favorite-status-mini () @@ -242,7 +245,10 @@ ;; Record track to listening history (only if logged in) (defun record-track-listen (title) - (when (and title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*))) + ;; Check global auth state - only call API if logged in + (when (and (not (= (typeof *auth-state*) "undefined")) + (ps:@ *auth-state* logged-in) + title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*))) (setf *last-recorded-title* title) (ps:chain (fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title)) @@ -784,13 +790,14 @@ (init-persistent-player)) ;; Check for popout player (when (ps:chain document (get-element-by-id "live-audio")) - (init-popout-player)))) - ;; Listen for messages from parent frame (e.g., favorites cache reload) - (ps:chain window (add-event-listener - "message" - (lambda (event) - (when (= (ps:@ event data) "reload-favorites") - (load-favorites-cache-mini))))))) + (init-popout-player))))) + + ;; Listen for messages from parent frame (e.g., favorites cache reload) + (ps:chain window (add-event-listener + "message" + (lambda (event) + (when (= (ps:@ event data) "reload-favorites") + (load-favorites-cache-mini)))))) ) "Compiled JavaScript for stream player - generated at load time") diff --git a/user-management.lisp b/user-management.lisp index 025daba..ef6f860 100644 --- a/user-management.lisp +++ b/user-management.lisp @@ -78,7 +78,8 @@ :format '(:year "-" (:month 2) "-" (:day 2) " " (:hour 2) ":" (:min 2) ":" (:sec 2)) :timezone local-time:+utc-zone+)) - (dm:save user)) + ;; Use data-model-save to normalize all timestamp fields before saving + (data-model-save user)) (error (e) (format t "Warning: Could not update last-login: ~a~%" e))) user)))))