texto sem traco longo

This commit is contained in:
Armando Soares 2026-09-16 05:13:10 +00:00
parent f7ce9e5a54
commit 651ab2363a
20 changed files with 29 additions and 31 deletions

View File

@ -1,5 +1,5 @@
---
description: Stack e pastas da Sinka — NestJS + Next.js, sem PHP
description: Stack e pastas da Sinka: NestJS + Next.js
alwaysApply: true
---

View File

@ -6,7 +6,7 @@ alwaysApply: true
# Tenant e segurança
- Isolamento: **um database MySQL por empresa** (`sinka_t_<slug>`). Catálogo em `sinka_platform`.
- Queries de operação usam o PrismaClient do tenant autenticado nunca o banco de outra empresa.
- Queries de operação usam o PrismaClient do tenant autenticado, nunca o banco de outra empresa.
- Auth desta etapa: JWT + refresh em cookie httpOnly, papéis ADMIN / MANAGER / OPERATOR. Google, GitHub e TOTP na seguinte.
- Segredos só em `.env`; nunca commitar
- Rate limit de login com Redis

View File

@ -27,7 +27,7 @@ FIREBASE_PROJECT_ID=
FIREBASE_DATABASE_URL=
NEXT_PUBLIC_FIREBASE_DATABASE_URL=
# Arquivos da operação (CSV/XLSX) pasta local por empresa
# Arquivos da operação (CSV/XLSX), pasta local por empresa
UPLOAD_DIR=
# WEB

View File

