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
3 changes: 2 additions & 1 deletion backend/src/modules/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { validateTextFields } from '../../shared/text-validation.js';
import { ValidationError, validateUuid } from "../../shared/errors.js";
import { AdminRepository, type PurgeProjectResult } from "./admin.repository.js";

Expand All @@ -13,7 +14,7 @@ const purgeProjectSchema = z
.refine((data) => !!(data.confirmacao_nome || data.nome_confirmacao), {
message: "Informe o nome do projeto para confirmar o expurgo.",
path: ["confirmacao_nome"],
});
}).superRefine(validateTextFields);

export class AdminService {
constructor(
Expand Down
9 changes: 7 additions & 2 deletions backend/src/modules/auth/auth.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { validDatabaseText } from '../../shared/text-validation.js';

export const USER_ROLES = ["admin", "po", "dev"] as const;

Expand All @@ -9,6 +10,7 @@ export const loginSchema = z.object({
.string({ required_error: "O e-mail é obrigatório." })
.trim()
.email("E-mail inválido.")
.max(255, "O e-mail deve ter no máximo 255 caracteres.")
.transform((value) => value.toLowerCase()),

password: z
Expand All @@ -22,12 +24,15 @@ export const registerSchema = z.object({
nome: z
.string({ required_error: "O nome é obrigatório." })
.trim()
.min(2, "O nome deve ter no mínimo 2 caracteres."),
.min(2, "O nome deve ter no mínimo 2 caracteres.")
.max(255, "O nome deve ter no máximo 255 caracteres.")
.refine(validDatabaseText, 'Use texto Unicode válido, sem caractere nulo.'),

email: z
.string({ required_error: "O e-mail é obrigatório." })
.trim()
.email("E-mail inválido.")
.max(255, "O e-mail deve ter no máximo 255 caracteres.")
.transform((value) => value.toLowerCase()),

password: z
Expand Down Expand Up @@ -76,4 +81,4 @@ export interface SessionWithUser extends SessionRecord {
email: string;
role: UserRole;
ativo: boolean;
}
}
2 changes: 2 additions & 0 deletions backend/src/modules/backlog-search/backlog-search.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js";
import { validDatabaseText } from '../../shared/text-validation.js';
import { ProjectsRepository } from "../projects/projects.repository.js";
import { BacklogSearchRepository } from "./backlog-search.repository.js";
import { likePatterns, makeSnippet, parseTerms } from "./backlog-search.text.js";
Expand Down Expand Up @@ -44,6 +45,7 @@ export class BacklogSearchService {
async search(input: BacklogSearchInput): Promise<BacklogSearchResponse> {
validateUuid(input.projetoId, "ID do projeto");
const query = (input.q ?? "").replace(/\s+/g, " ").trim();
if (!validDatabaseText(query)) throw new ValidationError('Use texto Unicode válido, sem caractere nulo.');
if (query.length < MIN_QUERY_LENGTH) throw new ValidationError(`Digite ao menos ${MIN_QUERY_LENGTH} caracteres para buscar.`);
if (query.length > MAX_QUERY_LENGTH) throw new ValidationError(`A busca pode ter no máximo ${MAX_QUERY_LENGTH} caracteres.`);
const terms = parseTerms(query);
Expand Down
7 changes: 4 additions & 3 deletions backend/src/modules/criteria/criteria.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { validateTextFields } from '../../shared/text-validation.js';
import { z } from "zod";

export const CRITERION_ENTITY_TYPES = ["epico", "feature", "pbi"] as const;
Expand All @@ -19,7 +20,7 @@ const justificationShape = {
.optional(),
};

export const criterionJustificationSchema = z.object(justificationShape);
export const criterionJustificationSchema = z.object(justificationShape).superRefine(validateTextFields);

const epicoCriterionSchema = z.object({
entidade_tipo: z.literal("epico"),
Expand Down Expand Up @@ -53,7 +54,7 @@ export const createCriterionSchema = z.discriminatedUnion("entidade_tipo", [
epicoCriterionSchema,
featureCriterionSchema,
pbiCriterionSchema,
]);
]).superRefine(validateTextFields);

export type CreateCriterionDTO = z.infer<typeof createCriterionSchema>;

Expand All @@ -67,7 +68,7 @@ export type CriterionQueryDTO = z.infer<typeof criterionQuerySchema>;
export const moveCriterionSchema = z.object({
direction: z.enum(["up", "down"], { required_error: "A direção do movimento é obrigatória." }),
justificativa: justificationShape.justificativa,
});
}).superRefine(validateTextFields);

export type MoveCriterionDTO = z.infer<typeof moveCriterionSchema>;

Expand Down
3 changes: 2 additions & 1 deletion backend/src/modules/decisions/decisions.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { validateTextFields } from '../../shared/text-validation.js';
import { z } from "zod";

export type DecisionEntityType = "projeto" | "epico" | "feature" | "pbi";
Expand Down Expand Up @@ -72,6 +73,6 @@ export const createDecisionSchema = z
.nullish()
.transform((value) => (value ? value : null)),
})
.strict();
.strict().superRefine(validateTextFields);

export type CreateDecisionBody = z.infer<typeof createDecisionSchema>;
27 changes: 27 additions & 0 deletions backend/src/modules/documents/documents.cursor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { DocumentsService } from './documents.service.js';
import { ValidationError } from '../../shared/errors.js';

test('cursor de documentos recusa datas inválidas antes de consultar o banco', async () => {
let calls = 0;
const service = new DocumentsService({ listByProject: async () => { calls++; return { items: [], hasMore: false }; } } as any,
{} as any, {} as any, { findById: async () => ({ id: randomUUID(), status: 'ativo' }) });
for (const date of ['0', '2026-02-30T12:00:00Z', '2026-10-25T12:00:00+23:00', '2026-10-25', '2026-10-25T12:00:00-16:00']) {
const cursor = Buffer.from(JSON.stringify({ created_at: date, id: randomUUID() })).toString('base64url');
await assert.rejects(() => service.list(randomUUID(), cursor), ValidationError);
}
assert.equal(calls, 0);
});

test('cursor de documentos mantém os microssegundos do formato produzido pelo PostgreSQL', async () => {
const received: unknown[] = [];
const service = new DocumentsService({ listByProject: async (_id: string, cursor: unknown) => { received.push(cursor); return { items: [], hasMore: false }; } } as any,
{} as any, {} as any, { findById: async () => ({ id: randomUUID(), status: 'ativo' }) });
const id = randomUUID();
for (const timestamp of ['2026-10-25 12:00:00.000123+00', '2026-10-25T12:00:00.000123Z', '2026-10-25 12:00:00.000123-03:00']) {
await service.list(randomUUID(), Buffer.from(JSON.stringify({ created_at: timestamp, id })).toString('base64url'));
}
assert.deepEqual(received, [{ createdAt: '2026-10-25T12:00:00.000123+00:00', id }, { createdAt: '2026-10-25T12:00:00.000123Z', id }, { createdAt: '2026-10-25T12:00:00.000123-03:00', id }]);
});
8 changes: 6 additions & 2 deletions backend/src/modules/documents/documents.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { validProjectDate } from '../../shared/text-validation.js';
import { env } from "../../config/env.js";
import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js";
import { ArchiveConflict } from "../projects/archive.types.js";
Expand Down Expand Up @@ -41,11 +42,14 @@ function decodeCursor(value: string | undefined): { createdAt: string; id: strin
throw new Error("invalid cursor");
}
const candidate = parsed as { created_at: unknown; id: unknown };
if (typeof candidate.created_at !== "string" || !Number.isFinite(Date.parse(candidate.created_at)) || typeof candidate.id !== "string") {
if (typeof candidate.created_at !== "string" || typeof candidate.id !== "string") {
throw new Error("invalid cursor");
}
// O PostgreSQL devolve microssegundos e fuso +00; mantém essa precisão na leitura.
const timestamp = candidate.created_at.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00');
if (timestamp.length <= 10 || !validProjectDate(timestamp)) throw new Error('invalid cursor');
validateUuid(candidate.id, "Cursor de documentos");
return { createdAt: candidate.created_at, id: candidate.id };
return { createdAt: timestamp, id: candidate.id };
} catch {
throw new ValidationError("Cursor de documentos inválido.");
}
Expand Down
8 changes: 6 additions & 2 deletions backend/src/modules/epics/epics.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { backlogCursorSchema } from '../../shared/backlog-cursor.js';
import { validateTextFields } from '../../shared/text-validation.js';
import { z } from "zod";

// Historical ativo remains writable; only dedicated operations complete or archive items.
Expand Down Expand Up @@ -25,7 +27,7 @@ export const createEpicSchema = z.object({
tecnologias_ids: z.array(z.string().uuid()).max(50).optional(),
prioridade: z.enum(EPIC_PRIORITIES).default("Must"),
status: z.literal("rascunho").optional(),
});
}).superRefine(validateTextFields);

export type CreateEpicDTO = z.infer<typeof createEpicSchema>;

Expand All @@ -39,14 +41,15 @@ export const updateEpicSchema = z.object({
justificativa: z.string().trim().optional().nullable(),
tecnologias_ids: z.array(z.string().uuid()).max(50).optional(),
status: z.never().optional(),
});
}).superRefine(validateTextFields);

