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.
This commit is contained in:
Glenn Thompson 2025-12-29 11:13:47 +03:00
parent eb03947f7f
commit c2452c0a45
5 changed files with 79 additions and 27 deletions

View File

@ -98,6 +98,37 @@
(string= (string-upcase (package-name (db:implementation))) (string= (string-upcase (package-name (db:implementation)))
"I-LAMBDALITE")) "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) (defun data-model-save (data-model)
"Wrapper on data-model save method to bypass error using dm:save on lambdalite. "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." 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))) (dm:field-table data-model)))
(progn (progn
(format t "Updating database table '~a'~%" (dm:collection data-model)) (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)))) (dm:save data-model))))

View File

@ -7,6 +7,9 @@
(ps:ps* (ps:ps*
'(progn '(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 ;; Check if user is logged in by calling the API
(defun check-auth-status () (defun check-auth-status ()
(ps:chain (ps:chain
@ -16,6 +19,8 @@
(then (lambda (result) (then (lambda (result)
;; api-output wraps response in {status, message, data} ;; api-output wraps response in {status, message, data}
(let ((data (or (ps:@ result data) result))) (let ((data (or (ps:@ result data) result)))
;; Store auth state globally for other scripts to use
(setf *auth-state* data)
data))) data)))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error checking auth status:" error)) (ps:chain console (error "Error checking auth status:" error))

View File

@ -171,8 +171,11 @@
;; Cache of user's favorite track titles for quick lookup ;; Cache of user's favorite track titles for quick lookup
(defvar *user-favorites-cache* (array)) (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 () (defun load-favorites-cache ()
;; Check global auth state - only call API if logged in
(when (and (not (= (typeof *auth-state*) "undefined"))
(ps:@ *auth-state* logged-in))
(ps:chain (ps:chain
(fetch "/api/asteroid/user/favorites") (fetch "/api/asteroid/user/favorites")
(then (lambda (response) (then (lambda (response)
@ -186,7 +189,7 @@
(map (lambda (f) (ps:@ f title))))) (map (lambda (f) (ps:@ f title)))))
;; Update UI after cache is loaded ;; Update UI after cache is loaded
(check-favorite-status)))) (check-favorite-status))))
(catch (lambda (error) nil)))) (catch (lambda (error) nil)))))
;; Check if current track is in favorites and update UI ;; Check if current track is in favorites and update UI
(defun check-favorite-status () (defun check-favorite-status ()
@ -205,7 +208,10 @@
;; Record track to listening history (only if logged in) ;; Record track to listening history (only if logged in)
(defun record-track-listen-main (title) (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*))) (not (= title "NA")) (not (= title *last-recorded-title-main*)))
(setf *last-recorded-title-main* title) (setf *last-recorded-title-main* title)
(ps:chain (ps:chain
@ -214,7 +220,7 @@
(then (lambda (response) (then (lambda (response)
(ps:@ response ok))) (ps:@ response ok)))
(catch (lambda (error) (catch (lambda (error)
;; Silently fail - user might not be logged in ;; Silently fail
nil))))) nil)))))
;; Update now playing info from API ;; Update now playing info from API

View File

@ -204,8 +204,11 @@
;; Cache of user's favorite track titles for quick lookup (mini player) ;; Cache of user's favorite track titles for quick lookup (mini player)
(defvar *user-favorites-cache-mini* (array)) (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 () (defun load-favorites-cache-mini ()
;; Check global auth state - only call API if logged in
(when (and (not (= (typeof *auth-state*) "undefined"))
(ps:@ *auth-state* logged-in))
(ps:chain (ps:chain
(fetch "/api/asteroid/user/favorites" (ps:create :credentials "include")) (fetch "/api/asteroid/user/favorites" (ps:create :credentials "include"))
(then (lambda (response) (then (lambda (response)
@ -222,7 +225,7 @@
(ps:chain favorites (map (lambda (f) (ps:@ f title))))) (ps:chain favorites (map (lambda (f) (ps:@ f title)))))
;; Update UI after cache is loaded ;; Update UI after cache is loaded
(check-favorite-status-mini)))))) (check-favorite-status-mini))))))
(catch (lambda (error) nil)))) (catch (lambda (error) nil)))))
;; Check if current track is in favorites and update mini player UI ;; Check if current track is in favorites and update mini player UI
(defun check-favorite-status-mini () (defun check-favorite-status-mini ()
@ -242,7 +245,10 @@
;; Record track to listening history (only if logged in) ;; Record track to listening history (only if logged in)
(defun record-track-listen (title) (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) (setf *last-recorded-title* title)
(ps:chain (ps:chain
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title)) (fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
@ -784,13 +790,14 @@
(init-persistent-player)) (init-persistent-player))
;; Check for popout player ;; Check for popout player
(when (ps:chain document (get-element-by-id "live-audio")) (when (ps:chain document (get-element-by-id "live-audio"))
(init-popout-player)))) (init-popout-player)))))
;; Listen for messages from parent frame (e.g., favorites cache reload) ;; Listen for messages from parent frame (e.g., favorites cache reload)
(ps:chain window (add-event-listener (ps:chain window (add-event-listener
"message" "message"
(lambda (event) (lambda (event)
(when (= (ps:@ event data) "reload-favorites") (when (= (ps:@ event data) "reload-favorites")
(load-favorites-cache-mini))))))) (load-favorites-cache-mini))))))
) )
"Compiled JavaScript for stream player - generated at load time") "Compiled JavaScript for stream player - generated at load time")

View File

@ -78,7 +78,8 @@
:format '(:year "-" (:month 2) "-" (:day 2) " " :format '(:year "-" (:month 2) "-" (:day 2) " "
(:hour 2) ":" (:min 2) ":" (:sec 2)) (:hour 2) ":" (:min 2) ":" (:sec 2))
:timezone local-time:+utc-zone+)) :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) (error (e)
(format t "Warning: Could not update last-login: ~a~%" e))) (format t "Warning: Could not update last-login: ~a~%" e)))
user))))) user)))))