Compare commits

..

4 Commits

Author SHA1 Message Date
196f7a53b3 Fix: Comment out unused ReadingRow interface in bulk-upload service 2026-01-26 19:49:29 -06:00
2d977b13b4 Convert user and role IDs from number to UUID string
Updated backend and frontend to use UUID strings instead of integers for user and role IDs
to match PostgreSQL database schema (UUID type).

Backend changes:
- Updated User and UserPublic interfaces: id and role_id now string (UUID)
- Updated JwtPayload: userId and roleId now string
- Updated user validators: role_id validation changed from number to UUID string
- Removed parseInt() calls in user controller (getUserById, updateUser, deleteUser, changePassword)
- Updated all user service function signatures to accept string IDs
- Updated create() and update() functions to accept role_id as string

Frontend changes:
- Updated User interface in users API: role_id is string
- Updated CreateUserInput and UpdateUserInput: role_id is string
- Added role filter in UsersPage sidebar
- Removed number conversion logic (parseInt)

This fixes the "invalid input syntax for type uuid" error when creating/updating users.
2026-01-26 19:49:15 -06:00
c910ce8996 Fix user schema to match database structure
Updated backend to use single 'name' field instead of 'first_name' and 'last_name'
to match the actual database schema where users table has a 'name' column.

Changes:
- Updated User and UserPublic interfaces to use 'name' and 'avatar_url'
- Updated user validators to use 'name' instead of first/last names
- Updated all SQL queries in user.service.ts to select u.name
- Updated search filters and sort columns
- Fixed user creation and update operations

This resolves the "column u.first_name does not exist" error.
2026-01-26 11:45:30 -06:00
6d25f5103b Add users and roles API integration to UsersPage
Created API modules for users and roles management:
- Added src/api/roles.ts with getAllRoles, getRoleById, createRole, etc.
- Added src/api/users.ts with getAllUsers, createUser, updateUser, etc.

Updated UsersPage to fetch data from backend:
- Fetch roles from /api/roles endpoint on mount
- Fetch users from /api/users endpoint on mount
- Integrated createUser API call with form submission
- Added proper validation and error handling
- Split name field into firstName and lastName for API compatibility
- Added loading states and refresh functionality
2026-01-26 11:45:01 -06:00
9 changed files with 553 additions and 140 deletions

68
src/api/roles.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Roles API
* Handles all role-related API requests
*/
import { apiClient } from './client';
export interface Role {
id: string;
name: string;
description: string;
permissions: Record<string, Record<string, boolean>>;
created_at: string;
updated_at: string;
}
export interface RoleListResponse {
success: boolean;
message: string;
data: Role[];
}
/**
* Get all roles
*/
export async function getAllRoles(): Promise<Role[]> {
const response = await apiClient.get<Role[]>('/api/roles');
return response;
}
/**
* Get a single role by ID
*/
export async function getRoleById(id: string): Promise<Role> {
return apiClient.get<Role>(`/api/roles/${id}`);
}
/**
* Create a new role
*/
export async function createRole(data: {
name: string;
description: string;
permissions?: Record<string, Record<string, boolean>>;
}): Promise<Role> {
return apiClient.post<Role>('/api/roles', data);
}
/**
* Update an existing role
*/
export async function updateRole(
id: string,
data: {
name?: string;
description?: string;
permissions?: Record<string, Record<string, boolean>>;
}
): Promise<Role> {
return apiClient.put<Role>(`/api/roles/${id}`, data);
}
/**
* Delete a role
*/
export async function deleteRole(id: string): Promise<void> {
return apiClient.delete<void>(`/api/roles/${id}`);
}

