diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a11d89a2..feb42df1 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -45,7 +45,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Run scanner - uses: aquasecurity/trivy-action@0.34.2 + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: filesystem scan-ref: . diff --git a/ansible/playbooks/02_readonly_access.yml b/ansible/playbooks/02_readonly_access.yml index 6c2318e7..33bb7a78 100644 --- a/ansible/playbooks/02_readonly_access.yml +++ b/ansible/playbooks/02_readonly_access.yml @@ -1,29 +1,47 @@ -- name: Setup readonly access to MySQL +- name: Setup readonly access to PostgreSQL hosts: all become: yes + vars: + database_port: 5432 + + pre_tasks: + - name: Ensure required Python packages are installed + tags: + - always + ansible.builtin.apt: + name: + - python3-psycopg2 + update_cache: yes + state: present tasks: - name: Ensure readonly_tunnel user is present - user: + tags: + - host + ansible.builtin.user: name: readonly_tunnel create_home: yes shell: /bin/false system: yes - name: Ensure readonly_tunnel user authorization key is present - authorized_key: + tags: + - host + ansible.posix.authorized_key: user: readonly_tunnel key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIARuxJ71FWsdjwVaZgoXILWRmVSdVEJBKfZi8kAYUkVA readonly_tunnel" - name: Ensure SSH access is strict - blockinfile: + tags: + - host + ansible.builtin.blockinfile: path: /etc/ssh/sshd_config.d/10-readonly-tunnel.conf block: | Match User readonly_tunnel PermitTTY no PermitTunnel no GatewayPorts no - PermitOpen localhost:3306 + PermitOpen localhost:{{ database_port }} ForceCommand /bin/false X11Forwarding no AllowAgentForwarding no @@ -36,8 +54,180 @@ register: sshd_config_file - name: Reload SSH - service: + tags: + - host + ansible.builtin.service: name: sshd enabled: yes state: reloaded when: sshd_config_file.changed + + - name: Ensure PostgreSQL readonly_tunnel user is present + tags: + - postgres + community.postgresql.postgresql_user: + name: "{{ postgres.readonly_user.name }}" + password: "{{ postgres.readonly_user.password }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + state: present + + - name: Grant all privileges on herbario_dev database + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_dev + type: database + privs: ALL + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + + - name: Grant usage on schemas in herbario_dev + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_dev + type: schema + objs: "{{ item }}" + privs: ALL + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + loop: + - public + - topology + + - name: Grant all privileges on all tables in herbario_dev + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_dev + type: table + objs: ALL_IN_SCHEMA + privs: ALL + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + + - name: Set default privileges for future tables in herbario_dev + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_dev + type: default_privs + objs: tables + privs: ALL + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + + - name: Set default privileges for future sequences in herbario_dev + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_dev + type: default_privs + objs: sequences + privs: ALL + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + + - name: Grant CONNECT on herbario_prod database + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_prod + type: database + privs: CONNECT + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + + - name: Grant usage on schemas in herbario_prod + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_prod + type: schema + objs: "{{ item }}" + privs: USAGE + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + loop: + - public + - topology + + - name: Grant SELECT on all tables in herbario_prod + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_prod + type: table + objs: ALL_IN_SCHEMA + privs: SELECT + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + + - name: Grant SELECT on all sequences in herbario_prod + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_prod + type: sequence + objs: ALL_IN_SCHEMA + privs: SELECT + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + + - name: Set default privileges for future tables in herbario_prod + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_prod + type: default_privs + objs: tables + privs: SELECT + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + + - name: Set default privileges for future sequences in herbario_prod + tags: + - postgres + community.postgresql.postgresql_privs: + database: herbario_prod + type: default_privs + objs: sequences + privs: SELECT + roles: "{{ postgres.readonly_user.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" diff --git a/src/controllers/coletor-controller.js b/src/controllers/coletor-controller.js index a8834f21..6af403ce 100644 --- a/src/controllers/coletor-controller.js +++ b/src/controllers/coletor-controller.js @@ -117,3 +117,34 @@ export const desativaColetor = async (req, res, next) => { next(error); } }; + +export const listaNumerosColetaPorColetor = async (req, res, next) => { + try { + const { coletorId } = req.params; + const { Tombo } = models; + const coletor = await Coletor.findByPk(coletorId); + if (!coletor) { + return res.status(404).json({ mensagem: 'Coletor não encontrado.' }); + } + const numerosColeta = await Tombo.findAll({ + attributes: ['numero_coleta'], + where: { + coletor_id: coletorId, + rascunho: false, + numero_coleta: { [Op.not]: null }, + }, + raw: true, + order: [['numero_coleta', 'ASC']], + }); + const numerosUnicos = [...new Set(numerosColeta.map(item => item.numero_coleta))].map((numero, index) => ({ + id: index, + numero, + })); + + res.status(200).json({ + numerosColeta: numerosUnicos, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/tombos-controller.js b/src/controllers/tombos-controller.js index 601a3d11..3be97093 100644 --- a/src/controllers/tombos-controller.js +++ b/src/controllers/tombos-controller.js @@ -157,7 +157,7 @@ export const cadastro = (request, response, next) => { } return undefined; }) - // //////////////CRIA COLECOES ANEXAS/////////// + // //////////////CRIA COLECOES ANEXAS/////////// .then(() => { if (colecoesAnexas) { const object = pick(colecoesAnexas, ['tipo', 'observacoes']); @@ -174,7 +174,7 @@ export const cadastro = (request, response, next) => { } return undefined; }) - // ///////// VALIDA A TAXONOMIA E A INSERE NO NOME CIENTIFICO ////////// + // ///////// VALIDA A TAXONOMIA E A INSERE NO NOME CIENTIFICO ////////// .then(() => { if (taxonomia && taxonomia.familia_id) { return Familia.findOne({ @@ -296,7 +296,7 @@ export const cadastro = (request, response, next) => { } return undefined; }) - // /////////// CADASTRA TOMBO ///////////// + // /////////// CADASTRA TOMBO ///////////// .then(() => { let jsonTombo = { numero_coleta: principal.numero_coleta, @@ -373,7 +373,7 @@ export const cadastro = (request, response, next) => { } return Tombo.create(jsonTombo, { transaction }); }) - // //////////// CADASTRA A ALTERACAO /////////// + // //////////// CADASTRA A ALTERACAO /////////// .then(tombo => { if (!tombo) { throw new BadRequestExeption(408); @@ -419,7 +419,7 @@ export const cadastro = (request, response, next) => { return alteracaoTomboCriado; }); }) - // /////////////// CADASTRA O INDETIFICADOR /////////////// + // /////////////// CADASTRA O INDETIFICADOR /////////////// .then(alteracaoTomboCriado => { if (!alteracaoTomboCriado) { throw new BadRequestExeption(409); @@ -687,6 +687,8 @@ export const listagem = (request, response, next) => { nome_popular: nomePopular, situacao, codigo_barra_foto, + coletor_id: coletorId, + numero_coleta: numeroColeta, } = request.query; let where = { @@ -708,6 +710,12 @@ export const listagem = (request, response, next) => { if (situacao) { where.situacao = situacao; } + if (coletorId) { + where.coletor_id = coletorId; + } + if (numeroColeta) { + where.numero_coleta = numeroColeta; + } let include = [ { @@ -1774,4 +1782,193 @@ export const verificarCoordenada = async (request, response, next) => { } }; +export const relatorioPorPeriodo = async (request, response, next) => { + try { + const { data_inicio, data_fim, granularidade } = request.query; + + if (!data_inicio || !data_fim || !granularidade) { + return response.status(400).json({ + error: { + message: 'Parâmetros obrigatórios: data_inicio, data_fim, granularidade', + }, + }); + } + + // Validar granularidade + if (!['dia', 'semana', 'mes', 'ano'].includes(granularidade)) { + return response.status(400).json({ + error: { + message: 'Granularidade inválida. Use: dia, semana, mes ou ano', + }, + }); + } + + const dataInicio = new Date(data_inicio); + const dataFim = new Date(data_fim); + + if (isNaN(dataInicio.getTime()) || isNaN(dataFim.getTime())) { + return response.status(400).json({ + error: { + message: 'Datas inválidas. Use o formato YYYY-MM-DD', + }, + }); + } + + if (dataInicio > dataFim) { + return response.status(400).json({ + error: { + message: 'Data de início deve ser menor ou igual à data de fim', + }, + }); + } + + // Calcular diferenças para validar granularidade + const diffMs = dataFim - dataInicio; + const diffDias = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + const diffSemanas = Math.ceil(diffDias / 7); + const diffMeses = Math.ceil((dataFim.getFullYear() - dataInicio.getFullYear()) * 12 + (dataFim.getMonth() - dataInicio.getMonth())); + const diffAnos = dataFim.getFullYear() - dataInicio.getFullYear(); + + // Validar granularidade baseada no período + let granularidadePermitida = 'ano'; + if (diffDias <= 30) { + granularidadePermitida = 'dia'; + } else if (diffSemanas <= 30) { + granularidadePermitida = 'semana'; + } else if (diffMeses <= 30) { + granularidadePermitida = 'mes'; + } else { + granularidadePermitida = 'ano'; + } + + // Mapear granularidades para verificação hierárquica + const granularidadeHierarquia = { dia: 0, semana: 1, mes: 2, ano: 3 }; + const granularidadeSolicitada = granularidadeHierarquia[granularidade]; + const granularidadeMaximaPermitida = granularidadeHierarquia[granularidadePermitida]; + + if (granularidadeSolicitada < granularidadeMaximaPermitida) { + return response.status(400).json({ + error: { + message: `Período muito grande para granularidade '${granularidade}'. Máximo: ${diffDias} dias. Use granularidade '${granularidadePermitida}' ou maior.`, + restricoes: { + difDias: diffDias, + difSemanas: diffSemanas, + difMeses: diffMeses, + difAnos: diffAnos, + granularidadePermitida: granularidadePermitida, + }, + }, + }); + } + + let query = ''; + + if (granularidade === 'dia') { + query = ` + WITH date_series AS ( + SELECT generate_series($1::date, $2::date, '1 day'::interval)::date AS data_p + ), + counts AS ( + SELECT + TO_DATE(CONCAT(data_coleta_ano, '-', LPAD(data_coleta_mes::text, 2, '0'), '-', LPAD(data_coleta_dia::text, 2, '0')), 'YYYY-MM-DD') AS data_c, + COUNT(*) AS qtd + FROM tombos + WHERE rascunho = false + AND data_coleta_ano IS NOT NULL + AND data_coleta_mes IS NOT NULL + AND data_coleta_dia IS NOT NULL + GROUP BY 1 + ) + SELECT + TO_CHAR(ds.data_p, 'DD/MM/YYYY') AS periodo, + COALESCE(c.qtd, 0) AS quantidade + FROM date_series ds + LEFT JOIN counts c ON ds.data_p = c.data_c + ORDER BY ds.data_p ASC + `; + } else if (granularidade === 'semana') { + query = ` + WITH date_series AS ( + SELECT generate_series(date_trunc('week', $1::date), date_trunc('week', $2::date), '1 week'::interval)::date AS data_p + ), + counts AS ( + SELECT + date_trunc('week', TO_DATE(CONCAT(data_coleta_ano, '-', LPAD(data_coleta_mes::text, 2, '0'), '-', LPAD(data_coleta_dia::text, 2, '0')), 'YYYY-MM-DD'))::date AS data_c, + COUNT(*) AS qtd + FROM tombos + WHERE rascunho = false + AND data_coleta_ano IS NOT NULL + AND data_coleta_mes IS NOT NULL + AND data_coleta_dia IS NOT NULL + GROUP BY 1 + ) + SELECT + 'Semana ' || LPAD((ROW_NUMBER() OVER(ORDER BY ds.data_p))::text, 2, '0') AS periodo, + COALESCE(c.qtd, 0) AS quantidade + FROM date_series ds + LEFT JOIN counts c ON ds.data_p = c.data_c + ORDER BY ds.data_p ASC + `; + } else if (granularidade === 'mes') { + query = ` + WITH date_series AS ( + SELECT generate_series(date_trunc('month', $1::date), date_trunc('month', $2::date), '1 month'::interval)::date AS data_p + ), + counts AS ( + SELECT + date_trunc('month', TO_DATE(CONCAT(t.data_coleta_ano, '-', LPAD(t.data_coleta_mes::text, 2, '0'), '-01'), 'YYYY-MM-DD'))::date AS data_c, + COUNT(*) AS qtd + FROM tombos t + WHERE t.rascunho = false + AND t.data_coleta_ano IS NOT NULL + AND t.data_coleta_mes IS NOT NULL + GROUP BY 1 + ) + SELECT + TO_CHAR(ds.data_p, 'MM/YYYY') AS periodo, + COALESCE(c.qtd, 0) AS quantidade + FROM date_series ds + LEFT JOIN counts c ON ds.data_p = c.data_c + ORDER BY ds.data_p ASC + `; + } else if (granularidade === 'ano') { + query = ` + WITH date_series AS ( + SELECT generate_series(date_trunc('year', $1::date), date_trunc('year', $2::date), '1 year'::interval)::date AS data_p + ), + counts AS ( + SELECT + data_coleta_ano AS ano_c, + COUNT(*) AS qtd + FROM tombos t + WHERE t.rascunho = false + AND t.data_coleta_ano IS NOT NULL + GROUP BY 1 + ) + SELECT + TO_CHAR(ds.data_p, 'YYYY') AS periodo, + COALESCE(c.qtd, 0) AS quantidade + FROM date_series ds + LEFT JOIN counts c ON EXTRACT(YEAR FROM ds.data_p) = c.ano_c + ORDER BY ds.data_p ASC + `; + } + + const resultado = await sequelize.query(query, { + bind: [data_inicio, data_fim], + type: models.Sequelize.QueryTypes.SELECT, + }); + + // Mapear resultados para o formato esperado + const dados = resultado.map(item => ({ + periodo: item.periodo, + quantidade: parseInt(item.quantidade, 10), + })); + + return response.status(200).json(dados); + } catch (err) { + return next(err); + } +}; + export default {}; diff --git a/src/database/migration/20260404170000_fix-nome-cientifico-inconsistente.ts b/src/database/migration/20260404170000_fix-nome-cientifico-inconsistente.ts new file mode 100644 index 00000000..bac7528a --- /dev/null +++ b/src/database/migration/20260404170000_fix-nome-cientifico-inconsistente.ts @@ -0,0 +1,28 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + await knex.transaction(async trx => { + // Atualiza tombos que possuem genero e especie + await trx.raw(` + UPDATE tombos + SET nome_cientifico = CONCAT(g.nome, ' ', e.nome) + FROM generos g, especies e + WHERE tombos.genero_id = g.id + AND tombos.especie_id = e.id + AND ( + tombos.nome_cientifico IS DISTINCT FROM CONCAT(g.nome, ' ', e.nome) + OR e.genero_id IS DISTINCT FROM tombos.genero_id + ) + `) + + // Atualiza tombos que possuem apenas genero (sem especie) + await trx.raw(` + UPDATE tombos + SET nome_cientifico = g.nome + FROM generos g + WHERE tombos.genero_id = g.id + AND tombos.especie_id IS NULL + AND tombos.nome_cientifico IS DISTINCT FROM g.nome + `) + }) +} diff --git a/src/routes/coletor.js b/src/routes/coletor.js index 3dbd0056..a5670cff 100644 --- a/src/routes/coletor.js +++ b/src/routes/coletor.js @@ -199,7 +199,6 @@ export default app => { * $ref: '#/components/responses/InternalServerError' */ app.route('/coletores').get([ - tokensMiddleware([TIPOS_USUARIOS.CURADOR]), validacoesMiddleware(listarColetoresEsquema), listagensMiddleware, coletoresController.listaColetores, @@ -303,4 +302,44 @@ export default app => { validacoesMiddleware(desativarColetorEsquema), coletoresController.desativaColetor, ]); + + /** + * @swagger + * /coletores/{coletorId}/numeros-coleta: + * get: + * summary: Lista números de coleta de um coletor + * tags: [Coletores] + * description: Retorna uma lista dos números de coleta cadastrados para um coletor específico. + * parameters: + * - in: path + * name: coletorId + * required: true + * schema: + * type: integer + * description: ID do coletor + * responses: + * 200: + * description: Lista de números de coleta do coletor + * content: + * application/json: + * schema: + * type: object + * properties: + * numerosColeta: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * numero: + * type: integer + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/coletores/:coletorId/numeros-coleta').get([ + coletoresController.listaNumerosColetaPorColetor, + ]); }; diff --git a/src/routes/identificador.js b/src/routes/identificador.js index b2511fdd..e7c39f09 100644 --- a/src/routes/identificador.js +++ b/src/routes/identificador.js @@ -167,7 +167,6 @@ export default app => { * $ref: '#/components/responses/InternalServerError' */ app.route('/identificadores').get([ - tokensMiddleware([TIPOS_USUARIOS.CURADOR]), validacoesMiddleware(listarIdentificadoresEsquema), listagensMiddleware, identificadoresController.listaIdentificadores, diff --git a/src/routes/tombos.js b/src/routes/tombos.js index ca3de4ce..c8d4fb5b 100644 --- a/src/routes/tombos.js +++ b/src/routes/tombos.js @@ -9,6 +9,7 @@ import { alteracao, getNumeroColetor, getUltimoNumeroTombo, getCodigoBarraTombo, editarCodigoBarra, getUltimoNumeroCodigoBarras, postCodigoBarraTombo, verificarCoordenada, getUltimoCodigoBarra, deletarCodigoBarras, listagemTombosPorIdentificador, + relatorioPorPeriodo, } from '../controllers/tombos-controller'; import exportarTombosController from '../controllers/tombos-exportacoes-controller'; import criaJsonMiddleware from '../middlewares/json-middleware'; @@ -20,7 +21,6 @@ import coletorCadastro from '../validators/coletor-cadastro'; import cadastrarTipoEsquema from '../validators/tipo-cadastro'; import cadastrarTomboEsquema from '../validators/tombo-cadastro'; import listagemTombo from '../validators/tombo-listagem'; - /** * @swagger * tags: @@ -73,6 +73,58 @@ export default app => { ]); /** + * @swagger + * /tombos/relatorio-periodo: + * get: + * summary: Gera relatório de tombos por período com granularidade configurável + * tags: [Tombos] + * parameters: + * - in: query + * name: data_inicio + * required: true + * schema: + * type: string + * format: date + * description: Data de início do período (formato YYYY-MM-DD) + * - in: query + * name: data_fim + * required: true + * schema: + * type: string + * format: date + * description: Data de fim do período (formato YYYY-MM-DD) + * - in: query + * name: granularidade + * required: true + * schema: + * type: string + * enum: [dia, semana, mes, ano] + * description: Granularidade do agrupamento dos dados + * responses: + * 200: + * description: Relatório gerado com sucesso + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * periodo: + * type: string + * quantidade: + * type: integer + * '400': + * description: Parâmetros inválidos + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/tombos/relatorio-periodo') + .get([ + relatorioPorPeriodo, + ]); + + /** * @swagger * /tombos/numeroColetor/{idColetor}: * get: diff --git a/src/validators/tombo-listagem.js b/src/validators/tombo-listagem.js index 3d6a88c0..80d39a00 100644 --- a/src/validators/tombo-listagem.js +++ b/src/validators/tombo-listagem.js @@ -40,6 +40,11 @@ export default { isInt: true, optional: true, }, + codigo_barra_foto: { + in: 'query', + isString: true, + optional: true, + }, limite: { in: 'query', isInt: true, @@ -50,4 +55,14 @@ export default { isInt: true, optional: true, }, + coletor_id: { + in: 'query', + isInt: true, + optional: true, + }, + numero_coleta: { + in: 'query', + isInt: true, + optional: true, + }, };