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 { useState, useEffect } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core"; 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 { interface User {
id: string; id: string;
@@ -13,58 +19,225 @@ interface User {
createdAt: string; createdAt: string;
} }
interface UserForm {
name: string;
email: string;
password?: string;
roleId: string;
status: "ACTIVE" | "INACTIVE";
createdAt: string;
}
export default function UsersPage() { export default function UsersPage() {
const initialRoles: Role[] = [ const [users, setUsers] = useState<User[]>([]);
{ 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 [activeUser, setActiveUser] = useState<User | null>(null); const [activeUser, setActiveUser] = useState<User | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedRoleFilter, setSelectedRoleFilter] = useState<string>(""); // Filter state
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); 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 emptyUser: UserForm = { name: "", email: "", roleId: "", password: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10) };
const [form, setForm] = useState<Omit<User, "id" | "roleName">>(emptyUser); const [form, setForm] = useState<UserForm>(emptyUser);
const handleSave = () => { useEffect(() => {
const roleName = roles.find(r => r.id === form.roleId)?.name || ""; fetchUsers();
if (editingId) { }, []);
setUsers(prev => prev.map(u => u.id === editingId ? { id: editingId, roleName, ...form } : u));
} else { const fetchUsers = async () => {
const newId = Date.now().toString(); try {
setUsers(prev => [...prev, { id: newId, roleName, ...form }]); 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);
}
};
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); setShowModal(false);
setEditingId(null); setEditingId(null);
setForm(emptyUser); 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 handleDelete = () => { const handleRefresh = async () => {
await fetchUsers();
};
const handleDelete = async () => {
if (!activeUser) return; if (!activeUser) return;
setUsers(prev => prev.filter(u => u.id !== activeUser.id));
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); 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 ( return (
<div className="flex gap-6 p-6 w-full bg-gray-100"> <div className="flex gap-6 p-6 w-full bg-gray-100">
{/* LEFT INFO SIDEBAR */} {/* LEFT INFO SIDEBAR */}
<div className="w-72 bg-white rounded-xl shadow p-4"> <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> <h3 className="text-xs font-semibold text-gray-500 mb-3">Filter Options</h3>
<p className="text-sm text-gray-700">Usuarios disponibles y sus roles.</p> <p className="text-sm text-gray-700 mb-4">Filter users by role</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> <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>)} {roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select> </select>
{selectedRoleFilter && (
<button
onClick={() => setSelectedRoleFilter("")}
className="mt-2 text-xs text-blue-600 hover:text-blue-800"
>
Clear filter
</button>
)}
</div> </div>
{/* MAIN */} {/* MAIN */}
@@ -77,10 +250,13 @@ export default function UsersPage() {
<p className="text-sm text-blue-100">Usuarios registrados</p> <p className="text-sm text-blue-100">Usuarios registrados</p>
</div> </div>
<div className="flex gap-3"> <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={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; 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={() => {
<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> if(!activeUser) return;
<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> 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>
</div> </div>
@@ -88,6 +264,11 @@ 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)} /> <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 */} {/* TABLE */}
{loadingUsers ? (
<div className="bg-white rounded-xl shadow p-8 text-center">
<p className="text-gray-500">Loading users...</p>
</div>
) : (
<MaterialTable <MaterialTable
title="Users" title="Users"
columns={[ columns={[
@@ -101,6 +282,7 @@ export default function UsersPage() {
onRowClick={(_, rowData) => setActiveUser(rowData as User)} 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" }) }} options={{ actionsColumnIndex: -1, search: false, paging: true, sorting: true, rowStyle: rowData => ({ backgroundColor: activeUser?.id === (rowData as User).id ? "#EEF2FF" : "#FFFFFF" }) }}
/> />
)}
</div> </div>
{/* MODAL */} {/* MODAL */}
@@ -108,17 +290,74 @@ export default function UsersPage() {
<div className="fixed inset-0 bg-black/40 flex items-center justify-center"> <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"> <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> <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})} /> {error && (
<select value={form.roleId} onChange={e => setForm({...form, roleId: e.target.value})} className="w-full border px-3 py-2 rounded mt-2"> <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">
<option value="">Select Role</option> {error}
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)} </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> </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"> <div className="flex justify-end gap-2 pt-3">
<button onClick={() => setShowModal(false)}>Cancel</button> <button
<button onClick={handleSave} className="bg-[#4c5f9e] text-white px-4 py-2 rounded">Save</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> </div>
</div> </div>

View File

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

View File

@@ -32,7 +32,7 @@ router.get('/:id', userController.getUserById);
/** /**
* POST /users * POST /users
* Create a new user (admin only) * 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 } * Response: { success, message, data: User }
*/ */
router.post('/', requireRole('ADMIN'), validateCreateUser, userController.createUser); router.post('/', requireRole('ADMIN'), validateCreateUser, userController.createUser);
@@ -40,7 +40,7 @@ router.post('/', requireRole('ADMIN'), validateCreateUser, userController.create
/** /**
* PUT /users/:id * PUT /users/:id
* Update a user (admin can update all, self can update limited fields) * 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 } * Response: { success, message, data: User }
*/ */
router.put('/:id', validateUpdateUser, userController.updateUser); router.put('/:id', validateUpdateUser, userController.updateUser);

View File

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

View File

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

View File

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

View File

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