106
src/api/users.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Users API
* Handles all user-related API requests
*/
import { apiClient } from './client';
export interface User {
id: string;
email: string;
name: string;
avatar_url: string | null;
role_id: string;
role?: {
id: string;
name: string;
description: string;
permissions: Record<string, Record<string, boolean>>;
};
is_active: boolean;
last_login: string | null;
created_at: string;
updated_at: string;
}
export interface CreateUserInput {
email: string;
password: string;
name: string;
role_id: string;
is_active?: boolean;
}
export interface UpdateUserInput {
email?: string;
name?: string;
role_id?: string;
is_active?: boolean;
}
export interface ChangePasswordInput {
current_password: string;
new_password: string;
}
export interface UserListResponse {
data: User[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
/**
* Get all users with optional filters and pagination
*/
export async function getAllUsers(params?: {
role_id?: number;
is_active?: boolean;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}): Promise<UserListResponse> {
return apiClient.get<UserListResponse>('/api/users', { params });
}
/**
* Get a single user by ID
*/
export async function getUserById(id: string): Promise<User> {
return apiClient.get<User>(`/api/users/${id}`);
}
/**
* Create a new user
*/
export async function createUser(data: CreateUserInput): Promise<User> {
return apiClient.post<User>('/api/users', data);
}
/**
* Update an existing user
*/
export async function updateUser(id: string, data: UpdateUserInput): Promise<User> {
return apiClient.put<User>(`/api/users/${id}`, data);
}
/**
* Delete (deactivate) a user
*/
export async function deleteUser(id: string): Promise<void> {
return apiClient.delete<void>(`/api/users/${id}`);
}
/**
* Change user password
*/
export async function changePassword(id: string, data: ChangePasswordInput): Promise<void> {
return apiClient.put<void>(`/api/users/${id}/password`, data);
}

View File

@@ -1,7 +1,13 @@
import { useState, useEffect } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core";
import { Role } from "./RolesPage"; // Importa los tipos de roles
import { createUser, updateUser, deleteUser, getAllUsers, CreateUserInput, UpdateUserInput, User as ApiUser } from "../api/users";
import { getAllRoles, Role as ApiRole } from "../api/roles";
interface RoleOption {
id: string;
name: string;
}
interface User {
id: string;
@@ -13,58 +19,225 @@ interface User {
createdAt: string;
}
interface UserForm {
name: string;
email: string;
password?: string;
roleId: string;
status: "ACTIVE" | "INACTIVE";
createdAt: string;
}
export default function UsersPage() {
const initialRoles: Role[] = [
{ id: "1", name: "SUPER_ADMIN", description: "Full access", status: "ACTIVE", createdAt: "2025-12-17" },
{ id: "2", name: "USER", description: "Regular user", status: "ACTIVE", createdAt: "2025-12-16" },
];
const initialUsers: User[] = [
{ id: "1", name: "Admin GRH", email: "grh@domain.com", roleId: "1", roleName: "SUPER_ADMIN", status: "ACTIVE", createdAt: "2025-12-17" },
{ id: "2", name: "User CESPT", email: "cespt@domain.com", roleId: "2", roleName: "USER", status: "ACTIVE", createdAt: "2025-12-16" },
];
const [users, setUsers] = useState<User[]>(initialUsers);
const [users, setUsers] = useState<User[]>([]);
const [activeUser, setActiveUser] = useState<User | null>(null);
const [search, setSearch] = useState("");
const [selectedRoleFilter, setSelectedRoleFilter] = useState<string>(""); // Filter state
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [roles, setRoles] = useState<Role[]>(initialRoles);
const [roles, setRoles] = useState<RoleOption[]>([]);
const [modalRoles, setModalRoles] = useState<ApiRole[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [loadingModalRoles, setLoadingModalRoles] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const emptyUser: Omit<User, "id" | "roleName"> = { name: "", email: "", roleId: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10) };
const [form, setForm] = useState<Omit<User, "id" | "roleName">>(emptyUser);
const emptyUser: UserForm = { name: "", email: "", roleId: "", password: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10) };
const [form, setForm] = useState<UserForm>(emptyUser);
const handleSave = () => {
const roleName = roles.find(r => r.id === form.roleId)?.name || "";
if (editingId) {
setUsers(prev => prev.map(u => u.id === editingId ? { id: editingId, roleName, ...form } : u));
} else {
const newId = Date.now().toString();
setUsers(prev => [...prev, { id: newId, roleName, ...form }]);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
setLoadingUsers(true);
const usersResponse = await getAllUsers();
console.log('Users API response:', usersResponse);
const mappedUsers: User[] = usersResponse.data.map((apiUser: ApiUser) => ({
id: apiUser.id,
name: apiUser.name,
email: apiUser.email,
roleId: apiUser.role_id,
roleName: apiUser.role?.name || '',
status: apiUser.is_active ? "ACTIVE" : "INACTIVE",
createdAt: new Date(apiUser.created_at).toISOString().slice(0, 10)
}));
setUsers(mappedUsers);
const uniqueRolesMap = new Map<string, RoleOption>();
usersResponse.data.forEach((apiUser: ApiUser) => {
if (apiUser.role) {
uniqueRolesMap.set(apiUser.role.id, {
id: apiUser.role.id,
name: apiUser.role.name
});
}
});
const uniqueRoles = Array.from(uniqueRolesMap.values());
console.log('Unique roles extracted:', uniqueRoles);
setRoles(uniqueRoles);
} catch (error) {
console.error('Failed to fetch users:', error);
setUsers([]);
setRoles([]);
} finally {
setLoadingUsers(false);
}
setShowModal(false);
setEditingId(null);
setForm(emptyUser);
};
const handleDelete = () => {
const handleSave = async () => {
setError(null);
if (!form.name || !form.email || !form.roleId) {
setError("Please fill in all required fields");
return;
}
if (!editingId && !form.password) {
setError("Password is required for new users");
return;
}
if (form.password && form.password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
try {
setSaving(true);
if (editingId) {
const updateData: UpdateUserInput = {
email: form.email,
name: form.name.trim(),
role_id: form.roleId,
is_active: form.status === "ACTIVE",
};
await updateUser(editingId, updateData);
} else {
const createData: CreateUserInput = {
email: form.email,
password: form.password!,
name: form.name.trim(),
role_id: form.roleId,
is_active: form.status === "ACTIVE",
};
await createUser(createData);
}
await handleRefresh();
setShowModal(false);
setEditingId(null);
setForm(emptyUser);
} catch (err) {
console.error('Failed to save user:', err);
setError(err instanceof Error ? err.message : 'Failed to save user');
} finally {
setSaving(false);
}
};
const handleRefresh = async () => {
await fetchUsers();
};
const handleDelete = async () => {
if (!activeUser) return;
setUsers(prev => prev.filter(u => u.id !== activeUser.id));
setActiveUser(null);
if (!window.confirm(`Are you sure you want to delete user "${activeUser.name}"?`)) {
return;
}
try {
setSaving(true);
await deleteUser(activeUser.id);
await handleRefresh();
setActiveUser(null);
} catch (error) {
console.error('Failed to delete user:', error);
alert('Failed to delete user. Please try again.');
} finally {
setSaving(false);
}
};
const filtered = users.filter(u => u.name.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase()));
const fetchModalRoles = async () => {
try {
setLoadingModalRoles(true);
const rolesData = await getAllRoles();
console.log('Modal roles fetched:', rolesData);
setModalRoles(rolesData);
} catch (error) {
console.error('Failed to fetch modal roles:', error);
} finally {
setLoadingModalRoles(false);
}
};
const handleOpenAddModal = () => {
setForm(emptyUser);
setEditingId(null);
setError(null);
setShowModal(true);
fetchModalRoles();
};
const handleOpenEditModal = (user: User) => {
setEditingId(user.id);
setForm({
name: user.name,
email: user.email,
roleId: user.roleId,
status: user.status,
createdAt: user.createdAt,
password: ""
});
setError(null);
setShowModal(true);
fetchModalRoles();
};
// Filter users by search and selected role
const filtered = users.filter(u => {
const matchesSearch = u.name.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase());
const matchesRole = !selectedRoleFilter || u.roleId === selectedRoleFilter;
return matchesSearch && matchesRole;
});
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
{/* LEFT INFO SIDEBAR */}
<div className="w-72 bg-white rounded-xl shadow p-4">
<h3 className="text-xs font-semibold text-gray-500 mb-3">Project Information</h3>
<p className="text-sm text-gray-700">Usuarios disponibles y sus roles.</p>
<select value={form.roleId} onChange={e => setForm({...form, roleId: e.target.value})} className="w-full border px-3 py-2 rounded mt-2">
<option value="">Select Role</option>
<h3 className="text-xs font-semibold text-gray-500 mb-3">Filter Options</h3>
<p className="text-sm text-gray-700 mb-4">Filter users by role</p>
<label className="text-xs font-semibold text-gray-500 mb-2 block">Role</label>
<select
value={selectedRoleFilter}
onChange={e => setSelectedRoleFilter(e.target.value)}
className="w-full border px-3 py-2 rounded"
disabled={loadingUsers}
>
<option value="">All Roles</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
{selectedRoleFilter && (
<button
onClick={() => setSelectedRoleFilter("")}
className="mt-2 text-xs text-blue-600 hover:text-blue-800"
>
Clear filter
</button>
)}
</div>
{/* MAIN */}
@@ -77,10 +250,13 @@ export default function UsersPage() {
<p className="text-sm text-blue-100">Usuarios registrados</p>
</div>
<div className="flex gap-3">
<button onClick={() => { setForm(emptyUser); setEditingId(null); setShowModal(true); }} className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"><Plus size={16} /> Add</button>
<button onClick={() => { if(!activeUser) return; setEditingId(activeUser.id); setForm({...activeUser}); setShowModal(true); }} disabled={!activeUser} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Pencil size={16}/> Edit</button>
<button onClick={handleDelete} disabled={!activeUser} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Trash2 size={16}/> Delete</button>
<button onClick={() => setUsers([...users])} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg"><RefreshCcw size={16}/> Refresh</button>
<button onClick={handleOpenAddModal} className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"><Plus size={16} /> Add</button>
<button onClick={() => {
if(!activeUser) return;
handleOpenEditModal(activeUser);
}} disabled={!activeUser} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Pencil size={16}/> Edit</button>
<button onClick={handleDelete} disabled={!activeUser || saving} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Trash2 size={16}/> Delete</button>
<button onClick={handleRefresh} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg" disabled={loadingUsers}><RefreshCcw size={16}/> Refresh</button>
</div>
</div>
@@ -88,19 +264,25 @@ export default function UsersPage() {
<input className="bg-white rounded-lg shadow px-4 py-2 text-sm" placeholder="Search user..." value={search} onChange={e => setSearch(e.target.value)} />
{/* TABLE */}
<MaterialTable
title="Users"
columns={[
{ title: "Name", field: "name" },
{ title: "Email", field: "email" },
{ title: "Role", field: "roleName" },
{ title: "Status", field: "status", render: rowData => <span className={`px-3 py-1 rounded-full text-xs font-semibold border ${rowData.status === "ACTIVE" ? "text-blue-600 border-blue-600" : "text-red-600 border-red-600"}`}>{rowData.status}</span> },
{ title: "Created", field: "createdAt", type: "date" }
]}
data={filtered}
onRowClick={(_, rowData) => setActiveUser(rowData as User)}
options={{ actionsColumnIndex: -1, search: false, paging: true, sorting: true, rowStyle: rowData => ({ backgroundColor: activeUser?.id === (rowData as User).id ? "#EEF2FF" : "#FFFFFF" }) }}
/>
{loadingUsers ? (
<div className="bg-white rounded-xl shadow p-8 text-center">
<p className="text-gray-500">Loading users...</p>
</div>
) : (
<MaterialTable
title="Users"
columns={[
{ title: "Name", field: "name" },
{ title: "Email", field: "email" },
{ title: "Role", field: "roleName" },
{ title: "Status", field: "status", render: rowData => <span className={`px-3 py-1 rounded-full text-xs font-semibold border ${rowData.status === "ACTIVE" ? "text-blue-600 border-blue-600" : "text-red-600 border-red-600"}`}>{rowData.status}</span> },
{ title: "Created", field: "createdAt", type: "date" }
]}
data={filtered}
onRowClick={(_, rowData) => setActiveUser(rowData as User)}
options={{ actionsColumnIndex: -1, search: false, paging: true, sorting: true, rowStyle: rowData => ({ backgroundColor: activeUser?.id === (rowData as User).id ? "#EEF2FF" : "#FFFFFF" }) }}
/>
)}
</div>
{/* MODAL */}
@@ -108,17 +290,74 @@ export default function UsersPage() {
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-xl p-6 w-96 space-y-3">
<h2 className="text-lg font-semibold">{editingId ? "Edit User" : "Add User"}</h2>
<input className="w-full border px-3 py-2 rounded" placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} />
<input className="w-full border px-3 py-2 rounded" placeholder="Email" value={form.email} onChange={e => setForm({...form, email: e.target.value})} />
<select value={form.roleId} onChange={e => setForm({...form, roleId: e.target.value})} className="w-full border px-3 py-2 rounded mt-2">
<option value="">Select Role</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">
{error}
</div>
)}
<input
className="w-full border px-3 py-2 rounded"
placeholder="Full Name *"
value={form.name}
onChange={e => setForm({...form, name: e.target.value})}
disabled={saving}
/>
<input
className="w-full border px-3 py-2 rounded"
type="email"
placeholder="Email *"
value={form.email}
onChange={e => setForm({...form, email: e.target.value})}
disabled={saving}
/>
{!editingId && (
<input
className="w-full border px-3 py-2 rounded"
type="password"
placeholder="Password * (min 8 characters)"
value={form.password || ""}
onChange={e => setForm({...form, password: e.target.value})}
disabled={saving}
/>
)}
<select
value={form.roleId}
onChange={e => setForm({...form, roleId: e.target.value})}
className="w-full border px-3 py-2 rounded"
disabled={loadingModalRoles || saving}
>
<option value="">{loadingModalRoles ? "Loading roles..." : "Select Role *"}</option>
{modalRoles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
<button onClick={() => setForm({...form, status: form.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"})} className="w-full border rounded px-3 py-2">Status: {form.status}</button>
<input type="date" className="w-full border px-3 py-2 rounded" value={form.createdAt} onChange={e => setForm({...form, createdAt: e.target.value})} />
<button
onClick={() => setForm({...form, status: form.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"})}
className="w-full border rounded px-3 py-2"
disabled={saving}
>
Status: {form.status}
</button>
<div className="flex justify-end gap-2 pt-3">
<button onClick={() => setShowModal(false)}>Cancel</button>
<button onClick={handleSave} className="bg-[#4c5f9e] text-white px-4 py-2 rounded">Save</button>
<button
onClick={() => { setShowModal(false); setError(null); }}
className="px-4 py-2"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded disabled:opacity-50"
disabled={saving || loadingModalRoles}
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>

View File

@@ -67,9 +67,9 @@ export async function getUserById(
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
const userId = req.params.id;
if (isNaN(userId)) {
if (!userId) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
@@ -80,7 +80,7 @@ export async function getUserById(
// Check if user is admin or requesting their own data
const requestingUser = req.user;
const isAdmin = requestingUser?.role === 'ADMIN';
const isSelf = requestingUser?.id === userId.toString();
const isSelf = requestingUser?.id === userId;
if (!isAdmin && !isSelf) {
res.status(403).json({
@@ -128,8 +128,8 @@ export async function createUser(
const user = await userService.create({
email: data.email,
password: data.password,
first_name: data.first_name,
last_name: data.last_name,
name: data.name,
avatar_url: data.avatar_url,
role_id: data.role_id,
is_active: data.is_active,
});
@@ -166,9 +166,9 @@ export async function updateUser(
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
const userId = req.params.id;
if (isNaN(userId)) {
if (!userId) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
@@ -178,7 +178,7 @@ export async function updateUser(
const requestingUser = req.user;
const isAdmin = requestingUser?.role === 'ADMIN';
const isSelf = requestingUser?.id === userId.toString();
const isSelf = requestingUser?.id === userId;
if (!isAdmin && !isSelf) {
res.status(403).json({
@@ -243,9 +243,9 @@ export async function deleteUser(
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
const userId = req.params.id;
if (isNaN(userId)) {
if (!userId) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
@@ -254,7 +254,7 @@ export async function deleteUser(
}
// Prevent admin from deleting themselves
if (req.user?.id === userId.toString()) {
if (req.user?.id === userId) {
res.status(400).json({
success: false,
error: 'Cannot deactivate your own account',
@@ -294,9 +294,9 @@ export async function changePassword(
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
const userId = req.params.id;
if (isNaN(userId)) {
if (!userId) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
@@ -305,7 +305,7 @@ export async function changePassword(
}
// Only allow users to change their own password
if (req.user?.id !== userId.toString()) {
if (req.user?.id !== userId) {
res.status(403).json({
success: false,
error: 'You can only change your own password',

View File

@@ -32,7 +32,7 @@ router.get('/:id', userController.getUserById);
/**
* POST /users
* Create a new user (admin only)
* Body: { email, password, first_name, last_name, role_id, is_active? }
* Body: { email, password, name, avatar_url?, role_id, is_active? }
* Response: { success, message, data: User }
*/
router.post('/', requireRole('ADMIN'), validateCreateUser, userController.createUser);
@@ -40,7 +40,7 @@ router.post('/', requireRole('ADMIN'), validateCreateUser, userController.create
/**
* PUT /users/:id
* Update a user (admin can update all, self can update limited fields)
* Body: { email?, first_name?, last_name?, role_id?, is_active? }
* Body: { email?, name?, avatar_url?, role_id?, is_active? }
* Response: { success, message, data: User }
*/
router.put('/:id', validateUpdateUser, userController.updateUser);

View File

@@ -371,14 +371,14 @@ export function generateMeterTemplate(): Buffer {
/**
* Expected columns in the Excel file for readings
*/
interface ReadingRow {
/* interface ReadingRow {
meter_serial: string;
reading_value: number;
reading_type?: string;
received_at?: string;
battery_level?: number;
signal_strength?: number;
}
} */
/**
* Normalize column name for readings

View File

@@ -61,7 +61,7 @@ export async function getAll(
if (filters?.search) {
conditions.push(
`(u.first_name ILIKE $${paramIndex} OR u.last_name ILIKE $${paramIndex} OR u.email ILIKE $${paramIndex})`
`(u.name ILIKE $${paramIndex} OR u.email ILIKE $${paramIndex})`
);
params.push(`%${filters.search}%`);
paramIndex++;
@@ -70,7 +70,7 @@ export async function getAll(
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Validate sortBy to prevent SQL injection
const allowedSortColumns = ['created_at', 'updated_at', 'email', 'first_name', 'last_name'];
const allowedSortColumns = ['created_at', 'updated_at', 'email', 'name'];
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
@@ -88,8 +88,8 @@ export async function getAll(
SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.name,
u.avatar_url,
u.role_id,
r.name as role_name,
r.description as role_description,
@@ -109,8 +109,8 @@ export async function getAll(
const users: UserPublic[] = usersResult.rows.map((row) => ({
id: row.id,
email: row.email,
first_name: row.first_name,
last_name: row.last_name,
name: row.name,
avatar_url: row.avatar_url,
role_id: row.role_id,
role: row.role_name
? {
@@ -148,14 +148,14 @@ export async function getAll(
* @param id - User ID
* @returns User without password_hash or null if not found
*/
export async function getById(id: number): Promise<UserPublic | null> {
export async function getById(id: string): Promise<UserPublic | null> {
const result = await query(
`
SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.name,
u.avatar_url,
u.role_id,
r.name as role_name,
r.description as role_description,
@@ -179,8 +179,8 @@ export async function getById(id: number): Promise<UserPublic | null> {
return {
id: row.id,
email: row.email,
first_name: row.first_name,
last_name: row.last_name,
name: row.name,
avatar_url: row.avatar_url,
role_id: row.role_id,
role: row.role_name
? {
@@ -232,9 +232,9 @@ export async function getByEmail(email: string): Promise<User | null> {
export async function create(data: {
email: string;
password: string;
first_name: string;
last_name: string;
role_id: number;
name: string;
avatar_url?: string | null;
role_id: string;
is_active?: boolean;
}): Promise<UserPublic> {
// Check if email already exists
@@ -248,15 +248,15 @@ export async function create(data: {
const result = await query(
`
INSERT INTO users (email, password_hash, first_name, last_name, role_id, is_active)
INSERT INTO users (email, password_hash, name, avatar_url, role_id, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, email, first_name, last_name, role_id, is_active, last_login, created_at, updated_at
RETURNING id, email, name, avatar_url, role_id, is_active, last_login, created_at, updated_at
`,
[
data.email.toLowerCase(),
password_hash,
data.first_name,
data.last_name,
data.name,
data.avatar_url ?? null,
data.role_id,
data.is_active ?? true,
]
@@ -275,12 +275,12 @@ export async function create(data: {
* @returns Updated user without password_hash
*/
export async function update(
id: number,
id: string,
data: {
email?: string;
first_name?: string;
last_name?: string;
role_id?: number;
name?: string;
avatar_url?: string | null;
role_id?: string;
is_active?: boolean;
}
): Promise<UserPublic | null> {
@@ -309,15 +309,15 @@ export async function update(
paramIndex++;
}
if (data.first_name !== undefined) {
updates.push(`first_name = $${paramIndex}`);
params.push(data.first_name);
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
params.push(data.name);
paramIndex++;
}
if (data.last_name !== undefined) {
updates.push(`last_name = $${paramIndex}`);
params.push(data.last_name);
if (data.avatar_url !== undefined) {
updates.push(`avatar_url = $${paramIndex}`);
params.push(data.avatar_url);
paramIndex++;
}
@@ -357,7 +357,7 @@ export async function update(
* @param id - User ID
* @returns True if deleted, false if user not found
*/
export async function deleteUser(id: number): Promise<boolean> {
export async function deleteUser(id: string): Promise<boolean> {
const result = await query(
`
UPDATE users
@@ -379,7 +379,7 @@ export async function deleteUser(id: number): Promise<boolean> {
* @returns True if password changed, throws error if verification fails
*/
export async function changePassword(
id: number,
id: string,
currentPassword: string,
newPassword: string
): Promise<boolean> {
@@ -421,7 +421,7 @@ export async function changePassword(
* @param id - User ID
* @returns True if updated, false if user not found
*/
export async function updateLastLogin(id: number): Promise<boolean> {
export async function updateLastLogin(id: string): Promise<boolean> {
const result = await query(
`
UPDATE users

View File

@@ -23,12 +23,12 @@ export interface Role {
}
export interface User {
id: number;
id: string;
email: string;
password_hash: string;
first_name: string;
last_name: string;
role_id: number;
name: string;
avatar_url: string | null;
role_id: string;
role?: Role;
is_active: boolean;
last_login: Date | null;
@@ -37,11 +37,11 @@ export interface User {
}
export interface UserPublic {
id: number;
id: string;
email: string;
first_name: string;
last_name: string;
role_id: number;
name: string;
avatar_url: string | null;
role_id: string;
role?: Role;
is_active: boolean;
last_login: Date | null;
@@ -50,9 +50,9 @@ export interface UserPublic {
}
export interface JwtPayload {
userId: number;
userId: string;
email: string;
roleId: number;
roleId: string;
roleName: string;
iat?: number;
exp?: number;

View File

@@ -5,7 +5,8 @@ import { Request, Response, NextFunction } from 'express';
* Schema for creating a new user
* - email: required, must be valid email format
* - password: required, minimum 8 characters
* - name: required (first_name + last_name combined or separate)
* - name: required, full name
* - avatar_url: optional, URL to avatar image
* - role_id: required, must be valid UUID
* - is_active: optional, defaults to true
*/
@@ -17,18 +18,18 @@ export const createUserSchema = z.object({
password: z
.string({ required_error: 'Password is required' })
.min(8, 'Password must be at least 8 characters'),
first_name: z
.string({ required_error: 'First name is required' })
.min(1, 'First name cannot be empty')
.max(100, 'First name cannot exceed 100 characters'),
last_name: z
.string({ required_error: 'Last name is required' })
.min(1, 'Last name cannot be empty')
.max(100, 'Last name cannot exceed 100 characters'),
name: z
.string({ required_error: 'Name is required' })
.min(1, 'Name cannot be empty')
.max(255, 'Name cannot exceed 255 characters'),
avatar_url: z
.string()
.url('Avatar URL must be a valid URL')
.optional()
.nullable(),
role_id: z
.number({ required_error: 'Role ID is required' })
.int('Role ID must be an integer')
.positive('Role ID must be a positive number'),
.string({ required_error: 'Role ID is required' })
.uuid('Role ID must be a valid UUID'),
is_active: z.boolean().default(true),
});
@@ -43,20 +44,19 @@ export const updateUserSchema = z.object({
.email('Invalid email format')
.transform((val) => val.toLowerCase().trim())
.optional(),
first_name: z
name: z
.string()
.min(1, 'First name cannot be empty')
.max(100, 'First name cannot exceed 100 characters')
.min(1, 'Name cannot be empty')
.max(255, 'Name cannot exceed 255 characters')
.optional(),
last_name: z
avatar_url: z
.string()
.min(1, 'Last name cannot be empty')
.max(100, 'Last name cannot exceed 100 characters')
.optional(),
.url('Avatar URL must be a valid URL')
.optional()
.nullable(),
role_id: z
.number()
.int('Role ID must be an integer')
.positive('Role ID must be a positive number')
.string()
.uuid('Role ID must be a valid UUID')
.optional(),
is_active: z.boolean().optional(),
});