This commit is contained in:
armando 2026-09-16 01:33:44 -03:00
parent 80eff119ce
commit 67589e9ec2
24 changed files with 474 additions and 9 deletions

View File

@ -27,5 +27,17 @@ FIREBASE_PROJECT_ID=
FIREBASE_DATABASE_URL= FIREBASE_DATABASE_URL=
NEXT_PUBLIC_FIREBASE_DATABASE_URL= NEXT_PUBLIC_FIREBASE_DATABASE_URL=
# Arquivos da operação (CSV/XLSX) — pasta local por empresa
UPLOAD_DIR=
# WEB # WEB
NEXT_PUBLIC_API_URL=http://localhost:3001/api NEXT_PUBLIC_API_URL=http://localhost:3001/api
# Docker (tudo no container): npm run docker:full
# Cookie Secure só com HTTPS na VPS (COOKIE_SECURE=true)
COOKIE_SECURE=false
# MYSQL_ROOT_PASSWORD=root
# MYSQL_PASSWORD=sinka
# Se 3306/3000 já estiverem ocupados na VPS:
# SINKA_MYSQL_PORT=3307
# SINKA_WEB_PORT=3000

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ web/.env.local
Thumbs.db Thumbs.db
prisma/*.db prisma/*.db
api/src/generated/ api/src/generated/
uploads/
api/uploads/

View File

@ -20,6 +20,8 @@ Isolamento: **um database MySQL por empresa** (`sinka_platform` + `sinka_t_<slug
## Subir local ## Subir local
Só MySQL + Redis no Docker; API e web no Node da máquina:
```bash ```bash
docker compose up -d docker compose up -d
cp .env.example .env cp .env.example .env
@ -27,6 +29,27 @@ cd api && npm install && npm run prisma:setup && npm run start:dev
cd web && npm run dev cd web && npm run dev
``` ```
### Tudo no Docker (demo / VPS)
Sobe MySQL, Redis, API e Next.js. Na VPS só precisa de Docker. Porta pública: **3000**.
```bash
cp .env.example .env
docker compose --profile full up -d --build
```
- App: http://localhost:3000 (ou `http://IP-DA-VPS:3000`)
- API (só na máquina): http://127.0.0.1:3001/api/health
Se a VPS já usa 3306, no `.env`: `SINKA_MYSQL_PORT=3307`. Com HTTPS na frente, `COOKIE_SECURE=true` e `WEB_ORIGIN=https://seu-dominio`.
Para apagar o projeto da VPS (containers, volumes, imagens locais, uploads):
```bash
docker compose --profile full down -v --rmi local --remove-orphans
rm -rf /opt/sinka
```
- API: http://localhost:3001/api/health - API: http://localhost:3001/api/health
- Web: http://localhost:3000 - Web: http://localhost:3000
- Login empresa demo: http://localhost:3000/demo/login - Login empresa demo: http://localhost:3000/demo/login

7
api/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
coverage
uploads
.env
*.log
*.tsbuildinfo

21
api/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM node:22-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
COPY . .
RUN npm run prisma:generate && npm run build
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["node", "docker-entrypoint.js"]

26
api/docker-entrypoint.js Normal file
View File

@ -0,0 +1,26 @@
const { spawn, spawnSync } = require('node:child_process');
function run(command, args) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
run('npx', ['prisma', 'db', 'push', '--schema', 'prisma/platform/schema.prisma', '--skip-generate']);
run('npx', ['tsx', 'prisma/seed.ts']);
const child = spawn('node', ['dist/main.js'], {
stdio: 'inherit',
env: process.env,
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});

11
api/package-lock.json generated
View File

@ -41,6 +41,7 @@
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-google-oauth20": "^2.0.17", "@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
@ -4283,6 +4284,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.4", "version": "24.13.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",

View File

@ -56,6 +56,7 @@
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-google-oauth20": "^2.0.17", "@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
@ -120,7 +121,10 @@
"users.service.ts", "users.service.ts",
"platform.service.ts" "platform.service.ts"
], ],
"coverageReporters": ["text", "lcov"], "coverageReporters": [
"text",
"lcov"
],
"testEnvironment": "node" "testEnvironment": "node"
} }
} }

View File

@ -134,3 +134,14 @@ model ImportJob {
@@index([createdAt]) @@index([createdAt])
} }
model OperationFile {
id String @id @default(uuid())
originalName String
storedName String
mimeType String
sizeBytes Int
createdAt DateTime @default(now())
@@index([createdAt])
}

View File

@ -11,6 +11,7 @@ import { CustomersModule } from './customers/customers.module';
import { FreightModule } from './freight/freight.module'; import { FreightModule } from './freight/freight.module';
import { DashboardModule } from './dashboard/dashboard.module'; import { DashboardModule } from './dashboard/dashboard.module';
import { ChatModule } from './chat/chat.module'; import { ChatModule } from './chat/chat.module';
import { FilesModule } from './files/files.module';
@Module({ @Module({
imports: [ imports: [
@ -28,6 +29,7 @@ import { ChatModule } from './chat/chat.module';
FreightModule, FreightModule,
DashboardModule, DashboardModule,
ChatModule, ChatModule,
FilesModule,
HealthModule, HealthModule,
], ],
}) })

View File

@ -7,7 +7,9 @@ function cookieBase() {
return { return {
httpOnly: true, httpOnly: true,
sameSite: 'lax' as const, sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production', secure:
process.env.COOKIE_SECURE === 'true' ||
(process.env.NODE_ENV === 'production' && process.env.COOKIE_SECURE !== 'false'),
path: '/', path: '/',
}; };
} }

View File

@ -0,0 +1,12 @@
import { countChatMessages } from './chat-count';
describe('countChatMessages', () => {
it('counts keys in a firebase snapshot', () => {
expect(countChatMessages({ a: {}, b: {} })).toBe(2);
});
it('treats empty chat as zero', () => {
expect(countChatMessages(null)).toBe(0);
expect(countChatMessages({})).toBe(0);
});
});

View File

@ -0,0 +1,6 @@
export function countChatMessages(data: unknown): number {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return 0;
}
return Object.keys(data).length;
}

View File

@ -13,6 +13,12 @@ import { SendMessageDto } from './dto/send-message.dto';
export class ChatController { export class ChatController {
constructor(private readonly chat: ChatService) {} constructor(private readonly chat: ChatService) {}
@Get('counts')
@Roles('PLATFORM_ADMIN')
counts(@CurrentUser() user: AuthUser) {
return this.chat.counts(user);
}
@Get('channel') @Get('channel')
channel(@CurrentUser() user: AuthUser, @Query('slug') slug?: string) { channel(@CurrentUser() user: AuthUser, @Query('slug') slug?: string) {
return this.chat.channel(user, slug); return this.chat.channel(user, slug);

View File

@ -1,9 +1,10 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import type { AuthUser } from '../auth/auth.types'; import type { AuthUser } from '../auth/auth.types';
import { PlatformPrismaService } from '../prisma/platform-prisma.service'; import { PlatformPrismaService } from '../prisma/platform-prisma.service';
import { resolveChatTenant } from './chat-access'; import { resolveChatTenant } from './chat-access';
import { chatChannelId } from './chat-channel'; import { chatChannelId } from './chat-channel';
import { countChatMessages } from './chat-count';
@Injectable() @Injectable()
export class ChatService { export class ChatService {
@ -46,6 +47,32 @@ export class ChatService {
return { ok: true, slug: room.slug }; return { ok: true, slug: room.slug };
} }
async counts(actor: AuthUser) {
if (actor.kind !== 'platform') {
throw new ForbiddenException();
}
const databaseURL = this.databaseUrl();
if (!databaseURL) {
return {};
}
const tenants = await this.platform.tenant.findMany({ select: { slug: true } });
const entries = await Promise.all(
tenants.map(async (tenant) => {
const channel = chatChannelId(tenant.slug, this.secret());
try {
const response = await fetch(`${databaseURL}/chats/${channel}/messages.json`);
if (!response.ok) {
return [tenant.slug, 0] as const;
}
return [tenant.slug, countChatMessages(await response.json())] as const;
} catch {
return [tenant.slug, 0] as const;
}
}),
);
return Object.fromEntries(entries) as Record<string, number>;
}
private secret() { private secret() {
return this.config.get<string>('JWT_SECRET') ?? 'sinka-chat'; return this.config.get<string>('JWT_SECRET') ?? 'sinka-chat';
} }

View File

@ -0,0 +1,18 @@
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
describe('file upload allow list', () => {
it('accepts csv and xlsx', () => {
expect(isAllowedUpload('tabela.csv')).toBe(true);
expect(isAllowedUpload('planilha.XLSX')).toBe(true);
});
it('rejects other extensions', () => {
expect(isAllowedUpload('foto.exe')).toBe(false);
expect(isAllowedUpload('sem-extensao')).toBe(false);
});
it('strips path from the original name', () => {
expect(fileExtension('C:\\tmp\\a.csv')).toBe('csv');
expect(safeOriginalName('C:\\tmp\\carga.csv')).toBe('carga.csv');
});
});

View File

@ -0,0 +1,16 @@
const ALLOWED = new Set(['csv', 'xlsx', 'xls', 'pdf', 'txt']);
export function fileExtension(name: string): string {
const base = name.replace(/\\/g, '/').split('/').pop() ?? '';
const dot = base.lastIndexOf('.');
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
}
export function isAllowedUpload(name: string): boolean {
return ALLOWED.has(fileExtension(name));
}
export function safeOriginalName(name: string): string {
const base = name.replace(/\\/g, '/').split('/').pop()?.trim() || 'arquivo';
return base.replace(/[^\w.\- ()[\]]+/g, '_').slice(0, 180);
}

View File

@ -0,0 +1,50 @@
import {
Controller,
Get,
Param,
Post,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { FilesService } from './files.service';
@Controller('files')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN', 'MANAGER', 'OPERATOR')
export class FilesController {
constructor(private readonly files: FilesService) {}
@Get()
list(@CurrentUser() user: AuthUser) {
return this.files.list(user);
}
@Post()
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 8 * 1024 * 1024 },
}),
)
create(@CurrentUser() user: AuthUser, @UploadedFile() file: Express.Multer.File) {
return this.files.create(user, file);
}
@Get(':id/download')
async download(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const file = await this.files.download(user, id);
return new StreamableFile(file.stream, {
type: file.mimeType,
disposition: `attachment; filename="${file.name.replace(/"/g, '')}"`,
});
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
@Module({
controllers: [FilesController],
providers: [FilesService],
})
export class FilesModule {}

View File

@ -0,0 +1,105 @@
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createReadStream } from 'node:fs';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import type { AuthUser } from '../auth/auth.types';
import { tenantClient } from '../tenancy/actor';
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { fileExtension, isAllowedUpload, safeOriginalName } from './file-allow';
const MAX_BYTES = 8 * 1024 * 1024;
type IncomingFile = {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
};
@Injectable()
export class FilesService {
constructor(
private readonly tenants: TenantConnectionService,
private readonly config: ConfigService,
) {}
async list(actor: AuthUser) {
const db = await tenantClient(this.tenants, actor);
const rows = await db.operationFile.findMany({ orderBy: { createdAt: 'desc' } });
return rows.map((row) => ({
id: row.id,
name: row.originalName,
type: fileExtension(row.originalName).toUpperCase() || '—',
sizeBytes: row.sizeBytes,
createdAt: row.createdAt,
}));
}
async create(actor: AuthUser, file: IncomingFile | undefined) {
if (!file?.buffer?.length) {
throw new BadRequestException('Selecione um arquivo.');
}
if (file.size > MAX_BYTES) {
throw new BadRequestException('Arquivo acima de 8 MB.');
}
if (!isAllowedUpload(file.originalname)) {
throw new BadRequestException('Use CSV, XLSX, XLS, PDF ou TXT.');
}
const slug = actor.tenantSlug;
if (!slug) {
throw new UnauthorizedException();
}
const id = randomUUID();
const originalName = safeOriginalName(file.originalname);
const storedName = `${id}.${fileExtension(originalName)}`;
const folder = this.folder(slug);
await mkdir(folder, { recursive: true });
const diskPath = join(folder, storedName);
await writeFile(diskPath, file.buffer);
try {
const db = await tenantClient(this.tenants, actor);
const row = await db.operationFile.create({
data: {
id,
originalName,
storedName,
mimeType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
},
});
await db.auditLog.create({
data: { userId: actor.sub, action: 'file.create', entity: 'OperationFile', entityId: row.id },
});
return { id: row.id, name: row.originalName, type: fileExtension(row.originalName).toUpperCase(), sizeBytes: row.sizeBytes, createdAt: row.createdAt };
} catch (error) {
await unlink(diskPath).catch(() => undefined);
throw error;
}
}
async download(actor: AuthUser, id: string) {
const slug = actor.tenantSlug;
if (!slug) {
throw new UnauthorizedException();
}
const db = await tenantClient(this.tenants, actor);
const row = await db.operationFile.findUnique({ where: { id } });
if (!row) {
throw new NotFoundException('Arquivo não encontrado.');
}
return {
name: row.originalName,
mimeType: row.mimeType,
stream: createReadStream(join(this.folder(slug), row.storedName)),
};
}
private folder(slug: string) {
const root = this.config.get<string>('UPLOAD_DIR') ?? join(process.cwd(), 'uploads');
return join(root, slug);
}
}

File diff suppressed because one or more lines are too long

View File

@ -4,17 +4,17 @@ services:
container_name: sinka-mysql container_name: sinka-mysql
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3306:3306" - "127.0.0.1:${SINKA_MYSQL_PORT:-3306}:3306"
environment: environment:
MYSQL_ROOT_PASSWORD: root MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
MYSQL_DATABASE: sinka_platform MYSQL_DATABASE: sinka_platform
MYSQL_USER: sinka MYSQL_USER: sinka
MYSQL_PASSWORD: sinka MYSQL_PASSWORD: ${MYSQL_PASSWORD:-sinka}
volumes: volumes:
- sinka_mysql_data:/var/lib/mysql - sinka_mysql_data:/var/lib/mysql
- ./docker/mysql-init:/docker-entrypoint-initdb.d - ./docker/mysql-init:/docker-entrypoint-initdb.d
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 20 retries: 20
@ -24,12 +24,83 @@ services:
container_name: sinka-redis container_name: sinka-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
- "6379:6379" - "127.0.0.1:${SINKA_REDIS_PORT:-6379}:6379"
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 20 retries: 20
api:
profiles: ["full"]
build:
context: ./api
dockerfile: Dockerfile
container_name: sinka-api
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- path: .env
required: false
environment:
PORT: 3001
NODE_ENV: production
COOKIE_SECURE: ${COOKIE_SECURE:-false}
WEB_ORIGIN: ${WEB_ORIGIN:-http://localhost:3000}
DATABASE_URL: mysql://sinka:${MYSQL_PASSWORD:-sinka}@mysql:3306/sinka_platform
MYSQL_ADMIN_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-root}@mysql:3306
REDIS_URL: redis://redis:6379
UPLOAD_DIR: /data/uploads
volumes:
- sinka_uploads:/data/uploads
ports:
- "127.0.0.1:${SINKA_API_PORT:-3001}:3001"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3001/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
web:
profiles: ["full"]
build:
context: ./web
dockerfile: Dockerfile
args:
API_INTERNAL_URL: http://api:3001/api
container_name: sinka-web
restart: unless-stopped
depends_on:
api:
condition: service_healthy
environment:
PORT: 3000
HOSTNAME: 0.0.0.0
ports:
- "${SINKA_WEB_PORT:-3000}:3000"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 8
volumes: volumes:
sinka_mysql_data: sinka_mysql_data:
sinka_uploads:

22
msg.txt Normal file
View File

@ -0,0 +1,22 @@
Olá,
Sinka: SaaS de frete. NestJS + Next.js + MySQL + Redis.
Isolamento: um banco MySQL por empresa. Admin em /admin, cliente em /<empresa>.
Auth: e-mail/senha (JWT + cookie) e Google. Papéis Administrador, Manager, Operador.
Frete: ViaCEP + mapa, cotação por transportadora, dashboard de custo.
Comunicação em tempo real: Firebase Realtime com um chat interno entre a Sinka e o cliente. Cada empresa só vê a conversa dela.
Arquivos: tela para anexar CSV/XLSX (e PDF/TXT), tabela e botão de baixar. Fica no disco, separado por empresa.
Testes: Jest na API. `npm test`.
Landing: http://localhost:3000
Admin: /admin/login — tina.r@example.net / SinkaPlatform!1
Demo: /demo/login — xena.w@example.org / SinkaAdmin!1
Obrigado,
[seu nome]

View File

@ -5,6 +5,8 @@
"scripts": { "scripts": {
"docker:up": "docker compose up -d", "docker:up": "docker compose up -d",
"docker:down": "docker compose down", "docker:down": "docker compose down",
"docker:full": "docker compose --profile full up -d --build",
"docker:uninstall": "docker compose --profile full down -v --rmi local --remove-orphans",
"dev:api": "npm run start:dev --prefix api", "dev:api": "npm run start:dev --prefix api",
"dev:web": "npm run dev --prefix web", "dev:web": "npm run dev --prefix web",
"test": "npm test --prefix api", "test": "npm test --prefix api",