Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions backend/src/eventos/handlers/documento-submetido.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ interface CoordenadorRow {
export async function aoDocumentoSubmetido(payload: EventoDocumentoSubmetido): Promise<void> {
try {
const res = await pool.query<CoordenadorRow>(
`SELECT id, nome, email
FROM usuarios
WHERE perfil = 'coordenador' AND curso_id = $1 AND ativo = true`,
`SELECT u.id, u.nome, u.email
FROM usuarios u
JOIN coordenadores_cursos cc ON cc.coordenador_id = u.id
WHERE u.perfil = 'coordenador' AND cc.curso_id = $1 AND u.ativo = true`,
[payload.cursoId],
);

Expand Down
1 change: 1 addition & 0 deletions backend/src/middleware/autenticacao.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface PayloadJWT {
perfil: 'estudante' | 'coordenador' | 'admin';
matricula?: string | null;
curso_id?: string | null;
curso_ids?: string[];
instituicao_id?: string | null;
instituicao_nome?: string | null;
}
Expand Down
15 changes: 10 additions & 5 deletions backend/src/modulos/auth/rotas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ import {
criarUsuario,
type UsuarioParaLogin,
} from '../usuarios/repositorio';
import { buscarCursoPorId } from '../cursos/repositorio';
import { buscarCursoPorId, listarCursoIdsDoCoordenador } from '../cursos/repositorio';

const router = Router();

function gerarToken(
async function gerarToken(
usuario: Omit<UsuarioParaLogin, 'dominios_email' | 'ativo' | 'criado_em' | 'atualizado_em' | 'senha_hash'>,
): string {
): Promise<string> {
const curso_ids = usuario.perfil === 'coordenador'
? await listarCursoIdsDoCoordenador(usuario.id)
: undefined;

return jwt.sign(
{
sub: usuario.id,
Expand All @@ -25,6 +29,7 @@ function gerarToken(
perfil: usuario.perfil,
matricula: usuario.matricula ?? null,
curso_id: usuario.curso_id ?? null,
...(curso_ids ? { curso_ids } : {}),
instituicao_id: usuario.instituicao_id ?? null,
instituicao_nome: usuario.instituicao_nome ?? null,
},
Expand Down Expand Up @@ -100,7 +105,7 @@ router.post('/login', async (req, res) => {
}
}

const token = gerarToken(usuario);
const token = await gerarToken(usuario);

registrador.info('[auth/login] Login bem-sucedido', {
id: usuario.id,
Expand Down Expand Up @@ -198,7 +203,7 @@ router.post('/cadastro', async (req, res) => {
curso_id,
});

const token = gerarToken(novoUsuario);
const token = await gerarToken(novoUsuario);

registrador.info('[auth/cadastro] Novo estudante cadastrado', {
id: novoUsuario.id,
Expand Down
53 changes: 53 additions & 0 deletions backend/src/modulos/cursos/repositorio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ export interface CursoComDominios extends Curso {
dominios_email: string[] | null;
}

export interface AlunoDoCurso {
id: string;
nome: string;
email: string;
matricula: string | null;
ativo: boolean;
criado_em: Date;
}

export interface CriarCursoInput {
nome: string;
instituicao_id: string;
Expand Down Expand Up @@ -166,3 +175,47 @@ export async function alterarAtivo(id: string, ativo: boolean): Promise<CursoCom
);
return buscarCursoPorId(id);
}

// ─── Vínculo coordenador ↔ cursos (relação N:N) ────────────────

// Lista os cursos pelos quais um coordenador é responsável
export async function listarCursosDoCoordenador(coordenadorId: string): Promise<Curso[]> {
const res = await pool.query<Curso>(
`${SELECT_CURSO}
JOIN coordenadores_cursos cc ON cc.curso_id = c.id
WHERE cc.coordenador_id = $1
ORDER BY i.nome ASC, c.nome ASC`,
[coordenadorId],
);
return res.rows;
}

// Lista os ids dos cursos pelos quais um coordenador é responsável (usado no JWT)
export async function listarCursoIdsDoCoordenador(coordenadorId: string): Promise<string[]> {
const res = await pool.query<{ curso_id: string }>(
`SELECT curso_id FROM coordenadores_cursos WHERE coordenador_id = $1`,
[coordenadorId],
);
return res.rows.map((r) => r.curso_id);
}

// Verifica se um coordenador é responsável por um determinado curso
export async function coordenadorTemCurso(coordenadorId: string, cursoId: string): Promise<boolean> {
const res = await pool.query(
`SELECT 1 FROM coordenadores_cursos WHERE coordenador_id = $1 AND curso_id = $2`,
[coordenadorId, cursoId],
);
return (res.rowCount ?? 0) > 0;
}

// Lista os estudantes vinculados a um curso (somente leitura)
export async function listarAlunosDoCurso(cursoId: string): Promise<AlunoDoCurso[]> {
const res = await pool.query<AlunoDoCurso>(
`SELECT id, nome, email, matricula, ativo, criado_em
FROM usuarios
WHERE curso_id = $1 AND perfil = 'estudante'
ORDER BY nome ASC`,
[cursoId],
);
return res.rows;
}
36 changes: 36 additions & 0 deletions backend/src/modulos/cursos/rotas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,42 @@ router.get('/admin', autenticar, exigirPerfil('admin'), async (req, res) => {
}
});

// GET /api/cursos/meus — cursos pelos quais o coordenador logado é responsável
router.get('/meus', autenticar, exigirPerfil('coordenador'), async (req, res) => {
try {
const cursos = await repositorio.listarCursosDoCoordenador(req.usuario!.sub);
res.json(cursos);
} catch (err: unknown) {
tratarErro(res, err, 'cursos/meus');
}
});

// GET /api/cursos/:id/alunos — lista os estudantes vinculados a um curso (somente leitura)
router.get('/:id/alunos', autenticar, exigirPerfil('coordenador', 'admin'), async (req, res) => {
const { id } = req.params as { id: string };

try {
if (req.usuario!.perfil === 'coordenador') {
const temAcesso = await repositorio.coordenadorTemCurso(req.usuario!.sub, id);
if (!temAcesso) {
res.status(403).json({ erro: 'Sem permissão: você não é coordenador deste curso' });
return;
}
}

const curso = await repositorio.buscarCursoPorId(id);
if (!curso) {
res.status(404).json({ erro: 'Curso não encontrado' });
return;
}

const alunos = await repositorio.listarAlunosDoCurso(id);
res.json(alunos);
} catch (err: unknown) {
tratarErro(res, err, 'cursos/alunos');
}
});

// GET /api/cursos/:id — detalhes de um curso (autenticado)
router.get('/:id', autenticar, async (req, res) => {
const { id } = req.params as { id: string };
Expand Down
25 changes: 19 additions & 6 deletions backend/src/modulos/documentos/repositorio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
status?: string;
tipo?: string;
estudante_id?: string;
curso_id?: string;
page?: number;
limite?: number;
}
Expand Down Expand Up @@ -62,11 +63,11 @@
return (res.rows[0] as Documento) || null;
}

export async function listar(

Check failure on line 66 in backend/src/modulos/documentos/repositorio.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=icrcode_valida-ai&issues=AZ60XXHg3C0qR_tYh7Nn&open=AZ60XXHg3C0qR_tYh7Nn&pullRequest=59
filtros: FiltrosDocumento,
usuarioId: string,
perfil: string,
cursoId?: string | null,
cursoIds?: string[] | null,
): Promise<{ dados: Documento[]; total: number }> {
const pagina = filtros.page || 1;
const limite = Math.min(filtros.limite || 10, 100);
Expand All @@ -80,19 +81,31 @@
params.push(usuarioId);
condicoes.push(`estudante_id = $${params.length}`);
} else if (perfil === 'coordenador') {
// Coordenador só vê documentos do seu curso
if (!cursoId) {
// Coordenador só vê documentos dos cursos pelos quais é responsável
if (!cursoIds || cursoIds.length === 0) {
return { dados: [], total: 0 };
}
params.push(cursoId);
condicoes.push(`curso_id = $${params.length}`);
if (filtros.curso_id) {
if (!cursoIds.includes(filtros.curso_id)) {
return { dados: [], total: 0 };
}
params.push(filtros.curso_id);
condicoes.push(`curso_id = $${params.length}`);
} else {
params.push(cursoIds);
condicoes.push(`curso_id = ANY($${params.length}::uuid[])`);
}
// Admin pode passar estudante_id como filtro adicional; coordenador não
if (filtros.estudante_id) {
params.push(filtros.estudante_id);
condicoes.push(`estudante_id = $${params.length}`);
}
} else {
// Admin: sem restrição, mas pode filtrar por estudante_id
// Admin: sem restrição, mas pode filtrar por curso_id e/ou estudante_id
if (filtros.curso_id) {
params.push(filtros.curso_id);
condicoes.push(`curso_id = $${params.length}`);
}
if (filtros.estudante_id) {
params.push(filtros.estudante_id);
condicoes.push(`estudante_id = $${params.length}`);
Expand Down
9 changes: 5 additions & 4 deletions backend/src/modulos/documentos/rotas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,21 @@ const upload = multer({

// GET /api/documentos — listar com filtros e paginação
router.get('/', autenticar, async (req, res) => {
const { status, tipo, estudante_id, page, limite } = req.query as Record<string, string>;
const { status, tipo, estudante_id, curso_id, page, limite } = req.query as Record<string, string>;

try {
const resultado = await repositorio.listar(
{
status,
tipo,
estudante_id,
curso_id,
page: page ? Number.parseInt(page, 10) : undefined,
limite: limite ? Number.parseInt(limite, 10) : undefined,
},
req.usuario!.sub,
req.usuario!.perfil,
req.usuario!.curso_id,
req.usuario!.curso_ids,
);

res.json({
Expand All @@ -58,7 +59,7 @@ router.get('/:id', autenticar, async (req, res) => {
const { id } = req.params as { id: string };
try {
const documento = await repositorio.buscarPorId(id);
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_id)) return;
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_ids)) return;
res.json(documento);
} catch (err: unknown) {
tratarErro(res, err);
Expand All @@ -70,7 +71,7 @@ router.get('/:id/download', autenticar, async (req, res) => {
const { id } = req.params as { id: string };
try {
const documento = await repositorio.buscarPorId(id);
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_id)) return;
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_ids)) return;
const url = await armazenamento.gerarUrlAssinada(documento!.caminho_arquivo);
res.json({ url, expira_em: new Date(Date.now() + 3600 * 1000).toISOString() });
} catch (err: unknown) {
Expand Down
8 changes: 4 additions & 4 deletions backend/src/modulos/validacao/rotas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ router.patch('/:id/aprovar', autenticar, exigirPerfil('coordenador', 'admin'), a

try {
const documento = await buscarDocumento(id);
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_id)) return;
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_ids)) return;

const resultado = await repositorio.executarAcao(id, req.usuario!.sub, 'aprovar', observacoes);

Expand Down Expand Up @@ -43,7 +43,7 @@ router.patch('/:id/reprovar', autenticar, exigirPerfil('coordenador', 'admin'),

try {
const documento = await buscarDocumento(id);
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_id)) return;
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_ids)) return;

const resultado = await repositorio.executarAcao(id, req.usuario!.sub, 'reprovar', observacoes);

Expand Down Expand Up @@ -76,7 +76,7 @@ router.patch(

try {
const documento = await buscarDocumento(id);
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_id)) return;
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_ids)) return;

const resultado = await repositorio.executarAcao(
id,
Expand Down Expand Up @@ -106,7 +106,7 @@ router.get('/:id/historico', autenticar, async (req, res) => {

try {
const documento = await buscarDocumento(id);
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_id)) return;
if (!verificarAcessoDocumento(res, documento, req.usuario!.perfil, req.usuario!.sub, req.usuario!.curso_ids)) return;
const historico = await repositorio.buscarHistoricoPorDocumento(id);
res.json(historico);
} catch (err: unknown) {
Expand Down
7 changes: 6 additions & 1 deletion backend/src/servicos/armazenamento.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ export async function gerarUrlAssinada(
chaveArquivo: string,
expiracaoSegundos = 3600,
): Promise<string> {
const comando = new GetObjectCommand({ Bucket: balde, Key: chaveArquivo });
const comando = new GetObjectCommand({
Bucket: balde,
Key: chaveArquivo,
ResponseContentDisposition: 'inline',
ResponseContentType: 'application/pdf',
});
return getSignedUrl(cliente, comando, { expiresIn: expiracaoSegundos });
}

Expand Down
4 changes: 2 additions & 2 deletions backend/src/utils/erros.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function verificarAcessoDocumento(
documento: { estudante_id: string; curso_id: string } | null | undefined,
perfil: string,
usuarioId: string,
cursoId?: string | null,
cursoIds?: string[] | null,
): boolean {
if (!documento) {
res.status(404).json({ erro: 'Documento não encontrado' });
Expand All @@ -36,7 +36,7 @@ export function verificarAcessoDocumento(
res.status(403).json({ erro: 'Sem permissão para este documento' });
return false;
}
if (perfil === 'coordenador' && documento.curso_id !== cursoId) {
if (perfil === 'coordenador' && !(cursoIds ?? []).includes(documento.curso_id)) {
res.status(403).json({ erro: 'Sem permissão: documento pertence a outro curso' });
return false;
}
Expand Down
4 changes: 2 additions & 2 deletions backend/testes/helpers/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Request, Response, NextFunction } from 'express';

export function criarModuloAutenticacao(sub: string, perfil: string, email: string, nome: string, cursoId?: string) {
export function criarModuloAutenticacao(sub: string, perfil: string, email: string, nome: string, cursoId?: string, cursoIds?: string[]) {
return {
autenticar: (req: Request, _res: Response, next: NextFunction) => {
(req as any).usuario = { sub, perfil, email, nome, curso_id: cursoId };
(req as any).usuario = { sub, perfil, email, nome, curso_id: cursoId, curso_ids: cursoIds };
next();
},
};
Expand Down
Loading
Loading