export type UpdateEpicDTO = z.infer<typeof updateEpicSchema>;

export const epicQuerySchema = z.object({
projeto_id: z.string().uuid().optional(),
status: z.enum([...EPIC_STATUSES, "todos"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
before: backlogCursorSchema.optional(),
offset: z.coerce.number().int().min(0).default(0),
});

Expand Down Expand Up @@ -79,4 +82,5 @@ export interface PaginatedEpics {
total: number;
limit: number;
offset: number;
next_cursor?: string | null;
}
8 changes: 6 additions & 2 deletions backend/src/modules/features/features.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { backlogCursorSchema } from '../../shared/backlog-cursor.js';
import { validateTextFields } from '../../shared/text-validation.js';
import { z } from "zod";

export const FEATURE_STATUSES = ["rascunho", "concluido", "arquivado"] as const;
Expand All @@ -19,7 +21,7 @@ export const createFeatureSchema = z.object({
objetivo: z.string().trim().optional().nullable(),
tecnologias_ids: z.array(z.string().uuid()).max(50).optional(),
prioridade: z.enum(FEATURE_PRIORITIES).default("Must"),
});
}).superRefine(validateTextFields);

export type CreateFeatureDTO = z.infer<typeof createFeatureSchema>;

Expand All @@ -30,14 +32,15 @@ export const updateFeatureSchema = z.object({
prioridade: z.enum(FEATURE_PRIORITIES).optional(),
justificativa: z.string().trim().optional().nullable(),
tecnologias_ids: z.array(z.string().uuid()).max(50).optional(),
});
}).superRefine(validateTextFields);

