a
This commit is contained in:
parent
80eff119ce
commit
67589e9ec2
12
.env.example
12
.env.example
@ -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
2
.gitignore
vendored
@ -12,3 +12,5 @@ web/.env.local
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
prisma/*.db
|
prisma/*.db
|
||||||
api/src/generated/
|
api/src/generated/
|
||||||
|
uploads/
|
||||||
|
api/uploads/
|
||||||
|
|||||||
23
README.md
23
README.md
@ -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
7
api/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
uploads
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
21
api/Dockerfile
Normal file
21
api/Dockerfile
Normal 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
26
api/docker-entrypoint.js
Normal 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
11
api/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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: '/',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
12
api/src/chat/chat-count.spec.ts
Normal file
12
api/src/chat/chat-count.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
6
api/src/chat/chat-count.ts
Normal file
6
api/src/chat/chat-count.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
18
api/src/files/file-allow.spec.ts
Normal file
18
api/src/files/file-allow.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
api/src/files/file-allow.ts
Normal file
16
api/src/files/file-allow.ts
Normal 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);
|
||||||
|
}
|
||||||
50
api/src/files/files.controller.ts
Normal file
50
api/src/files/files.controller.ts
Normal 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, '')}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/files/files.module.ts
Normal file
9
api/src/files/files.module.ts
Normal 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 {}
|
||||||
105
api/src/files/files.service.ts
Normal file
105
api/src/files/files.service.ts
Normal 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
@ -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
22
msg.txt
Normal 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]
|
||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user