Add 3-level role permissions, organismos operadores, and Histórico de Tomas page

Implements the full ADMIN → ORGANISMO_OPERADOR → OPERATOR permission hierarchy
with scope-filtered data access across all backend services. Adds organismos
operadores management (ADMIN only) and a new Histórico page for viewing
per-meter reading history with chart, consumption stats, and CSV export.

Key changes:
- Backend: 3-level scope filtering on all services (meters, readings, projects, users)
- Backend: Protect GET /meters routes with authenticateToken for role-based filtering
- Backend: Pass requestingUser to reading service for scoped meter readings
- Frontend: New HistoricoPage with meter selector, AreaChart, paginated table
- Frontend: Consumption cards (Actual, Pasado, Diferencial) above date filters
- Frontend: Meter search by name, serial, location, CESPT account, cadastral key
- Frontend: OrganismosPage, updated Sidebar with 3-level visibility
- SQL migrations for organismos_operadores table and FK columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Exteban08
2026-02-09 10:21:33 +00:00
parent 61dafa83ac
commit 613fb2d787
43 changed files with 3049 additions and 324 deletions

View File

@@ -27,7 +27,8 @@ export async function getAll(req: AuthenticatedRequest, res: Response): Promise<
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await concentratorService.getAll(filters, pagination, requestingUser);

View File

@@ -38,7 +38,8 @@ export async function getAll(req: AuthenticatedRequest, res: Response): Promise<
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await meterService.getAll(filters, { page, pageSize }, requestingUser);
@@ -243,7 +244,7 @@ export async function deleteMeter(req: AuthenticatedRequest, res: Response): Pro
* Get meter readings history with optional date range filter
* Query params: start_date, end_date, page, pageSize
*/
export async function getReadings(req: Request, res: Response): Promise<void> {
export async function getReadings(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
@@ -273,7 +274,14 @@ export async function getReadings(req: Request, res: Response): Promise<void> {
filters.end_date = req.query.end_date as string;
}
const result = await readingService.getAll(filters, { page, pageSize });
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await readingService.getAll(filters, { page, pageSize }, requestingUser);
res.status(200).json({
success: true,

View File

@@ -0,0 +1,186 @@
import { Request, Response } from 'express';
import * as organismoService from '../services/organismo-operador.service';
/**
* GET /organismos-operadores
* List all organismos operadores
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 50, 100);
const result = await organismoService.getAll({ page, pageSize });
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching organismos:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch organismos operadores',
});
}
}
/**
* GET /organismos-operadores/:id
* Get a single organismo by ID
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const organismo = await organismoService.getById(id);
if (!organismo) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
res.status(200).json({
success: true,
data: organismo,
});
} catch (error) {
console.error('Error fetching organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch organismo operador',
});
}
}
/**
* POST /organismos-operadores
* Create a new organismo operador (ADMIN only)
*/
export async function create(req: Request, res: Response): Promise<void> {
try {
const data = req.body as organismoService.CreateOrganismoInput;
const organismo = await organismoService.create(data);
res.status(201).json({
success: true,
data: organismo,
});
} catch (error) {
console.error('Error creating organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to create organismo operador',
});
}
}
/**
* PUT /organismos-operadores/:id
* Update an organismo operador (ADMIN only)
*/
export async function update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const data = req.body as organismoService.UpdateOrganismoInput;
const organismo = await organismoService.update(id, data);
if (!organismo) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
res.status(200).json({
success: true,
data: organismo,
});
} catch (error) {
console.error('Error updating organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to update organismo operador',
});
}
}
/**
* DELETE /organismos-operadores/:id
* Delete an organismo operador (ADMIN only)
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const deleted = await organismoService.remove(id);
if (!deleted) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
res.status(200).json({
success: true,
data: { message: 'Organismo operador deleted successfully' },
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete organismo operador';
if (message.includes('Cannot delete')) {
res.status(409).json({
success: false,
error: message,
});
return;
}
console.error('Error deleting organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to delete organismo operador',
});
}
}
/**
* GET /organismos-operadores/:id/projects
* Get projects belonging to an organismo
*/
export async function getProjects(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const organismo = await organismoService.getById(id);
if (!organismo) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
const projects = await organismoService.getProjectsByOrganismo(id);
res.status(200).json({
success: true,
data: projects,
});
} catch (error) {
console.error('Error fetching organismo projects:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch organismo projects',
});
}
}

View File