export type UpdateFeatureDTO = z.infer<typeof updateFeatureSchema>;

export const featureQuerySchema = z.object({
epico_id: z.string().uuid().optional(),
status: z.enum([...FEATURE_STATUSES, "todos"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
before: backlogCursorSchema.optional(),
offset: z.coerce.number().int().min(0).default(0),
});

Expand Down Expand Up @@ -70,4 +73,5 @@ export interface PaginatedFeatures {
total: number;
limit: number;
offset: number;
next_cursor?: string | null;
}
8 changes: 6 additions & 2 deletions backend/src/modules/pbis/pbis.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { backlogCursorSchema } from '../../shared/backlog-cursor.js';
import { validateTextFields } from '../../shared/text-validation.js';
import { z } from "zod";

export const PBI_STATUSES = ["rascunho", "concluido", "arquivado"] as const;
Expand Down Expand Up @@ -30,7 +32,7 @@ export const createPbiSchema = z.object({
prioridade: z.enum(PBI_PRIORITIES).default("Must"),
requer_interface: z.boolean().default(false),
tecnologias_ids: z.array(z.string().uuid()).max(50).optional(),
});
}).superRefine(validateTextFields);

export type CreatePbiDTO = z.infer<typeof createPbiSchema>;

Expand All @@ -45,14 +47,15 @@ export const updatePbiSchema = z.object({
requer_interface: z.boolean().optional(),
justificativa: z.string().trim().optional().nullable(),
tecnologias_ids: z.array(z.string().uuid()).max(50).optional(),
});
}).superRefine(validateTextFields);

export type UpdatePbiDTO = z.infer<typeof updatePbiSchema>;

