docs: README e textos
This commit is contained in:
parent
f7ce9e5a54
commit
dd772b0905
@ -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
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
6
RULES.md
6
RULES.md
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Arquitetura — Sinka
|
||||
# Arquitetura: Sinka
|
||||
|
||||
```
|
||||
[Next.js :3000] → [NestJS :3001/api] → [MySQL sinka_platform]
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 -->
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.",
|
||||
};
|
||||
|
||||
@ -62,7 +62,7 @@ export function SinkaLanding() {
|
||||
<h2>A cotação existe. A decisão some.</h2>
|
||||
<p className="lede">
|
||||
Peso, destino e valor já 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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user