fix(admin-users): normalise date fields to ISO across DB drivers (#485)
Admin > Users page crashed with "TypeError: e.split is not a function" on native installs (SQLite default). Reported by @blazmaric in #485 with a clean diagnosis: SQLite returns lastLogin / createdAt / updatedAt as integer milliseconds since epoch, while Postgres returns ISO strings via the standard JSON serialiser. The page used parseISO() on the raw value and parseISO trips on numbers. Fix at both layers — defence in depth: - backend/src/routes/adminUsers.js: new toIso() helper applied in transformUser + transformInvitation. Coerces Date / number / numeric-string / null to a single ISO 8601 string contract before the response leaves the API. Protects every consumer (frontend AND external API tokens / n8n) regardless of which DB driver is underneath. - frontend/src/services/userManagement.service.ts: same helper as defence-in-depth for stale backends mid-deploy and any cached pre-fix response shape. Also surfaced an existing transformInvitation gap — invitations endpoints were returning raw response.data.invitations without going through the transformer. 10 unit tests pin the toIso contract: all known driver shapes (Date, number, numeric-string, ISO-string, null/undefined/empty) plus the full transformer paths for transformUser and transformInvitation. Out of scope: same epoch-ms surface may exist on other admin pages that were never tested against SQLite (events list, customers, webhooks, api tokens, activity log). Worth a follow-up audit pass to apply toIso() in every snake_case→camelCase transformer the admin routes use, but the immediate Users-page crash is the only reported one and shipping that fix unblocks @blazmaric.
This commit is contained in:
@@ -1,6 +1,33 @@
|
||||
import { api } from '../config/api';
|
||||
import type { AdminUser, AdminRole, AdminInvitation } from '../types';
|
||||
|
||||
/**
|
||||
* Defence-in-depth normalisation for date fields (#485). The backend
|
||||
* now always returns these as ISO strings via toIso() in adminUsers.js,
|
||||
* but this wrapper:
|
||||
*
|
||||
* - Lets the page survive a stale backend during a mid-deploy window
|
||||
* (older backend still returns SQLite epoch-ms numbers).
|
||||
* - Lets the page survive an external API consumer's response cache
|
||||
* that captured the pre-fix shape.
|
||||
*
|
||||
* The original crash was `parseISO(123456789)` in
|
||||
* AdminUsersPage → `e.split is not a function`. Coercing here means
|
||||
* the next consumer of the AdminUser type can rely on the field
|
||||
* being a string regardless of how it landed.
|
||||
*/
|
||||
function normalizeDateValue(value: unknown): string | null | undefined {
|
||||
if (value === null || value === undefined || value === '') return value as null | undefined;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'number') return new Date(value).toISOString();
|
||||
if (typeof value === 'string') {
|
||||
// SQLite via some drivers stringifies large integers — coerce back.
|
||||
if (/^\d{10,}$/.test(value)) return new Date(Number(value)).toISOString();
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Transform snake_case API response to camelCase for frontend
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformUser(user: any): AdminUser {
|
||||
@@ -9,10 +36,10 @@ function transformUser(user: any): AdminUser {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isActive: user.isActive ?? user.is_active,
|
||||
lastLogin: user.lastLogin ?? user.last_login,
|
||||
lastLogin: normalizeDateValue(user.lastLogin ?? user.last_login),
|
||||
lastLoginIp: user.lastLoginIp ?? user.last_login_ip,
|
||||
createdAt: user.createdAt ?? user.created_at,
|
||||
updatedAt: user.updatedAt ?? user.updated_at,
|
||||
createdAt: normalizeDateValue(user.createdAt ?? user.created_at),
|
||||
updatedAt: normalizeDateValue(user.updatedAt ?? user.updated_at),
|
||||
roleId: user.roleId ?? user.role_id,
|
||||
roleName: user.roleName ?? user.role_name,
|
||||
roleDisplayName: user.roleDisplayName ?? user.role_display_name,
|
||||
@@ -20,6 +47,18 @@ function transformUser(user: any): AdminUser {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformInvitation(invitation: any): AdminInvitation {
|
||||
return {
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
roleName: invitation.roleName ?? invitation.role_name,
|
||||
invitedBy: invitation.invitedBy ?? invitation.invited_by,
|
||||
expiresAt: normalizeDateValue(invitation.expiresAt ?? invitation.expires_at) as string,
|
||||
createdAt: normalizeDateValue(invitation.createdAt ?? invitation.created_at) as string,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformRole(role: any): AdminRole {
|
||||
return {
|
||||
@@ -127,7 +166,7 @@ export const userManagementService = {
|
||||
*/
|
||||
async getInvitations(): Promise<AdminInvitation[]> {
|
||||
const response = await api.get<GetInvitationsResponse>('/admin/users/invitations');
|
||||
return response.data.invitations;
|
||||
return response.data.invitations.map(transformInvitation);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -135,7 +174,7 @@ export const userManagementService = {
|
||||
*/
|
||||
async createInvitation(data: CreateInvitationData): Promise<AdminInvitation> {
|
||||
const response = await api.post<CreateInvitationResponse>('/admin/users/invite', data);
|
||||
return response.data.invitation;
|
||||
return transformInvitation(response.data.invitation);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user