export const pbiQuerySchema = z.object({
feature_id: z.string().uuid().optional(),
status: z.enum([...PBI_STATUSES, "todos"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
before: backlogCursorSchema.optional(),
offset: z.coerce.number().int().min(0).default(0),
});

Expand Down Expand Up @@ -94,4 +97,5 @@ export interface PaginatedPbis {
total: number;
limit: number;
offset: number;
next_cursor?: string | null;
}
3 changes: 2 additions & 1 deletion backend/src/modules/projects/hierarchy-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pool } from "../../database/db.js";
import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js";
import { ArchiveConflict } from "./archive.types.js";
import { auditService } from "../audit/audit.service.js";
import { validateTextFields } from '../../shared/text-validation.js';

type Kind = "epico" | "feature" | "pbi";
const scope = {
Expand All @@ -17,7 +18,7 @@ export const hierarchyArchiveSchema = z.object({
confirmado: z.literal(true),
impacto: z.object({ epicos: count, features: count, pbis: count }).strict(),
justificativa: z.string().trim().max(2000).optional(),
});
}).superRefine(validateTextFields);

// The same lock order is used by archive and ordinary hierarchy writers.
export async function lockHierarchy(client: PoolClient, kind: Kind | "projeto", id: string) {
Expand Down
5 changes: 3 additions & 2 deletions backend/src/modules/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "./projects.service.js";
import { ValidationError } from "../../shared/errors.js";
import { z } from "zod";
import { validateTextFields } from '../../shared/text-validation.js';

const confirmation = z.object({
confirmado: z.literal(true),
Expand Down Expand Up @@ -43,7 +44,7 @@ const confirmation = z.object({
.trim()
.max(2000)
.optional(),
});
}).superRefine(validateTextFields);

function getParamId(
param: string | string[] | undefined,
Expand Down Expand Up @@ -219,4 +220,4 @@ export class ProjectsController {
}

export const projectsController =
new ProjectsController();
new ProjectsController();
21 changes: 15 additions & 6 deletions backend/src/modules/projects/projects.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { z } from "zod";
import { validDatabaseText, validProjectDate } from '../../shared/text-validation.js';

function validateProjectText(data: Record<string, unknown>, ctx: z.RefinementCtx) {
for (const [field, value] of Object.entries(data)) {
if (typeof value === 'string' && !validDatabaseText(value)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: 'Use texto Unicode válido, sem caractere nulo.' });
}
}
}

export const PROJECT_STATUSES = [
"ativo",
Expand Down Expand Up @@ -58,15 +67,15 @@ export const createProjectSchema = z.object({
data_inicio: z
.string()
.refine(
(val) => !isNaN(Date.parse(val)),
validProjectDate,
{
message:
"Data de início deve ser uma data válida.",
},
)
.optional()
.nullable(),
});
}).superRefine(validateProjectText);

export type CreateProjectDTO =
z.infer<typeof createProjectSchema>;
Expand Down Expand Up @@ -115,7 +124,7 @@ export const updateProjectSchema = z.object({
data_inicio: z
.string()
.refine(
(val) => !isNaN(Date.parse(val)),
validProjectDate,
{
message:
"Data de início deve ser uma data válida.",
Expand All @@ -129,7 +138,7 @@ export const updateProjectSchema = z.object({
.trim()
.optional()
.nullable(),
});
}).superRefine(validateProjectText);

export type UpdateProjectDTO =
z.infer<typeof updateProjectSchema>;
Expand Down Expand Up @@ -168,7 +177,7 @@ export const projectQuerySchema = z.object({
"nome_desc",
])
.default("created_at_desc"),
});
}).superRefine(validateProjectText);

export type ProjectQueryDTO =
z.infer<typeof projectQuerySchema>;
Expand Down Expand Up @@ -235,4 +244,4 @@ export interface ProjectBacklogTree {

epics: BacklogEpicNode[];
technologies: BacklogTechnology[];
}
}
2 changes: 2 additions & 0 deletions backend/src/modules/quality/organization-quality.types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { z } from 'zod';
import { validDatabaseText } from '../../shared/text-validation.js';
import { TERMOS_VAGOS_PADRAO } from './quality.rules.js';

export const organizationChecksSchema = z.object({
campos_obrigatorios: z.boolean(), criterios_aceitacao: z.boolean(), termos_vagos: z.boolean(),
}).strict();
export const vagueTermSchema = z.string().trim().min(1).max(80)
.refine(validDatabaseText, 'Use texto Unicode válido, sem caractere nulo.')
.refine(term=>!/[\r\n\u2028\u2029]/u.test(term),'Cada termo deve ocupar uma única linha.')
.transform(term=>term.normalize('NFC'));
export const organizationConfigurationSchema = z.object({
Expand Down
Loading
Loading