@@ -8,7 +8,7 @@ import { CreateProjectInput, UpdateProjectInput, ProjectStatusType } from '../va
* List all projects with pagination and optional filtering
* Query params: page, pageSize, status, area_name, search
*/
export async function getAll(req: Request, res: Response): Promise<void> {
export async function getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 10, 100);
@@ -27,7 +27,14 @@ export async function getAll(req: Request, res: Response): Promise<void> {
filters.search = req.query.search as string;
}
const result = await projectService.getAll(filters, { page, pageSize });
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await projectService.getAll(filters, { page, pageSize }, requestingUser);
res.status(200).json({
success: true,

View File

@@ -1,11 +1,12 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import * as readingService from '../services/reading.service';
/**
* GET /readings
* List all readings with pagination and filtering
*/
export async function getAll(req: Request, res: Response): Promise<void> {
export async function getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const {
page = '1',
@@ -31,7 +32,14 @@ export async function getAll(req: Request, res: Response): Promise<void> {
pageSize: Math.min(parseInt(pageSize as string, 10), 100), // Max 100 per page
};
const result = await readingService.getAll(filters, pagination);
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await readingService.getAll(filters, pagination, requestingUser);
res.status(200).json({
success: true,
@@ -136,12 +144,20 @@ export async function deleteReading(req: Request, res: Response): Promise<void>
* GET /readings/summary
* Get consumption summary statistics
*/
export async function getSummary(req: Request, res: Response): Promise<void> {
export async function getSummary(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { project_id } = req.query;
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const summary = await readingService.getConsumptionSummary(
project_id as string | undefined
project_id as string | undefined,
requestingUser
);
res.status(200).json({

View File

@@ -41,7 +41,13 @@ export async function getAllUsers(
sortOrder: (req.query.sortOrder as 'asc' | 'desc') || 'desc',
};
const result = await userService.getAll(filters, pagination);
// Pass requesting user for scope filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await userService.getAll(filters, pagination, requestingUser);
res.status(200).json({
success: true,
@@ -125,12 +131,20 @@ export async function createUser(
try {
const data = req.body as CreateUserInput;
// If ORGANISMO_OPERADOR is creating a user, force their own organismo_operador_id
let organismoOperadorId = data.organismo_operador_id;
if (req.user?.roleName === 'ORGANISMO_OPERADOR' && req.user?.organismoOperadorId) {
organismoOperadorId = req.user.organismoOperadorId;
}
const user = await userService.create({
email: data.email,
password: data.password,
name: data.name,
avatar_url: data.avatar_url,
role_id: data.role_id,
project_id: data.project_id,
organismo_operador_id: organismoOperadorId,
is_active: data.is_active,
});

View File

@@ -2,6 +2,8 @@ import { Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt';
import { AuthenticatedRequest } from '../types';
export { AuthenticatedRequest };
/**
* Middleware to authenticate JWT access tokens
* Extracts Bearer token from Authorization header, verifies it,
@@ -42,6 +44,7 @@ export function authenticateToken(
roleId: (decoded as any).roleId || (decoded as any).role,
roleName: (decoded as any).roleName || (decoded as any).role,
projectId: (decoded as any).projectId,
organismoOperadorId: (decoded as any).organismoOperadorId,
};
next();

View File

@@ -16,6 +16,7 @@ import bulkUploadRoutes from './bulk-upload.routes';
import csvUploadRoutes from './csv-upload.routes';
import auditRoutes from './audit.routes';
import notificationRoutes from './notification.routes';
import organismoOperadorRoutes from './organismo-operador.routes';
import testRoutes from './test.routes';
import systemRoutes from './system.routes';
@@ -119,6 +120,17 @@ router.use('/users', userRoutes);
*/
router.use('/roles', roleRoutes);
/**
* Organismos Operadores routes:
* - GET /organismos-operadores - List all organismos
* - GET /organismos-operadores/:id - Get organismo by ID
* - GET /organismos-operadores/:id/projects - Get organismo's projects
* - POST /organismos-operadores - Create organismo (admin only)
* - PUT /organismos-operadores/:id - Update organismo (admin only)
* - DELETE /organismos-operadores/:id - Delete organismo (admin only)
*/
router.use('/organismos-operadores', organismoOperadorRoutes);
/**
* TTS (The Things Stack) webhook routes:
* - GET /webhooks/tts/health - Health check

View File

@@ -7,26 +7,26 @@ const router = Router();
/**
* GET /meters
* Public endpoint - list all meters with pagination and filtering
* Protected endpoint - list meters filtered by user role/scope
* Query params: page, pageSize, project_id, status, area_name, meter_type, search
* Response: { success: true, data: Meter[], pagination: {...} }
*/
router.get('/', meterController.getAll);
router.get('/', authenticateToken, meterController.getAll);
/**
* GET /meters/:id
* Public endpoint - get a single meter by ID with device info
* Protected endpoint - get a single meter by ID with device info
* Response: { success: true, data: MeterWithDevice }
*/
router.get('/:id', meterController.getById);
router.get('/:id', authenticateToken, meterController.getById);
/**
* GET /meters/:id/readings
* Public endpoint - get meter readings history
* Query params: start_date, end_date
* Response: { success: true, data: MeterReading[] }
* Protected endpoint - get meter readings history filtered by user role/scope
* Query params: start_date, end_date, page, pageSize
* Response: { success: true, data: MeterReading[], pagination: {...} }
*/
router.get('/:id/readings', meterController.getReadings);
router.get('/:id/readings', authenticateToken, meterController.getReadings);
/**
* POST /meters

View File

@@ -0,0 +1,48 @@
import { Router } from 'express';
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
import * as organismoController from '../controllers/organismo-operador.controller';
const router = Router();
/**
* All routes require authentication
*/
router.use(authenticateToken);
/**
* GET /organismos-operadores
* List all organismos operadores (ADMIN and ORGANISMO_OPERADOR)
*/
router.get('/', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), organismoController.getAll);
/**
* GET /organismos-operadores/:id
* Get a single organismo by ID
*/
router.get('/:id', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), organismoController.getById);
/**
* GET /organismos-operadores/:id/projects
* Get projects belonging to an organismo
*/
router.get('/:id/projects', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), organismoController.getProjects);
/**
* POST /organismos-operadores
* Create a new organismo operador (ADMIN only)
*/
router.post('/', requireRole('ADMIN'), organismoController.create);
/**
* PUT /organismos-operadores/:id
* Update an organismo operador (ADMIN only)
*/
router.put('/:id', requireRole('ADMIN'), organismoController.update);
/**
* DELETE /organismos-operadores/:id
* Delete an organismo operador (ADMIN only)
*/
router.delete('/:id', requireRole('ADMIN'), organismoController.remove);
export default router;

View File

@@ -11,7 +11,7 @@ const router = Router();
* Query params: page, pageSize, status, area_name, search
* Response: { success: true, data: Project[], pagination: {...} }
*/
router.get('/', projectController.getAll);
router.get('/', authenticateToken, projectController.getAll);
/**
* GET /projects/:id

View File

@@ -10,15 +10,15 @@ const router = Router();
* Query params: project_id
* Response: { success: true, data: { totalReadings, totalMeters, avgReading, lastReadingDate } }
*/
router.get('/summary', readingController.getSummary);
router.get('/summary', authenticateToken, readingController.getSummary);
/**
* GET /readings
* Public endpoint - list all readings with pagination and filtering
* Protected endpoint - list all readings with pagination and filtering
* Query params: page, pageSize, meter_id, project_id, area_name, start_date, end_date, reading_type
* Response: { success: true, data: Reading[], pagination: {...} }
*/
router.get('/', readingController.getAll);
router.get('/', authenticateToken, readingController.getAll);
/**
* GET /readings/:id

View File

@@ -20,7 +20,7 @@ router.use(authenticateToken);
* Query params: role_id, is_active, search, page, limit, sortBy, sortOrder
* Response: { success, message, data: User[], pagination }
*/
router.get('/', requireRole('ADMIN'), userController.getAllUsers);
router.get('/', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), userController.getAllUsers);
/**
* GET /users/:id
@@ -35,7 +35,7 @@ router.get('/:id', userController.getUserById);
* Body: { email, password, name, avatar_url?, role_id, is_active? }
* Response: { success, message, data: User }
*/
router.post('/', requireRole('ADMIN'), validateCreateUser, userController.createUser);
router.post('/', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), validateCreateUser, userController.createUser);
/**
* PUT /users/:id

View File

@@ -29,6 +29,8 @@ export interface UserProfile {
role: string;
avatarUrl?: string | null;
projectId?: string | null;
organismoOperadorId?: string | null;
organismoName?: string | null;
createdAt: Date;
}
@@ -48,7 +50,7 @@ export async function login(
email: string,
password: string
): Promise<LoginResult> {
// Find user by email with role name
// Find user by email with role name and organismo
const userResult = await query<{
id: string;
email: string;
@@ -57,9 +59,10 @@ export async function login(
avatar_url: string | null;
role_name: string;
project_id: string | null;
organismo_operador_id: string | null;
created_at: Date;
}>(
`SELECT u.id, u.email, u.name, u.password_hash, u.avatar_url, r.name as role_name, u.project_id, u.created_at
`SELECT u.id, u.email, u.name, u.password_hash, u.avatar_url, r.name as role_name, u.project_id, u.organismo_operador_id, u.created_at
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE LOWER(u.email) = LOWER($1) AND u.is_active = true
@@ -86,6 +89,7 @@ export async function login(
roleId: user.id,
roleName: user.role_name,
projectId: user.project_id,
organismoOperadorId: user.organismo_operador_id,
});
const refreshToken = generateRefreshToken({
@@ -174,8 +178,9 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
email: string;
role_name: string;
project_id: string | null;
organismo_operador_id: string | null;
}>(
`SELECT u.id, u.email, r.name as role_name, u.project_id
`SELECT u.id, u.email, r.name as role_name, u.project_id, u.organismo_operador_id
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = $1 AND u.is_active = true
@@ -195,6 +200,7 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
roleId: user.id,
roleName: user.role_name,
projectId: user.project_id,
organismoOperadorId: user.organismo_operador_id,
});
return { accessToken };
@@ -232,11 +238,15 @@ export async function getMe(userId: string): Promise<UserProfile> {
avatar_url: string | null;
role_name: string;
project_id: string | null;
organismo_operador_id: string | null;
organismo_name: string | null;
created_at: Date;
}>(
`SELECT u.id, u.email, u.name, u.avatar_url, r.name as role_name, u.project_id, u.created_at
`SELECT u.id, u.email, u.name, u.avatar_url, r.name as role_name, u.project_id,
u.organismo_operador_id, oo.name as organismo_name, u.created_at
FROM users u
JOIN roles r ON u.role_id = r.id
LEFT JOIN organismos_operadores oo ON u.organismo_operador_id = oo.id
WHERE u.id = $1 AND u.is_active = true
LIMIT 1`,
[userId]
@@ -255,6 +265,8 @@ export async function getMe(userId: string): Promise<UserProfile> {
role: user.role_name,
avatarUrl: user.avatar_url,
projectId: user.project_id,
organismoOperadorId: user.organismo_operador_id,
organismoName: user.organismo_name,
createdAt: user.created_at,
};
}

View File

@@ -76,7 +76,7 @@ export interface PaginatedResult<T> {
export async function getAll(
filters?: ConcentratorFilters,
pagination?: PaginationOptions,
requestingUser?: { roleName: string; projectId?: string | null }
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<PaginatedResult<Concentrator>> {
const page = pagination?.page || 1;
const limit = pagination?.limit || 10;
@@ -89,15 +89,19 @@ export async function getAll(
const params: unknown[] = [];
let paramIndex = 1;
// Role-based filtering: OPERATOR users can only see their assigned project
if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
// Role-based filtering: 3-level hierarchy
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $${paramIndex})`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`project_id = $${paramIndex}`);
params.push(requestingUser.projectId);
paramIndex++;
}
// Additional filter by project_id (only applies if user is ADMIN or no user context)
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN')) {
// Additional filter by project_id (applies if user is ADMIN, ORGANISMO_OPERADOR, or no user context)
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN' || requestingUser.roleName === 'ORGANISMO_OPERADOR')) {
conditions.push(`project_id = $${paramIndex}`);
params.push(filters.project_id);
paramIndex++;

View File

@@ -73,6 +73,11 @@ export interface Meter {
// Additional Data
data?: Record<string, any> | null;
// Address & Account Fields
address?: string | null;
cespt_account?: string | null;
cadastral_key?: string | null;
}
/**
@@ -165,6 +170,9 @@ export interface CreateMeterInput {
latitude?: number;
longitude?: number;
data?: Record<string, any>;
address?: string;
cespt_account?: string;
cadastral_key?: string;
}
/**
@@ -216,6 +224,9 @@ export interface UpdateMeterInput {
latitude?: number;
longitude?: number;
data?: Record<string, any>;
address?: string;
cespt_account?: string;
cadastral_key?: string;
}
/**
@@ -227,7 +238,7 @@ export interface UpdateMeterInput {
export async function getAll(
filters?: MeterFilters,
pagination?: PaginationParams,
requestingUser?: { roleName: string; projectId?: string | null }
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<PaginatedResult<MeterWithDetails>> {
const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 50;
@@ -237,8 +248,12 @@ export async function getAll(
const params: unknown[] = [];
let paramIndex = 1;
// Role-based filtering: OPERATOR users can only see meters from their assigned project
if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
// Role-based filtering: 3-level hierarchy
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`c.project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $${paramIndex})`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`c.project_id = $${paramIndex}`);
params.push(requestingUser.projectId);
paramIndex++;
@@ -250,8 +265,8 @@ export async function getAll(
paramIndex++;
}
// Additional filter by project_id (only applies if user is ADMIN or no user context)
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN')) {
// Additional filter by project_id (applies if user is ADMIN, ORGANISMO_OPERADOR, or no user context)
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN' || requestingUser.roleName === 'ORGANISMO_OPERADOR')) {
conditions.push(`c.project_id = $${paramIndex}`);
params.push(filters.project_id);
paramIndex++;
@@ -296,7 +311,8 @@ export async function getAll(
c.name as concentrator_name, c.serial_number as concentrator_serial,
c.project_id, p.name as project_name,
m.protocol, m.voltage, m.signal, m.leakage_status, m.burst_status,
m.current_flow, m.total_flow_reverse, m.manufacturer, m.latitude, m.longitude
m.current_flow, m.total_flow_reverse, m.manufacturer, m.latitude, m.longitude,
m.address, m.cespt_account, m.cadastral_key
FROM meters m
JOIN concentrators c ON m.concentrator_id = c.id
JOIN projects p ON c.project_id = p.id
@@ -329,7 +345,8 @@ export async function getById(id: string): Promise<MeterWithDetails | null> {
m.status, m.last_reading_value, m.last_reading_at, m.installation_date,
m.created_at, m.updated_at,
c.name as concentrator_name, c.serial_number as concentrator_serial,
c.project_id, p.name as project_name
c.project_id, p.name as project_name,
m.address, m.cespt_account, m.cadastral_key
FROM meters m
JOIN concentrators c ON m.concentrator_id = c.id
JOIN projects p ON c.project_id = p.id
@@ -360,8 +377,8 @@ export async function create(data: CreateMeterInput): Promise<Meter> {
}
const result = await query<Meter>(
`INSERT INTO meters (serial_number, meter_id, name, project_id, concentrator_id, location, type, status, installation_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`INSERT INTO meters (serial_number, meter_id, name, project_id, concentrator_id, location, type, status, installation_date, address, cespt_account, cadastral_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *`,
[
data.serial_number,
@@ -373,6 +390,9 @@ export async function create(data: CreateMeterInput): Promise<Meter> {
data.type || 'LORA',
data.status || 'ACTIVE',
data.installation_date || null,
data.address || null,
data.cespt_account || null,
data.cadastral_key || null,
]
);
@@ -454,6 +474,24 @@ export async function update(id: string, data: UpdateMeterInput): Promise<Meter
paramIndex++;
}
if (data.address !== undefined) {
updates.push(`address = $${paramIndex}`);
params.push(data.address);
paramIndex++;
}
if (data.cespt_account !== undefined) {
updates.push(`cespt_account = $${paramIndex}`);
params.push(data.cespt_account);
paramIndex++;
}
if (data.cadastral_key !== undefined) {
updates.push(`cadastral_key = $${paramIndex}`);
params.push(data.cadastral_key);
paramIndex++;
}
updates.push(`updated_at = NOW()`);
if (updates.length === 1) {

View File

@@ -100,7 +100,7 @@ export async function getAllForUser(
ORDER BY is_read ASC, ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const dataResult = await query(dataQuery, [...params, limit, offset]);
const dataResult = await query<Notification>(dataQuery, [...params, limit, offset]);
const totalPages = Math.ceil(total / limit);
@@ -144,7 +144,7 @@ export async function getById(id: string, userId: string): Promise<Notification
FROM notifications
WHERE id = $1 AND user_id = $2
`;
const result = await query(sql, [id, userId]);
const result = await query<Notification>(sql, [id, userId]);
return result.rows[0] || null;
}
@@ -167,7 +167,7 @@ export async function create(input: CreateNotificationInput): Promise<Notificati
RETURNING *
`;
const result = await query(sql, [
const result = await query<Notification>(sql, [
input.user_id,
input.meter_id || null,
input.notification_type,
@@ -176,7 +176,7 @@ export async function create(input: CreateNotificationInput): Promise<Notificati
input.meter_serial_number || null,
input.flow_value || null,
]);
return result.rows[0];
}
@@ -193,7 +193,7 @@ export async function markAsRead(id: string, userId: string): Promise<Notificati
WHERE id = $1 AND user_id = $2
RETURNING *
`;
const result = await query(sql, [id, userId]);
const result = await query<Notification>(sql, [id, userId]);
return result.rows[0] || null;
}
@@ -269,7 +269,7 @@ export async function getMetersWithNegativeFlow(): Promise<Array<{
WHERE m.last_reading_value < 0
AND m.status = 'ACTIVE'
`;
const result = await query(sql);
const result = await query<{ id: string; serial_number: string; name: string; last_reading_value: number; concentrator_id: string; project_id: string }>(sql);
return result.rows;
}

View File

@@ -0,0 +1,224 @@
import { query } from '../config/database';
export interface OrganismoOperador {
id: string;
name: string;
description: string | null;
region: string | null;
contact_name: string | null;
contact_email: string | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface OrganismoOperadorWithStats extends OrganismoOperador {
project_count: number;
user_count: number;
}
export interface CreateOrganismoInput {
name: string;
description?: string;
region?: string;
contact_name?: string;
contact_email?: string;
is_active?: boolean;
}
export interface UpdateOrganismoInput {
name?: string;
description?: string;
region?: string;
contact_name?: string;
contact_email?: string;
is_active?: boolean;
}
export interface PaginationParams {
page: number;
pageSize: number;
}
export interface PaginatedResult<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
/**
* Get all organismos operadores with pagination
*/
export async function getAll(
pagination?: PaginationParams
): Promise<PaginatedResult<OrganismoOperadorWithStats>> {
const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 50;
const offset = (page - 1) * pageSize;
const countResult = await query<{ total: string }>(
'SELECT COUNT(*) as total FROM organismos_operadores'
);
const total = parseInt(countResult.rows[0]?.total || '0', 10);
const result = await query<OrganismoOperadorWithStats>(
`SELECT oo.*,
COALESCE((SELECT COUNT(*) FROM projects p WHERE p.organismo_operador_id = oo.id), 0)::int as project_count,
COALESCE((SELECT COUNT(*) FROM users u WHERE u.organismo_operador_id = oo.id), 0)::int as user_count
FROM organismos_operadores oo
ORDER BY oo.created_at DESC
LIMIT $1 OFFSET $2`,
[pageSize, offset]
);
return {
data: result.rows,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
/**
* Get a single organismo by ID with stats
*/
export async function getById(id: string): Promise<OrganismoOperadorWithStats | null> {
const result = await query<OrganismoOperadorWithStats>(
`SELECT oo.*,
COALESCE((SELECT COUNT(*) FROM projects p WHERE p.organismo_operador_id = oo.id), 0)::int as project_count,
COALESCE((SELECT COUNT(*) FROM users u WHERE u.organismo_operador_id = oo.id), 0)::int as user_count
FROM organismos_operadores oo
WHERE oo.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Create a new organismo operador
*/
export async function create(data: CreateOrganismoInput): Promise<OrganismoOperador> {
const result = await query<OrganismoOperador>(
`INSERT INTO organismos_operadores (name, description, region, contact_name, contact_email, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
data.name,
data.description || null,
data.region || null,
data.contact_name || null,
data.contact_email || null,
data.is_active ?? true,
]
);
return result.rows[0];
}
/**
* Update an existing organismo operador
*/
export async function update(id: string, data: UpdateOrganismoInput): Promise<OrganismoOperador | null> {
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
params.push(data.name);
paramIndex++;
}
if (data.description !== undefined) {
updates.push(`description = $${paramIndex}`);
params.push(data.description);
paramIndex++;
}
if (data.region !== undefined) {
updates.push(`region = $${paramIndex}`);
params.push(data.region);
paramIndex++;
}
if (data.contact_name !== undefined) {
updates.push(`contact_name = $${paramIndex}`);
params.push(data.contact_name);
paramIndex++;
}
if (data.contact_email !== undefined) {
updates.push(`contact_email = $${paramIndex}`);
params.push(data.contact_email);
paramIndex++;
}
if (data.is_active !== undefined) {
updates.push(`is_active = $${paramIndex}`);
params.push(data.is_active);
paramIndex++;
}
if (updates.length === 0) {
return getById(id) as Promise<OrganismoOperador | null>;
}
updates.push(`updated_at = NOW()`);
params.push(id);
const result = await query<OrganismoOperador>(
`UPDATE organismos_operadores SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
params
);
return result.rows[0] || null;
}
/**
* Delete an organismo operador
*/
export async function remove(id: string): Promise<boolean> {
// Check for dependent projects
const projectCheck = await query<{ count: string }>(
'SELECT COUNT(*) as count FROM projects WHERE organismo_operador_id = $1',
[id]
);
const projectCount = parseInt(projectCheck.rows[0]?.count || '0', 10);
if (projectCount > 0) {
throw new Error(`Cannot delete organismo: ${projectCount} project(s) are associated with it`);
}
// Check for dependent users
const userCheck = await query<{ count: string }>(
'SELECT COUNT(*) as count FROM users WHERE organismo_operador_id = $1',
[id]
);
const userCount = parseInt(userCheck.rows[0]?.count || '0', 10);
if (userCount > 0) {
throw new Error(`Cannot delete organismo: ${userCount} user(s) are associated with it`);
}
const result = await query('DELETE FROM organismos_operadores WHERE id = $1', [id]);
return (result.rowCount || 0) > 0;
}
/**
* Get projects belonging to an organismo
*/
export async function getProjectsByOrganismo(organismoId: string): Promise<{ id: string; name: string; status: string }[]> {
const result = await query<{ id: string; name: string; status: string }>(
'SELECT id, name, status FROM projects WHERE organismo_operador_id = $1 ORDER BY name',
[organismoId]
);
return result.rows;
}

View File

@@ -65,7 +65,8 @@ export interface PaginatedResult<T> {
*/
export async function getAll(
filters?: ProjectFilters,
pagination?: PaginationParams
pagination?: PaginationParams,
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<PaginatedResult<Project>> {
const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 10;
@@ -76,6 +77,17 @@ export async function getAll(
const params: unknown[] = [];
let paramIndex = 1;
// Role-based filtering: 3-level hierarchy
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`organismo_operador_id = $${paramIndex}`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`id = $${paramIndex}`);
params.push(requestingUser.projectId);
paramIndex++;
}
if (filters?.status) {
conditions.push(`status = $${paramIndex}`);
params.push(filters.status);
@@ -103,7 +115,7 @@ export async function getAll(
// Get paginated data
const dataQuery = `
SELECT id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at
SELECT id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at
FROM projects
${whereClause}
ORDER BY created_at DESC
@@ -131,7 +143,7 @@ export async function getAll(
*/
export async function getById(id: string): Promise<Project | null> {
const result = await query<Project>(
`SELECT id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at
`SELECT id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at
FROM projects
WHERE id = $1`,
[id]
@@ -148,9 +160,9 @@ export async function getById(id: string): Promise<Project | null> {
*/
export async function create(data: CreateProjectInput, userId: string): Promise<Project> {
const result = await query<Project>(
`INSERT INTO projects (name, description, area_name, location, status, meter_type_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at`,
`INSERT INTO projects (name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at`,
[
data.name,
data.description || null,
@@ -158,6 +170,7 @@ export async function create(data: CreateProjectInput, userId: string): Promise<
data.location || null,
data.status || 'ACTIVE',
data.meter_type_id || null,
data.organismo_operador_id || null,
userId,
]
);
@@ -213,6 +226,12 @@ export async function update(id: string, data: UpdateProjectInput): Promise<Proj
paramIndex++;
}
if (data.organismo_operador_id !== undefined) {
updates.push(`organismo_operador_id = $${paramIndex}`);
params.push(data.organismo_operador_id);
paramIndex++;
}
// Always update the updated_at timestamp
updates.push(`updated_at = NOW()`);
@@ -227,7 +246,7 @@ export async function update(id: string, data: UpdateProjectInput): Promise<Proj
`UPDATE projects
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at`,
RETURNING id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at`,
params
);
@@ -347,7 +366,7 @@ export async function deactivateProjectAndUnassignUsers(id: string): Promise<Pro
`UPDATE projects
SET status = 'INACTIVE', updated_at = NOW()
WHERE id = $1
RETURNING id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at`,
RETURNING id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at`,
[id]
);

View File

@@ -82,7 +82,8 @@ export interface CreateReadingInput {
*/
export async function getAll(
filters?: ReadingFilters,
pagination?: PaginationParams
pagination?: PaginationParams,
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<PaginatedResult<MeterReadingWithMeter>> {
const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 50;
@@ -93,6 +94,17 @@ export async function getAll(
const params: unknown[] = [];
let paramIndex = 1;
// Role-based filtering: 3-level hierarchy
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`c.project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $${paramIndex})`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`c.project_id = $${paramIndex}`);
params.push(requestingUser.projectId);
paramIndex++;
}
if (filters?.meter_id) {
conditions.push(`mr.meter_id = $${paramIndex}`);
params.push(filters.meter_id);
@@ -246,20 +258,38 @@ export async function deleteReading(id: string): Promise<boolean> {
* @param projectId - Optional project ID to filter
* @returns Summary statistics
*/
export async function getConsumptionSummary(projectId?: string): Promise<{
export async function getConsumptionSummary(
projectId?: string,
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<{
totalReadings: number;
totalMeters: number;
avgReading: number;
lastReadingDate: Date | null;
}> {
const params: unknown[] = [];
let whereClause = '';
const conditions: string[] = [];
let paramIndex = 1;
if (projectId) {
whereClause = 'WHERE c.project_id = $1';
conditions.push(`c.project_id = $${paramIndex}`);
params.push(projectId);
paramIndex++;
}
// Role-based filtering
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`c.project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $${paramIndex})`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`c.project_id = $${paramIndex}`);
params.push(requestingUser.projectId);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await query<{
total_readings: string;
total_meters: string;

View File

@@ -34,7 +34,8 @@ export interface PaginatedUsers {
*/
export async function getAll(
filters?: UserFilter,
pagination?: PaginationParams
pagination?: PaginationParams,
requestingUser?: { roleName: string; organismoOperadorId?: string | null }
): Promise<PaginatedUsers> {
const page = pagination?.page || 1;
const limit = pagination?.limit || 10;
@@ -47,6 +48,13 @@ export async function getAll(
const params: unknown[] = [];
let paramIndex = 1;
// Role-based filtering: ORGANISMO_OPERADOR sees only users of their organismo
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`u.organismo_operador_id = $${paramIndex}`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
}
if (filters?.role_id !== undefined) {
conditions.push(`u.role_id = $${paramIndex}`);
params.push(filters.role_id);
@@ -83,7 +91,7 @@ export async function getAll(
const countResult = await query<{ total: string }>(countQuery, params);
const total = parseInt(countResult.rows[0].total, 10);
// Get users with role name
// Get users with role name and organismo info
const usersQuery = `
SELECT
u.id,
@@ -94,12 +102,20 @@ export async function getAll(
r.name as role_name,
r.description as role_description,
u.project_id,
u.organismo_operador_id,
oo.name as organismo_name,
u.is_active,
u.last_login,
u.phone,
u.street,
u.city,
u.state,
u.zip_code,
u.created_at,
u.updated_at
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN organismos_operadores oo ON u.organismo_operador_id = oo.id
${whereClause}
ORDER BY u.${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
@@ -124,8 +140,15 @@ export async function getAll(
}
: undefined,
project_id: row.project_id,
organismo_operador_id: row.organismo_operador_id,
organismo_name: row.organismo_name,
is_active: row.is_active,
last_login: row.last_login,
phone: row.phone,
street: row.street,
city: row.city,
state: row.state,
zip_code: row.zip_code,
created_at: row.created_at,
updated_at: row.updated_at,
}));
@@ -163,12 +186,20 @@ export async function getById(id: string): Promise<UserPublic | null> {
r.description as role_description,
r.permissions as role_permissions,
u.project_id,
u.organismo_operador_id,
oo.name as organismo_name,
u.is_active,
u.last_login,
u.phone,
u.street,
u.city,
u.state,
u.zip_code,
u.created_at,
u.updated_at
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN organismos_operadores oo ON u.organismo_operador_id = oo.id
WHERE u.id = $1
`,
[id]
@@ -196,8 +227,15 @@ export async function getById(id: string): Promise<UserPublic | null> {
}
: undefined,
project_id: row.project_id,
organismo_operador_id: row.organismo_operador_id,
organismo_name: row.organismo_name,
is_active: row.is_active,
last_login: row.last_login,
phone: row.phone,
street: row.street,
city: row.city,
state: row.state,
zip_code: row.zip_code,
created_at: row.created_at,
updated_at: row.updated_at,
};
@@ -240,7 +278,13 @@ export async function create(data: {
avatar_url?: string | null;
role_id: string;
project_id?: string | null;
organismo_operador_id?: string | null;
is_active?: boolean;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
}): Promise<UserPublic> {
// Check if email already exists
const existingUser = await getByEmail(data.email);
@@ -253,9 +297,9 @@ export async function create(data: {
const result = await query(
`
INSERT INTO users (email, password_hash, name, avatar_url, role_id, project_id, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, name, avatar_url, role_id, project_id, is_active, last_login, created_at, updated_at
INSERT INTO users (email, password_hash, name, avatar_url, role_id, project_id, organismo_operador_id, is_active, phone, street, city, state, zip_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, email, name, avatar_url, role_id, project_id, organismo_operador_id, is_active, last_login, created_at, updated_at
`,
[
data.email.toLowerCase(),
@@ -264,7 +308,13 @@ export async function create(data: {
data.avatar_url ?? null,
data.role_id,
data.project_id ?? null,
data.organismo_operador_id ?? null,
data.is_active ?? true,
data.phone ?? null,
data.street ?? null,
data.city ?? null,
data.state ?? null,
data.zip_code ?? null,
]
);
@@ -288,7 +338,13 @@ export async function update(
avatar_url?: string | null;
role_id?: string;
project_id?: string | null;
organismo_operador_id?: string | null;
is_active?: boolean;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
}
): Promise<UserPublic | null> {
// Check if user exists
@@ -340,12 +396,48 @@ export async function update(
paramIndex++;
}
if (data.organismo_operador_id !== undefined) {
updates.push(`organismo_operador_id = $${paramIndex}`);
params.push(data.organismo_operador_id);
paramIndex++;
}
if (data.is_active !== undefined) {
updates.push(`is_active = $${paramIndex}`);
params.push(data.is_active);
paramIndex++;
}
if (data.phone !== undefined) {
updates.push(`phone = $${paramIndex}`);
params.push(data.phone);
paramIndex++;
}
if (data.street !== undefined) {
updates.push(`street = $${paramIndex}`);
params.push(data.street);
paramIndex++;
}
if (data.city !== undefined) {
updates.push(`city = $${paramIndex}`);
params.push(data.city);
paramIndex++;
}
if (data.state !== undefined) {
updates.push(`state = $${paramIndex}`);
params.push(data.state);
paramIndex++;
}
if (data.zip_code !== undefined) {
updates.push(`zip_code = $${paramIndex}`);
params.push(data.zip_code);
paramIndex++;
}
if (updates.length === 0) {
return existingUser;
}

View File

@@ -45,8 +45,15 @@ export interface UserPublic {
role_id: string;
role?: Role;
project_id: string | null;
organismo_operador_id?: string | null;
organismo_name?: string | null;
is_active: boolean;
last_login: Date | null;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
created_at: Date;
updated_at: Date;
}
@@ -57,10 +64,23 @@ export interface JwtPayload {
roleId: string;
roleName: string;
projectId?: string | null;
organismoOperadorId?: string | null;
iat?: number;
exp?: number;
}
export interface OrganismoOperador {
id: string;
name: string;
description: string | null;
region: string | null;
contact_name: string | null;
contact_email: string | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface AuthenticatedRequest extends Request {
user?: JwtPayload;
}

View File

@@ -1,13 +1,14 @@
import jwt, { SignOptions, VerifyOptions } from 'jsonwebtoken';
import config from '../config';
import logger from './logger';
import type { JwtPayload } from '../types';
interface TokenPayload {
userId?: string;
email?: string;
roleId?: string;
roleName?: string;
projectId?: string | null;
organismoOperadorId?: string | null;
id?: string;
role?: string;
[key: string]: unknown;

View File

@@ -0,0 +1,33 @@
import { query } from '../config/database';
interface ScopeUser {
roleName: string;
projectId?: string | null;
organismoOperadorId?: string | null;
}
/**
* Get allowed project IDs for a user based on their role hierarchy.
* - ADMIN: returns null (all projects)
* - ORGANISMO_OPERADOR: returns project IDs belonging to their organismo
* - OPERADOR/OPERATOR: returns their single project_id
*/
export async function getAllowedProjectIds(user: ScopeUser): Promise<string[] | null> {
if (user.roleName === 'ADMIN') {
return null; // No restriction
}
if (user.roleName === 'ORGANISMO_OPERADOR' && user.organismoOperadorId) {
const result = await query<{ id: string }>(
'SELECT id FROM projects WHERE organismo_operador_id = $1',
[user.organismoOperadorId]
);
return result.rows.map(r => r.id);
}
if (user.projectId) {
return [user.projectId];
}
return [];
}

View File

@@ -131,6 +131,11 @@ export const createMeterSchema = z.object({
// Additional Data
data: z.record(z.any()).optional().nullable(),
// Address & Account Fields
address: z.string().optional().nullable(),
cespt_account: z.string().max(50).optional().nullable(),
cadastral_key: z.string().max(50).optional().nullable(),
});
/**
@@ -233,6 +238,11 @@ export const updateMeterSchema = z.object({
// Additional Data
data: z.record(z.any()).optional().nullable(),
// Address & Account Fields
address: z.string().optional().nullable(),
cespt_account: z.string().max(50).optional().nullable(),
cadastral_key: z.string().max(50).optional().nullable(),
});
/**

View File

@@ -49,6 +49,11 @@ export const createProjectSchema = z.object({
.uuid('Meter type ID must be a valid UUID')
.optional()
.nullable(),
organismo_operador_id: z
.string()
.uuid('Organismo operador ID must be a valid UUID')
.optional()
.nullable(),
});
/**
@@ -84,6 +89,11 @@ export const updateProjectSchema = z.object({
.uuid('Meter type ID must be a valid UUID')
.optional()
.nullable(),
organismo_operador_id: z
.string()
.uuid('Organismo operador ID must be a valid UUID')
.optional()
.nullable(),
});
/**

View File

@@ -35,7 +35,17 @@ export const createUserSchema = z.object({
.uuid('Project ID must be a valid UUID')
.nullable()
.optional(),
organismo_operador_id: z
.string()
.uuid('Organismo Operador ID must be a valid UUID')
.nullable()
.optional(),
is_active: z.boolean().default(true),
phone: z.string().max(20).optional().nullable(),
street: z.string().max(255).optional().nullable(),
city: z.string().max(100).optional().nullable(),
state: z.string().max(100).optional().nullable(),
zip_code: z.string().max(10).optional().nullable(),
});
/**
@@ -68,7 +78,17 @@ export const updateUserSchema = z.object({
.uuid('Project ID must be a valid UUID')
.nullable()
.optional(),
organismo_operador_id: z
.string()
.uuid('Organismo Operador ID must be a valid UUID')
.nullable()
.optional(),
is_active: z.boolean().optional(),
phone: z.string().max(20).optional().nullable(),
street: z.string().max(255).optional().nullable(),
city: z.string().max(100).optional().nullable(),
state: z.string().max(100).optional().nullable(),
zip_code: z.string().max(10).optional().nullable(),
});
/**