Lalabase
Tickets, Doku, Zeiterfassung und Kundengespräche an einem Ort, durchsuchbar per KI. Für Softwarefirmen mit Kundenprojekten. Open Source: auf deinem eigenen Server oder in unserer Cloud in Deutschland.
Translation pending
Gestão e aprovação de conteúdo para agências de social media.
Translation pending
BusinessHTML · TypeScriptGitHub @madebyfelipeOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Loading comments.
Checking sign-in status.
Loading the full README. The saved excerpt is shown below.
SaaS de gestão e aprovação de conteúdo para agências de social media. Cobrança por cliente atendido (teto R$100/cliente/mês). Público inicial: agências pequenas (3–12 pessoas, 8–30 clientes). Três áreas: aprovação de conteúdo (diferencial), gestão operacional de tarefas e CRM pós-venda + saúde da carteira.Read the full README ↗
SaaS de gestão e aprovação de conteúdo para agências de social media. Cobrança por cliente atendido (teto R$100/cliente/mês). Público inicial: agências pequenas (3–12 pessoas, 8–30 clientes).
Três áreas: aprovação de conteúdo (diferencial), gestão operacional de tarefas e CRM pós-venda + saúde da carteira.
src/app/globals.css via @theme)DATABASE_URL está definida; sem a variável — dev local e todo teste — cai no arquivo JSON com fallback em memória (src/lib/store/{index,json-file,postgres}.ts). Artes seguem a mesma lógica com BLOB_READ_WRITE_TOKEN (Vercel Blob) — ver "Arquitetura de dados".node:crypto) e sessão em cookie httpOnly assinada com HMAC-SHA256 (Web Crypto). Sem dependência externa.currentColor em src/components/icons.tsx.npm install
npm run dev # http://localhost:3000
npm run build # build de produção
npm run typecheck
npm test # testes das funções puras (node --test, sem build)
npm run test:watch # o mesmo, refazendo a cada salvamento
Os testes ficam em tests/ e rodam no runner do próprio Node — nada de
framework, transpilador ou build: o Node 22 lê TypeScript direto. O que ele não
faz sozinho é entender o atalho @/… do tsconfig e os imports sem extensão do
código-fonte; tests/helpers/ts-resolve.mjs cuida disso, então os testes
importam os módulos exatamente como o app os importa.
Cobrem as funções puras que sustentam o produto: sessão e senha, filtros e
ordenações das tarefas e dos clientes, normalização e validação nos
repositórios (incluindo a conversa da tarefa), leitura de dimensões de imagem,
régua de formatos das peças, a revalidação do store de arquivo e a régua do
Inbox (título, prévia, não lidas, agrupamento por dia, carimbos de hora e o
registro da chamada, mais o isolamento por agência e por participante). O que
depende de requisição (route handlers, next/headers) fica de fora — esse
caminho é conferido subindo o app. A exceção é o freio do login
(tests/auth-login-route.test.ts), que chama a rota de verdade com 200
tentativas em paralelo: o caminho de senha errada não toca next/headers.
O mesmo trio roda no CI (.github/workflows/ci.yml) a cada push e pull
request: npm run typecheck, npm test e npm run build.
O seed cria uma conta de demonstração na primeira execução:
| Senha | |
|---|---|
[email protected] | blackberry (ou DEMO_PASSWORD) |
Em produção a senha pública não vale: com o banco ainda sem contas, o seed
exige DEMO_PASSWORD (mín. 12 caracteres) e recusa semear sem ela.
Dá para criar outra conta em /criar-conta — o cadastro já entra logado.
| Variável | Para quê |
|---|---|
AUTH_SECRET | Chave que assina o cookie de sessão. Obrigatória em produção (mín. 16 caracteres): sem ela o servidor recusa subir e qualquer assinatura/conferência de cookie lança. Gere com openssl rand -base64 32 e defina em Vercel → Settings → Environment Variables. Fora de produção o app cai num segredo de desenvolvimento, que não protege nada e invalida as sessões a cada deploy. |
DEMO_PASSWORD | Senha da conta semeada, para instalações compartilhadas. Obrigatória em produção no primeiro boot de um banco vazio (mín. 12 caracteres): sem ela o seed de contas lança em vez de gravar a senha blackberry do README. Instância que já tem contas não precisa dela. |
DATABASE_URL | String de conexão do Postgres (Neon, criado pelo marketplace da Vercel — Storage → Marketplace Database Providers → Neon). Presente, os cinco stores (tarefas, clientes, lotes, contas, índice de mídia — e o Inbox) passam a gravar lá. Ausente, o app usa arquivo JSON local (dev) — nunca em produção sem disco gravável. |
TENOR_API_KEY · GIPHY_API_KEY | A biblioteca de GIFs do Inbox (uma das duas basta; com as duas vale o Giphy — o Tenor anunciou o fim da API). A chave fica no servidor — o navegador busca por /api/inbox/gifs. Sem nenhuma, o botão de GIF diz o que configurar. |
BLOB_READ_WRITE_TOKEN | Token do Vercel Blob (Storage → Create Database → Blob), injetado automaticamente ao conectar o projeto. Presente, a arte vai do navegador direto para o Blob (sem passar pela função, ver "O teto de 4,5 MB") e sobrevive a redeploy. Ausente, o editor volta ao upload multipart e os bytes ficam em data/uploads/, com fallback em memória em disco somente-leitura. O store precisa ser criado com acesso "Private" — ver "Store privado" abaixo; a Vercel não deixa trocar o modo de acesso depois de criado. |
| Rota | O que é |
|---|---|
/login · /criar-conta · /recuperar-senha | Entrada (black berry) — login e cadastro reais; a recuperação registra o pedido, o disparo de e-mail ainda não existe |
/tarefas | Tarefas — Lista + Quadro (Kanban), CRUD, seleção em massa, drag-and-drop, busca, menu de filtros (F) e menu "Personalizar" |
/tarefas/[id] | Descrição da tarefa — visualizador e editor na mesma tela: título, descrição, propriedades (coluna fixa à direita) e a conversa da tarefa |
/clientes | Clientes — a carteira da agência: Lista + Grade, abas por saúde do cliente, filtros, busca, seleção em massa, criação/edição pelo modal, menu de visualização (agrupamento, ordenação, colunas) e o cartão de hover do nome |
/clientes/[id] | Ficha do cliente (export "Clientes · Detalhe") — Visão geral, Serviços, Financeiro, Arquivos e Atividades (?aba=); ver "Ficha do cliente" abaixo |
/social | Social media › clientes — passo 1 do fluxo de aprovação: de quem são os lotes (lotes, peças e pendentes de cada um). Lê o nome do cliente gravado no lote, não a ficha de /clientes — ver "Cliente ainda é texto livre" no roadmap |
/social/[cliente] | Lotes do cliente — passo 2: progresso de cada lote e o modal "Novo lote" |
/social/[cliente]/[lote] | Detalhe do lote — grade de peças + painel de decisão + link público |
/social/[cliente]/[lote]/editor | Editor de lote — peças, detalhes da peça (data, formato, canal, legenda, hashtags), preview do post e as ações do link (enviar, gerar, desativar) |
/a/[token] | Aprovação pública (cliente, sem login): tela de início + swipe para aprovar/pedir ajuste |
/configuracoes | Configurações › Pessoal (todo mundo): perfil e foto, disponibilidade, avisos, chamada e áudio, senha e sessão |
/configuracoes/agencia | Configurações › Agência (Admin e Gerente): logo, nome, domínio do convite, padrões para cliente novo, integrações, excluir agência (só Admin) |
Tudo segue os exports do pen.dev na raiz do repo (*-export.html) — a fonte de
verdade do visual black berry. Ao divergir do design, confirmar antes.
Tokens e componentes-base ficam em src/components/ui.
O vocabulário está empilhado, e a camada de cima manda onde as duas discordarem:
| Camada | Exports | Onde está aplicada |
|---|---|---|
| v3 (atual) | Tarefas · Painel (Lista), Tarefas · Painel (Quadro), Tarefas · Descrição da tarefa, Clientes · Painel (Lista), Clientes · Painel (Grade), Inbox, Novo Fluxo | Shell (lateral), Tarefas, Descrição da tarefa, Clientes, Inbox, Novo fluxo |
| v2 (gradiente) | 2. Gradiente, Clínica Aurora - *, Lotes de Aprovação*, Filtros · Menu | Só o link público do cliente (/a/<token>). Social media, Lote, Editor, Planejamento e Configurações passaram para o painel v3 (2026-09-23) — o conteúdo dos exports v2 continua, dentro do vocabulário v3 |
Os exports List View e 2. Board · Kanban são a versão v2 das Tarefas, hoje
substituída pelos dois Tarefas · Painel (…); ficam no repo como histórico.
As telas de aprovação da agência usam o painel, as abas, a barra e o card do
v3 desde 2026-09-23; o link público do cliente segue o desenho mobile próprio.
O que o v3 traz. Painel de conteúdo de 28px de raio sobre o fundo preto
(ui/Screen), abas em pílula (ui/Tabs), barra de ferramentas com
Filtros/busca/Personalizar (ui/Toolbar), lista de 44px por linha com caixa de
seleção e barra flutuante de ações (ui/DataTable, ui/SelectionBar), card de
14px comum ao Quadro e à Grade (ui/EntityCard), selo de status (ui/Badge) e
as duas marcas redondas (ui/Mark). Tela nova se monta com esses componentes —
não se cria um segundo conjunto ao lado.
O vocabulário inteiro — cor, raio e o alvo de toque dos botões redondos — mora
no @theme de src/app/globals.css, e o Tailwind v4 o transforma em classe
(--color-danger → text-danger, --radius-menu → rounded-menu,
--spacing-control → h-control). Fora dali não entra hex nem rounded-[Npx]
de cor/forma de interface: quem precisa de um cinza novo cria o token. Duas
regras que valem a pena saber de cor:
faint é decorativo (marcadores, divisórias, pontos). Em texto que a
pessoa lê ele dá ~2,5:1 — use muted, que passa dos 4,5:1 em qualquer uma das
superfícies do produto.--color-danger (a ação destrutiva do menu) e a escala de saúde do cliente
(--color-client-*: Ativo verde, Renovação âmbar, Em risco vermelho,
Pausado cinza, Novo azul, VIP roxo), que veio com os exports de Clientes. O
resto do produto continua monocromático — o status da tarefa, por exemplo,
é fundo neutro com o texto num degrau de cinza. Matiz novo só entra com
desenho.Toda lista do produto (Tarefas e Clientes hoje; qualquer outra que nasça de
ui/DataTable) redimensiona coluna: a divisória do cabeçalho arrasta, o
duplo clique devolve a medida do export e as setas ← → fazem o mesmo pelo
teclado (a divisória recebe foco). A largura fica guardada no localStorage
de quem está olhando (bb.colunas.<lista>), não no servidor: é preferência de
quem usa a tela, não dado da agência.
A régua mora em src/lib/ui/columns.ts (pura, testada em
tests/ui-columns.test.ts) — mínimo de 72px por coluna, máximo de 560px,
valor guardado sempre validado na leitura. Quem monta uma lista declara um
ColumnSpec por coluna, abre um ColumnsProvider com o useColumnWidths e
usa HeadCell/Cell: cabeçalho e linha leem a mesma largura, sem prop
viajando célula a célula. A coluna que cresce (CLIENTE, TAREFA) continua
crescendo até alguém arrastá-la.
A descrição da tarefa tem os três menus dos exports (menu_comando_tarefas,
menu_formatacao, formatacao_hover), em src/components/editor:
/ abre os comandos (títulos, anexos, bloco de código, listas) e @,
as pessoas que já aparecem na tarefa — o cursor não sai do texto: o que se
digita depois do gatilho filtra o menu;Ctrl Alt 1/2/3 (títulos), Ctrl ⇧ 8/9 (listas), Ctrl ⇧ C
(bloco de código), Ctrl B/I/U.O dado continua sendo uma string. As marcas são markdown
(**negrito**, ## Título, - item, > citação, `código`, e __ para
sublinhado, que o markdown não tem) — decisão técnica registrada em
src/lib/editor/markdown.ts: um editor de HTML mudaria o formato do campo que
a busca varre e a lista mostra, pediria sanitização e migração, e entregaria a
mesma coisa. Fora da edição o campo mostra o markdown formatado
(MarkdownText); clicar volta ao texto cru. As duas pontas — escrever e ler —
são funções puras, testadas em tests/editor-markdown.test.ts.
A única exceção é AuroraBackdrop: o espectro do fundo do login é arte, não cor
de interface, e está marcado assim no arquivo.
Os testes de tests/design-tokens.test.ts seguram a ponta: as cores das réguas
de status — tarefa (ponto e selo), peça e cliente (fundo e texto do selo) —
têm de ser var(--token) de um token que exista no @theme.
O app só conversa com repository.ts, que só conversa com store.ts. Trocar o armazenamento é um drop-in em store.ts sem tocar no resto.
src/lib/agency/{types,id}.ts — ver "Multi-tenant" abaixo. Os ajustes da agência (logo, padrões para cliente novo) moram em src/lib/agency/{settings,store,repository}.ts (agency.json, por agencyId); o nome continua em cada conta e muda para todas de uma vez (renameAgency). "Excluir agência" é src/lib/agency/delete.ts: passa por todas as áreas filtrando pelo agencyId da sessão e só no fim apaga os arquivos — área nova com dado de agência precisa entrar ali (o teste agency-settings-repository prova que a agência vizinha fica inteira).src/lib/dashboard/metrics.ts — funções puras sobre tarefas, lotes, clientes, fichas e time; nenhum número do painel é gravado.src/lib/tasks/{types,constants,priority,seed,store,repository}.ts — o pipeline de status vive só em constants.ts; a régua de prioridade, só em priority.ts. A conversa da tarefa (Task.comments) entra por POST /api/tasks/<id>/comments, com o autor vindo da sessão — nunca do corpo.src/lib/clients/{types,constants,seed,store,repository,view}.ts — mesma forma das tarefas; a régua de saúde do cliente (e os pares fundo/texto do selo) vive só em constants.ts, e o que a tela filtra/ordena, só em view.ts. O vínculo com tarefa e lote: Task.clientId/Batch.clientId guardam o Client.id cujo nome bate com o texto livre gravado (resolveClientId, sem acento/caixa, escopado por agência) — resolvido de novo a cada gravação, null quando não bate com nenhuma ficha. client continua sendo o texto de registro; clientId é para quem precisa do id de verdade (listTasksForClient, futura ficha do cliente e Health Score). Registro gravado antes deste vínculo fica com clientId: null até ser salvo de novo, ou até rodar npm run backfill:client-ids (src/lib/maintenance/backfill-client-ids.ts): backfill manual, idempotente, que resolve client contra a ficha cadastrada da mesma agência sem tocar em quem já tem vínculo — não é migração de leitura porque essa é síncrona e não pode consultar a lista de clientes.src/lib/approval/{types,constants,seed,store,repository}.ts. Todo evento novo do histórico da peça grava (ISO) — é o que a linha do tempo da ficha do cliente lê.Na Vercel, o corpo de uma requisição e o de uma resposta são cortados em
4,5 MB — em qualquer plano, e antes de a função rodar. O upload multipart
de sempre (POST .../media com o arquivo dentro) morria com 413 em produção
para qualquer arte acima disso, com source: "static" no log, o que é o jeito
da plataforma dizer que a função nem foi chamada. Passava batido em dev porque
next dev não tem esse teto. A dropzone promete 50 MB.
Então, com Blob configurado, os bytes não passam pela função em nenhuma das duas direções:
POST .../media/token, handleUpload do
@vercel/blob/client) e depois registra o que chegou (POST .../media com
{ pathname }). Quem valida sessão, dono do lote, tipo e tamanho é a rota do
token — o Blob passa a impor allowedContentTypes e maximumSizeInBytes por
conta própria, então o token não serve para gravar outra coisa.GET /api/media/<id> responde 307 para uma URL assinada
de 5 minutos (presignMediaUrl, issue #39), não para a blobUrl direto.
Devolver uma arte de 9 MB pelo corpo da resposta bateria no mesmo teto: a
imagem subiria e não apareceria. De quebra, o navegador pega os bytes do
CDN, não da função — só que agora com uma URL que expira sozinha, porque o
store é privado.Três regras ao mexer nisso:
pathname (validado por isMediaBlobPathname) e o nome do arquivo; tamanho,
tipo e URL vêm do head() do Blob. Aceitar URL do cliente seria um SSRF,
porque readMedia busca os bytes pelo pathname guardado no registro.onUploadCompleted. A Vercel não consegue chamar de volta um
localhost, então registrar a arte por ali faria o dev local se comportar
diferente da produção — exatamente a diferença que escondeu o 413. Quem
registra é o navegador, depois que o upload termina.Location do 307), então "existe no Blob" não prova de quem é. A
rota do token anota o pathname pedido para agência + pessoa + destino
(lib/media/upload-grants.ts; o nonce de blobPathnameFor faz cada pedido
ser único), saveBlobMedia só aceita pathname dessa permissão e nunca um
já registrado, e deleteMedia não apaga objeto que outro registro ainda
usa. tests/media-blob-owner.test.ts roda o ataque.Sem BLOB_READ_WRITE_TOKEN nada disso liga: o editor volta ao multipart e os
bytes vão para o disco, como sempre foi em dev.
Todo objeto do Blob é privado (BLOB_ACCESS, em lib/media/constants.ts):
o que o servidor grava (putBlob) e o que o navegador sobe direto
(upload nas artes do lote, nos arquivos da ficha e nos anexos do Inbox —
um store privado recusa objeto público, então os dois lados precisam pedir o
mesmo; tests/media-blob-access.test.ts trava isso). O store não serve mais nenhum objeto
só por quem tiver a blobUrl — toda leitura passa por get()/presignUrl()
com o BLOB_READ_WRITE_TOKEN do servidor. Isso fecha o buraco que a issue #39
descreveu (arte de cliente acessível para sempre a quem vazasse a URL, sem
sessão nem revogação).
Mas a Vercel não deixa trocar o modo de acesso (public/private) de um
store depois de criado. O store atual (ragick-artes, criado pela issue
#11 como público) continua público enquanto for esse store — put(..., { access: "private" }) nele responde com o erro "Cannot use private access
on a public store", que putBlob (e friendlyBlobError, no envio direto do
navegador) transforma na mensagem "o Blob store precisa ser recriado como
privado" em vez de um 500 cru. Até então novos uploads ficam bloqueados, não inseguros: o
código não volta sozinho a gravar público.
Para ligar de verdade, alguém com acesso ao dashboard da Vercel precisa:
Criar um novo Blob store com acesso Private (Storage → Create Database → Blob → Private) — não dá para converter o existente.
Conectar o novo store ao projeto e atualizar BLOB_READ_WRITE_TOKEN para
o token dele, nos ambientes de produção e preview.
Copiar as mídias do store antigo para o novo — é um comando:
BLOB_READ_WRITE_TOKEN=<token do store novo> DATABASE_URL=<o de produção> \
npm run migrate:blob-private -- --dry-run # só lista o que copiaria
BLOB_READ_WRITE_TOKEN=<...> DATABASE_URL=<...> npm run migrate:blob-private
Ele baixa cada arquivo pela URL pública do store antigo, grava no novo com
o mesmo caminho (privado) e troca o registro da mídia
(src/lib/maintenance/migrate-blob-private.ts, testado). Pula o que já
está no store novo, então dá para rodar de novo se cair no meio. Sem ele,
o que já foi enviado para de abrir. Só depois de conferir as artes abrindo,
apague o store antigo.
Criar o store é o único passo que o código não faz — é no painel da Vercel. Ver a issue #39 para o estado disso.
A tela do Inbox (issue #30, export "Inbox") é comunicação interna: só o time da agência — cliente não entra em sala nenhuma; ele continua decidindo pelo link público. Grupos e diretas, como o Discord separa servidor de DM, com o histórico salvo (e pesquisável, dentro da conversa e pela lista).
Três decisões que vale saber antes de mexer:
InboxMember), não entre
contas. O vínculo é o e-mail: ensureMember (em viewer.ts) encontra o
membro da sessão ou cria um na primeira visita — é o que povoa a agência
enquanto não existir convite de equipe. Uma conversa exige duas
condições, não uma: ser da agência e ter você dentro. Conversa da sua
agência em que você não está responde como inexistente, igual a conversa de
outra agência.CallProvider monta a CallOverlay no layout do app, então ela não cai ao
trocar de página (minimizada, vira o cartão do canto). Aberta no desktop, o
Inbox marca o lugar da conversa (call.setSlot) e a chamada se posiciona
por cima dele (export "Chamada · Call View") — posição fixa medida com
ResizeObserver, sem trocar de pai no DOM, porque remontar os <video>
cortaria a imagem. Sem lugar marcado (celular, outra tela) ela cobre a tela
inteira; "abrir" de outra tela leva até /inbox?conversa=<id>. A mídia é o
LiveKit (src/lib/realtime/*); sem as variáveis dele a chamada abre, conta o
tempo e deixa o registro no histórico, com os controles desligados
dizendo por quê — nada acende fingindo que alguém ouve do outro lado. As
preferências de quem transmite (qualidade da tela, câmera) são regra pura em
lib/inbox/screenQuality.ts e lib/inbox/callPrefs.ts, guardadas no
navegador entre uma chamada e outra. Sem o Ably, mensagem nova chega por
releitura periódica da tela (12s, só com a aba à vista), não por push.Três peças, cada uma num lugar:
lib/flows, tela /configuracoes/fluxos): etapas em ordem, e
em cada uma quem toca — uma pessoa fixa, o squad do cliente ou o
próprio cliente (a aprovação pelo link) —, o prazo em dias úteis e a
próxima etapa. As regras ("qual vem depois", "quem recebe") são funções
puras em view.ts, testadas em tests/flows-view.test.ts.Flow.appliesTo, o "Todos os clientes · Clientes
específicos" do Novo fluxo): o criativo segue o fluxo escolhido na ficha do
cliente (Client.flowId) se ele estiver ativo; senão, o fluxo ativo
marcado "Todos os clientes". Fluxo "Clientes específicos" nunca pega
cliente de fora, e fluxo sem etapa ligada (o "Começar do zero" recém-criado)
não pega ninguém — pickFlowForClient em view.ts. Fluxo gravado antes
disso é lido como "Todos os clientes", que é o que ele já fazia. A lista de
"Clientes específicos" grava de uma vez só (setFlowClients, em
lib/clients/repository.ts): cliente fica em um fluxo só.Client.squad, ids do time). Etapa
marcada "squad do cliente" vai para o primeiro do squad que ainda está no
time — é assim que o mesmo fluxo serve todos os clientes.lib/flows/automation.ts) costura lote, cliente, time e
tarefa: todo criativo novo no lote vira tarefa na primeira etapa do fluxo
do cliente; concluir a tarefa a entrega ao responsável da próxima etapa
(prazo novo, registro na conversa assinado "black berry"); a aprovação do
cliente pelo link avança a etapa do cliente, e o ajuste volta uma etapa com
o motivo. A etapa da tarefa só muda por aqui (moveTaskToStep), nunca por
PATCH.O time é o mesmo registro do Inbox (InboxMember), agora com função
(Admin, Gerente, Editor, Visualizador, Financeiro), status (ativo, convite
pendente, inativo, arquivado), último acesso e data de entrada. A régua mora
em lib/inbox/users.ts (testada em tests/inbox-users.test.ts).
ForbiddenError), não da tela. Ninguém arquiva a si mesmo nem tira de si
a administração. Na migração, agência sem Admin promove quem tem conta./criar-conta?convite=… cria a conta dentro da agência
(o cadastro normal sempre abre agência nova) e exige o e-mail convidado. A
primeira entrada vira o convite em ativo. Não há disparo de e-mail — o link
é copiado por quem convidou.@dominio-da-agencia entra na agência como pedido de entrada
e vê /acesso ("Quase lá") até um Admin ou Gerente aprovar. Não é entrada
direta porque o produto não confirma e-mail — com entrada direta, qualquer
um que digitasse algo@dominio leria os clientes. O domínio precisa ser o
do e-mail de quem configura, e e-mail pessoal (Gmail etc.) não vale
(lib/inbox/domain.ts, testado em tests/inbox-domain.test.ts).requireAgency/currentAgencyScope (memberAccess):
arquivado perde o acesso na hora, e pedido esperando aprovação não recebe
escopo. As telas do shell usam redirectWithoutScope(), que manda quem tem
conta mas não pode entrar para /acesso em vez de apagar a sessão no login.Cada membro do time tem um handle único na agência (lib/inbox/handle.ts),
sugerido pelo nome e editável em Configurações. O menu de @ do briefing, do
comentário e do Inbox oferece o time inteiro e insere o @ — não o nome, que
pode repetir. O responsável da tarefa aceita @handle e o repositório troca
pelo nome de quem atende por ele (@ de ninguém é recusado).
/ abre o menu; com ele aberto, C cria tarefa e F
cria cliente. (Fora de campo de texto — digitando, a barra é só barra.)+ no hover do dia cria o criativo; arrastar o criativo
para outro dia remarca (e o prazo da tarefa acompanha).A tela /notificacoes e o número ao lado dela na lateral. Quatro tipos, cada
um nascendo num lugar só (lib/notifications/dispatch.ts, chamado pelas rotas
e pelo motor dos fluxos — sempre depois de gravar, e falhar em avisar não
desfaz nada):
| Tipo | Quando |
|---|---|
atribuicao | A tarefa passou a ser sua — na mão (criar/editar o responsável) ou pelo fluxo (etapa nova, ajuste do cliente) |
mencao | Seu @ novo no briefing, num comentário ou numa mensagem (a menção fura o silêncio da conversa) |
comentario | Comentaram numa tarefa que é sua |
mensagem | Mensagem nova numa conversa sua não silenciada; várias da mesma conversa, até você ler, viram um aviso só |
Cada pessoa escolhe o que interrompe (Configurações › Pessoal ›
Notificações, gravado no membro: InboxMember.notify): os quatro tipos, os
dois canais (toast no app, notificação do sistema) e o som. A régua é
alertRoute (lib/inbox/notifyPrefs.ts, testada) e vale só para o aviso
— a notificação continua registrada na tela. Ocupado tira o sistema e o
som; só a menção ainda aparece no app. Ligação que começa avisa sempre no app.
Quem recebe o quê é função pura (rules.ts, testada): ninguém é avisado do
que ele mesmo fez, só quem está ativo no time recebe, e é um aviso por pessoa
por evento. Abrir a tarefa ou a conversa dá por lidos os avisos dela (ref
tarefa:<id>/conversa:<id>). A entrega ao vivo vai pelo canal pessoal do
Ably ({ tipo: "notificacao" }); sem tempo real, o notificador relê a cada
20s.
MESSAGE_EDIT_WINDOW_MS, em lib/inbox/constants.ts — a regra é do
repositório, a tela só esconde o que ele recusaria). Apagar deixa a linha
"Mensagem apagada" para as respostas a ela não ficarem órfãs.media/store.ts), com
outra política (ATTACHMENT_POLICY: 20 MB, pasta anexos/ no Blob, tipos
fechados — nada que o navegador execute; documento é servido como
download, com nosniff). O navegador sobe e recebe um id; a mensagem leva
os ids, e o servidor monta o anexo a partir do registro, nunca da descrição
que veio do navegador.isGifUrl).components/inbox/VoicePlayer). Duração e forma de onda são medidas no
navegador de quem manda (audioAnalysis.ts, decodificando a 8 kHz) e
sobem junto com o registro do anexo; o servidor limpa os números em
voiceMeta (lib/inbox/voice.ts) e só os guarda em arquivo de áudio.
Medir na origem resolve duas coisas: o WebM do gravador do Chrome chega sem
duração no cabeçalho, e quem ouve não precisa baixar o áudio inteiro para
ver as barras. Anexo antigo, sem as medidas, é medido por quem ouve.components/inbox/InboxNotifier,
no shell): notificação do sistema — nativa no app de desktop, a do navegador
no site. Não avisa mensagem sua, de conversa silenciada, nem a da conversa
que está aberta com a janela em foco (foco, não "aba visível": no
desktop a página sempre se diz visível, e era por isso que o aviso não
chegava no PC com o Inbox aberto). Sem permissão do sistema, vira toast. Ligação fica na tela até o clique; clicar abre a conversa
(/inbox?conversa=<id>) e, no desktop, traz a janela da bandeja.ablyReady() (lib/realtime/server.ts)
confere o que a chave concede (a cada 10 min por instância); faltando
permissão, o app trata o tempo real como desligado e o log diz o que ligar.lib/realtime/revoke.ts). Sem a opção, a revogação do Ably falha (o log
avisa) e o que sobra é o TTL do token: 10 min, o mesmo do crachá da chamada.A pasta desktop/ é o black berry para Windows, macOS e Linux: uma janela
Electron que abre o site publicado, com bandeja, atalho global de mudo na
chamada e permissões de microfone/câmera/tela só para a origem do app. Tem
package.json próprio (fora do tsconfig, do ESLint e do build do Next) —
como rodar, gerar o instalador e assinar está em desktop/README.md.
Toda tarefa e todo lote carrega um agencyId, e nenhuma leitura ou escrita
acontece sem dizer de qual agência: as funções de repository.ts exigem um
AgencyScope como primeiro argumento, e o TypeScript recusa a chamada sem ele.
O escopo nasce num lugar só — requireAgency() / currentAgencyScope() em
src/lib/auth/session.ts, a partir da sessão do servidor. Nunca de query
string, corpo ou header.
Vale para escrita como vale para leitura: getTask, updateTask, deleteTask
e toda operação de lote/peça procuram por id e agência. Registro de outra
agência responde 404, nunca 403 — 403 confirmaria que o id existe.
O id da agência. user.agency continua sendo o nome que a pessoa escreve;
quem identifica o tenant é user.agencyId, gravado no cadastro e imutável
depois disso (renomear a agência troca o rótulo, não o tenant). O id é o slug
do nome — "Estúdio Norte" → estudio-norte — mais um sufixo aleatório nos
cadastros novos, porque ainda não existe convite de equipe: sem o sufixo,
bastaria digitar o nome de uma agência para cair dentro dela.
Migração. Roda sozinha na primeira leitura de cada store (revive), no
mesmo espírito dos seed.ts. Conta antiga recebe o slug do nome que já tinha;
tarefa e lote antigos, sem dono, caem na agência semeada (estudio-norte) — a
única que existia antes do multi-tenant. Nada some e nada vaza para quem acabou
de se cadastrar.
A exceção é o link público. /a/<token> e POST /api/approve/<token>
rodam sem sessão, e portanto sem agência: quem autoriza é o token, e ele
resolve exatamente um lote (getBatchByToken recusa token ambíguo). As duas
funções ficam isoladas no fim de approval/repository.ts, sem AgencyScope
por desenho — um escopo ali seria escopo escolhido por quem chama. GET /api/media/<id> segue igualmente público, protegido pelo id de 16 bytes, para
o cliente conseguir carregar as artes.
Quando o Postgres entrar (banco no lugar do JSON), este filtro vira o WHERE agency_id de cada consulta e ganha RLS por cima.
src/proxy.ts (Next 16 — o antigo middleware.ts) barra as telas do shell autenticado e devolve a pessoa ao destino original depois do login (?next=). Ficam de fora, de propósito: /a/<token> (aprovação do cliente, sem login) e /api/media/<id>, que precisa carregar as artes no navegador do cliente — o que protege a arte é o id de 16 bytes aleatórios.
O matcher do proxy também deixa /api de fora, então cada rota da agência checa a sessão por conta própria (requireUser() de src/lib/auth/session.ts, que responde 401). As duas públicas continuam públicas: POST /api/approve/<token> e GET /api/media/<id>.
Trocar a senha derruba as sessões dos outros aparelhos. O usuário tem uma passwordVersion que sobe a cada troca, e o token carrega a versão que valia quando foi emitido; session.ts compara as duas e recusa o que for anterior. Quem trocou continua logado, porque a rota reemite o cookie com a versão nova. O proxy.ts não participa dessa checagem (ele só confere a assinatura, porque não pode ler o store): quem foi recusado lá dentro chega ao login com ?sessao=encerrada, e é aí que o proxy apaga o cookie morto.
A biblioteca de animação vive em globals.css: uma curva de entrada (--ease-out-soft), cascata de listas por --d (.stagger-item), troca de tela em app/(app)/template.tsx, esqueletos (.skeleton + loading.tsx) e feedback de toque (.tap). Só transform e opacity são animados, e tudo respeita prefers-reduced-motion.
/configuracoes/painel | Painel da agência (Admin, Gerente e Financeiro): operação, aprovação, financeiro (só Admin e Financeiro), carteira, entregas, equipe e agenda; filtros ?mes=&cliente=&pessoa= |
/configuracoes/fluxos | Fluxos e Processos — a esteira de trabalho: etapas, quem toca cada uma, prazo, próxima etapa, aprovadores e automações |
/configuracoes/fluxos/novo | Novo fluxo (export "Novo Fluxo") — modelo → detalhes → revisão; cria o fluxo ativo, inativo ou como rascunho |
/configuracoes/fluxos/[id]/editar | O passo "Detalhes" do Novo fluxo com o fluxo preenchido — o "Editar" do cabeçalho do fluxo |
/inbox | Inbox — a conversa do time: grupos e diretas, histórico salvo e pesquisável, presença (disponível · ocupado · ausente · offline) e a chamada com registro no histórico |
/equipe | Usuários (export "Usuários · Painel (Lista)") — o time: função, status, último acesso; "Adicionar usuário" cria convite com link de cadastro que entra nesta agência |
/notificacoes | Notificações — menções, tarefas atribuídas, comentários na sua tarefa e mensagens novas; ver "Notificações" abaixo |
/conversas | Redireciona para /inbox ("Conversas" saiu da lateral) |
/relatorios | Placeholder pronto para desenhar (saiu da lateral — o lugar é de Fluxos e Processos) |
atsrc/lib/crm/{types,constants,defaults,seed,store,repository,view,http}.ts — uma conta por cliente (crm.json): serviços, faturas, contrato, forma de pagamento, arquivos e agenda, dinheiro sempre em centavos inteiros. O cadastro continua em lib/clients (que ganhou contactName e nps); a linha do tempo e os números dos cartões são funções puras em view.ts, nunca gravados. As rotas ficam em /api/clients/<id>/{servicos,faturas,contrato,arquivos,eventos} e todas passam por withClient (http.ts): sessão, cliente desta agência (404 para o resto) e erro de validação virando 422. Apagar o cliente apaga a ficha e os arquivos dela.src/lib/inbox/{types,constants,view,seed,store,repository,viewer}.ts — a equipe e as conversas dela no mesmo arquivo (inbox.json), porque conversa sem saber quem é quem não existe. A régua de presença vive só em constants.ts; título, prévia, não lidas, ordem e carimbos de hora, só em view.ts. viewer.ts é a única porta que liga a sessão ao membro da equipe — ver "Inbox" abaixo.src/lib/auth/{types,password,token,session,seed,store,repository}.ts. token.ts não importa nada do Node nem do Next — é o único pedaço compartilhado com o proxy.ts.src/lib/media/* — metadados pelo store comum; bytes em data/uploads/ (ou fallback em memória) sem BLOB_READ_WRITE_TOKEN, no Vercel Blob com ela. Como os bytes entram e saem está em "O teto de 4,5 MB" abaixo — leia antes de mexer em upload de arte.src/lib/store/index.ts decide entre os dois backends por DATABASE_URL, com a mesma interface (read/transaction) para quem consome:
json-file.ts — arquivo JSON + memória, revalida pelo mtime a cada leitura, porque next start roda vários workers: sem isso, quem grava e quem renderiza a tela veem estados diferentes.postgres.ts — cada área é uma linha jsonb em kv_store (mesmo formato que ia para o arquivo); transaction tranca a linha (SELECT ... FOR UPDATE) para ler e gravar na mesma conexão, o equivalente ao problema do mtime resolvido por lock de banco em vez de detecção depois do fato.