@ -1,8 +1,6 @@
# Sinka
SaaS de inteligência logística. Cada operação simula frete, compara transportadoras e acompanha custo — com os dados de uma empresa isolados dos das outras.
**Demo:** [armandosoares.tech](https://armandosoares.tech) · código: [git.armandosoares.tech/armando/sinka](https://git.armandosoares.tech/armando/sinka)
SaaS de inteligência logística. Cada operação simula frete, compara transportadoras e acompanha custo, com os dados de uma empresa isolados dos das outras.
## O que faz
@ -19,7 +17,7 @@ Dois produtos no mesmo sistema: o **admin da Sinka** (`/admin`) cria e suspende
Um database MySQL por empresa (`sinka_t_<slug>`). O catálogo fica em `sinka_platform`.
O JWT carrega o tenant. As queries de operação usam o PrismaClient daquele banco um `WHERE` errado não cruza empresa. Backup, restore e exclusão também são por tenant.
O JWT carrega o tenant. As queries de operação usam o PrismaClient daquele banco: um `WHERE` errado não cruza empresa. Backup, restore e exclusão também são por tenant.
## Stack

View File

@ -1,4 +1,4 @@
# RULES Sinka
# RULES: Sinka
Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Blade, `public/index.php` nem pasta `app/` no estilo PHP.
@ -6,7 +6,7 @@ Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Bla
- Backend: Node.js + **NestJS** + TypeScript (`api/`)
- Frontend: **Next.js** + TypeScript (`web/`)
- Banco: **MySQL** `sinka_platform` + um database por empresa
- Banco: **MySQL**, `sinka_platform` + um database por empresa
- Infra: **Docker**, **Docker Compose**, **Redis**
## Pastas
@ -22,7 +22,7 @@ Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Bla
1. Modelar no Prisma tenant (`api/prisma/tenant`) se for dado da empresa; no platform se for catálogo
2. Módulo NestJS: controller → use case/service → DTO + validação
3. Abrir o PrismaClient do tenant do JWT nunca consultar outro database
3. Abrir o PrismaClient do tenant do JWT, nunca consultar outro database
4. Tela em `web/src/app` (App Router)
5. Teste no módulo da API quando a regra for importante

View File

@ -31,7 +31,7 @@ export class FilesService {
return rows.map((row) => ({
id: row.id,
name: row.originalName,
type: fileExtension(row.originalName).toUpperCase() || '',
type: fileExtension(row.originalName).toUpperCase() || '-',
sizeBytes: row.sizeBytes,
createdAt: row.createdAt,
}));

View File

@ -1,4 +1,4 @@
# Arquitetura Sinka
# Arquitetura: Sinka
```
[Next.js :3000] → [NestJS :3001/api] → [MySQL sinka_platform]

View File

@ -4,7 +4,7 @@
Cada empresa recebe o próprio database (`sinka_t_<slug>`). O catálogo de empresas e o administrador da plataforma ficam em `sinka_platform`.
A API resolve o banco no login (slug da empresa) e reutiliza um `PrismaClient` por database. Dados de operação — usuários, clientes, simulações — nunca compartilham tabela com outra empresa.
A API resolve o banco no login (slug da empresa) e reutiliza um `PrismaClient` por database. Dados de operação (usuários, clientes, simulações) nunca compartilham tabela com outra empresa.
**Por quê:** o desafio pede isolamento entre empresas. Database-per-tenant é o isolamento mais forte no MySQL: backup, restore e exclusão por empresa; um vazamento de `WHERE` não cruza tenant. O custo extra (provisionar database + `db push` no onboarding) cabe no desafio e deixa a justificativa explícita.
@ -28,8 +28,8 @@ Fila de importação (próxima etapa), rate limit de login, cache de CEP/geocodi
## Integrações externas
1. **ViaCEP** cidade/UF a partir do CEP de origem e destino
2. **OpenStreetMap Nominatim** coordenadas; distância por haversine
1. **ViaCEP**: cidade/UF a partir do CEP de origem e destino
2. **OpenStreetMap Nominatim**: coordenadas; distância por haversine
Se o Nominatim falhar, a simulação usa uma distância de fallback (mesmo município / mesmo estado / interestadual).

View File

@ -2,7 +2,7 @@
Estratégia: **testes de unidade Jest na API**, um `*.spec.ts` ao lado do código. Sem banco, Redis, Firebase ou browser.
O desafio avalia cobertura, qualidade e organização. A regra de negócio (frete, papéis, isolamento, chat, dashboard, OAuth) vive em funções puras e serviços com dependências mockadas — é o caminho mais estável e o que um avaliador consegue rodar com `npm test`.
O desafio avalia cobertura, qualidade e organização. A regra de negócio (frete, papéis, isolamento, chat, dashboard, OAuth) vive em funções puras e serviços com dependências mockadas. É o caminho mais estável e o que um avaliador consegue rodar com `npm test`.
```bash
npm test

View File

@ -1,7 +1,7 @@
{
"name": "sinka",
"private": true,
"description": "Plataforma SaaS de inteligência logística NestJS + Next.js",
"description": "Plataforma SaaS de inteligência logística: NestJS + Next.js",
"scripts": {
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",

View File

@ -2,8 +2,8 @@
# This is NOT the Next.js you know
This version has breaking changes APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This version has breaking changes. APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
This block is written and re-added by `next dev`. Verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

View File

@ -127,9 +127,9 @@ export default function ClientesPage() {
{rows.map((row) => (
<tr key={row.id} className="border-t border-white/10">
<td className="px-5 py-3 font-medium">{row.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{row.document || ""}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{[row.city, row.state].filter(Boolean).join(" / ") || ""}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{row.email || row.phone || ""}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{row.document || "-"}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{[row.city, row.state].filter(Boolean).join(" / ") || "-"}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{row.email || row.phone || "-"}</td>
{canWrite ? (
<td className="px-5 py-3">
<button className="text-[#82C85C]" type="button" onClick={() => openEdit(row)}>

View File

@ -42,7 +42,7 @@ export default function HistoricoPage() {
{row.originLabel} {row.destinationLabel}
</p>
<p className="text-sm text-[#E8F4E5]/55">
{row.bestCarrierName} · {row.quotedPrice !== null ? money(row.quotedPrice) : ""} · {row.distanceKm} km
{row.bestCarrierName} · {row.quotedPrice !== null ? money(row.quotedPrice) : "-"} · {row.distanceKm} km
{row.customer ? ` · ${row.customer.name}` : ""} ·{" "}
{new Date(row.createdAt).toLocaleString("pt-BR")}
</p>

View File

@ -54,12 +54,12 @@ export default function DashboardPage() {
},
{
label: "Custo médio (melhor opção)",
value: data.kpis.averageCost.value === null ? "" : money(data.kpis.averageCost.value),
value: data.kpis.averageCost.value === null ? "-" : money(data.kpis.averageCost.value),
hint: formatDelta(data.kpis.averageCost.deltaPct),
},
{
label: "Custo por km",
value: data.kpis.costPerKm.value === null ? "" : money(data.kpis.costPerKm.value),
value: data.kpis.costPerKm.value === null ? "-" : money(data.kpis.costPerKm.value),
hint: formatDelta(data.kpis.costPerKm.deltaPct),
},
{

View File

@ -131,7 +131,7 @@ export default function SimularPage() {
{result.distanceSource === "fallback" ? " (distância estimada)" : ""}
</p>
<h2 className="font-display mt-2 text-2xl font-bold uppercase">
Melhor opção: {result.bestCarrierName} · {result.quotedPrice !== null ? money(result.quotedPrice) : ""}
Melhor opção: {result.bestCarrierName} · {result.quotedPrice !== null ? money(result.quotedPrice) : "-"}
</h2>
<ul className="mt-6 divide-y divide-white/10 rounded-2xl border border-white/10">
{result.quotes.map((quote, index) => (

View File

@ -273,7 +273,7 @@ export default function PlatformPage() {
<tr key={user.id} className="border-t border-white/10">
<td className="py-3 pr-4 font-medium">{user.name}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/80">{user.email}</td>
<td className="py-3 pr-4 font-mono text-[#82C85C]">{user.password || ""}</td>
<td className="py-3 pr-4 font-mono text-[#82C85C]">{user.password || "-"}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/70">{roleLabel[user.role]}</td>
<td className="py-3 text-right">
<button

View File

@ -15,7 +15,7 @@ const condensed = Barlow_Condensed({
});
export const metadata: Metadata = {
title: "Sinka Inteligência em Logística",
title: "Sinka: Inteligência em Logística",
description:
"Plataforma SaaS de inteligência logística: simulação de fretes, gestão de clientes e transportadoras, dashboard e insights para a operação.",
};

View File

@ -62,7 +62,7 @@ export function SinkaLanding() {
<h2>A cotação existe. A decisão some.</h2>
<p className="lede">
Peso, destino e valor estão na operação. O que falta é um lugar em que isso vire
comparação não print de conversa nem aba de planilha.
comparação, não print de conversa nem aba de planilha.
</p>
<ul className="pain-list">
<li>

View File

@ -6,7 +6,7 @@ export function SiteFooter() {
<div className="mx-auto flex max-w-6xl flex-col items-start justify-between gap-6 md:flex-row md:items-center">
<Image
src="/brand/sinka-logo.webp"
alt="Sinka Inteligência em Logística"
alt="Sinka: Inteligência em Logística"
width={160}
height={52}
className="h-7 w-auto"

View File

@ -19,7 +19,7 @@ export function SiteHeader() {
<a href="#topo" className="shrink-0" onClick={() => setOpen(false)}>
<Image
src="/brand/sinka-logo.webp"
alt="Sinka Inteligência em Logística"
alt="Sinka: Inteligência em Logística"
width={180}
height={58}
className="h-7 w-auto md:h-8"