Plataforma de automação de tarefas agendadas com suporte a webhooks, scripts Shell/Node.js/Python e editor de código integrado. Construída para rodar em Raspberry Pi com recursos limitados.
- Stack
- Arquitetura do Projeto
- Banco de dados — Modelos
- Features
- Fluxos detalhados
- Variáveis de ambiente
- Como rodar
- Camadas e responsabilidades
- CSP — Regras importantes
- Guia: Como criar uma nova tela/feature
- Deploy (Raspberry Pi)
| Camada | Tecnologia | Por quê |
|---|---|---|
| HTTP Server | Fastify v5 | Leve, rápido, plugin-first — menos RAM que Express |
| Templates | EJS + @fastify/view |
Renderização server-side, sem bundle JS |
| Interatividade | HTMX v2 | Swap parcial de HTML via hx-*, sem framework frontend |
| Estilos | DaisyUI v4 + Tailwind CSS (CDN) | Componentes prontos, sem build step |
| Ícones | Lucide (CDN) | SVG leve e consistente |
| Banco de dados | SQLite via Prisma v6 | Arquivo local, zero infra extra |
| Agendamento | node-cron v3 | Expressões cron nativas do Node |
| Execução | child_process.spawn / exec (Node built-in) |
Sem dependências externas |
| Editor de código | Monaco Editor 0.52 (CDN jsDelivr) | VS Code no browser |
| Validação | Zod v3 | Schema type-safe no server |
| Auth | Sessões em SQLite + cookie HMAC | Sem JWT, sem Redis — tudo local |
| Notificações | Discord Webhook via Axios | Opcional por tarefa |
| Linguagem | TypeScript 5 + tsx |
Sem compile em dev (tsx watch) |
v2/
├── prisma/
│ ├── schema.prisma # Modelos: Session, Tarefa, Script, Execucao
│ └── migrations/ # Histórico de migrations (nunca editar manualmente)
│
├── scripts/
│ └── user/ # Scripts criados pelo usuário (Shell/JS/Python)
│ ├── uuid.sh # Arquivos nomeados com UUID do banco
│ ├── uuid.js
│ ├── uuid.py
│ ├── package.json # Dependências npm dos scripts Node.js
│ └── node_modules/ # Instalado manualmente: cd scripts/user && npm install lib
│
├── src/
│ ├── server.ts # Ponto de entrada: listen + graceful shutdown
│ ├── app.ts # Bootstrap Fastify: registra plugins, rotas, CSP
│ ├── config.ts # Lê variáveis .env com required()/optional()
│ │
│ ├── plugins/
│ │ ├── prisma.ts # Decora app.prisma (PrismaClient singleton)
│ │ ├── auth.ts # Sessões, login/logout, requireAuth, limpeza expiradas
│ │ └── scheduler.ts # SchedulerManager: loadAll/schedule/unschedule/reschedule
│ │
│ ├── routes/
│ │ ├── auth.ts # GET /login, POST /login, POST /logout
│ │ ├── dashboard.ts # GET / — stats agregadas
│ │ ├── tarefas.ts # CRUD completo + toggle + clonar
│ │ ├── scripts.ts # CRUD + POST /scripts/:id/executar
│ │ └── execucoes.ts # Listagem paginada + detalhes
│ │
│ ├── services/
│ │ ├── tarefa.service.ts # createTarefa, updateTarefa, listTarefas, toggle, delete, getDashboardStats
│ │ ├── script.service.ts # createScript, updateScript, deleteScript, executeScriptManually
│ │ ├── cron.service.ts # generateCronExpressions, formatDiasSemana, formatHorarios
│ │ ├── execucao.service.ts# executeTask: decide script vs webhook, captura saída, salva
│ │ └── webhook.service.ts # sendDiscordWebhook: embed formatado
│ │
│ ├── validators/
│ │ ├── auth.schema.ts # loginSchema (zod)
│ │ ├── tarefa.schema.ts # tarefaCreateSchema, parseFormTarefa
│ │ └── script.schema.ts # scriptCreateSchema, parseFormScript
│ │
│ └── views/
│ ├── layouts/
│ │ ├── header.ejs # DOCTYPE, CDNs, HTMX, Lucide, abertura do <main>
│ │ └── footer.ejs # fecha <main>, <footer>, scripts JS globais + event delegation
│ ├── partials/
│ │ ├── navbar.ejs # Navbar desktop + dropdown mobile
│ │ ├── toast.ejs # Fragmento de alerta (injetado via HTMX)
│ │ ├── task-card.ejs # Card individual de tarefa
│ │ ├── script-card.ejs # Card de script com botão Executar (HTMX)
│ │ ├── agendamento-fields.ejs# Campos de dia/horário do agendamento
│ │ ├── execution-row.ejs # <tr> de execução
│ │ ├── execution-detail.ejs # Conteúdo do modal de detalhes
│ │ └── execution-output.ejs # Output de execução manual de script
│ └── pages/
│ ├── login.ejs
│ ├── dashboard.ejs
│ ├── tarefas.ejs
│ ├── tarefa-form.ejs
│ ├── scripts.ejs
│ ├── script-form.ejs
│ ├── execucoes.ejs
│ └── error.ejs
│
├── public/
│ └── css/
│ └── style.css # TailwindCSS v4 + tema DaisyUI "fatia" (rosa)
│
├── deploy.sh # Script de deploy para Raspberry Pi
├── ecosystem.config.cjs # PM2: processo único, restart on crash
└── .env # Variáveis de ambiente (não versionar)
Request HTTP
└─ Fastify
├─ Plugin: auth (verifica sessão em cada request)
├─ Plugin: prisma (app.prisma disponível em toda a app)
├─ Route Handler (src/routes/)
│ ├─ preHandler: app.requireAuth (redireciona /login se não autenticado)
│ ├─ Valida input com Zod (src/validators/)
│ ├─ Chama Service (src/services/)
│ │ └─ Acessa banco via app.prisma (Prisma ORM)
│ └─ reply.view('pages/nome', { dados })
└─ EJS renderiza: header + página + footer
sid String @id -- ID aleatório (32 bytes hex)
data String -- JSON: { username, authenticatedAt }
expiresAt DateTime -- TTL do cookie (padrão: 7 dias)
id String @id uuid()
nome String
descricao String?
comandoOuPayload String? -- Comando shell ou payload Discord
webhookUrl String? -- URL do webhook Discord (opcional)
ativo Boolean -- false = cron jobs pausados
scriptId String? FK → Script (executa script em vez de comando)
diasSemana String -- JSON: number[] ex: "[1,2,3,4,5]"
horarios String -- JSON: string[] ex: '["08:00","18:00"]'
createdAt DateTime
updatedAt DateTime
→ script Script?
→ execucoes Execucao[] -- cascade delete
id String @id uuid()
nome String
descricao String?
tipo String -- "SHELL" | "NODEJS" | "PYTHON"
arquivo String @unique -- nome do arquivo em disco (uuid.sh / uuid.js / uuid.py)
conteudo String -- espelho do arquivo salvo no banco para exibir no editor
ativo Boolean
createdAt DateTime
updatedAt DateTime
→ tarefas Tarefa[]
→ execucoes Execucao[]
id String @id uuid()
status String -- "SUCESSO" | "FALHA" | "EM_ANDAMENTO"
saida String? -- JSON estruturado (ver abaixo)
duracao Int? -- milissegundos
executadoEm DateTime -- timestamp de início
tarefaId String? FK → Tarefa (nullable — execuções manuais de script têm tarefaId=null)
scriptId String? FK → Script (preenchido em execuções manuais)
Estrutura do campo saida (JSON):
Autenticação single-user via sessão HTTP (cookie assinado com SESSION_SECRET).
- Login em
POST /logincomusername+password - Username/password configurados em
.env(ADMIN_EMAIL,ADMIN_PASSWORD_HASH) - Todas as rotas exceto
/loginexigem sessão válida - Para requests HTMX responde com
HX-Redirect: /loginem vez de 302
Arquivos: src/plugins/auth.ts · src/routes/auth.ts · src/validators/auth.schema.ts · src/views/pages/login.ejs
Unidade central do sistema. Define o que executar (script vinculado ou comando direto + webhook) e quando executar (dias + horários).
Cada tarefa tem agendamento flat na própria tabela:
diasSemana: JSON array de números (0=Dom,1=Seg …6=Sáb)horarios: JSON array de strings no formato"HH:MM"
O scheduler combina todos horários × dias para criar jobs cron independentes.
Botão Clonar na listagem abre o formulário pré-preenchido. Cria uma nova tarefa independente.
Rota: GET /tarefas/clonar/:id
Arquivos: src/routes/tarefas.ts · src/services/tarefa.service.ts · src/validators/tarefa.schema.ts · src/views/pages/tarefa-form.ejs · src/views/partials/agendamento-fields.ejs · src/views/partials/task-card.ejs
Módulo para criar, editar e executar scripts diretamente na máquina onde o servidor roda.
- Usuário cria um script pela UI com nome, tipo e conteúdo no Monaco Editor
- Sistema salva o conteúdo em disco em
scripts/user/<uuid>.<ext>e no banco - O UUID do arquivo é exibido na listagem e no formulário para ser referenciado em outros scripts
- Script pode ser executado manualmente (botão Executar no card) ou agendado via Tarefa vinculada
| Tipo | Extensão | Linux | Windows |
|---|---|---|---|
SHELL |
.sh |
bash -c "arquivo 2>&1" |
bash -c "arquivo 2>&1" (Git Bash) |
NODEJS |
.js |
node arquivo |
node arquivo |
PYTHON |
.py |
python3 arquivo |
python arquivo |
Para SHELL, stdout e stderr são mesclados em ordem real via
2>&1. Tudo que o script imprimir chega comostdout— exatamente como você veria no terminal.
O filename (UUID) aparece na UI. Use-o para orquestrar:
#!/bin/bash
# Script SHELL que ativa venv e chama Python
cd "$(dirname "$0")"
source /home/pi/venv/bin/activate
python3 ./uuid-do-script-python.py#!/bin/bash
# Script que instala deps e executa Node.js
cd "$(dirname "$0")"
npm install --silent
node ./uuid-do-script-node.js# Instalar uma vez no servidor
cd /caminho/do/projeto/scripts/user
npm install nodemailer axiosO node_modules/ criado é compartilhado por todos os .js do diretório. O sistema executa os scripts com cwd: scripts/user/, então require('nodemailer') funciona automaticamente.
| Método | Rota | Descrição |
|---|---|---|
GET |
/scripts |
Listagem |
GET |
/scripts/novo |
Formulário de criação |
GET |
/scripts/:id/editar |
Formulário de edição |
POST |
/scripts |
Cria script + arquivo em disco |
POST |
/scripts/:id (_method=PUT) |
Atualiza script + arquivo |
DELETE |
/scripts/:id |
Remove script + arquivo (HTMX) |
POST |
/scripts/:id/executar |
Executa manualmente, retorna HTML via HTMX |
Arquivos: src/routes/scripts.ts · src/services/script.service.ts · src/validators/script.schema.ts · src/views/pages/scripts.ejs · src/views/pages/script-form.ejs · src/views/partials/script-card.ejs · src/views/partials/execution-output.ejs
Registro histórico de todas as execuções (agendadas ou manuais).
- Execuções agendadas têm
tarefaIdpreenchido - Execuções manuais de script têm
tarefaId = nullescriptIdpreenchido - A coluna "Tarefa" na listagem exibe o nome da tarefa, ou
Script: nomepara execuções manuais
Arquivos: src/routes/execucoes.ts · src/services/execucao.service.ts · src/views/pages/execucoes.ejs · src/views/partials/execution-row.ejs
Plugin src/plugins/scheduler.ts iniciado junto com o servidor:
loadAll()— busca tarefas ativas e cria jobs cron para cada combinação horário × dia- A cada minuto que bate, dispara
executeTask(prisma, tarefa) - Jobs identificados por chave
"tarefaId:agendamentoIndex:horarioIndex"— permite remoção cirúrgica
| Evento | Ação no scheduler |
|---|---|
| Startup | loadAll() — carrega todos os ativos |
| Criar tarefa | scheduleTask(id) |
| Editar tarefa | rescheduleTask(id) → unschedule + schedule |
| Toggle ativar | scheduleTask(id) |
| Toggle pausar | unscheduleTask(id) |
| Deletar tarefa | unscheduleTask(id) antes do delete |
| Shutdown | task.stop() em todos os jobs |
Arquivos: src/plugins/scheduler.ts · src/services/cron.service.ts · src/services/execucao.service.ts
POST /tarefas
│
├─ 1. parseFormTarefa(body) src/validators/tarefa.schema.ts
│ extrai diasSemana → number[]
│ extrai horarios → string[]
│ extrai scriptId (opcional)
│
├─ 2. tarefaCreateSchema.safeParse(input)
│ nome: min 1, max 100
│ horarios: regex /^\d{2}:\d{2}$/
│ diasSemana: pelo menos 1 dia
│ webhookUrl: url válida ou vazia
│
├─ 3. createTarefa(prisma, data) src/services/tarefa.service.ts
│ prisma.tarefa.create({
│ diasSemana: JSON.stringify([1,2]),
│ horarios: JSON.stringify(["08:00","18:00"]),
│ scriptId: "uuid-do-script" | null
│ })
│
├─ 4. app.scheduler.scheduleTask(tarefa.id)
│
└─ 302 → /tarefas
scheduleTask(tarefaId) src/plugins/scheduler.ts
│
├─ busca tarefa com diasSemana e horarios
│
└─ generateCronExpressions(diasSemana, horarios) src/services/cron.service.ts
│
│ diasSemana = "[1,2,3,4,5]" → diasStr = "1,2,3,4,5"
│ horarios = '["08:00","18:00"]'
│
└─ Uma expressão por horário:
"0 8 * * 1,2,3,4,5"
"0 18 * * 1,2,3,4,5"
│
└─ cron.schedule(expr, callback)
key = "tarefaId:horarioIndex"
jobs.set(key, task)
node-cron dispara
│
└─ executeTask(prisma, tarefa) src/services/execucao.service.ts
│
├─ prisma.execucao.create({ status: "EM_ANDAMENTO" })
│
├─ tarefa.scriptId preenchido?
│ ├─ SIM → busca Script no banco
│ │ spawn bash/node/python3 com o arquivo UUID
│ │ cwd: config.scriptsDir
│ │ captura stdout (stderr mergeado via 2>&1 para SHELL)
│ │
│ └─ NÃO → tarefa.comandoOuPayload preenchido?
│ ├─ SIM → child_process.exec(comando, { timeout: 60s })
│ │ captura stdout e stderr separados
│ │
│ └─ NÃO → type: "noop"
│
├─ tarefa.webhookUrl preenchido?
│ └─ SIM → sendDiscordWebhook(url, { nome, descricao, payload })
│
└─ prisma.execucao.update({ status, saida, duracao })
SUCESSO se exitCode === 0
FALHA se qualquer erro / timeout / exitCode !== 0
POST /scripts/:id/executar (botão Executar no card — HTMX)
│
└─ executeScriptManually(prisma, scriptId) src/services/script.service.ts
│
├─ prisma.execucao.create({ scriptId, tarefaId: null, status: "EM_ANDAMENTO" })
│
├─ executorPorTipo(tipo, filePath)
│ SHELL → bash ["-c", '"arquivo" 2>&1']
│ NODEJS → node [filePath]
│ PYTHON → python3 [filePath] (python no Windows)
│
├─ spawn(cmd, args, { cwd: config.scriptsDir, timeout: 60s })
│
└─ prisma.execucao.update({ status, saida, duracao })
reply.view("partials/execution-output.ejs", { result })
→ HTMX injeta o HTML no #output-{id} do card
POST /login
│
├─ loginSchema.safeParse({ username, password })
│
└─ app.login(reply, username, password) src/plugins/auth.ts
│
├─ bcrypt.compare(password, ADMIN_PASSWORD_HASH)
├─ generateSessionId() → 32 bytes hex
├─ signCookie(sid, SESSION_SECRET) → "sid.hashHMAC"
├─ prisma.session.create({ sid, data, expiresAt })
└─ reply.setCookie("fatia.sid", signedValue, { httpOnly, sameSite: "lax" })
Verificação a cada request (onRequest hook):
1. lê cookie "fatia.sid"
2. verifySignedCookie() — recomputa HMAC e compara
3. prisma.session.findUnique({ sid })
4. verifica session.expiresAt > now
5. request.isAuthenticated = true
DATABASE_URL="file:./data/fatia.db"
# Credenciais do único usuário admin
ADMIN_EMAIL=admin # username (não precisa ser email)
ADMIN_PASSWORD_HASH=$2b$10$... # gerar com: npx tsx scripts/hash-password.ts
# Assina os cookies de sessão
SESSION_SECRET=troque-isso-por-algo-aleatorio-e-longo
# Scheduler
ENABLE_SCHEDULER=true
TZ=America/Sao_Paulo
# Diretório dos scripts (padrão: <projeto>/scripts/user)
# SCRIPTS_DIR=/caminho/customizado/scripts
LOG_LEVEL=info
PORT=3000
HOST=0.0.0.0
NODE_ENV=development# 1. Instalar dependências
npm install
# 2. Gerar hash da senha
npx tsx scripts/hash-password.ts
# Cole o hash em ADMIN_PASSWORD_HASH no .env
# 3. Criar/migrar o banco
npx prisma migrate dev
# 4. Desenvolvimento (tsx watch, sem compile)
npm run dev
# 5. Produção
npm run build
npm start
# 6. Produção com PM2
pm2 start ecosystem.config.cjs┌──────────────────────────────────────────────────────────┐
│ Browser │
│ DaisyUI + Tailwind (CDN) · HTMX · Lucide · Monaco (CDN)│
└──────────────────────┬───────────────────────────────────┘
│ HTTP
form POST · hx-patch · hx-delete · hx-get
│
┌──────────────────────▼───────────────────────────────────┐
│ Rotas (src/routes/) │
│ • Entrada validada com Zod (src/validators/) │
│ • Guard: preHandler app.requireAuth │
│ • Orquestra serviços e renderiza view │
│ auth · dashboard · tarefas · scripts · execucoes │
└─────────┬────────────────────────────────────────────────┘
│
┌────┴─────┐
│ │
┌────▼───┐ ┌───▼─────────────────────────────────────────┐
│Services│ │ Plugins │
│ │ │ │
│tarefa │ │ prisma.ts → app.prisma (PrismaClient) │
│script │ │ auth.ts → app.login/logout/requireAuth │
│execucao│ │ scheduler.ts → app.scheduler (cron jobs) │
│cron │ └─────────────────────────────────────────────┘
│webhook │
└────┬───┘
│
┌────▼──────────────────────────────────────────────────────┐
│ Prisma ORM → SQLite (data/fatia.db) │
│ sessions · tarefas · scripts · execucoes │
└────┬──────────────────────────────────────────────────────┘
│
┌────▼──────────────────────────────────────────────────────┐
│ Sistema Operacional │
│ spawn bash/node/python3 → stdout/stderr capturados │
│ axios → Discord Webhook (opcional, por tarefa) │
└───────────────────────────────────────────────────────────┘
O app usa Content Security Policy restritivo. Regras críticas para desenvolvedores:
| Diretiva | Valor | Impacto |
|---|---|---|
script-src-attr |
'none' |
Proibido qualquer onclick=, onchange= em atributo HTML |
script-src |
'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net |
Scripts externos só do jsDelivr; unsafe-eval necessário para Monaco |
worker-src |
blob: |
Necessário para Monaco Editor funcionar |
Padrão obrigatório para interações JS:
<!-- ERRADO — bloqueado pelo CSP -->
<button onclick="minhaFuncao()">Click</button>
<!-- CORRETO — classe CSS + event delegation no footer.ejs -->
<button class="minha-acao" data-id="<%= item.id %>">Click</button>// src/views/layouts/footer.ejs — adicionar ao event delegation existente
document.body.addEventListener("click", function (e) {
var btn = e.target.closest(".minha-acao");
if (!btn) return;
var id = btn.dataset.id;
// lógica aqui
});Exemplo completo adicionando um módulo Notificacoes.
Adicione o model em prisma/schema.prisma:
model Notificacao {
id String @id @default(uuid())
mensagem String
lida Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("notificacoes")
}Rode a migration:
npx prisma migrate dev --name add-notificacoesCrie src/validators/notificacao.schema.ts:
import { z } from "zod";
export const notificacaoSchema = z.object({
mensagem: z.string().min(1, "Mensagem obrigatória").max(500),
});
export type NotificacaoInput = z.infer<typeof notificacaoSchema>;
export function parseFormNotificacao(body: Record<string, unknown>) {
return notificacaoSchema.safeParse({ mensagem: body.mensagem });
}Crie src/services/notificacao.service.ts:
import type { PrismaClient } from "@prisma/client";
import type { NotificacaoInput } from "../validators/notificacao.schema.js";
export function listNotificacoes(prisma: PrismaClient) {
return prisma.notificacao.findMany({ orderBy: { createdAt: "desc" } });
}
export function createNotificacao(
prisma: PrismaClient,
data: NotificacaoInput,
) {
return prisma.notificacao.create({ data });
}
export function deleteNotificacao(prisma: PrismaClient, id: string) {
return prisma.notificacao.delete({ where: { id } });
}Crie src/routes/notificacoes.ts:
import type { FastifyPluginAsync } from "fastify";
import {
listNotificacoes,
createNotificacao,
deleteNotificacao,
} from "../services/notificacao.service.js";
import { parseFormNotificacao } from "../validators/notificacao.schema.js";
export const notificacaoRoutes: FastifyPluginAsync = async (app) => {
app.addHook("preHandler", app.requireAuth);
app.get("/", async (_req, reply) => {
const notificacoes = await listNotificacoes(app.prisma);
return reply.view("pages/notificacoes.ejs", {
notificacoes,
isAuthenticated: true,
currentPage: "notificacoes",
});
});
app.post("/", async (req, reply) => {
const parsed = parseFormNotificacao(req.body as Record<string, unknown>);
if (!parsed.success) {
const notificacoes = await listNotificacoes(app.prisma);
return reply.view("pages/notificacoes.ejs", {
notificacoes,
errors: parsed.error.issues,
isAuthenticated: true,
currentPage: "notificacoes",
});
}
await createNotificacao(app.prisma, parsed.data);
return reply.redirect("/notificacoes");
});
// HTMX delete — retorna string vazia para remover o elemento
app.delete("/:id", async (req, reply) => {
const { id } = req.params as { id: string };
await deleteNotificacao(app.prisma, id);
return reply.send("");
});
};// src/app.ts
import { notificacaoRoutes } from "./routes/notificacoes.js";
// dentro de buildApp():
await app.register(notificacaoRoutes, { prefix: "/notificacoes" });src/views/pages/notificacoes.ejs:
<%- include('../layouts/header', { title: 'Notificações', isAuthenticated: true,
currentPage: 'notificacoes' }) %>
<div class="max-w-3xl mx-auto space-y-4">
<h2 class="text-2xl font-bold">Notificações</h2>
<div id="notificacoes-list">
<% notificacoes.forEach(function(n) { %> <%-
include('../partials/notificacao-card', { n }) %> <% }) %>
</div>
</div>
<%- include('../layouts/footer') %>src/views/partials/notificacao-card.ejs:
<div class="card bg-base-200" id="notificacao-<%= n.id %>">
<div class="card-body flex-row items-center justify-between">
<p><%= n.mensagem %></p>
<button
class="btn btn-sm btn-error btn-outline"
hx-delete="/notificacoes/<%= n.id %>"
hx-target="#notificacao-<%= n.id %>"
hx-swap="outerHTML"
hx-confirm="Remover notificação?"
>
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>Em src/views/partials/navbar.ejs, adicione o link seguindo o padrão:
<li>
<a
href="/notificacoes"
class="<% if (typeof currentPage !== 'undefined' && currentPage === 'notificacoes') { %>active<% } %>"
>
<i data-lucide="bell" class="w-4 h-4"></i> Notificações
</a>
</li>Adicione também no dropdown mobile (mesmo arquivo, seção lg:hidden).
-
npx tsc --noEmitsem erros - Migration criada e aplicada (
npx prisma migrate dev) - Rota registrada em
src/app.ts - Nenhum
onclick=ou handler inline nas views (CSP bloqueia — use classes +footer.ejs) - HTMX para ações destrutivas (delete) com
hx-confirm - Link na navbar com
currentPageativo em desktop e mobile -
isAuthenticated: trueecurrentPagepassados para todas as views da feature
chmod +x deploy.sh
./deploy.shO deploy.sh:
pm2 delete app— remove processo antigonpm ci— instala dependências limpasnpx prisma generate && npx prisma migrate deploy— aplica migrationsnpm run build— compila TypeScriptpm2 start ecosystem.config.cjs+pm2 save
DATABASE_URL="file:/home/pi/fatia-rapida/prisma/prod.db"
SESSION_SECRET="string-longa-diferente-do-dev"
ADMIN_EMAIL="seu-username"
ADMIN_PASSWORD_HASH="$2b$10$..."
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
ENABLE_SCHEDULER=true
TZ=America/Sao_Paulo