Initial SeConectai briefing system

This commit is contained in:
2026-10-04 10:42:23 -03:00
commit 45cd8d2f03
163 changed files with 12659 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { decryptCpf } from "@/lib/crypto";
export async function getClienteConta() {
const session = await auth();
if (!session?.user?.id || session.user.papel !== "CLIENTE") {
redirect("/login");
}
const cliente = await db.cliente.findUnique({
where: { userId: session.user.id },
include: {
user: true,
briefings: {
include: {
respostas: true,
anexos: {
select: {
id: true,
nomeOriginal: true,
mime: true,
tamanho: true,
criadoEm: true
}
}
}
},
solicitacoesExclusao: {
orderBy: { criadoEm: "desc" }
}
}
});
if (!cliente) {
notFound();
}
return cliente;
}
export async function getExportacaoDadosCliente() {
const cliente = await getClienteConta();
return {
exportadoEm: new Date().toISOString(),
cliente: {
id: cliente.id,
nomeCompleto: cliente.nomeCompleto,
cpf: decryptCpf(cliente.cpfCifrado),
whatsapp: cliente.whatsapp,
email: cliente.user.email,
tipo: cliente.tipo,
razaoSocial: cliente.razaoSocial,
cnpj: cliente.cnpj,
endereco: {
cep: cliente.cep,
logradouro: cliente.logradouro,
numero: cliente.numero,
complemento: cliente.complemento,
bairro: cliente.bairro,
cidade: cliente.cidade,
uf: cliente.uf
},
lgpdAceiteEm: cliente.lgpdAceiteEm
},
briefings: cliente.briefings.map((briefing) => ({
id: briefing.id,
titulo: briefing.titulo,
status: briefing.status,
criadoEm: briefing.criadoEm,
atualizadoEm: briefing.atualizadoEm,
enviadoEm: briefing.enviadoEm,
confirmadoEm: briefing.confirmadoEm,
respostas: briefing.respostas.map((resposta) => ({
perguntaId: resposta.perguntaId,
texto: resposta.texto,
complemento: resposta.complemento,
selecionadas: resposta.selecionadas,
atualizadoEm: resposta.atualizadoEm
})),
anexos: briefing.anexos
})),
solicitacoesExclusao: cliente.solicitacoesExclusao
};
}
+203
View File
@@ -0,0 +1,203 @@
import { notFound, redirect } from "next/navigation";
import type { StatusBriefing, TipoPessoa } from "@prisma/client";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { decryptCpf } from "@/lib/crypto";
export async function requireAdmin() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
if (session.user.papel !== "ADMIN") {
redirect("/briefings");
}
return session.user;
}
export function maskCpf(cpf: string) {
const digits = cpf.replace(/\D/g, "");
if (digits.length !== 11) {
return "***.***.***-**";
}
return `***.${digits.slice(3, 6)}.${digits.slice(6, 9)}-**`;
}
export function whatsappLink(value: string) {
const digits = value.replace(/\D/g, "");
return `https://wa.me/${digits}`;
}
export async function getAdminDashboard() {
await requireAdmin();
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const [porStatus, novosUltimos7Dias, ultimosEnvios] = await Promise.all([
db.briefing.groupBy({
by: ["status"],
_count: { _all: true }
}),
db.briefing.count({
where: {
criadoEm: {
gte: since
}
}
}),
db.briefing.findMany({
where: {
enviadoEm: {
not: null
}
},
orderBy: { enviadoEm: "desc" },
take: 8,
include: {
cliente: {
include: { user: true }
}
}
})
]);
return { porStatus, novosUltimos7Dias, ultimosEnvios };
}
export async function listarClientesAdmin(filters: {
busca?: string;
tipo?: TipoPessoa;
}) {
await requireAdmin();
const busca = filters.busca?.trim();
return db.cliente.findMany({
where: {
tipo: filters.tipo,
OR: busca
? [
{ nomeCompleto: { contains: busca, mode: "insensitive" } },
{ razaoSocial: { contains: busca, mode: "insensitive" } },
{ cnpj: { contains: busca.replace(/\D/g, "") } },
{ whatsapp: { contains: busca.replace(/\D/g, "") || busca } },
{ user: { email: { contains: busca, mode: "insensitive" } } }
]
: undefined
},
orderBy: { criadoEm: "desc" },
include: {
user: true,
briefings: {
orderBy: { atualizadoEm: "desc" }
}
}
});
}
export async function getClienteAdmin(clienteId: string) {
await requireAdmin();
const cliente = await db.cliente.findUnique({
where: { id: clienteId },
include: {
user: true,
briefings: {
orderBy: { atualizadoEm: "desc" }
}
}
});
if (!cliente) {
notFound();
}
return cliente;
}
export async function revelarCpfCliente(clienteId: string) {
const admin = await requireAdmin();
const cliente = await db.cliente.findUnique({
where: { id: clienteId }
});
if (!cliente) {
notFound();
}
await db.auditoriaAcesso.create({
data: {
adminId: admin.id,
clienteId,
acao: "REVELOU_CPF"
}
});
return decryptCpf(cliente.cpfCifrado);
}
export async function getBriefingAdmin(briefingId: string) {
await requireAdmin();
const briefing = await db.briefing.findUnique({
where: { id: briefingId },
include: {
cliente: {
include: { user: true }
},
notasInternas: {
orderBy: { criadoEm: "desc" },
include: { admin: true }
},
anexos: true
}
});
if (!briefing) {
notFound();
}
const secoes = await db.secao.findMany({
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
perguntas: {
orderBy: { ordem: "asc" },
include: {
respostas: {
where: { briefingId }
},
anexos: {
where: { briefingId },
orderBy: { criadoEm: "desc" }
}
}
}
}
});
return { briefing, secoes };
}
export async function listarPerguntasAdmin() {
await requireAdmin();
return db.secao.findMany({
orderBy: { ordem: "asc" },
include: {
perguntas: {
orderBy: { ordem: "asc" },
include: { ajuda: true }
}
}
});
}
export async function atualizarStatusBriefing(briefingId: string, status: StatusBriefing) {
await requireAdmin();
return db.briefing.update({
where: { id: briefingId },
data: { status }
});
}
+75
View File
@@ -0,0 +1,75 @@
import { notFound } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function getAnexoAutorizado(anexoId: string) {
const session = await auth();
if (!session?.user?.id) {
return null;
}
const anexo = await db.anexo.findUnique({
where: { id: anexoId },
include: {
briefing: {
include: {
cliente: true
}
}
}
});
if (!anexo) {
return null;
}
if (session.user.papel === "ADMIN") {
return anexo;
}
if (session.user.papel === "CLIENTE" && anexo.briefing.cliente.userId === session.user.id) {
return anexo;
}
return null;
}
export async function getBriefingPerguntaParaUpload(briefingId: string, perguntaId: string) {
const session = await auth();
if (!session?.user?.id || session.user.papel !== "CLIENTE") {
notFound();
}
const briefing = await db.briefing.findFirst({
where: {
id: briefingId,
cliente: {
userId: session.user.id
}
},
include: {
cliente: true
}
});
if (!briefing) {
notFound();
}
const pergunta = await db.pergunta.findFirst({
where: {
id: perguntaId,
tipo: "ANEXO",
ativo: true
},
select: { id: true }
});
if (!pergunta) {
notFound();
}
return { briefing, pergunta };
}
+154
View File
@@ -0,0 +1,154 @@
import {
Document,
Page,
Text,
View,
StyleSheet,
pdf
} from "@react-pdf/renderer";
import type { getBriefingPdfData } from "@/lib/pdf-data";
type PdfData = Awaited<ReturnType<typeof getBriefingPdfData>>;
const styles = StyleSheet.create({
page: {
padding: 32,
fontSize: 10,
color: "#1f2933",
fontFamily: "Helvetica"
},
title: {
fontSize: 20,
marginBottom: 6,
fontWeight: 700
},
subtitle: {
fontSize: 10,
color: "#52606d",
marginBottom: 16
},
section: {
marginTop: 16,
paddingTop: 10,
borderTop: "1 solid #d9e2ec"
},
sectionTitle: {
fontSize: 13,
fontWeight: 700,
marginBottom: 8
},
question: {
marginBottom: 10
},
label: {
fontWeight: 700,
marginBottom: 3
},
answer: {
color: "#52606d",
lineHeight: 1.5
},
meta: {
marginBottom: 4
}
});
function selectedText(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string").join(", ")
: "";
}
function opcoesTexto(opcoes: unknown, selecionadas: unknown) {
const options = Array.isArray(opcoes)
? opcoes.filter((item): item is string => typeof item === "string")
: [];
const selected = Array.isArray(selecionadas)
? selecionadas.filter((item): item is string => typeof item === "string")
: [];
if (options.length === 0) {
return selected.join(", ");
}
return options
.map((opcao) => `${selected.includes(opcao) ? "☒" : "☐"} ${opcao}`)
.join(" ");
}
function respostaTexto(resposta: {
texto: string | null;
complemento: string | null;
selecionadas: unknown;
}) {
const partes = [
resposta.texto?.trim(),
selectedText(resposta.selecionadas),
resposta.complemento?.trim() ? `Complemento: ${resposta.complemento.trim()}` : ""
].filter(Boolean);
return partes.join(" | ");
}
function BriefingPdfDocument({ data }: { data: PdfData }) {
return (
<Document>
<Page size="A4" style={styles.page}>
<Text style={styles.title}>{data.briefing.titulo}</Text>
<Text style={styles.subtitle}>Briefing de Site | SeConectai</Text>
<View>
<Text style={styles.meta}>Cliente: {data.briefing.cliente.nomeCompleto}</Text>
<Text style={styles.meta}>E-mail: {data.briefing.cliente.user.email}</Text>
<Text style={styles.meta}>WhatsApp: {data.briefing.cliente.whatsapp}</Text>
<Text style={styles.meta}>CPF: {data.cpfMascarado}</Text>
<Text style={styles.meta}>Status: {data.briefing.status}</Text>
<Text style={styles.meta}>
Enviado em: {data.briefing.enviadoEm ? data.briefing.enviadoEm.toLocaleString("pt-BR") : "Nao enviado"}
</Text>
</View>
{data.secoes.map((secao) => (
<View key={secao.id} style={styles.section} wrap={false}>
<Text style={styles.sectionTitle}>{secao.ordem}. {secao.titulo}</Text>
{secao.perguntas.map((pergunta) => {
const resposta = pergunta.respostas[0];
const texto =
resposta && (pergunta.tipo === "RADIO" || pergunta.tipo === "CHECKBOX")
? [
opcoesTexto(pergunta.opcoes, resposta.selecionadas),
resposta.complemento?.trim() ? `Complemento: ${resposta.complemento.trim()}` : ""
].filter(Boolean).join(" | ")
: resposta
? respostaTexto(resposta)
: "";
return (
<View key={pergunta.id} style={styles.question}>
<Text style={styles.label}>{pergunta.rotulo}</Text>
<Text style={styles.answer}>{texto || "Sem resposta informada."}</Text>
{pergunta.anexos.length > 0 ? (
<Text style={styles.answer}>
Anexos: {pergunta.anexos.map((anexo) => anexo.nomeOriginal).join(", ")}
</Text>
) : null}
</View>
);
})}
</View>
))}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Confirmacao do cliente</Text>
<Text style={styles.answer}>
Confirmado em: {data.briefing.confirmadoEm ? data.briefing.confirmadoEm.toLocaleString("pt-BR") : "Nao confirmado"}
</Text>
</View>
</Page>
</Document>
);
}
export async function gerarBriefingPdfBuffer(data: PdfData) {
return pdf(<BriefingPdfDocument data={data} />).toBuffer();
}
+17
View File
@@ -0,0 +1,17 @@
import { db } from "@/lib/db";
export async function getEstruturaBriefing() {
return db.secao.findMany({
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
perguntas: {
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
ajuda: true
}
}
}
});
}
+271
View File
@@ -0,0 +1,271 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
async function getClienteAtual() {
const session = await auth();
if (!session?.user?.id || session.user.papel !== "CLIENTE") {
redirect("/login");
}
const cliente = await db.cliente.findUnique({
where: { userId: session.user.id },
include: { user: true }
});
if (!cliente) {
notFound();
}
return cliente;
}
function isRespondida(resposta: {
texto: string | null;
selecionadas: unknown;
complemento?: string | null;
}) {
const selecionadas = Array.isArray(resposta.selecionadas) ? resposta.selecionadas : [];
return Boolean(
resposta.texto?.trim() ||
resposta.complemento?.trim() ||
selecionadas.length > 0
);
}
export async function listarBriefingsDoCliente() {
const cliente = await getClienteAtual();
return db.briefing.findMany({
where: { clienteId: cliente.id },
orderBy: { atualizadoEm: "desc" },
include: {
respostas: true
}
});
}
export async function criarBriefingParaCliente() {
const cliente = await getClienteAtual();
const titulo = cliente.razaoSocial ?? cliente.nomeCompleto;
const briefing = await db.briefing.create({
data: {
clienteId: cliente.id,
titulo: `Briefing - ${titulo}`
}
});
return briefing;
}
export async function getBriefingDoCliente(briefingId: string) {
const cliente = await getClienteAtual();
const briefing = await db.briefing.findFirst({
where: {
id: briefingId,
clienteId: cliente.id
},
include: {
respostas: true,
cliente: {
include: { user: true }
}
}
});
if (!briefing) {
notFound();
}
return briefing;
}
export async function getAberturaBriefing(briefingId: string) {
const briefing = await getBriefingDoCliente(briefingId);
const [secoes, configuracoes] = await Promise.all([
db.secao.findMany({
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
perguntas: {
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
anexos: {
where: { briefingId: briefing.id },
orderBy: { criadoEm: "desc" }
},
respostas: {
where: { briefingId: briefing.id }
}
}
}
}
}),
db.configuracao.findMany({
where: {
chave: {
in: ["comoPreencher", "avisoSenhas"]
}
}
})
]);
const progresso = secoes.map((secao) => {
const total = secao.perguntas.length;
const respondidas = secao.perguntas.filter((pergunta) =>
pergunta.respostas.some((resposta) => isRespondida(resposta))
).length;
return {
id: secao.id,
ordem: secao.ordem,
titulo: secao.titulo,
descricao: secao.descricao,
total,
respondidas
};
});
return {
briefing,
progresso,
comoPreencher:
configuracoes.find((configuracao) => configuracao.chave === "comoPreencher")?.valor ?? [],
avisoSenhas:
configuracoes.find((configuracao) => configuracao.chave === "avisoSenhas")?.valor ?? null
};
}
export async function getSecaoBriefing(briefingId: string, ordemSecao: number) {
const briefing = await getBriefingDoCliente(briefingId);
const secoes = await db.secao.findMany({
where: { ativo: true },
orderBy: { ordem: "asc" },
select: {
id: true,
ordem: true,
titulo: true,
perguntas: {
where: { ativo: true },
select: {
id: true,
respostas: {
where: { briefingId: briefing.id },
select: {
texto: true,
selecionadas: true,
complemento: true
}
}
}
}
}
});
const secao = await db.secao.findFirst({
where: {
ordem: ordemSecao,
ativo: true
},
include: {
perguntas: {
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
ajuda: true,
anexos: {
where: { briefingId: briefing.id },
orderBy: { criadoEm: "desc" }
},
respostas: {
where: { briefingId: briefing.id }
}
}
}
}
});
if (!secao) {
notFound();
}
const progressoGeral = secoes.reduce(
(acc, item) => {
const respondidas = item.perguntas.filter((pergunta) =>
pergunta.respostas.some((resposta) => isRespondida(resposta))
).length;
return {
total: acc.total + item.perguntas.length,
respondidas: acc.respondidas + respondidas
};
},
{ total: 0, respondidas: 0 }
);
return {
briefing,
secao,
secoes: secoes.map((item) => ({
id: item.id,
ordem: item.ordem,
titulo: item.titulo,
total: item.perguntas.length,
respondidas: item.perguntas.filter((pergunta) =>
pergunta.respostas.some((resposta) => isRespondida(resposta))
).length
})),
progressoGeral,
anterior: secoes.find((item) => item.ordem === ordemSecao - 1) ?? null,
proxima: secoes.find((item) => item.ordem === ordemSecao + 1) ?? null
};
}
export async function getRevisaoBriefing(briefingId: string) {
const briefing = await getBriefingDoCliente(briefingId);
const [secoes, confirmacao] = await Promise.all([
db.secao.findMany({
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
perguntas: {
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
anexos: {
where: { briefingId: briefing.id },
orderBy: { criadoEm: "desc" }
},
respostas: {
where: { briefingId: briefing.id }
}
}
}
}
}),
db.configuracao.findUnique({
where: { chave: "confirmacaoCliente" }
})
]);
return {
briefing,
secoes,
confirmacaoCliente: confirmacao?.valor ?? null
};
}
export async function reabrirBriefing(briefingId: string) {
return db.briefing.update({
where: { id: briefingId },
data: {
status: "RASCUNHO",
confirmadoEm: null,
enviadoEm: null
}
});
}
+60
View File
@@ -0,0 +1,60 @@
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
function getEncryptionKey() {
const rawKey = process.env.CPF_ENC_KEY;
if (!rawKey) {
throw new Error("CPF_ENC_KEY nao configurada.");
}
const key = Buffer.from(rawKey, "base64");
if (key.length !== 32) {
throw new Error("CPF_ENC_KEY deve ter 32 bytes em base64.");
}
return key;
}
function getHashKey() {
const key = process.env.CPF_HASH_KEY;
if (!key) {
throw new Error("CPF_HASH_KEY nao configurada.");
}
return key;
}
export function onlyDigits(value: string) {
return value.replace(/\D/g, "");
}
export function encryptCpf(cpf: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(onlyDigits(cpf), "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString("base64")}.${tag.toString("base64")}.${encrypted.toString("base64")}`;
}
export function decryptCpf(payload: string) {
const [ivValue, tagValue, encryptedValue] = payload.split(".");
if (!ivValue || !tagValue || !encryptedValue) {
throw new Error("CPF cifrado invalido.");
}
const decipher = createDecipheriv("aes-256-gcm", getEncryptionKey(), Buffer.from(ivValue, "base64"));
decipher.setAuthTag(Buffer.from(tagValue, "base64"));
return Buffer.concat([
decipher.update(Buffer.from(encryptedValue, "base64")),
decipher.final()
]).toString("utf8");
}
export function hashCpf(cpf: string) {
return createHmac("sha256", getHashKey()).update(onlyDigits(cpf)).digest("hex");
}
+18
View File
@@ -0,0 +1,18 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"]
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = db;
}
+7
View File
@@ -0,0 +1,7 @@
export function formatFileSize(bytes: number) {
if (bytes < 1024 * 1024) {
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
+33
View File
@@ -0,0 +1,33 @@
import nodemailer from "nodemailer";
type MailOptions = {
to: string;
subject: string;
text: string;
};
export async function sendMail({ to, subject, text }: MailOptions) {
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT ?? 587);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const from = process.env.SMTP_FROM;
if (!host || !from) {
return;
}
const transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user && pass ? { user, pass } : undefined
});
await transporter.sendMail({
to,
from,
subject,
text
});
}
+110
View File
@@ -0,0 +1,110 @@
import { db } from "@/lib/db";
import { sendMail } from "@/lib/mail";
async function registrarEmail(params: {
briefingId: string;
destino: string;
assunto: string;
texto: string;
}) {
const registro = await db.envioNotificacao.create({
data: {
briefingId: params.briefingId,
canal: "EMAIL",
destino: params.destino,
assunto: params.assunto,
status: "PENDENTE"
}
});
try {
await sendMail({
to: params.destino,
subject: params.assunto,
text: params.texto
});
await db.envioNotificacao.update({
where: { id: registro.id },
data: {
status: "ENVIADO",
tentativas: { increment: 1 },
enviadoEm: new Date()
}
});
} catch (error) {
await db.envioNotificacao.update({
where: { id: registro.id },
data: {
status: "ERRO",
tentativas: { increment: 1 },
erro: error instanceof Error ? error.message : "Erro desconhecido"
}
});
}
}
export async function notificarBriefingEnviado(briefingId: string) {
const briefing = await db.briefing.findUnique({
where: { id: briefingId },
include: {
cliente: {
include: { user: true }
}
}
});
if (!briefing) {
return;
}
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
const pdfUrl = `${appUrl}/api/briefings/${briefing.id}/pdf`;
const adminEmail = process.env.ADMIN_EMAIL;
await registrarEmail({
briefingId,
destino: briefing.cliente.user.email,
assunto: "Briefing enviado - SeConectai",
texto: [
`O briefing "${briefing.titulo}" foi enviado com sucesso.`,
"",
`Voce pode baixar o PDF em: ${pdfUrl}`,
"",
"Este e-mail nao inclui CPF completo nem senhas."
].join("\n")
});
if (adminEmail) {
await registrarEmail({
briefingId,
destino: adminEmail,
assunto: "Novo briefing enviado",
texto: [
`Um novo briefing foi enviado por ${briefing.cliente.nomeCompleto}.`,
`Titulo: ${briefing.titulo}`,
`Painel: ${appUrl}/admin/briefings/${briefing.id}`,
"",
"Este e-mail nao inclui CPF completo nem senhas."
].join("\n")
});
}
if (
process.env.EVOLUTION_API_URL &&
process.env.EVOLUTION_API_KEY &&
process.env.EVOLUTION_API_INSTANCE &&
process.env.ADMIN_WHATSAPP
) {
await db.envioNotificacao.create({
data: {
briefingId,
canal: "WHATSAPP",
destino: process.env.ADMIN_WHATSAPP,
assunto: "Novo briefing enviado",
status: "PENDENTE",
erro: "Pendente de consulta da versao/formato de payload da Evolution API antes do envio."
}
});
}
}
+59
View File
@@ -0,0 +1,59 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { maskCpf } from "@/lib/admin-service";
import { decryptCpf } from "@/lib/crypto";
export async function getBriefingPdfData(briefingId: string) {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
const briefing = await db.briefing.findUnique({
where: { id: briefingId },
include: {
cliente: {
include: { user: true }
}
}
});
if (!briefing) {
notFound();
}
const isAdmin = session.user.papel === "ADMIN";
const isOwner = session.user.papel === "CLIENTE" && briefing.cliente.userId === session.user.id;
if (!isAdmin && !isOwner) {
notFound();
}
const secoes = await db.secao.findMany({
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
perguntas: {
where: { ativo: true },
orderBy: { ordem: "asc" },
include: {
respostas: {
where: { briefingId }
},
anexos: {
where: { briefingId },
orderBy: { criadoEm: "asc" }
}
}
}
}
});
return {
briefing,
secoes,
cpfMascarado: maskCpf(decryptCpf(briefing.cliente.cpfCifrado))
};
}
+23
View File
@@ -0,0 +1,23 @@
type Bucket = {
count: number;
resetAt: number;
};
const buckets = new Map<string, Bucket>();
export function checkRateLimit(key: string, limit = 5, windowMs = 15 * 60 * 1000) {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: limit - 1 };
}
if (bucket.count >= limit) {
return { allowed: false, remaining: 0 };
}
bucket.count += 1;
return { allowed: true, remaining: limit - bucket.count };
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { checkRateLimit } from "@/lib/rate-limit";
export function assertSameOrigin(request: Request) {
const origin = request.headers.get("origin");
const host = request.headers.get("host");
if (!origin || !host) {
return null;
}
try {
const originUrl = new URL(origin);
if (originUrl.host !== host) {
return NextResponse.json({ message: "Origem nao autorizada." }, { status: 403 });
}
} catch {
return NextResponse.json({ message: "Origem invalida." }, { status: 403 });
}
return null;
}
export function rateLimitResponse(key: string, limit = 10, windowMs = 15 * 60 * 1000) {
const result = checkRateLimit(key, limit, windowMs);
if (!result.allowed) {
return NextResponse.json({ message: "Muitas tentativas. Tente novamente mais tarde." }, { status: 429 });
}
return null;
}
+134
View File
@@ -0,0 +1,134 @@
import { randomUUID } from "crypto";
import { mkdir, unlink, writeFile } from "fs/promises";
import path from "path";
const allowedExtensions = new Set([
".png",
".jpg",
".jpeg",
".webp",
".svg",
".pdf",
".docx",
".doc",
".xlsx",
".xls",
".pptx",
".zip",
".mp4",
".mov"
]);
const allowedMimes = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/svg+xml",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/zip",
"video/mp4",
"video/quicktime"
]);
export function getUploadDir() {
return process.env.UPLOAD_DIR ?? path.join(process.cwd(), "storage", "uploads");
}
export function getUploadMaxBytes() {
const maxMb = Number(process.env.UPLOAD_MAX_MB ?? 25);
return maxMb * 1024 * 1024;
}
function hasMagicBytes(buffer: Buffer, mime: string, extension: string) {
if (mime === "image/png") {
return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
}
if (mime === "image/jpeg") {
return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
}
if (mime === "image/webp") {
return buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
}
if (mime === "application/pdf") {
return buffer.subarray(0, 4).toString("ascii") === "%PDF";
}
if (mime === "video/mp4" || mime === "video/quicktime") {
return buffer.subarray(4, 8).toString("ascii") === "ftyp";
}
if (extension === ".zip" || extension === ".docx" || extension === ".xlsx" || extension === ".pptx") {
return buffer[0] === 0x50 && buffer[1] === 0x4b;
}
if (extension === ".doc" || extension === ".xls") {
return buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0;
}
if (mime === "image/svg+xml") {
const start = buffer.subarray(0, 512).toString("utf8").trimStart().toLowerCase();
return start.startsWith("<svg") || start.startsWith("<?xml");
}
return false;
}
export async function validateAndStoreUpload(file: File) {
const originalName = file.name;
const extension = path.extname(originalName).toLowerCase();
const mime = file.type;
const maxBytes = getUploadMaxBytes();
if (!allowedExtensions.has(extension)) {
throw new Error("Tipo de arquivo nao permitido.");
}
if (!allowedMimes.has(mime)) {
throw new Error("MIME do arquivo nao permitido.");
}
if (file.size <= 0) {
throw new Error("Arquivo vazio.");
}
if (file.size > maxBytes) {
throw new Error(`Arquivo maior que o limite de ${Math.round(maxBytes / 1024 / 1024)} MB.`);
}
const buffer = Buffer.from(await file.arrayBuffer());
if (!hasMagicBytes(buffer, mime, extension)) {
throw new Error("Conteudo do arquivo nao confere com o tipo informado.");
}
const uploadDir = getUploadDir();
await mkdir(uploadDir, { recursive: true });
const storedName = `${randomUUID()}${extension}`;
const storedPath = path.join(uploadDir, storedName);
await writeFile(storedPath, buffer);
return {
originalName,
storedPath,
mime,
size: file.size
};
}
export async function removeStoredFile(filePath: string) {
try {
await unlink(filePath);
} catch {
// A ausencia do arquivo nao deve impedir a remocao do registro.
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+115
View File
@@ -0,0 +1,115 @@
import { z } from "zod";
import { onlyDigits } from "@/lib/crypto";
function hasSameDigits(value: string) {
return /^(\d)\1+$/.test(value);
}
export function isValidCpf(value: string) {
const cpf = onlyDigits(value);
if (cpf.length !== 11 || hasSameDigits(cpf)) {
return false;
}
const calcDigit = (base: string, factor: number) => {
let total = 0;
for (const digit of base) {
total += Number(digit) * factor;
factor -= 1;
}
const rest = (total * 10) % 11;
return rest === 10 ? 0 : rest;
};
const firstDigit = calcDigit(cpf.slice(0, 9), 10);
const secondDigit = calcDigit(cpf.slice(0, 10), 11);
return firstDigit === Number(cpf[9]) && secondDigit === Number(cpf[10]);
}
export function isValidCnpj(value: string) {
const cnpj = onlyDigits(value);
if (cnpj.length !== 14 || hasSameDigits(cnpj)) {
return false;
}
const calcDigit = (base: string, weights: number[]) => {
const total = base
.split("")
.reduce((sum, digit, index) => sum + Number(digit) * weights[index], 0);
const rest = total % 11;
return rest < 2 ? 0 : 11 - rest;
};
const firstDigit = calcDigit(cnpj.slice(0, 12), [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]);
const secondDigit = calcDigit(cnpj.slice(0, 13), [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]);
return firstDigit === Number(cnpj[12]) && secondDigit === Number(cnpj[13]);
}
export function normalizeWhatsapp(value: string) {
const digits = onlyDigits(value);
const withoutCountry = digits.startsWith("55") && digits.length === 13 ? digits.slice(2) : digits;
if (withoutCountry.length !== 11) {
return null;
}
return `+55${withoutCountry}`;
}
export const cadastroSchema = z
.object({
nomeCompleto: z.string().trim().min(3, "Informe seu nome completo."),
cpf: z.string().refine(isValidCpf, "Informe um CPF valido."),
whatsapp: z.string().refine((value) => normalizeWhatsapp(value) !== null, "Informe um WhatsApp com DDD."),
email: z.string().trim().email("Informe um e-mail valido.").toLowerCase(),
senha: z.string().min(8, "A senha precisa ter pelo menos 8 caracteres."),
confirmarSenha: z.string(),
tipo: z.enum(["PF", "PJ"]),
razaoSocial: z.string().trim().optional(),
cnpj: z.string().optional(),
cep: z.string().optional(),
logradouro: z.string().optional(),
numero: z.string().optional(),
complemento: z.string().optional(),
bairro: z.string().optional(),
cidade: z.string().optional(),
uf: z.string().optional(),
lgpdAceite: z.literal("on", {
errorMap: () => ({ message: "Aceite a Politica de Privacidade para continuar." })
})
})
.superRefine((data, ctx) => {
if (data.senha !== data.confirmarSenha) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmarSenha"],
message: "As senhas nao conferem."
});
}
if (data.tipo === "PJ") {
if (!data.razaoSocial) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["razaoSocial"],
message: "Informe a razao social."
});
}
if (!data.cnpj || !isValidCnpj(data.cnpj)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["cnpj"],
message: "Informe um CNPJ valido."
});
}
}
});
export type CadastroInput = z.infer<typeof cadastroSchema>;