API Construflow — documentação de integração
Este documento é autossuficiente: ele leva você da emissão da credencial até uma cópia sincronizada dos dados de obra, passando pelos limites de uso, pelos filtros de consulta, pela escrita e pela importação de verificação de modelo. Tudo o que a sua integração precisa saber está aqui.
1. Abertura
O que a API entrega
O Construflow é uma plataforma de gestão de obras. O objeto central é o apontamento: um problema, uma tarefa ou uma verificação registrada num projeto, com disciplinas responsáveis, prazo, situação, prioridade, local, etiquetas, imagens, posição no modelo 3D e uma conversa de comentários.
A API expõe esse acervo para leitura e escrita:
| O que você consegue fazer | Onde |
|---|---|
| Descobrir quais projetos a sua credencial enxerga | §6 |
| Ler o cadastro de cada projeto — disciplinas, situações de disciplina, locais, etiquetas, categorias, fases | §6 |
| Manter uma cópia sincronizada de apontamentos e comentários, inclusive exclusões | §7 |
| Consultar com o vocabulário completo de filtros | §10 |
| Criar e alterar apontamentos e comentários, anexar imagens | §11 |
| Importar uma verificação de modelo em BCF | §12 |
Os casos de uso que essa superfície atende bem: espelhar o acervo de apontamentos num ERP ou num painel de BI; alimentar um agente de IA que acompanha a obra; publicar de volta o que o seu sistema decidiu; e levar o resultado de uma verificação de modelo BIM para dentro do projeto.
O que ela não atende: notificação em tempo real. Não há canal de assinatura aberto a integradores — o caminho é a sincronização incremental de §7.
Como a API é servida
- Uma superfície principal, GraphQL:
POST /graphql. Uma operação por chamada HTTP. É por onde passa todo o acervo — projetos, apontamentos, comentários — e toda a escrita. - Duas rotas REST, cada uma para o que não cabe no grafo:
GET /identity/users/me, o cadastro de quem assina a credencial (§2), ePOST /bcf-server/bcf/import-process/{projectId}, a importação de verificação de modelo (§12). - Binário nunca trafega pela API: imagens sobem por URL assinada, direto no armazenamento (§11).
Vocabulário
Este texto usa os termos de obra; a API usa os nomes em inglês. O mapa:
| Neste documento | Na API |
|---|---|
| apontamento | issue |
| comentário | comment |
| disciplina | discipline |
| local | local |
| etiqueta | label |
| categoria | category |
| fase | phase |
| situação | status |
| visibilidade | visibility |
Quanto custa integrar
Uma integração de espelhamento completa são três consultas, executadas em laço por projeto, mais uma consulta de descoberta (§6) e o cadastro de cada projeto (§6), relido de tempos em tempos e não a cada rodada. Escrever as chamadas é a parte barata.
O que consome o tempo de quem integra é tratar corretamente cinco coisas, e todas estão documentadas aqui:
- Paginação e cursor — inclusive um falso positivo na sinalização de próxima página (§10).
- A janela de data — as bordas são inclusivas e o início errado perde edições (§7).
- Exclusões — duas fontes independentes, e um conjunto de mudanças que não produz sinal nenhum (§9).
- Os limites de uso e o contrato de recusa (§5).
- As regras da alteração parcial na escrita, onde lista vazia apaga em uns campos e é recusada em outros (§11).
Leia §4, §5, §7 e §9 antes de decidir a arquitetura da sua rotina. Os três últimos mudam o desenho, não o detalhe.
Índice
- API Construflow — documentação de integração
- 1. Abertura
- 2. Autenticação
- 3. Endereço e rotas
- 4. Erros e recusas
- 5. Limites de uso
- 6. Descoberta
- 7. Sincronização incremental
- 8. Seleção de campos
- 9. Exclusões e reconciliação
- 10. Consultas e filtros
- 11. Escrita
- 12. Importação de verificação de modelo (BCF)
- 13. Checklist de integração
2. Autenticação
A API usa HTTP Basic Auth com uma Chave de Acesso. Toda requisição leva o cabeçalho:
Authorization: Basic base64(<chave>:<segredo>)
Emitir a chave e o segredo
Você emite a sua credencial sozinho, pela interface do Construflow. Não há aprovação, papel especial nem módulo contratado envolvido:
- Clique no avatar do usuário, no canto da tela, e escolha Configurações.
- No menu lateral, escolha Chaves de acesso.
- Selecione Criar para gerar a chave e o segredo.
- Copie os dois. O segredo é exibido uma única vez, no momento da criação — depois não há como recuperá-lo, só emitir outra chave.
⚠️ Conta demonstrativa não emite chave. A criação é recusada para o usuário de demonstração. Para integrar é preciso um usuário comum da plataforma.
Teste imediatamente, na rota que responde de quem é a credencial:
curl https://api.construflow.com.br/identity/users/me \
-u "$CONSTRUFLOW_KEY:$CONSTRUFLOW_SECRET" \
-H "User-Agent: MinhaIntegracao/1.0 (contato@empresa.com)"
{
"id": 3367,
"guid": "9f1c…",
"name": "Integração",
"lastName": "Empresa",
"email": "integracao@empresa.com.br",
"photo": null,
"isAdmin": false,
"isBeta": false,
"isDemo": false,
"acceptedPolicies": true,
"confirmedRegister": "2025-04-02T13:20:11.000Z",
"belongsToAccount": true
}
Se o cadastro veio, está autenticado. Se a resposta for 401, leia
Quando a credencial não é aceita.
Esta é a chamada mais barata da API — ela não toca em projeto nenhum —, e é a certa para o healthcheck da sua integração e para a tela de configuração dela ("chave conectada como Integração Empresa"). Ela responde quem é a credencial; o que ela alcança vem da lista de projetos (§6).
Todas as operações deste documento assumem que a requisição já leva o cabeçalho de autenticação.
O que a credencial enxerga
A chave é o usuário que a emitiu. Ela não tem escopo próprio:
| Característica | Comportamento |
|---|---|
| Acesso | Exatamente o do usuário dono: os projetos dele, a visibilidade dele, as permissões dele em cada projeto |
| Restrição por projeto | Não existe. Não há como emitir uma chave limitada a um subconjunto de projetos |
| Restrição a somente-leitura | Não existe. A mesma chave lê e escreve |
| Validade | Não expira. Não há data de vencimento nem rotação automática |
| Revogação | Manual, na mesma tela em que a chave foi criada |
| Quantidade | Sem limite de chaves por usuário |
A consequência prática de "a chave é o usuário": emita a credencial a partir de um usuário criado para a integração, com as permissões de projeto que a integração precisa e nada além. Uma chave emitida por um coordenador enxerga tudo que aquele coordenador enxerga, inclusive apontamentos restritos que a integração não deveria receber.
As permissões relevantes, por projeto:
| Para | É preciso ter no projeto |
|---|---|
| Ler apontamentos, comentários e cadastro | permissão de visualizar |
| Criar/alterar apontamento e comentário, pedir URL de upload | permissão de participar |
| Alterar o cadastro do projeto | coordenação ou administração |
⚠️ A revogação não é instantânea. Uma chave revogada pode continuar sendo aceita por até 10 minutos. Se você precisa cortar o acesso agora — vazamento de segredo, desligamento de um fornecedor — conte com essa janela e trate o que acontecer nela.
⚠️ A cota de uso é por identidade, não por chave. Criar uma segunda Chave de Acesso para o mesmo usuário não dobra nada: as duas somam no mesmo balde (§5). Para separar cotas é preciso separar usuários.
A identidade é derivada da credencial pelo servidor. Não existe parâmetro de usuário em nenhuma operação, e cabeçalhos de identidade enviados por você são descartados na entrada — não há como uma integração agir em nome de outra pessoa.
Quando a credencial não é aceita
Nas rotas REST, a recusa é direta: 401, com WWW-Authenticate: Bearer e o corpo
{ "code": "UNAUTHENTICATED", "message": "User not authenticated" }
No POST /graphql, não. A requisição segue, a identidade simplesmente não é resolvida, e a
operação falha na autorização:
{
"errors": [
{
"message": "User not authenticated",
"extensions": { "code": "UNAUTHENTICATED" }
}
]
}
⚠️ O status dessa resposta é 200, não 401 — a porta do GraphQL admite operações
legitimamente anônimas, então ela não recusa por falta de identidade. É por isso que o teste de
credencial acima usa a rota REST: lá o desfecho está no status.
Em qualquer das duas, chave revogada, segredo errado e cabeçalho ausente produzem o mesmo desfecho — não há como distinguir os três pela resposta. Se a sua integração parou de autenticar, verifique nesta ordem: o cabeçalho está sendo enviado; o base64 está correto; a chave não foi revogada.
3. Endereço e rotas
O host é https://api.construflow.com.br, e sobre ele existem duas rotas:
| Rota | Para quê |
|---|---|
POST /graphql | Todas as consultas e alterações |
GET /identity/users/me | O cadastro de quem assina a credencial (§2) |
POST /bcf-server/bcf/import-process/{projectId} | Abrir importação de BCF (§12) |
O esquema GraphQL não é navegável. Introspecção (__schema, __type) é recusada. Este documento
é a referência. __typename continua funcionando normalmente.
4. Erros e recusas
Antes de escrever a primeira consulta, entenda como uma recusa chega até você. Integração que confunde as famílias abaixo faz retry do que nunca vai mudar e desiste do que só precisava esperar.
As três formas de recusa
É o status HTTP que as separa. Valem para o POST /graphql, por onde passa quase tudo; as rotas
REST recusam credencial com 401 (§2):
| Situação | Status | Onde está o detalhe |
|---|---|---|
| Erro de negócio, de permissão, de autenticação ou de formato de filtro | 200 | errors[0].extensions.code e errors[0].message |
| Consulta acima de um teto de forma/custo, ou array de operações | 400 | errors[0].extensions |
| Excesso de taxa, em qualquer balde | 429 | cabeçalhos RateLimit* + corpo |
⚠️ 200 não significa sucesso. Erro de permissão, de autenticação e de formato de filtro chegam
com 200 e a lista errors preenchida — é o comportamento normal do GraphQL, e é a causa mais comum
de integração que grava dados vazios sem perceber. Sempre verifique errors antes de ler data.
⚠️ Recusa 400 não tem prazo de espera. Ela não traz Retry-After nem retryAfter, e isso é
deliberado: repetir a mesma consulta produz a mesma recusa para sempre. Corrija a consulta — não
faça retry.
Os códigos de 400 são quatro, e todos são sobre a forma da chamada, nunca sobre o conteúdo de
um filtro:
extensions.code | O que aconteceu | extensions traz | Onde está explicado |
|---|---|---|---|
QUERY_DEPTH_EXCEEDED | A consulta passou de 10 níveis de profundidade | code, reason, limit, observed | §5 |
PAGE_SIZE_EXCEEDED | first acima de 200 | code, reason, limit, observed | §5 |
QUERY_COST_EXCEEDED | A consulta instancia objetos demais | code, reason, limit, observed | §5 |
BATCHING_NOT_SUPPORTED | Você enviou um array de operações numa chamada só | só code | §5 |
A ordem de avaliação é fixa — profundidade, depois tamanho de página, depois preço. Uma consulta que viola dois tetos é sempre recusada pelo primeiro.
⚠️ As recusas por formato de filtro NÃO são 400. Cursor de paginação fora de formato
(INVALID_PAGINATION_CURSOR) e filter.ids com item inválido (INVALID_ISSUES_IDS_FILTER) chegam
com status 200, o campo consultado nulo e o erro em errors[0]. É contraintuitivo — parecem
requisição malformada, e não são tratadas como tal. Detalhe em §10.
A quarta forma: recusa na borda
⚠️ Corpo grande demais não devolve 429 nem 413 — devolve 403. O teto de 2 MB por requisição
é aplicado na borda da rede, antes de a requisição chegar à aplicação. A resposta não é GraphQL: não
tem errors, não tem extensions, não tem cabeçalho de limite. Isso contradiz a intuição e é a
recusa mais difícil de diagnosticar — se você recebeu um 403 seco onde esperava um erro de API,
meça o tamanho do corpo que enviou.
Binário não deve ir embutido na requisição. Use a URL assinada
(§11) e faça o PUT direto no armazenamento.
O que pode ser retentado
| Status | Retry? | Como |
|---|---|---|
200 com errors | Não | É erro de negócio, permissão ou autenticação. Corrija a chamada ou a permissão |
400 | Nunca | A resposta não muda. Corrija a consulta |
403 | Não | Reduza o corpo |
429 | Sim | Espere exatamente o que o Retry-After mandar (§5) |
5xx | Sim | Backoff exponencial 2^n segundos, com jitter. Nunca em laço imediato |
5. Limites de uso
A política é não bloquear. Não existe suspensão automática, bloqueio automático nem revogação automática de chave. Exceder um limite recusa aquela requisição, e nada mais: a requisição seguinte, dentro da faixa, passa normalmente. Se um padrão de uso passa a degradar a plataforma, nós procuramos o responsável pela conta; a revogação de uma Chave de Acesso, se chegar a esse ponto, é sempre uma ação manual tomada depois desse contato.
Isso não faz dos limites uma sugestão. A capacidade é compartilhada entre todos os clientes, e uma integração que trabalha acima da faixa produz falha primeiro para si mesma: a maior parte das recusas cai sobre quem está causando a rajada, na forma de rodadas incompletas e dados faltando na sua base — descobertos dias depois, quando alguém nota o buraco. Dimensionar dentro da faixa é, antes de tudo, o que torna a sua integração previsível.
A tabela de limites
| Limite | Valor | Ao exceder |
|---|---|---|
| Volume total por identidade (portaria: toda requisição que passa pelo gateway) | 160 / 10 s e 420 / 60 s | 429 user-envelope |
| Consultas por identidade | 120 / 10 s e 300 / 60 s | 429 user-read |
| Mutations por identidade | 40 / 10 s e 120 / 60 s | 429 user-write |
| Emissão de URL assinada por identidade (balde próprio) | 40 / 10 s e 120 / 60 s | 429 user-upload-url |
| Requisições simultâneas por identidade | 8 em voo | 429 credential-concurrency |
| Requisições por endereço de origem | 6.000 / 60 s | 429 |
| Operações por chamada HTTP | 1 | 400 BATCHING_NOT_SUPPORTED |
| Profundidade da consulta | 10 níveis | 400 QUERY_DEPTH_EXCEEDED |
Tamanho de página (first) | 200 | 400 PAGE_SIZE_EXCEEDED |
| Preço da operação | por objeto instanciado — ver abaixo | 400 QUERY_COST_EXCEEDED |
Corpo em application/json | 2 MB | 403 na borda |
| Tempo de cada consulta ao banco | 30 s | 200 com erro genérico — ver abaixo |
⚠️ Os 30 s são por consulta ao banco, não por operação. Uma operação que dispare muitas consultas
curtas pode levar bem mais de 30 s no total sem que nenhuma seja cortada; o que estoura é a consulta
individual que passa disso. Quando estoura, você recebe 200 com a lista errors
preenchida, um erro genérico e sem código dedicado — não há code que identifique "tempo
esgotado". A pista é a mensagem, não o código. Do lado do cliente, o remédio é reduzir a página e a
seleção, não fazer retry.
Não há limite diário. As janelas são de 10 e de 60 segundos. Não existe cota mensal, nem número máximo de requisições por dia — o que existe é ritmo.
Como os limites se combinam
As duas janelas coexistem. A de 10 segundos (rajada) e a de 60 segundos (sustentado) valem ao mesmo tempo, e basta uma estourar para a requisição ser recusada. A janela curta existe porque a longa, sozinha, admitiria a cota inteira nos dois primeiros segundos do minuto — que é exatamente a forma de tráfego que derruba o serviço.
A portaria recusa primeiro — e o motivo não é o número, é o que ela conta. Os números da portaria empatam com a soma dos baldes finos (120+40 = 160, 300+120 = 420), então parece que ela nunca chegaria antes. Chega, porque ela conta toda requisição autenticada que atravessa o gateway, sem distinguir método, rota nem tipo de operação: consultas, alterações, emissão de URL assinada, importação de BCF e qualquer chamada a outro serviço da plataforma. Os baldes finos orçam só as operações GraphQL de leitura e de escrita; a portaria orça o seu tráfego inteiro.
O exemplo mais direto: 120 consultas + 40 alterações + 40 emissões de URL assinada em 10 segundos.
Nenhum balde fino estourou — cada um está exatamente no teto — e a portaria recusa na 161ª
requisição. Quando isso acontece, a razão devolvida é user-envelope, não user-read nem
user-write, e ela vem com o corpo plano (o envelope de recusa).
Ao dimensionar, conte todas as suas requisições contra 160/10 s e 420/60 s — não só as que você classificaria como consulta ou como escrita.
A cota é por identidade. Não por chave (duas chaves do mesmo usuário somam no mesmo balde), não por empresa, e não por endereço de origem. Distribuir a carga entre várias máquinas não aumenta o que a credencial pode fazer — o limite por IP é uma proteção adicional, não uma cota que se multiplica.
Emissão de URL assinada tem balde próprio. Ela não consome o balde de mutations, mas consome a portaria. Emitir a URL é barato — o binário não passa pela API. Peça as URLs do gesto inteiro numa chamada só (até 50 nomes), sob demanda: N chamadas paralelas é o que estoura este balde.
Forma e custo da consulta
Uma operação por chamada HTTP. Enviar um array de operações num único POST /graphql
responde 400 com BATCHING_NOT_SUPPORTED e nada é executado — inclusive quando o array está
vazio. Se o seu cliente GraphQL agrupa automaticamente (BatchHttpLink e equivalentes), desligue
isso. Não confunda com as mutations em lote (createIssues, updateIssues, copyProjectIssues),
que continuam sendo a forma recomendada de escrever muitos apontamentos: elas são uma operação
com muitos itens (§11).
Profundidade máxima de 10 níveis. Cada nível de seleção conta, incluindo o nível do campo
escalar final. Em project → issues → issues → userPersonalData → tags → tag → id a profundidade é
7. Fragmentos são medidos onde foram espalhados e não acrescentam nível por si. A recusa acontece
antes de qualquer acesso ao banco, então uma consulta profunda demais é barata para nós e inútil
para você.
Página máxima de 200. Passe first sempre de forma explícita, em toda consulta paginada — e é
justamente a omissão que o teto não protege. Quando você não informa o tamanho da página, a
consulta é avaliada como se fosse 100, passa folgada pelo teto de 200 — e o servidor devolve até
500, que é o padrão real das duas consultas paginadas. Você recebe cinco vezes a página que a
avaliação considerou, sem nenhum sinal. Uma página de 500 itens é mais cara e mais lenta do que
qualquer página que você conseguiria pedir de propósito, e é por isso que informar o valor é
obrigatório, não recomendado.
Preço da operação. O custo é cobrado por objeto instanciado, não por campo pedido:
- Campo escalar folha (
id,title,updatedAt,createdByUserId) custa zero. - O que multiplica é o tamanho da página vezes o número de objetos por item. Cada
disciplines { discipline { ... } },locals { local { ... } },images { ... },createdByUser { ... }é um objeto por item. - Uma listagem de apontamentos com os relacionamentos expandidos custa da ordem de 15 objetos por
apontamento. Com
first: 50, são 750 objetos numa chamada — dentro da faixa. O mesmo pedido comfirst: 200seria recusado comQUERY_COST_EXCEEDED.
⚠️ A regra prática de tamanho de página, e ela é a que evita a recusa:
| A sua seleção | first |
|---|---|
Tem relacionamentos expandidos (disciplines { discipline { … } }, locals { local { … } }, labels { … }, images { … }, createdByUser { … }) | da ordem de 50 |
Só campos escalares (id, code, title, status, updatedAt, createdByUserId, deletedAt…) | até 200 |
Peça menos campos e ajuste a página ao que pediu. Numa varredura, trocar createdByUser { name }
por createdByUserId elimina um objeto por item — o nome vem depois, aninhado, na consulta de
detalhe, que traz um apontamento por vez (§8).
O envelope de recusa por taxa
Toda recusa por taxa traz o status 429 e os mesmos sete cabeçalhos. O corpo, porém, vem em duas
formas. A mais comum é o envelope GraphQL:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Policy: "burst";q=120;w=10, "sustained";q=300;w=60
RateLimit: "burst";r=0;t=3, "sustained";r=0;t=12
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785000060
X-RateLimit-Reason: user-read
Content-Type: application/json; charset=utf-8
{
"errors": [
{
"message": "You have reached the request rate limit",
"extensions": {
"code": "REQUEST_RATE_EXCEEDED",
"reason": "user-read",
"retryAfter": 12,
"policy": "\"burst\";q=120;w=10, \"sustained\";q=300;w=60"
}
}
]
}
Como ler:
Retry-Afteré em segundos e tem precedência sobre tudo o mais. Se você só for tratar um campo, trate este. Não invente o seu próprio intervalo a partir das janelas anunciadas: quem sabe quanto falta é o servidor.RateLimit-Policyanuncia as políticas simultâneas do balde que recusou —q= cota,w= janela em segundos.RateLimitdá o estado atual das mesmas políticas —r= restante,t= segundos até a virada.X-RateLimit-Limit/-Remaining/-Resetdescrevem só a política que efetivamente recusou. ⚠️-Reseté época Unix em segundos — base de tempo diferente dot=doRateLimit, que é um delta. Não são o mesmo número escrito de dois jeitos, e somar um ao relógio local dá um instante no ano de 1970 ou daqui a 56 anos, conforme o que você confundir.X-RateLimit-Reasondiz qual balde estourou. É o que permite ajustar o lado certo sem adivinhação: recusa poruser-writenão se resolve reduzindo leitura.- Os mesmos campos vêm no corpo (
reason,retryAfter,policy) porque a leitura de cabeçalho depende de CORS: numa integração de servidor o corpo sempre chega. ⏳ Escopado àdesbur/main— NÃO vale namainainda: a API não aceita chamada feita pelo navegador a partir de um site que não seja o aplicativo do Construflow. Uma requisição que chega com origem de navegador não autorizada recebe403{"error":"origin_not_allowed"}antes de qualquer processamento, e o navegador não expõe nem cabeçalhos nem corpo. Integração roda no servidor, nunca no navegador.
O vocabulário de X-RateLimit-Reason é fechado. Qualquer valor fora desta lista é defeito nosso,
não caso novo a tratar:
| Razão | O que estourou |
|---|---|
user-envelope | volume total da sua identidade, contado na portaria |
user-read | volume de consultas |
user-write | volume de mutations |
user-upload-url | volume de emissão de URL assinada |
credential-concurrency | trabalho simultâneo da sua identidade |
⚠️ Nem toda recusa por taxa traz a lista errors. As recusas aplicadas antes de a requisição
chegar ao GraphQL vêm com corpo plano, com os mesmos cinco campos na raiz. O status e os sete
cabeçalhos são idênticos; só a forma do corpo muda:
{
"message": "You have reached the request rate limit",
"code": "REQUEST_RATE_EXCEEDED",
"reason": "credential-concurrency",
"retryAfter": 1,
"policy": "\"concurrency\";q=8"
}
Qual forma vem em cada caso:
| Razão | Corpo |
|---|---|
user-envelope (portaria) | plano |
credential-concurrency | plano |
user-read, user-write, user-upload-url | envelope GraphQL, com errors[0].extensions |
Repare que é a portaria — o balde que recusa primeiro — que vem no formato plano. A recusa mais
provável de todas é justamente a que não tem errors.
E há um caso em que não vem nada: a recusa por corpo grande demais acontece na borda da rede
(§4) e responde 403 sem nenhum dos sete cabeçalhos e sem corpo
JSON nosso.
Trate 429 pelo status e pelos cabeçalhos, nunca pela forma do corpo. Um cliente que procura
errors[0].extensions.code para decidir se houve recusa por taxa não enxerga as recusas planas e
entra em laço.
O teto de simultâneas conta requisições em voo, não por minuto. Ele existe porque saturação chega em rajadas de décimos de segundo, invisíveis a qualquer contagem por minuto. Mantenha no máximo 4 requisições abertas ao mesmo tempo e você nunca o encontra.
Dimensionamento
Dimensione pela taxa, e mantenha o paralelismo baixo. O balde que manda numa rodada de sincronização é o de consultas: 300 por minuto, ou 5 por segundo — a rodada é integralmente de leitura, e a portaria (420/min) nunca chega a ser o gargalo. Um limitador global de ~4,5 requisições por segundo para toda a integração deixa margem para a variação de tempo de resposta e mantém você dentro da faixa. Se a sua integração também escreve, o balde de mutations é separado e menor (120/min) — dimensione a escrita à parte.
Na prática isso são 2 a 4 processos (ou conexões) trabalhando em paralelo, nunca mais: com quatro requisições em voo você já satura a taxa recomendada, bem antes de encostar no teto de simultâneas. Se as respostas ficarem lentas, reduza a taxa — não abra mais conexões.
Paralelismo alto não acelera nada aqui: acima do ritmo do balde, o que você ganha em disparo perde em recusa, e a rajada é justamente o padrão que degrada o tempo de resposta — inclusive o seu.
O resto do bom comportamento cabe em cinco linhas:
- Sincronize incrementalmente, não em laço. Não repita a mesma consulta em intervalo curto para "ver se mudou" — use a janela de data (§7). Se precisar de verificação periódica, espace-a em minutos, não em segundos.
- Sem fan-out. Não dispare N consultas em paralelo a partir de uma lista recém-recebida.
- Peça só os campos que vai usar. É o que forma o preço da operação.
- Guarde o cadastro do projeto no seu lado. Disciplinas, categorias, fases, etiquetas e locais mudam raramente: releia-os periodicamente — uma vez por dia é folgado —, não a cada rodada (§6).
- Identifique-se. Envie sempre um
User-Agentdescritivo —MinhaIntegracao/1.2 (contato@empresa.com). É por ele que conseguimos falar com você antes de qualquer revogação, em vez de descobrir um endereço anônimo gerando carga.
6. Descoberta
Duas consultas dão todo o contexto de que a sincronização precisa: a lista de projetos, barata e executada a cada rodada, e o cadastro de cada projeto, relido periodicamente.
Os projetos que a credencial enxerga
Não existe uma query projects de raiz. A lista vem aninhada no usuário logado, e é o ponto de
partida de toda integração — não há como listar apontamentos, comentários ou documentos fora de um
projeto.
query AccessibleProjects {
loggedUserData {
projects(permission: "view") {
id
guid
name
status
account { id name }
}
}
}
🔴 Passe permission: "view" — o padrão não é o que você quer. Sem o argumento, a lista traz só
os projetos em que o usuário tem permissão de participar, isto é, de escrever. Uma credencial
provisionada apenas para leitura — que é o que este documento recomenda
(§2) — recebe lista vazia, embora leia todos os projetos
normalmente por project(projectId). As duas permissões são independentes: ter uma não implica a
outra. Como todo papel de projeto concede as duas, view devolve um superconjunto de
participate — é sempre o valor certo para descobrir o que se pode ler.
⚠️ O argumento é texto livre, sem validação. O vocabulário é participate, view, manage e
invites; qualquer outro valor — inclusive um erro de digitação — devolve lista vazia, sem erro.
Se a descoberta voltou vazia, confira a grafia antes de suspeitar de permissão.
⚠️ A lista inclui projeto inativo. O campo status é o que distingue: sincronizar tudo que vem
na lista significa varrer projetos encerrados a cada rodada, o que multiplica o custo sem trazer
dado novo. Filtre pelo status e decida conscientemente se projeto inativo entra na sua rodada — em
geral vale sincronizar uma última vez e depois parar.
⚠️ Projeto de acesso público em que a credencial não é membro não aparece aqui — com nenhum valor
de permission. Nesses projetos a permissão de visualizar é concedida no momento da consulta e
nunca fica gravada, e esta lista sai do que está gravado. O projeto é perfeitamente consultável por
project(projectId) se você souber o identificador — só não há como descobri-lo por aqui, e não há
consulta que os enumere.
Projeto inativo não muda nada nas consultas de apontamento: os apontamentos continuam respondendo normalmente.
⚠️ Esta lista é o recorte da sua credencial, e ela encolhe em silêncio. Se o usuário dono perder acesso a um projeto, o projeto some daqui sem nenhum aviso e sem nenhum erro. Guarde o conjunto de identificadores de uma rodada para a outra e compare: projeto que sumiu é o sinal (§9).
O cadastro do projeto
Todo o cadastro vem numa chamada só. Não existem queries dedicadas para essas coleções — são
campos aninhados em project(projectId):
query ProjectCatalog($projectId: Int!) {
project(projectId: $projectId) {
id
guid
name
disciplines { id guid name abbreviation }
disciplinesStatus { name active }
locals
labels
categories { id name active }
phases { id key abbreviation name active }
groups { id name }
}
}
| Coleção | O que é | Formato |
|---|---|---|
disciplines | Disciplinas do projeto (arquitetura, estrutura, hidráulica…) | Tipo forte |
locals | Árvore de locais do projeto (torre, pavimento, sala…) | JSON livre |
labels | Árvore de etiquetas do projeto | JSON livre |
categories | Catálogo global de categorias, anotado com active — ver abaixo | Tipo forte |
phases | Catálogo global de fases, anotado com active — ver abaixo | Tipo forte |
disciplinesStatus | Catálogo global de situações de disciplina, anotado com active | Tipo forte |
groups | Grupos do projeto de que o usuário é membro — ver abaixo | Tipo forte |
Catálogo do projeto × catálogo global
Três dessas coleções — categories, phases e disciplinesStatus — não são listas do projeto.
Elas devolvem o catálogo global da plataforma inteiro, sempre com o mesmo número de itens em
qualquer projeto, e o que varia de projeto para projeto é a flag active de cada item:
- Projeto que não customiza o catálogo → todos os itens vêm com
active: true. - Projeto que customiza → só os escolhidos vêm com
active: true; os demais continuam na lista, comactive: false.
A lista nunca vem vazia, e "sem restrição" se manifesta como todo mundo ativo, não como ausência
de itens. Sempre filtre por active antes de usar um identificador para escrever: escrever com
categoria ou fase inativa é recusado (CATEGORY_MUST_BE_ACTIVE / PHASE_MUST_BE_ACTIVE).
O mesmo catálogo global, sem a anotação por projeto, está nas consultas de raiz
systemIssueCategories, systemProjectPhases e systemIssueDisciplineStatus — úteis quando você
precisa do vocabulário antes de saber de que projeto se trata. Para todo o resto, project { … }
já traz o que interessa, e com o active certo.
A forma de locals e labels
locals e labels vêm como JSON livre, e não como tipos fortes — os tipos Local e Label só
aparecem aninhados dentro do apontamento (IssueLocal / IssueLabel). Cada um devolve um objeto
com duas visões dos mesmos nós:
{
"flatten": [
{ "id": 201, "parentId": null, "name": "Torre A", "abbreviation": "TA",
"abbreviationTree": "TA", "index": 0, "guid": "…", "projectId": 42 },
{ "id": 202, "parentId": 201, "name": "Pavimento 2", "abbreviation": "P02",
"abbreviationTree": "TA/P02", "index": 0, "guid": "…", "projectId": 42 }
],
"tree": [
{ "id": 201, "parentId": null, "name": "Torre A", "abbreviation": "TA",
"abbreviationTree": "TA", "index": 0, "guid": "…", "projectId": 42,
"children": [ { "id": 202, "parentId": 201, "…": "… os mesmos campos …" } ] }
]
}
flattené a lista completa, sem aninhamento, ordenada porid. É a visão que você quer para indexar por identificador e para procurar porabbreviationTree.treetraz só as raízes (parentId: null), com os descendentes aninhados emchildren— e o nó folha não tem a chavechildren, ela simplesmente não aparece. A ordem emtreee em cadachildrené a de exibição (index), não a deid.- Os nós de
treesão os mesmos objetos deflatten, com os mesmos campos. Além dos mostrados acima, cada nó trazoldId,createdAt,updatedAtedeletedAt— trate-os como ruído. abbreviationTreeé o caminho de abreviações da raiz até o nó, separado por/—TA/P02/S05. É a chave que a importação de BCF usa para casar locais (§12).- Um nó cujo pai foi apagado aparece em
flattene desaparece detree. Se você depende da árvore para navegação, reconstrua-a a partir deflattene trate o órfão explicitamente.
Grupos
Um grupo é um subconjunto nomeado de usuários dentro de um projeto — a unidade que a visibilidade
group endereça: apontamento ou comentário criado com essa visibilidade é visto pelos membros do
grupo indicado, e não pelo projeto inteiro. O identificador vem de project { groups { id name } } e
é o que se envia em groupId nas operações de escrita (§11).
⚠️ A lista de grupos é recortada pela credencial: ela devolve apenas os grupos de que o usuário
dono é membro. Não há como enumerar os demais grupos do projeto. Para escrever com visibilidade
group, ou o usuário da integração pertence ao grupo, ou o identificador tem de vir de fora da API.
Frequência
O cadastro muda raramente. Carregue-o periodicamente — uma vez por dia é folgado — e guarde do seu lado; não o releia a cada rodada. Não use estas coleções para traduzir identificador em nome nos apontamentos: os nomes vêm resolvidos na própria consulta do apontamento, e mais barato (§8). O cadastro serve para outro propósito: preencher um seletor na sua interface e validar um valor antes de escrever (§11).
7. Sincronização incremental
Este é o fluxo recomendado para manter uma cópia dos apontamentos e comentários no seu sistema. Ele roda projeto a projeto, em três passos:
- Apontamentos alterados na janela — inclui os excluídos, sob pedido explícito
(
filter.includeDeleted: true). - Comentários alterados na janela — inclui os excluídos, sempre, sem parâmetro.
- Detalhe apenas da união dos dois primeiros.
Os dois primeiros passos dizem o que mudou; o terceiro busca o detalhe só do que mudou. É isso que faz a carga da sua rotina acompanhar a atividade da obra em vez do tamanho do acervo.
Por que são duas fontes, e não uma
⚠️ O passo 2 não é opcional.
Um comentário novo não altera a data de atualização do apontamento a que pertence.
Isso significa que o passo 1 não traz o apontamento que recebeu apenas um comentário. Sem o passo 2, toda conversa em apontamento que ninguém editou fica invisível para você — e comentário é justamente o que mais se move num projeto ativo.
As duas consultas são fontes independentes e ambas obrigatórias. Cada comentário volta com o
issueIddo apontamento a que pertence: guarde esses identificadores, eles entram na lista do passo 3.
A janela de data
Os dois primeiros passos usam o mesmo filtro de data, com a mesma semântica:
| Característica | Comportamento |
|---|---|
| Formato | ISO 8601 — "2026-01-15", "2026-01-15T10:00:00-03:00", "2026-01-15T10:00:00Z" |
Data pura, sem hora ("2026-01-15") | Meia-noite UTC, por especificação |
Data e hora sem fuso ("2026-01-15T10:00:00") | Interpretada como hora local do processo do servidor — não é UTC |
| Bordas | Inclusivas nos dois lados |
Janela invertida (From > To) | Devolve lista vazia, sem erro |
| Valor inválido | Silenciosamente ignorado — o filtro simplesmente não é aplicado |
⚠️ Envie sempre o fuso explícito — Z ou -03:00. É a única forma que significa a mesma coisa
dos dois lados. Data e hora sem fuso são resolvidas contra o relógio do processo do servidor, que
não é contrato: você não controla esse valor e ele não é o seu. E cuidado com a data pura na borda
de cima: updatedAtTo: "2026-01-31" é meia-noite UTC e exclui o dia 31 inteiro.
Duas consequências que mudam o seu código:
Bordas inclusivas significam duplicação, não perda. Janelas adjacentes repetem o item que caiu
exatamente na borda. Isso é seguro e é o comportamento desejado — mas exige que a sua gravação seja
idempotente: grave por upsert sobre o identificador, nunca por insert.
⚠️ Valor inválido ser ignorado em silêncio é a armadilha mais cara deste filtro. Uma data mal formatada não devolve erro: ela desliga o filtro, e a consulta vira uma varredura total do projeto. A rodada "funciona", demora dez vezes mais e estoura os limites. Se uma rodada incremental começou a trazer o acervo inteiro, o primeiro lugar a olhar é o formato das datas que você mandou.
⚠️ Use o INÍCIO da rodada anterior como início da janela — nunca o fim dela, nem o início da rodada
atual. Guarde o instante em que cada rodada começa: ele é o updatedAtTo da rodada corrente e o
updatedAtFrom da próxima. Usar o fim da rodada anterior perde tudo que foi alterado enquanto
ela estava executando.
janela desta rodada = [ instante em que a rodada ANTERIOR começou , instante em que ESTA rodada começou ]
O motivo é concreto: o filtro de data é reavaliado a cada página, e uma rodada leva minutos. Quem
começa a janela no fim da rodada anterior perde tudo que foi alterado enquanto ela executava — e
o que é editado durante a rodada entra ou não conforme a página em que estiver, produzindo uma
perda que não se repete e que ninguém percebe. Quem usa updatedAtFrom = início desta rodada perde
o intervalo inteiro entre as duas rodadas. Com bordas inclusivas e a janela encostada no início
da anterior, o pior caso é receber o mesmo item duas vezes — e a sua gravação já é idempotente.
Passo 1 — apontamentos alterados
query UpdatedIssues($projectId: Int!, $first: Int!, $after: String, $filter: JSON) {
project(projectId: $projectId) {
id
issues(first: $first, after: $after, filter: $filter) {
pageInfo { endCursor hasNextPage }
issues {
id guid code
title description
status priority visibility
deadline
category creationPhase resolutionPhase
createdAt updatedAt editedAt statusUpdatedAt visibilityUpdatedAt
createdByUserId editedByUserId statusUpdatedByUserId visibilityUpdatedByUserId
deletedAt deletedByUserId
}
}
}
}
Variáveis:
{
"projectId": 42,
"first": 200,
"after": null,
"filter": {
"updatedAtFrom": "2026-01-14T00:00:00Z",
"updatedAtTo": "2026-01-15T00:00:00Z",
"includeDeleted": true
}
}
A seleção acima é a recomendação. Ela é deliberadamente enxuta: só escalares, nenhum objeto
aninhado. Custo zero de instanciação — é por isso que ela suporta first: 200, o teto
(§5) — e é tudo o que o passo 1 precisa entregar: a lista do que mudou,
para alimentar o passo 3. Os relacionamentos e os nomes vêm no detalhe.
🔴 includeDeleted: true não é opcional nesta rotina. Sem ele — que é o padrão — a listagem
devolve só os ativos, e a exclusão de apontamento fica invisível para você: a sua cópia acumula
para sempre registros que já não existem, sem erro nenhum e sem nada na resposta que denuncie a
ausência. É a diferença mais cara entre as duas fontes da sincronização, porque na de comentários o
excluído vem sem pedir. Detalhe do parâmetro em
§10.
Com o pedido, o apontamento excluído vem na janela — a exclusão carimba a data de atualização,
então cai na janela em que aconteceu, sem tratamento especial. Do seu lado a regra é uma linha:
deletedAt nulo = ativo; preenchido = remover da sua base.
⚠️ Mas ele vem reduzido, não íntegro. Chegam preenchidos apenas os identificadores — id,
guid, code, projectId — e a autoria da exclusão — deletedAt, deletedByUserId,
deletedByUser. Todo o resto vem vazio, mesmo que você peça na seleção: título, descrição,
situação, prioridade, visibilidade, prazo, datas de criação e edição e os usuários correspondentes
chegam nulos; as coleções (comments, history, images, disciplines, labels, locals,
viewpoints, referências a documentos e a modelos) chegam vazias. Pedir esses campos não é erro —
apenas não traz nada. Ramifique por deletedAt antes de ler qualquer outro campo, senão o
excluído vira, na sua base, um apontamento ativo sem título.
⚠️ O updatedAt da excluída também não volta preenchido: o recorte usa a data guardada, a
resposta não a devolve. Se o seu marcador de posição é o maior updatedAt da página, avance-o pelo
fim da janela que você pediu — nunca por um campo que nas excluídas chega nulo.
Apontamento excluído não precisa de detalhe: tire-o da lista do passo 3 — e não adianta tentar, a consulta de detalhe responde "não encontrado" para excluído (§10). O que a listagem entregou é tudo o que existe dele pela API.
⚠️ Exclusões anteriores a 24/03/2025 não aparecem numa janela de data. Até essa data a exclusão
era gravada de outra forma e não tocava o updatedAt — ele ficou congelado no valor anterior à
exclusão, e nenhum dado histórico foi reescrito. Para recuperá-las, faça uma varredura completa
(includeDeleted: true, sem updatedAtFrom/updatedAtTo) uma única vez, paginando até
hasNextPage ser false, e reconcilie contra a sua base; depois disso, o incremental basta.
Passo 2 — comentários alterados
query UpdatedComments($projectId: Int!, $first: Int!, $after: String, $filter: JSON) {
project(projectId: $projectId) {
id
comments(first: $first, after: $after, filter: $filter) {
pageInfo { endCursor hasNextPage }
comments {
id guid issueId
message
visibility issueVisibility
createdAt updatedAt editedAt
createdByUserId editedByUserId
deletedAt deletedByUserId
images { id index title original markedUp markedUpThumb }
}
}
}
}
Variáveis: as mesmas do passo 1, com first: 50 — esta seleção tem um relacionamento expandido
(images), e a regra de tamanho de página manda encolher a página nesse caso
(§5). Se você não trata imagens de comentário, tire images da seleção
e volte a first: 200.
Uma chamada por projeto. A consulta devolve uma lista plana de comentários do projeto inteiro — não é preciso varrer apontamento por apontamento.
O comentário excluído vem nesta mesma consulta, com deletedAt e deletedByUserId preenchidos e
os demais campos íntegros. Mesma regra do passo 1: preenchido = remover da sua base.
O campo issueVisibility traz a visibilidade do apontamento pai, útil para você decidir o que expor
no seu lado sem precisar do detalhe.
Passo 3 — detalhe da união
Monte a união de:
- os
iddo passo 1, excluindo os que vieram comdeletedAt— esses você marca como removidos e não busca detalhe; - os
issueIddos comentários do passo 2, exceto os que apareceram só por exclusão de comentário: a exclusão já está no próprio comentário, e não há nada de novo no apontamento por causa dela.
query IssueDetail($projectId: Int!, $issueId: Int!) {
issue(projectId: $projectId, issueId: $issueId) {
id guid code title description
status priority visibility deadline
createdAt updatedAt editedAt statusUpdatedAt
createdByUserId editedByUserId statusUpdatedByUserId
createdByUser { id name }
category categoryName { id name }
creationPhase creationPhaseName { id name }
resolutionPhase resolutionPhaseName { id name }
disciplines { disciplineId status deadline doneAt discipline { id name abbreviation } }
locals { localId local { id name abbreviation abbreviationTree parentId } }
labels { labelId label { id name abbreviation abbreviationTree parentId } }
images { id guid index title original markedUp markedUpThumb }
history { _id dataTime userId entityType fields }
}
}
Esta seleção é a recomendação para o passo 3, e ela entrega de uma vez: os vínculos com nome
resolvido (disciplinas, locais, etiquetas), o nome de quem criou, as imagens e o histórico. Repare no
que não está nela: comments (você já os recebeu no passo 2 — pedi-los aqui traz o thread
inteiro, incluindo o que já estava sincronizado), notifications, permissions, userPersonalData,
viewpoints e bimPins. O porquê de cada ausência está em §8.
createdByUser { id name } está aqui porque é barato resolvê-lo neste passo — um apontamento por
chamada, um objeto a mais. Na varredura do passo 1 ele não caberia. A alternativa, quando você
prefere guardar só o identificador, é ler a lista de membros do projeto de tempos em tempos
(§8).
O campo history traz fields, com o nome do campo alterado, o valor anterior e o valor novo — dá
para reconstruir o antes/depois sem nenhuma consulta adicional. Não existe consulta incremental de
histórico, então este é o único caminho; como você só visita o que mudou, o volume fica sob
controle.
Buscar o detalhe em lotes
O passo 3 é o grosso das chamadas da rodada — uma por apontamento alterado, contra duas por
projeto nos passos 1 e 2. Dá para reduzi-lo drasticamente: a listagem aceita
filter: { ids: [...] }, então você pode pedir o detalhe da união em lotes paginados, com a
mesma seleção acima, em vez de um a um.
query IssueDetailBatch($projectId: Int!, $first: Int!, $after: String, $ids: [Int]!) {
project(projectId: $projectId) {
issues(first: $first, after: $after, filter: { ids: $ids }) {
pageInfo { endCursor hasNextPage }
issues { … a mesma seleção do detalhe, sem history … }
}
}
}
Use first: 50 — a seleção tem relacionamentos expandidos
(§5) —, pagine até hasNextPage ser false, e leia
o contrato de filter.ids antes: ele é o filtro estrito, recusa a
consulta inteira se um item vier fora de formato, e não preserva a ordem que você informou
(§10). Indexe a resposta pelo id de cada item, não pela posição.
O que obriga a manter o passo 3 um a um é o history. Na listagem ele é o log completo de
alterações por apontamento — numa página de 50, é a explosão que a
seleção de campos manda evitar. Se você espelha o histórico, busque o
detalhe individualmente; se não espelha, o lote é o caminho.
Carga inicial
A primeira execução usa as mesmas consultas, sem updatedAtFrom — uma varredura completa por
projeto, paginada até o fim. Ela traz o acervo e também os comentários já excluídos e, se você
mantiver o includeDeleted: true do passo 1, os apontamentos já excluídos — reduzidos ao aviso de
exclusão, como sempre. É esta varredura que recupera as exclusões antigas que nenhuma janela de data
alcança (apontamento, anteriores a 24/03/2025; comentário, anteriores a 08/2026).
⚠️ Com uma ressalva: nem a carga inicial nasce completa. Comentário de apontamento excluído nunca é devolvido pela listagem de comentários — nem numa varredura sem janela de data (§9). Se o seu modelo precisa da conversa de apontamentos já apagados, esse dado não existe pela API, e nenhuma recarga o traz.
Depois disso, o incremental basta — com a recarga periódica de §9 como rede de segurança.
A carga inicial é a operação mais pesada da integração inteira. Faça-a fora do horário de pico, com o mesmo limitador de taxa de sempre, projeto a projeto — não em paralelo entre projetos.
Custo por rodada
Com N projetos, A apontamentos alterados e janelas curtas:
rodada normal = 1 (descoberta) + 2N (listagens) + detalhe
detalhe = A (um a um) ou A / 50 (em lotes por filter.ids)
rodada em que o cadastro é relido = a de cima + N (uma chamada de cadastro por projeto)
O cadastro não entra em toda rodada: ele é relido periodicamente, não a cada passagem (§6).
Um exemplo concreto: 50 projetos e 800 apontamentos alterados no dia.
| Estratégia do passo 3 | Chamadas | A ~4,5 req/s |
|---|---|---|
| Um a um | 1 + 100 + 800 = 901 | pouco mais de 3 minutos |
| Em lotes de 50 | 1 + 100 + 16 = 117 | menos de 30 segundos |
Nas rodadas em que o cadastro é relido, some 50 chamadas. O custo cresce com a atividade da obra, não com o tamanho do acervo.
Acrescente uma página vazia a mais por listagem sempre que o total for múltiplo exato do tamanho da página: a sinalização de próxima página tem falso positivo nesse caso (§10). É esperado, e o laço converge.
8. Seleção de campos
Pedir menos campos é a economia mais barata que existe: não muda arquitetura e reduz tempo de resposta, tráfego e custo dos dois lados. E é o que forma o preço da operação (§5) — campo escalar folha custa zero; objeto aninhado custa, vezes o tamanho da página.
Campos a evitar, e por quê
| Campo | Por que evitar |
|---|---|
notifications | ⚠️ É o único campo do apontamento que não é resolvido em lote: dispara uma consulta dedicada por apontamento. Numa página de 200, são 200 consultas escondidas atrás de um campo. Se você precisa do agregado, use notificationsSummary { count mentions last }, que é resolvido junto com o resto |
comments dentro do detalhe | Repetição. Você já recebeu os comentários alterados no passo 2. Aqui ele traz o thread inteiro, incluindo tudo que já estava sincronizado |
comments dentro da listagem | Explosão N×M: uma página de 50 apontamentos com comments { images { ... } } instancia milhares de objetos |
history na listagem | Log completo de alterações por apontamento. No detalhe é aceitável; na varredura, não |
viewpoints, bimPins, ccodeDocumentsReferences, documentsReferences | Dados pesados de BIM e de documentos, com conteúdo estruturado (câmeras, planos de corte, componentes). Ver abaixo |
permissions | Diz o que o usuário da credencial pode fazer naquele apontamento. Raramente útil a um espelho, que replica dados e não autorização |
userPersonalData | Etiquetas pessoais e favorito do usuário da credencial — não do autor do apontamento |
mentionedUsers | Só faz sentido se você trata menções |
project { ... } aninhado | Repete os dados do projeto em cada item da resposta. Busque o projeto uma vez, à parte |
createdByUser { ... }, editedByUser { ... } na varredura | Um objeto por item. Na varredura, prefira o escalar createdByUserId / editedByUserId; peça o nome aninhado no detalhe, onde é um apontamento por chamada (§7) |
⚠️ modelReferences não é um campo a evitar — é um campo que não existe na saída. Ele só existe
como argumento de entrada de createIssue / updateIssue. Pedi-lo numa seleção falha a
consulta inteira na validação do esquema (Cannot query field "modelReferences" on type "Issue"),
e nenhum dado volta. Não confunda com ccodeDocumentsReferences e documentsReferences, que são
campos de saída legítimos — pesados, mas resolvidos em lote.
viewpoints e bimPins — só no fluxo BIM
São a posição do apontamento no modelo 3D. Vêm de outro banco, o conteúdo é estruturado e o tamanho da resposta cresce rápido.
A regra é o fluxo, não o campo: peça nos fluxos em que a posição 3D é usada, e deixe de fora nos demais. Numa varredura de sincronização eles quase nunca fazem falta; num fluxo de verificação de modelo são exatamente o que importa.
Deixe a consulta resolver os nomes
Quando você pede disciplines { discipline { name } }, locals { local { ... } },
labels { label { ... } } ou createdByUser { name }, a API resolve tudo isso em lote para todos
os itens da requisição — não é uma consulta por apontamento. Peça esses nomes onde precisar deles:
é mais barato do que a alternativa e sempre traz o valor atual.
Para nome de usuário você tem duas opções, e as duas servem:
- Aninhar na consulta —
createdByUser { id name }/editedByUser { id name }, resolvidos em lote como os demais. Traz sempre o valor atual, e é o caminho quando você precisa do nome em uma consulta que já está fazendo. - Ler a lista de membros do projeto —
project(id: $projectId) { users { id name lastName email removed } }. É a tradução de identificador em nome para o projeto inteiro, numa chamada, e também é a fonte dos identificadores quando você precisa mencionar alguém num comentário (§11). Releia-a com frequência baixa, como o resto do cadastro; a marcaremoveddiz quem saiu do projeto sem sumir do histórico.
⚠️ O que não vale é guardar createdByUserId sem nunca resolver o nome — nem aninhado, nem pela
lista de membros. Você fica com um identificador que o seu lado não sabe exibir.
O que não vale a pena é sincronizar os catálogos completos do projeto a cada rodada só para traduzir identificador em nome. Isso troca uma resolução barata por um download recorrente de tabelas inteiras, e ainda deixa a sua tradução desatualizada entre uma sincronização e outra.
Os catálogos de §6 continuam sendo o lugar certo para outro propósito: quando você precisa da lista completa de opções — preencher um seletor na sua interface, ou validar um valor antes de escrever. Aí sim vale sincronizar, com frequência baixa.
Rótulos que a API não traduz
status, priority e visibility vêm como código. Não existe statusName nem equivalente: o
de-para para português (ou para o vocabulário do seu sistema) é responsabilidade sua.
| Campo | Valores |
|---|---|
status | active · resolved · reproved |
priority | low · medium · high |
visibility | creator · group · project_management · public |
O valor group endereça um grupo do projeto — um subconjunto nomeado de usuários. O conceito, de
onde vem o identificador e o que a API não expõe estão em §6.
A situação por disciplina (disciplines { status }) tem vocabulário próprio, e quais valores
estão ativos é configuração de cada projeto — consulte disciplinesStatus e filtre por active
(§6) em vez de fixar a lista no código. O conjunto completo:
| Situação da disciplina | Grupo | Mantém o apontamento ativo? |
|---|---|---|
participate, follow | não responsável | não |
todo, awaiting, aware, doing, review | ativa (pendência) | sim |
validate, validated | resolvida | sim |
done | resolvida | não |
⚠️ A terceira coluna é o que a escrita cobra. validate e validated são "resolvida" na leitura
humana e mantêm o apontamento active para a plataforma — é o desencontro que mais recusa
alteração (§11).
Situação do apontamento não é estado de arquivo. O apontamento está ativo ou excluído; status é
fluxo de trabalho, e apontamento resolved continua aparecendo normalmente em todas as consultas.
Não existe "arquivado".
Imagens
Cada imagem traz três URLs, com papéis diferentes:
| Campo | O que é |
|---|---|
original | O arquivo como foi enviado, sem marcações |
markedUp | O render com as marcações — é esta que se exibe |
markedUpThumb | Miniatura do render com marcações |
⚠️ As URLs de imagem são públicas e permanentes. Não expiram e não exigem autenticação: quem tiver o endereço vê o arquivo, dentro ou fora do Construflow. Se você armazena essas URLs, elas herdam a mesma característica na sua base — leve isso em conta antes de repassá-las a um sistema com controle de acesso diferente do nosso, ou de embuti-las num relatório que circula por e-mail.
9. Exclusões e reconciliação
Uma cópia espelhada acerta o que chega. O problema de qualquer espelho é o que não chega — e aqui a lista é conhecida. Esta seção é a matriz honesta do que você detecta e do que não detecta.
O que você detecta
| O que aconteceu | Detecta? | Como |
|---|---|---|
| Apontamento criado ou alterado | ✅ | §7 passo 1 — cai na janela de data |
| Apontamento excluído | ✅ só se você pedir | §7 passo 1 — vem na listagem apenas com filter.includeDeleted: true, reduzido a identificadores + deletedAt/deletedByUserId. Sem o parâmetro, a exclusão é invisível |
| Comentário novo ou editado | ✅ | §7 passo 2 |
| Comentário excluído | ✅ | §7 passo 2 — vem na listagem com deletedAt e deletedByUserId |
| Disciplina, local ou etiqueta desvinculada pela alteração do apontamento | ✅ | A alteração carimba a data do apontamento; o detalhe traz os vínculos atuais |
| Projeto que muda de situação (ativo → inativo) | ✅ | O status em §6 |
O que você NÃO detecta
| O que aconteceu | Detecta? | Por que não |
|---|---|---|
| Etiqueta ou local apagado no projeto | ❌ | As linhas de vínculo são destruídas sem tocar a data de atualização de nenhum apontamento. Uma etiqueta apagada some de N apontamentos e nenhum entra na janela |
| Comentário de apontamento excluído | ❌ | A listagem de comentários não devolve comentário de apontamento excluído, esteja ele apagado ou não. Excluir um apontamento não marca os comentários dele — você fica sabendo pelo próprio apontamento. E não há a outra ponta: o excluído volta com comments vazio |
| Conteúdo de um apontamento excluído | ❌ | Não existe caminho na API. A listagem entrega o aviso de exclusão sem conteúdo, o detalhe responde "não encontrado", e as coleções aninhadas nele vêm vazias. O que você precisar do texto, das imagens ou do histórico tem de estar na sua cópia, gravado antes da exclusão |
| Mudança de visibilidade ou de grupo que tira o apontamento do seu acesso | ❌ | O apontamento simplesmente para de aparecer nas suas consultas. Não há evento, não há erro, e a sua cópia mantém para sempre um registro que a credencial já não pode ver |
| Perda de acesso ao projeto | ⚠️ parcial | Os apontamentos somem em silêncio — a listagem devolve vazio, não erro. Só duas coisas denunciam: o projeto desaparecer da lista de §6, ou a consulta ao projeto passar a devolver erro de permissão. Uma rodada que voltou vazia num projeto ativo é sempre suspeita |
⚠️ Repare no caso da etiqueta apagada, porque ele é contraintuitivo: a desvinculação pelo apontamento é detectável, e a desvinculação pelo cadastro do projeto não é. São dois caminhos para o mesmo resultado visível, com sinais opostos. Se você usa etiqueta ou local para relatório, a sua base vai acumular vínculos para itens que já não existem.
Mitigação específica e barata: toda vez que você reler o cadastro do projeto — periodicamente, não a cada rodada (§6) —, compare-o com os vínculos que você guardou. Etiqueta ou local que sumiu do catálogo deve ser removido dos seus vínculos, sem precisar reler apontamento nenhum. A defasagem é, no máximo, o intervalo entre duas releituras do cadastro.
A recarga total periódica
Nenhuma das mitigações acima cobre tudo. Programe uma recarga total periódica por projeto — mensal, por exemplo — como rede de segurança:
- Rode as consultas do passo 1 e do passo 2 sem janela de data, paginando até o fim.
- Compare o conjunto de identificadores recebido com o que você tem armazenado para aquele projeto.
- O que existe na sua base e não veio na varredura: removeu-se, mudou de visibilidade, ou o acesso mudou. Em qualquer dos casos, não deveria mais estar visível para você.
É a única forma de fechar os buracos da tabela acima, e é prática padrão de qualquer base espelhada. Agende fora do horário de pico e respeite o mesmo limitador de taxa da rodada normal (§5) — a recarga é pesada, mas não é urgente.
10. Consultas e filtros
Referência completa da leitura. A sincronização de §7 usa um recorte disto; esta seção é o vocabulário inteiro, para consulta sob demanda, relatório e busca.
Listagem de apontamentos
Campo aninhado em Project.issues, paginado por cursor.
query ListIssues($projectId: Int!, $first: Int!, $after: String, $filter: JSON) {
project(projectId: $projectId) {
id
issues(first: $first, after: $after, filter: $filter) {
issues {
id guid code title description
status priority visibility deadline
category creationPhase resolutionPhase
createdAt updatedAt editedAt
createdByUserId editedByUserId
deletedAt deletedByUserId
disciplines { disciplineId status deadline doneAt discipline { name abbreviation } }
locals { localId local { name abbreviationTree } }
labels { labelId label { name abbreviationTree } }
images { id index title markedUp }
}
pageInfo { endCursor hasNextPage }
}
}
}
Retorno: IssuesPaginated { issues: [Issue], pageInfo: { endCursor, hasNextPage } }.
A seleção acima é a de uma tela de listagem — traz os relacionamentos com nome resolvido, ao
custo de cerca de 15 objetos por apontamento. Por isso ela pede uma página da ordem de 50, não
200 (§5). Para uma varredura de sincronização, use a seleção enxuta do
passo 1, que é só escalar e suporta first: 200. Para o detalhe
de um apontamento específico, use a consulta de detalhe em vez de
engordar a listagem.
Paginação e cursor
| Argumento | Contrato |
|---|---|
first: Int | Tamanho da página. Máximo 200, e ajustado à seleção (§5). Passe sempre explícito — o padrão é 500, acima do teto |
after: String | Cursor: exatamente o pageInfo.endCursor da página anterior. null ou omitido na primeira página |
pageInfo.endCursor | Cursor da última linha da página |
pageInfo.hasNextPage | Sinaliza se há próxima página |
O cursor é um inteiro não-negativo, e deve ser devolvido exatamente como veio. Qualquer outra
coisa — texto, objeto, negativo, notação científica — falha a consulta com
INVALID_PAGINATION_CURSOR, antes de qualquer acesso ao banco.
⚠️ first negativo não é recusado pelo teto — ele quebra a consulta. O que existe é um teto de
200, não um piso: first: -1 passa pela validação de forma e estoura mais adiante, com 200 e um
erro interno genérico. Trate first como inteiro entre 1 e 200 do seu lado.
⚠️ A armadilha real é a dupla codificação. endCursor é declarado String mas transporta um
número. Cliente que re-serializa a resposta e devolve o cursor com as aspas embutidas
("\"1615091\"") é recusado, não desembrulhado — a plataforma aceita um formato só. Paginação que
funcionava e passou a falhar com INVALID_PAGINATION_CURSOR é, quase sempre, dupla codificação do
lado do cliente. Trate o cursor como opaco: guarde a string como veio e devolva a string como veio.
⚠️ hasNextPage tem falso positivo quando o total é múltiplo exato do tamanho da página. Com 400
resultados e first: 200, a segunda página vem cheia e ainda assim hasNextPage é true; a
terceira chamada devolve zero itens e false. Itere até hasNextPage ser false — uma chamada
vazia a mais é esperada e não é erro. Não pare no primeiro retorno vazio como se fosse anomalia, e
não deduza o fim pela contagem de itens.
O objeto filter
filter é um objeto JSON livre. Todas as chaves são opcionais e combinam via AND.
| Chave | Tipo | O que faz |
|---|---|---|
standard | String | Filtro predefinido (tabela abaixo) |
code | Int | Casa o código do apontamento exatamente |
text | String | Busca textual em título e descrição |
ids | [Int] | Restringe a um conjunto explícito. ⚠️ Validação estrita — não tolera item inválido e não trata [] como "sem filtro" |
disciplineIds | [Int] | Apontamentos associados a qualquer uma das disciplinas (OR entre os ids) |
labelIds | [Int] | Apontamentos associados a qualquer uma das etiquetas (OR entre os ids) |
localIds | [Int] | Apontamentos associados a qualquer um dos locais (OR entre os ids) |
ccodeDocumentId | String | Apontamentos vinculados a um documento na ConstruCode (CCode), a plataforma externa de gestão documental integrada ao projeto. O identificador é texto, não número |
documentId | String | Apontamentos vinculados a um documento do CDE (Common Data Environment) do próprio Construflow. Também texto |
withViewpoints | Boolean | Liga o filtro "tem viewpoints". false não traz os sem viewpoint — desliga o filtro |
updatedAtFrom | String | ISO 8601 — criados ou editados a partir desta data (inclusivo) |
updatedAtTo | String | ISO 8601 — criados ou editados até esta data (inclusivo) |
includeDeleted | Boolean | Traz também os apontamentos excluídos, junto com os ativos — reduzidos ao aviso de exclusão, sem conteúdo. Padrão false |
⚠️ Chave que não está na tabela acima não existe — e não dá erro. filter é um objeto livre: a
tabela desta seção é a lista fechada do que a listagem de apontamentos entende, e qualquer outra
chave é aceita, ignorada e respondida com status 200, sem erro e sem aviso. São dois enganos
comuns, com o mesmo desfecho:
- Grafia errada —
{ disciplineId: 3 }, sem os: o filtro simplesmente não existe, e a consulta vira varredura total do projeto. É o mesmo desfecho de mandar uma data inválida. - Chave emprestada de outra consulta — cada listagem tem a sua tabela de contrato, e elas não
coincidem.
includeDeletedvale aqui e não existe na listagem de comentários do projeto, onde é ignorado: lá o excluído vem sempre, e não há como pedir só os ativos.
🔴 Resposta bem-sucedida não prova que o parâmetro existe. Nada na resposta denuncia uma chave ignorada — o único jeito de saber o que uma consulta aceita é a tabela de contrato dela, neste documento. Toda vez que uma consulta filtrada devolver muito mais do que deveria, confira a grafia das chaves e se elas pertencem a esta listagem antes de qualquer outra coisa.
Filtro nunca expande permissão. Todos os filtros respeitam a visibilidade dos apontamentos (
creator,group,project_management,public) e as permissões do usuário da credencial. Eles apenas reduzem o conjunto que a permissão já permitia; nunca o ampliam. Um resultado vazio pode significar "não existe" ou "você não pode ver" — não há como distinguir pela resposta.
filter.standard
| Valor | O que traz |
|---|---|
pendencies | Apontamentos com pendências atribuídas ao usuário da credencial. O conjunto de situações depende do perfil — ver abaixo |
latePendencies | Idem, mas apenas os com prazo vencido |
notifications | Apontamentos com notificações não lidas para o usuário |
newMentions | Apontamentos com menção não lida ao usuário — de qualquer época |
validate | Apontamentos pendentes de validação. Exige papel de coordenação — ver abaixo |
starred | Apontamentos marcados como favoritos pelo usuário |
actives | Apenas os com status = "active" |
(omitido, null ou valor desconhecido) | Todos os apontamentos visíveis ao usuário — valor fora da lista não é erro, é "sem filtro" |
Repare que quase todos são relativos ao usuário da credencial, não ao projeto. Numa integração,
actives costuma ser o único de uso geral.
⚠️ pendencies não tem um conjunto fixo de situações — ele sai do perfil de quem consulta. Quem
tem papel de coordenação no projeto vê mais situações do que um colaborador comum, incluindo
validate e validated, que este documento classifica como resolvidas
(§8):
| Perfil no projeto | Situações de disciplina que contam como pendência |
|---|---|
| Coordenação / administração | todo, awaiting, aware, doing, review, validate, validated |
| Colaborador | todo, awaiting, aware, doing, review |
| Só visualização | nenhuma — devolve lista vazia |
done nunca conta como pendência, para nenhum perfil. E há uma segunda condição: o filtro exige que
o usuário seja responsável por alguma disciplina do apontamento — um coordenador sem disciplina
atribuída recebe lista vazia.
⚠️ validate devolve sempre lista vazia para credencial sem papel de coordenação. Não é erro,
não é "nada pendente": é o filtro não sendo aplicável ao perfil. Se a sua integração usa validate,
confirme antes que o usuário dono da chave tem esse papel no projeto.
⚠️ newMentions não tem janela temporal. É menção não lida, sem corte de data: uma menção de
2023 que ninguém marcou como lida continua aparecendo. Ela só sai da lista quando a notificação é
apagada — o usuário marca como lida, o apontamento é excluído, ou o usuário perde a visibilidade
dele.
filter.code
- Tipo
Int. Aceita também string numérica ("1234") — coerção automática. - Valor não numérico (
"abc"),nullou omitido é silenciosamente ignorado: o filtro não é aplicado.
filter.ids — o filtro estrito
⚠️ Este filtro não segue a tolerância dos vizinhos. Onde disciplineIds / labelIds /
localIds descartam item inválido em silêncio e degradam para "sem filtro", ids recusa a
consulta inteira. Se você escreveu o seu cliente por analogia com os outros filtros de lista, é
aqui que ele quebra.
- Tipo
[Int]. Item válido = inteiro positivo, ou a string numérica equivalente ("1234"). - Qualquer item inválido —
0, negativo,null, string não numérica, objeto — faz a consulta falhar comINVALID_ISSUES_IDS_FILTER. Nada é descartado em silêncio e nenhum apontamento é devolvido. - Valor que não é lista (
12,"12",{}) produz a mesma recusa. - ⚠️
[]significa conjunto vazio, não "sem filtro". Devolve zero apontamentos — o oposto dedisciplineIds: [], que desliga o filtro. A assimetria é deliberada: "quero exatamente estes apontamentos, e a lista está vazia" não pode degradar para "quero todos". Um cliente que monta a lista dinamicamente e às vezes a monta vazia precisa omitir a chave, não mandar[]. - Chave omitida,
nullouundefined⇒ filtro não aplicado — aí sim, igual aos vizinhos. - Ids duplicados são deduplicados.
- ⚠️ A ordem informada não é preservada, e a ordem do array na resposta não é garantida em nada.
A resposta é servida em parte por cache, que devolve primeiro o que estava quente — a mesma
consulta pode voltar em ordens diferentes. Indexe pelo
idde cada item e ordene do seu lado. Isso não afeta a paginação: o cursor continua confiável, porque a página é recortada por identificador crescente, não pela ordem do array. - Id que existe mas não é visível ao usuário simplesmente não casa nada. A recusa acima é sempre sobre formato, nunca sobre existência ou permissão.
filter.disciplineIds, filter.labelIds, filter.localIds
Os três têm exatamente a mesma semântica:
- Tipo
[Int]. Cada elemento deve ser inteiro positivo; string numérica ("3") é aceita e convertida;0, negativos,null,NaNe strings não numéricas são silenciosamente ignorados; duplicados são deduplicados. - OR entre os ids da mesma chave, AND entre chaves diferentes.
- Consideram apenas vínculos ativos: vínculo removido é ignorado mesmo que o id corresponda.
- ⚠️ Match exato — não expande hierarquia. Filtrar por um local pai (um pavimento) não inclui apontamentos vinculados apenas aos filhos (as salas); o mesmo vale para etiquetas. Se você quer a subárvore, envie também os ids dos filhos — a árvore vem em §6.
- Id que não pertence ao projeto, ou inexistente, simplesmente não casa nada — sem erro, sem aviso.
[],null,undefinedou chave omitida ⇒ filtro não aplicado (as quatro formas são equivalentes).
filter.text
- Busca substring, sem diferenciar maiúsculas, no título e na descrição.
- A string é tokenizada por espaço. Cada token vira um filtro independente combinado via AND, e cada token, individualmente, casa em título OU descrição.
%,_e$são tratados como literais — não atuam como curinga.- String vazia ou só com espaços é ignorada. Comprimento truncado em 200 caracteres.
filter.text | Casa em |
|---|---|
"infiltração" | título OU descrição contendo "infiltração" |
"rodrigo ferreira" | contém "rodrigo" e (independentemente) contém "ferreira" — inclusive "Ferreira, rodrigo" |
"trinca pequena" | "trinca" e "pequena" aparecem, em qualquer combinação de título/descrição |
"50%" | casa 50% literal — % não é curinga |
filter.updatedAtFrom / filter.updatedAtTo
- Devolve apontamentos criados ou editados no período. Cada borda é opcional; ambas são inclusivas.
- ISO 8601, e envie sempre o fuso explícito. Só a data pura (
"2026-01-15") é UTC por especificação — e é meia-noite UTC, entãoupdatedAtTo: "2026-01-31"exclui o dia 31 inteiro. Data com hora e sem fuso ("2026-01-15T10:00:00") é resolvida contra o relógio do processo do servidor, que não é contrato. Detalhe em §7. updatedAtFrommaior queupdatedAtTodevolve lista vazia, sem erro.- Valor inválido (não-string, data não parseável,
null,"") é silenciosamente ignorado — e a consulta vira varredura total. - ⚠️ Não filtra a data dos comentários. Comentário novo não altera a data de atualização do apontamento. Para comentários, use a listagem de comentários do projeto.
filter.includeDeleted — pedir os excluídos
Sem este parâmetro a listagem devolve só os ativos, exatamente como sempre devolveu. Com ele, o conjunto de sempre mais os apontamentos excluídos. É a única via da API para descobrir que um apontamento foi apagado — e é o passo 1 da sincronização (§7).
- Ligam o filtro: o booleano
truee a string"true". ⚠️ Qualquer outro valor (1,"1","TRUE",null, chave omitida) cai no padrãofalse, sem erro e sem aviso — um valor "quase certo" devolve silenciosamente só os ativos, e a sua detecção de exclusão nunca dispara. - Nada muda nos ativos: nenhum apontamento ativo sai da resposta, nenhum campo deles muda. O que muda é o conteúdo dos excluídos, que vêm reduzidos a identificadores e autoria da exclusão — ver §7 passo 1 para a lista exata do que chega preenchido.
- É honrado em todos os recortes por atributo —
updatedAtFrom/updatedAtTo,code,text,disciplineIds,labelIds,localIds,ids,ccodeDocumentId,documentId,withViewpoints—, que passam a recortar também sobre os excluídos. - ⚠️ O recorte enxerga o conteúdo do excluído; a resposta não o devolve. Um apontamento excluído
pode entrar na página por casar
text,disciplineIdsouwithViewpointse ainda assim chegar com título, descrição, disciplinas e viewpoints vazios. Não tente conferir pelo payload por que ele casou, nem reaplicar o filtro no cliente sobre o que voltou — você descartaria exclusões legítimas. - ⚠️ É ignorado junto com um
filter.standardde trabalho (pendencies,latePendencies,notifications,newMentions,validate,starred,actives): a resposta traz só ativos, sem erro. Esses recortes descrevem o trabalho corrente do usuário, e apontamento excluído não está nele. Se você precisa dos excluídos, não usestandard. - Não afeta permissão nem visibilidade: apontamento que a credencial não podia ver continua invisível, excluído ou não.
- Não muda ordenação nem cursor — os excluídos contam como itens comuns da página.
Erros de formato
A doutrina geral deste contrato é tolerância: filtro malformado é ignorado e a consulta segue. Mas a tolerância não é universal — há entradas fora de formato que derrubam a consulta, e elas se dividem em duas famílias muito diferentes na hora de tratar.
Família 1 — as duas com código próprio e mensagem legível. São as únicas recusas de formato que você pode reconhecer programaticamente:
| Entrada fora de formato | errors[0].extensions.code |
|---|---|
after (cursor de paginação) | INVALID_PAGINATION_CURSOR |
filter.ids | INVALID_ISSUES_IDS_FILTER |
⚠️ As duas respondem 200, não 400. O campo consultado é nulável, então o erro nulifica o
campo e a resposta permanece 200 com data presente e issues nulo. É mais um caso concreto da
regra de §4: verifique errors antes de ler data — quem lê data
direto vê um campo nulo e conclui "o projeto não tem apontamentos". O extensions traz só o
code; a mensagem legível vem em errors[0].message.
A regra do cursor vale igualmente para a
listagem de comentários do projeto, que usa o mesmo cursor. Quanto ao
momento: o cursor é validado antes de qualquer acesso ao banco; o filter.ids é validado um
pouco depois, já com o registro de acesso gravado — a diferença não muda o que você faz, mas a
recusa de ids não é tão barata quanto a do cursor.
Família 2 — as que produzem erro interno genérico. Também derrubam a consulta, também com 200,
mas sem código próprio: chegam como INTERNAL_SERVER_ERROR, com uma mensagem crua vinda da
camada de dados. Não há nada a distinguir programaticamente:
| Entrada fora de formato | O que acontece |
|---|---|
filter.documentId com número (123) em vez de texto ("123") | erro interno — o campo é texto |
filter.ccodeDocumentId com objeto | erro interno |
first negativo | erro interno |
Não escreva tratamento por código para estas: previna-as validando o tipo do seu lado. Se uma
consulta que "sempre funcionou" passou a devolver INTERNAL_SERVER_ERROR, o primeiro lugar a olhar é
o tipo dos valores que você montou no filter.
A tolerância vale para o conteúdo de um filtro reconhecido. Ela não vale para a forma nem
para o tamanho da consulta: profundidade, tamanho de página, preço e array de operações têm tetos
próprios, e a recusa deles é 400 com código próprio
(§4, §5).
Exemplos
Filtro predefinido:
query {
project(projectId: 42) {
issues(first: 20, filter: { standard: "actives" }) {
issues { id code title status priority deadline }
pageInfo { endCursor hasNextPage }
}
}
}
Busca textual com tokenização:
query {
project(projectId: 42) {
issues(first: 20, filter: { text: "infiltração laje" }) {
issues { id code title description }
pageInfo { endCursor hasNextPage }
}
}
}
Por etiquetas e locais (OR dentro de cada chave, AND entre elas):
query {
project(projectId: 42) {
issues(first: 20, filter: { labelIds: [11, 12], localIds: [201] }) {
issues {
id code title
labels { labelId label { name } }
locals { localId local { name abbreviationTree } }
}
pageInfo { endCursor hasNextPage }
}
}
}
Janela de alteração — as duas pontas:
query {
project(projectId: 42) {
issues(
first: 200
filter: {
updatedAtFrom: "2026-01-01T00:00:00Z"
updatedAtTo: "2026-01-31T23:59:59Z"
}
) {
issues { id code title updatedAt deletedAt }
pageInfo { endCursor hasNextPage }
}
}
}
Combinação (AND entre as chaves):
query {
project(projectId: 42) {
issues(
first: 20
filter: { standard: "actives", text: "umidade", disciplineIds: [101, 102], labelIds: [11] }
) {
issues { id code title }
pageInfo { endCursor hasNextPage }
}
}
}
Detalhe de um apontamento
query IssueById($projectId: Int!, $issueId: Int!) {
issue(projectId: $projectId, issueId: $issueId) { id guid code title status updatedAt }
}
Argumentos: projectId: Int!, issueId: Int!. Retorno: Issue — todos os campos da
listagem, mais categoryName, creationPhaseName, resolutionPhaseName, history, comments,
viewpoints, bimPins, notificationsSummary e as referências de documento
(ccodeDocumentsReferences, documentsReferences). Não existe modelReferences na saída —
pedi-lo falha a consulta (§8). A seleção completa recomendada está em
§7 passo 3.
Use para o detalhe de um apontamento específico, depois de selecioná-lo numa listagem. Para buscar o
detalhe de muitos apontamentos, prefira a listagem com filter.ids, em lotes paginados
(§7) — o laço um a um só se justifica quando você precisa de
history.
Erros:
- Apontamento excluído e apontamento inexistente respondem igual —
NOT_FOUND(semântica de "não encontrado"), com status200e o erro emerrors[0]. São indistinguíveis por esta consulta, de propósito: nos dois casos não há apontamento visível para aquele identificador. - Apontamento de outro projeto, ou sem permissão de visualização, responde
FORBIDDEN.
⚠️ Esta consulta nunca devolve apontamento excluído. Se a sua sincronização recebeu o identificador de um excluído pela listagem, não tente completar aqui o que faltou lá: o detalhe responde "não encontrado", e o conteúdo de um apontamento excluído não é acessível por nenhum caminho da API — a listagem já entregou tudo o que existe dele (§7 passo 1).
⚠️ Mudança de contrato: identificador inexistente respondia erro genérico de servidor (
INTERNAL_SERVER_ERROR) e passou a responderNOT_FOUND. Se o seu cliente aplica backoff e retry em erro de servidor (§4), um identificador inválido deixa de virar retentativa — trateNOT_FOUNDcomo resposta final.
Comentários de um apontamento
Campo aninhado em Issue.comments. Traz o thread completo do apontamento, sem paginação.
query IssueComments($projectId: Int!, $issueId: Int!) {
issue(projectId: $projectId, issueId: $issueId) {
id
comments {
id guid issueId message visibility
createdAt updatedAt editedAt
createdByUserId editedByUserId
deletedAt deletedByUserId
groupId
images { id index title markedUp markedUpThumb }
}
}
}
Retorno: [Comment].
Serve a uma tela de conversa. Não serve a sincronização: para acompanhar comentários em massa, use a listagem plana abaixo.
Comentários do projeto
Campo aninhado em Project.comments. Devolve todos os comentários visíveis do projeto — inclusive
os excluídos — numa lista plana e paginada, com filtro opcional por data de alteração. É a consulta
do passo 2 da sincronização (§7).
Argumentos:
| Argumento | Contrato |
|---|---|
first: Int | Tamanho da página. Máximo 200, e ajustado à seleção (§5). Passe sempre explícito — o padrão é 500, o mesmo da listagem de apontamentos |
after: String | Mesmo contrato do cursor de apontamentos: inteiro não-negativo, devolvido como veio. Fora de formato ⇒ INVALID_PAGINATION_CURSOR, com status 200 |
filter.updatedAtFrom | ISO 8601 — comentários criados, editados ou excluídos a partir desta data (inclusivo) |
filter.updatedAtTo | ISO 8601 — idem, até esta data (inclusivo) |
⚠️ A tabela acima é fechada — não existe includeDeleted nesta consulta. Ele é chave da
listagem de apontamentos; aqui é aceito, ignorado e respondido com 200, sem
erro e sem aviso. O comentário excluído vem sempre, e não há como pedir só os ativos.
Retorno: CommentsPaginated { comments: [Comment], pageInfo: { endCursor, hasNextPage } }.
Notas de comportamento:
- Ordenação fixa por ordem de criação; o cursor é o identificador do último comentário da página.
- Data inválida é silenciosamente ignorada. ⚠️ O cursor não é — a tolerância vale para os filtros de data, não para a paginação.
- A página pode devolver até
firstcomentários visíveis: a visibilidade é avaliada depois da paginação, então uma página pode vir com menos itens do que o pedido sem que isso signifique fim dos dados. Itere atéhasNextPageserfalse. - O conjunto devolvido é exatamente o que o usuário já enxerga via
Issue.comments. Esta consulta achata a listagem; não expande o acesso.
Comentário excluído permanece na listagem, com deletedAt e deletedByUserId preenchidos e os
demais campos íntegros. A exclusão conta como alteração e carimba a data de atualização, então cai na
janela em que aconteceu, sem tratamento especial: deletedAt nulo = ativo, preenchido = excluído.
⚠️ Comentário de apontamento excluído não é devolvido, esteja ele apagado ou não — excluir um
apontamento não marca os comentários dele. E também não há como alcançá-los pela outra ponta: o
apontamento excluído volta com comments vazio. Quem precisa reagir à exclusão observa o próprio
apontamento, pedindo os excluídos na listagem
(§7 passo 1).
⚠️ Atenção à assimetria com apontamentos — ela é de forma e de conteúdo. Aqui o excluído vem
sempre, sem parâmetro, e com a mensagem íntegra; lá o apontamento excluído vem só sob
pedido (filter.includeDeleted: true) e sem conteúdo algum — apenas identificadores e autoria
da exclusão. Quem espelha as duas entidades não consegue tratá-las pelo mesmo caminho de código: o
comentário excluído continua um registro legível na sua base; do apontamento excluído não há o que
espelhar além do identificador para removê-lo.
11. Escrita
Escrever exige permissão de participar do projeto. Sem ela, a operação falha na autorização — com
status 200 e a lista errors preenchida (§4).
Uma exceção: copiar apontamentos entre projetos exige papel de gestão no projeto de destino (lote). Participar não basta ali.
Criar apontamento
mutation CreateIssue {
createIssue(
projectId: 42
title: "Infiltração na laje do 3º pavimento"
description: "Mancha de umidade junto ao pilar P12."
status: "active"
priority: "high"
visibility: "public"
category: 3
creationPhase: 9
locals: [201]
labels: [11]
disciplines: [{ disciplineId: 101, status: "todo", deadline: "2026-02-10" }]
) {
id guid code title status deadline
}
}
Obrigatórios no contrato — são cinco: projectId, title, status, priority, visibility.
Obrigatórios pelas regras de negócio, embora o esquema os aceite ausentes:
| Argumento | Quando é exigido | O que acontece se faltar |
|---|---|---|
locals | sempre | ⚠️ ver o aviso abaixo |
disciplines | sempre | ⚠️ ver o aviso abaixo |
category | quando o projeto tem o catálogo de categorias configurado | recusa CATEGORY_MUST_BE_ACTIVE, antes de gravar |
creationPhase | quando o projeto tem o catálogo de fases configurado | recusa PHASE_MUST_BE_ACTIVE, antes de gravar |
resolutionPhase | sempre que status não for "active" | recusa MISSING_RESOLUTION_PHASE, antes de gravar |
Se o projeto não restringe categorias e fases, category e creationPhase podem ser omitidos — o
projeto aceita o catálogo global inteiro (§6). Consulte o cadastro para
saber em qual caso você está.
🔴 Omitir locals ou disciplines cria o apontamento e só depois estoura. A validação desses
dois vínculos acontece depois da gravação, e não há transação: você recebe um erro interno
genérico, sem o apontamento na resposta — mas o apontamento existe, gravado, com código
consumido e sem local nem disciplina. Não reenvie: reenviar duplica. Se você mandou um guid
próprio, dá para reconciliar; senão, o registro fica órfão. Mandar locals: [] ou disciplines: []
é diferente e seguro: recusa limpa, antes de gravar
(regra 3). Sempre envie os dois, não-vazios.
⚠️ Não envie deadline no topo. O prazo do apontamento é derivado das disciplinas — como
disciplines é obrigatório aqui, o valor que você mandar no topo é sempre descartado. O prazo se
define no deadline de cada item de InputIssueDiscipline
(regra 5).
Demais argumentos aceitos: description: String, deadline: Date, labels: [Int],
images: [IssueImageInput], groupId: Int, guid: String,
ccodeDocumentsReferences: [IssueCcodeDocumentReferenceInput],
documentsReferences: [IssueDocumentReferenceInput], modelReferences: [ModelReferenceInput],
viewpoints: [IssueViewpointInput], bimPins: JSON, aiAssistantLogs: JSON.
Os valores de status, priority e visibility estão em §8. Os
identificadores de locals, labels, disciplines e groupId vêm do cadastro do projeto
(§6); os de category, creationPhase e resolutionPhase vêm de um catálogo
global, e precisam estar active neste projeto
(§6). Valide-os antes de escrever: identificador de
outro projeto não é aceito, e categoria ou fase inativa é recusada.
Você pode enviar o seu próprio guid na criação. É a forma de amarrar o apontamento a um registro do
seu sistema sem manter uma tabela de correspondência: o mesmo guid depois identifica o apontamento
na alteração.
Input types:
input InputIssueDiscipline {
disciplineId: Int!
status: String # obrigatório na prática — item sem status derruba a operação
deadline: Date # omitir ZERA o prazo gravado da disciplina
doneAt: Date # omitir SOBRESCREVE a data de conclusão gravada — ver a regra 4
remove: Boolean # lido SÓ pela mutation em lote — ver a regra 4
}
input IssueImageInput {
id: Int
guid: String
title: String! # obrigatório
cover: Boolean
deleteImage: Boolean
viewpoint: JSON
markUp: JSON
index: Int
original: String # URL, após o upload pela URL assinada
originalFile: Upload # alternativa por multipart
markedUp: String # a versão que se exibe — ver "Imagens em três passos"
markedUpFile: Upload
markedUpThumb: String # gerado pelo servidor apenas se markedUp estiver preenchido
markedUpThumbFile: Upload # ≤200×200 PNG, proporção preservada
}
Alterar apontamento
mutation UpdateIssue {
updateIssue(
projectId: 42
issueId: 1234
status: "resolved"
resolutionPhase: 9
disciplines: [
{ disciplineId: 101, status: "done", doneAt: "2026-01-20", deadline: "2026-02-10" }
]
) {
id guid title status deadline updatedAt
}
}
Obrigatório: projectId. O apontamento é identificado por issueId ou por guid — o que for
mais conveniente para o seu lado.
Demais campos: os mesmos de createIssue, inclusive as referências de documento e modelo. Envie
só os que mudaram — veja as regras abaixo.
Repare no deadline dentro da disciplina, no exemplo: ele não é decoração. Sem ele, esta chamada
apagaria o prazo da disciplina e o prazo do apontamento, em silêncio (regra 5).
Alteração parcial: as cinco regras
A alteração é parcial: campo não enviado é preservado. Não é preciso reenviar o apontamento inteiro — e não se deve, porque cada campo enviado é um campo que pode sobrescrever a edição de outra pessoa.
Cinco regras governam o que acontece com o que você envia:
-
⚠️ Enviar
statusexigeresolutionPhaseno mesmo envio — exceto quando o status enviado éactive. O gatilho é enviar, não mudar: reenviar o mesmo status que já estava lá, sem repetirresolutionPhase, é recusado comMISSING_RESOLUTION_PHASE. Um cliente que monta o payload a partir do objeto inteiro cai nisso na primeira alteração. Para não mexer na situação, omitastatus.E o contrário também vale: enviar
status: "active"apaga aresolutionPhasegravada, mesmo que você mande um valor junto. Numa reabertura, o registro de em que etapa o apontamento tinha sido resolvido se perde. -
⚠️
statusedisciplinestêm de ser coerentes entre si. O invariante é simples: o apontamento estáactivese e somente se ao menos uma disciplina está em situação ativa.Situações de disciplina no envio statusexigidoao menos uma em todo,awaiting,aware,doing,review,validateouvalidated"active"— qualquer outro é recusado comDISCIPLINE_STATUS_MUST_KEEP_ISSUE_ACTIVEtodas em done,participateoufollownão "active"— recusado comDISCIPLINE_STATUS_MUST_KEEP_ISSUE_NOT_ACTIVE⚠️
validateevalidatedmantêm o apontamento ativo, embora sejam situações de "resolvido" na leitura humana (§8). Mandar as disciplinas comovalidateobrigastatus: "active".Enviar
disciplinessemstatusnão é a saída: num dos caminhos a operação é recusada por falta de fase de resolução, exigindo um campo (autoResolutionPhase) que só existe no input da mutation em lote — na alteração individual não há como satisfazê-la. Fechar todas as disciplinas de um apontamento ativo, portanto, exige enviarstatusjunto. -
⚠️ Lista vazia apaga — em uns campos, e é recusada em outros.
Campo []significalabelsapaga todas as etiquetas ccodeDocumentsReferences,documentsReferences,modelReferencesapagam todas as referências localsrecusado — o apontamento precisa de ao menos um local disciplinesrecusado — o apontamento precisa de ao menos uma disciplina Para preservar o valor atual, omita o campo. Nunca mande
[]com a intenção de "não mexer". Este é o erro que mais apaga dado em integração nova, porque muitos clientes serializam uma coleção vazia onde deveriam omitir a chave. -
⚠️ A lista de disciplinas enviada é o estado final — é substituição total. Disciplina que está no apontamento e não vem na lista é removida. Para remover uma, omita o item e reenvie as demais; para preservar todas, reenvie todas.
remove: truenão funciona aqui. O campo existe no input e é descartado na alteração individual — só a mutation em lote o lê (lote). Mandarremove: truecom a situação presente mantém a disciplina; se odisciplineIdnão estava no apontamento, ela é criada. E mandarremove: truesem a situação não é ignorado com elegância: responde erro interno.E, dentro de cada item, campo omitido não é preservado: um item
{ disciplineId, status }semdeadlinezera o prazo gravado da disciplina, e semdoneAtsobrescreve a data de conclusão gravada — pela data de hoje, nas situações que exigem conclusão (validate,validated,done). Reenvie os dois sempre que quiser mantê-los.O servidor ainda normaliza os dois conforme a situação, e o que ele normaliza você não controla: em
participateefollowo prazo é anulado, tenha sido enviado ou não; a data de conclusão é anulada em todas as situações excetovalidate,validatededone; e nessas três, se você não mandardoneAt, entra a data de hoje. -
⚠️ O prazo do apontamento é derivado das disciplinas — você não o define diretamente. Sempre que
disciplinesé enviado, o servidor sobrescrevedeadlinecom o maior prazo entre as disciplinas enviadas — ou com nulo, se nenhuma delas tiver prazo. Umdeadlinemandado no topo, junto comdisciplines, é descartado sem aviso.Consequência prática: uma alteração que só quis fechar uma disciplina, e mandou
[{ disciplineId, status: "done", doneAt }]semdeadline, apaga o prazo do apontamento — sem erro, sem aviso. Mandardeadlineisolado (semdisciplines) grava, mas não resolve: a próxima alteração que enviar disciplinas sobrescreve de novo. Trate o prazo como propriedade das disciplinas.
Escrita em lote
Para criar ou alterar muitos apontamentos, use as mutations em lote em vez de disparar chamadas individuais:
| Mutation | Para quê |
|---|---|
createIssues | Criar vários apontamentos |
updateIssues(projectId: Int!, issuesIds: [Int]!, newValues: IssueNewValuesInput): JSON | Aplicar os mesmos novos valores a um conjunto de apontamentos |
updateIssuesDisciplinesStatus(projectId: Int!, issuesIds: [Int!]!, disciplines: [IssueDisciplineStatusInput!]!, autoResolutionPhase: Int): IssuesDisciplinesStatusResult | Mover só a situação de disciplinas escolhidas num conjunto de apontamentos — a única escrita que alcança quem não pode alterar o apontamento |
copyProjectIssues | Copiar apontamentos de um projeto para outro |
updateIssues é uma operação parcial — leia o retorno
O retorno é JSON, e tem esta forma:
{
"updatedIssues": [ { "id": 1234, "…": "…" } ],
"rejectedIssues": [ { "id": 1240, "error": { "status": 403, "code": "ISSUE_MUST_HAVE_LOCALS" } } ]
}
🔴 Apontamento rejeitado não derruba a mutation. A resposta HTTP é 200, a lista errors do
GraphQL vem vazia, e as rejeições estão só em rejectedIssues. Quem checa apenas errors
conclui que tudo passou e nunca descobre o que ficou para trás. Sempre percorra rejectedIssues.
O objeto error de cada rejeição traz status e code; não traz a mensagem. Trate pelo code.
O input do lote não é o mesmo da alteração individual
IssueNewValuesInput parece o input de updateIssue, e não é:
| Campo | No lote |
|---|---|
locals, labels | Não são listas de ids. São { "action": "add" | "remove", "ids": [Int] }. action fora desses dois valores derruba a mutation inteira |
disciplines | Mesclagem, não substituição: item com disciplineId já presente tem só as chaves enviadas atualizadas (omitir deadline preserva o prazo — o oposto da alteração individual); item novo só entra se tiver status, senão é descartado em silêncio; remove: true funciona aqui e tira a disciplina |
autoResolutionPhase: Int | Existe só no lote. É a fase de resolução usada quando o fechamento das disciplinas leva o apontamento a sair de active sozinho |
images | Declarado no input e nunca processado — aceito e descartado em silêncio |
guid, viewpoints, bimPins, referências de documento e de modelo | Não existem no input do lote |
⚠️ Enviar status: "resolved" ou "reproved" no lote reescreve para done todas as disciplinas
ativas dos apontamentos alcançados — inclusive as que você não mencionou.
⚠️ O lote regrava a linha inteira, a partir do estado que leu no início da operação — não só os
campos que você mandou. Edição feita por outra pessoa nesse intervalo é revertida, inclusive em campo
que o seu newValues nem menciona. A janela é curta, mas existe, e é maior que a da alteração
individual (concorrência).
🔴 Identificador inexistente em issuesIds nem sempre vira rejeição — às vezes derruba a rodada
inteira. Depende do que você está alterando:
O newValues inclui | Identificador inexistente |
|---|---|
Nenhum de disciplines, locals, labels, status | vai para rejectedIssues; os demais são gravados |
| Qualquer um deles | erro interno, e nada é gravado — nem os apontamentos válidos do lote |
É a diferença entre perder um item e perder a rodada, e o caso perigoso é justamente o mais comum
(alterar situação ou disciplinas). Filtre issuesIds contra o que você sabe existir antes de enviar.
Identificador de outro projeto é sempre rejeitado individualmente, sem esse risco.
updateIssuesDisciplinesStatus — mover a situação das disciplinas
Esta operação move a situação das disciplinas de um ou mais apontamentos, e nada mais. Ela é
separada de updateIssues por causa de quem a alcança: é a única escrita de apontamento
disponível a quem não pode alterá-lo — quem participa do projeto e responde por uma disciplina
move a situação dela por aqui, sem ter permissão para tocar no resto do apontamento.
updateIssues continua aceitando disciplines exatamente como antes, e nada foi removido dela: quem
já integrou pelo lote comum não precisa mudar nada.
mutation MoverSituacaoDeDisciplinas {
updateIssuesDisciplinesStatus(
projectId: 42
issuesIds: [1234, 1240]
disciplines: [{ disciplineId: 101, status: "doing" }]
autoResolutionPhase: 9
) {
updatedIssues { id status deadline updatedAt disciplines { disciplineId status } }
rejectedIssues { id code message status }
}
}
Cada item de disciplines tem dois campos, e os dois são obrigatórios:
| Campo | O que é |
|---|---|
disciplineId | A disciplina a mover. Ela precisa já estar no apontamento |
status | A situação de destino (§8), entre as que estão ativas no catálogo do projeto |
Os mesmos itens valem para todos os issuesIds — como no lote comum, não existe alteração
heterogênea.
⚠️ A lista não é o estado final: mande só as disciplinas que se movem. É o oposto da alteração individual, em que a lista substitui tudo (alteração parcial, regra 4). Aqui, disciplina que você não mencionar fica intacta — mesma situação, mesmo prazo, mesma data de conclusão. Prazo e data de conclusão você não escreve nem por engano: os campos não existem neste input.
⚠️ autoResolutionPhase é exigido quando o gesto tira a última disciplina que mantinha o
apontamento ativo — é a fase de resolução com que ele se fecha, como no lote comum. Faltando nesse
caso, o apontamento é recusado com MISSING_RESOLUTION_PHASE. Se o seu gesto não fecha apontamento
nenhum, omita o campo.
Quem pode mover o quê:
| Quem chama | O que consegue mover |
|---|---|
| quem pode alterar o apontamento | qualquer disciplina dele, para qualquer situação ativa no catálogo do projeto |
| quem não pode alterá-lo, mas responde pela disciplina | só as disciplinas pelas quais responde, e só para os destinos que a situação atual alcança |
Nessa segunda faixa, três limites não se contornam: de validated e de done não se sai; para
validated e para done não se entra — fechar uma disciplina é sempre de quem coordena; e o
apontamento precisa ser visível para a sua credencial, senão ele é recusado como inexistente.
O que a operação recusa. Prazo, data de conclusão, acrescentar disciplina e retirar disciplina
não existem neste input: o GraphQL recusa o pedido inteiro com erro de campo desconhecido, antes
de qualquer coisa ser gravada. Para esses gestos o caminho continua sendo updateIssue ou
updateIssues. O resto vira rejeição do apontamento, com code e status:
| Situação | code | status |
|---|---|---|
| apontamento inexistente — ou existente e invisível para a sua credencial | ISSUE_NOT_FOUND | 404 |
| apontamento de outro projeto | ISSUE_DOES_NOT_BELONGS_TO_PROJECT | 403 |
| disciplina pedida não está no apontamento | DISCIPLINE_NOT_IN_ISSUE | 400 |
| disciplina fora do catálogo do projeto | DISCIPLINE_NOT_FOUND | 404 |
| situação desligada no catálogo do projeto | DISCIPLINE_STATUS_NOT_AVAILABLE | 403 |
| você não responde pela disciplina, ou o destino não é alcançável a partir da situação atual | FORBIDDEN | 403 |
o gesto fecharia o apontamento e autoResolutionPhase não veio | MISSING_RESOLUTION_PHASE | 400 |
⚠️ Invisível e inexistente respondem igual, de propósito. Nos dois casos vem ISSUE_NOT_FOUND —
não conclua que o identificador está errado.
🔴 A recusa é do apontamento inteiro, e o retorno é parcial. Basta uma disciplina recusada para
que nada daquele apontamento seja gravado: as outras que vieram no mesmo pedido não se movem. Os
demais apontamentos da lista seguem normalmente, e valem aqui as mesmas advertências de
updateIssues — resposta 200, lista
errors do GraphQL vazia, rejeições só em rejectedIssues. Percorra-a sempre.
Uma diferença de forma, se você reaproveita o tratamento de retorno do outro lote: aqui a rejeição é
plana e traz a mensagem — { "id": 1240, "code": "…", "message": "…", "status": 403 } —, em vez
do objeto error aninhado e sem mensagem de updateIssues. A resposta também é tipada:
updatedIssues é uma lista de apontamentos, com os mesmos campos de qualquer consulta.
Criação em lote e cópia — as duas restrições que surpreendem
⚠️ createIssues grava imagens, mas não passa pelo serviço de upload. O que você manda em
images é persistido como veio, sem tratamento. Na prática:
- Só a URL já hospedada tem efeito — use o fluxo de URL assinada, suba o arquivo antes e informe a URL pronta.
- Envio de arquivo direto é ignorado (
originalFile,markedUpFile,markedUpThumbFile). - Nenhuma miniatura é gerada, e a versão com marcações cai de volta para a original.
cover: trueé descartado — o lote nunca define a capa do apontamento.
Se você precisa de miniatura ou de capa, crie o apontamento pelo caminho individual (createIssue),
ou crie em lote e anexe as imagens depois por updateIssue — os dois passam pelo pipeline completo.
O input do lote também não aceita viewpoints, as referências de documento, as referências de
documento CCode nem as de modelo.
🔴 copyProjectIssues exige papel de gestão no projeto de destino — não basta a permissão de
participar que serve para todo o resto da escrita. Na origem basta visualizar. É a única mutation de
apontamento que sai do padrão, e a recusa vem como erro de permissão genérico.
Duas observações que evitam desenho errado
- Não existe operação em lote heterogênea.
updateIssuesaplica o mesmo conjunto de valores a todos os identificadores da lista. Alterações diferentes para apontamentos diferentes são chamadas diferentes. - Lote não é agrupamento de operações. Uma mutation em lote é uma operação GraphQL com muitos itens, e é recomendada. Enviar um array de operações numa chamada HTTP é outra coisa, e é recusada (§5).
Não atropelar quem está editando
Não há trava. Duas escritas concorrentes sobre o mesmo apontamento não conflitam: a última vence, em silêncio. O que existe é o material para você detectar a concorrência antes de escrever, sem custo nenhum:
editedAteeditedByUserIdmarcam quem tocou por último e quando, e avançam em qualquer edição.- Leia o apontamento, guarde o
editedAt, e compare-o no momento de escrever. Se mudou, alguém editou no intervalo — decida se sobrescreve, mescla ou descarta.
Reduzir a janela é mais eficaz do que detectá-la: envie a alteração no momento em que ela acontece, em vez de acumular numa fila que drena de tempos em tempos. A janela de conflito é proporcional ao tempo que a alteração espera enfileirada; enviando na hora, ela quase desaparece — e boa parte da leitura prévia deixa de ser necessária.
Criar e editar comentário
mutation CreateComment {
createProjectIssueComment(
projectId: 42
issueId: 1234
message: "Reparo executado em 20/01, com registro fotográfico."
visibility: "public"
) {
id issueId message visibility createdAt createdByUserId
images { id title original markedUp markedUpThumb }
}
}
Obrigatórios: projectId, issueId, message, visibility. Demais argumentos:
images: [IssueCommentImageInput], mentionedUsers: [Int], groupId: Int.
mutation UpdateComment {
updateProjectIssueComment(projectId: 42, commentId: 987, message: "Texto corrigido.") {
id message visibility editedAt editedByUserId
}
}
Obrigatórios: projectId, commentId. Demais argumentos: message: String,
visibility: String, images: [IssueCommentImageInput], mentionedUsers: [Int], groupId: Int.
Campos omitidos permanecem inalterados — com uma exceção destrutiva:
🔴 Omitir mentionedUsers apaga todas as menções do comentário. É o único campo em que a regra
"campo omitido é preservado" não vale. Quem edita só o texto e não reenvia a lista perde as
menções, sem erro e sem aviso — e não há como recuperá-las pela API, porque o tipo Comment não
expõe as menções na leitura. Reenvie sempre a lista completa de mentionedUsers, mesmo quando
ela não mudou.
⚠️ images na edição é aditivo: os itens enviados são acrescentados. Para remover uma imagem,
mande o item com { id, deleteImage: true }.
Input de imagem do comentário:
input IssueCommentImageInput {
id: Int
type: String
title: String!
cover: Boolean
deleteImage: Boolean
viewpoint: JSON
markUp: JSON
index: Int
original: String
originalFile: Upload
markedUp: String
markedUpFile: Upload
markedUpThumb: String
markedUpThumbFile: Upload
}
⚠️ Criar um comentário não altera a data de atualização do apontamento. Se a sua integração escreve comentários e também sincroniza, ela não vai reencontrar o próprio comentário pela janela de apontamentos — só pela janela de comentários (§7).
Imagens em três passos
O binário nunca passa pela API. O caminho é sempre: pedir uma URL assinada, subir o arquivo diretamente no armazenamento, e referenciar a URL final na mutation.
Passo 1 — pedir as URLs:
mutation UploadSignedUrls($projectId: Int!, $filenames: [String!]!) {
createProjectIssueFileUploadSignedUrls(projectId: $projectId, filenames: $filenames)
}
Retorno: JSON — uma lista de { signedUrl, contentType }, na mesma ordem de filenames.
Lista vazia devolve [].
⚠️ O casamento é por POSIÇÃO, nunca por nome. Do nome o servidor usa só a extensão — a chave do objeto é um identificador que ele gera. Dois arquivos de mesmo nome são dois itens distintos, e ler o retorno por nome entrega o link de um arquivo a outro: o anexo abre o conteúdo errado, sem erro nenhum.
| Característica | Valor |
|---|---|
| Teto por chamada | 50 nomes |
| Acima do teto | recusa com UPLOAD_URL_BATCH_LIMIT_EXCEEDED, e nenhuma URL é emitida |
| Armazenamento | Google Cloud Storage (storage.googleapis.com) |
| Validade da URL | 1 hora |
| Método aceito | PUT |
| Balde de taxa | Próprio (user-upload-url), separado do de mutations (§5) |
A versão de um nome só, createProjectIssueFileUploadSignedUrl(projectId, filename), continua
existindo e devolve o objeto direto em vez da lista. Ela está obsoleta — resolve pelo mesmo
caminho da plural, que atende um nome ou muitos.
⚠️ Não existe campo key nem fileUrl no retorno. A URL final do arquivo é a signedUrl sem a
query string — corte tudo a partir do ?.
Passo 2 — subir o arquivo:
PUT {signedUrl}
Content-Type: {contentType devolvido no passo 1}
Corpo: os bytes do arquivo
⚠️ Não acrescente cabeçalhos ao PUT. Em particular, não envie x-goog-acl: ele não faz
parte da assinatura, e a requisição passa a ser recusada pelo armazenamento — com um erro que vem do
Google, não do Construflow, e que confunde o diagnóstico. A leitura pública do arquivo vem da
política do bucket, não de permissão por objeto. Envie o Content-Type que a mutation devolveu e
nada mais.
Passo 3 — referenciar na mutation:
Use a URL final (sem query string) ao criar ou alterar o apontamento ou o comentário — e preencha
original e markedUp com ela, deixando markedUpThumb de fora:
images: [{ title: "fachada-leste.jpg", original: "<url final>", markedUp: "<url final>" }]
🔴 A miniatura só é gerada se markedUp estiver preenchido. Quem faz os três passos e envia
só original fica sem miniatura, em silêncio: nenhum erro, nenhum aviso, e a imagem aparece
sem capa e sem versão reduzida — a interface passa a carregar o arquivo em tamanho cheio onde
deveria haver miniatura. É a armadilha do fluxo de URL assinada, e ela não existe no caminho por
multipart (originalFile), onde o servidor preenche markedUp sozinho.
Duas formas de fazer certo, e a primeira basta:
- Repita a URL em
markedUpe omitamarkedUpThumb— o servidor baixa o arquivo, gera o PNG (≤200×200, proporção preservada) e o publica. - Ou suba a sua própria miniatura por uma segunda URL assinada e preencha
markedUpThumb.
⚠️ A URL precisa ser a URL pública final do arquivo — a signedUrl sem a query string. Se você
passar a URL assinada inteira, com a query string, a geração da miniatura falha em silêncio e você
volta ao mesmo desfecho.
Se você subiu duas versões do arquivo — o original e uma cópia com marcações —, aí sim
original e markedUp recebem URLs diferentes, e a miniatura é gerada a partir da versão com
marcações.
Peça as URLs do gesto inteiro numa chamada só, e sob demanda. A URL vale uma hora e o balde de emissão é pequeno, então o que não se faz é pré-alocar para depois: pedir N URLs em N chamadas paralelas é o que estoura o balde, e pedir antes da hora as desperdiça.
12. Importação de verificação de modelo (BCF)
A importação de um arquivo .bcf cria (ou atualiza) apontamentos no projeto a partir dos tópicos do
arquivo. É o caminho recomendado para trazer o resultado de uma verificação de modelo BIM para dentro
do Construflow — não há endpoint melhor.
São três passos, e os dois primeiros precisam acontecer em sequência imediata.
Passo 1 — abrir o processo de importação
POST /bcf-server/bcf/import-process/{projectId}
Content-Type: application/json
Corpo:
| Parâmetro | Obrigatório | Valores | O que faz |
|---|---|---|---|
visibility | sim | creator · project_management · public | Visibilidade com que os apontamentos do arquivo entram no projeto. ⚠️ Não use group — ver abaixo |
commentVisibility | se o arquivo tiver comentários | mesmos valores acima | Visibilidade com que os comentários do arquivo entram |
nonResponsibleDisciplinesStatus | não (padrão participate) | participate · follow | Como entram as disciplinas listadas em Labels que não são a responsável |
includeBcfTopicNumber | não (padrão false) | booleano | Prefixa o título com o número do tópico no arquivo (BCF#12) |
Resposta: 200 com { "signedUrl": "..." }.
🔴 visibility: "group" faz falhar TODOS os tópicos. A importação não tem como dizer de que
grupo se trata — não existe parâmetro para isso —, então a verificação de permissão nunca passa e
cada tópico é recusado com erro de permissão. Não há caminho em que funcione. Use creator,
project_management ou public (§6).
⚠️
commentVisibilitynão é um campo do BCF — é uma decisão de importação que o arquivo não carrega. O padrão BCF não tem noção de visibilidade, então é aqui que se define quem enxerga os comentários importados. Se o arquivo trouxer comentários e esse parâmetro não for enviado, cada comentário falha individualmente — os apontamentos entram normalmente, e você só descobre a perda no e-mail de resultado. Se há qualquer chance de o arquivo ter comentários, envie sempre.
⚠️ commentVisibility não aceita os mesmos valores da visibilidade do apontamento, e o que você
pede não é o que necessariamente é gravado. Três diferenças:
groupé descartado na importação. Pedi-lo não é erro: o comentário entra com a maior visibilidade abaixo degroupque a sua permissão permitir — na prática,creator.- O valor funciona como teto, e é rebaixado em silêncio conforme a permissão de quem importa e a
visibilidade do apontamento. Um usuário que só participa do projeto pedindo
publicnum apontamentopublicrecebe comentárioscreator— sem erro, sem aviso. - E, quando nenhuma visibilidade permitida cabe abaixo do teto pedido, o comentário entra com a
menor visibilidade disponível acima dele — ou seja, o teto pode ser furado para cima. Se a
confidencialidade importa, não confie no parâmetro: dimensione a permissão do usuário da
integração pela visibilidade que você quer de fato, e confira a
visibilitydos comentários depois da importação.
Passo 2 — subir o arquivo
PUT {signedUrl}
Corpo: os bytes do arquivo .bcf
⚠️ A janela é de 200 segundos a partir do passo 1. O processamento começa a procurar o arquivo assim que o processo é aberto, e desiste depois desse tempo. Suba o arquivo imediatamente após receber a URL — não trate os dois passos como estágios de filas separadas, nem enfileire a abertura do processo para "subir depois".
🔴 A URL vive muito mais que a janela, e é aí que se perde o arquivo. A URL assinada vale quase
sete dias; a janela de processamento é de 200 segundos. Subir o arquivo depois desses 200
segundos tem sucesso — o armazenamento aceita o PUT e devolve 200 — e não faz nada: o
processamento já desistiu, o arquivo fica órfão no armazenamento, nenhum apontamento é criado,
nenhum e-mail é enviado. O seu lado vê um upload bem-sucedido e uma importação que nunca aconteceu.
Um PUT com sucesso não é confirmação de importação.
Passo 3 — o resultado
O resultado chega por e-mail ao usuário dono da credencial: quantos apontamentos foram criados, quantos foram atualizados, e a lista de tópicos que falharam com o motivo de cada um.
⚠️ Não há consulta de andamento nem de resultado por API. O e-mail é a única saída. Duas consequências que precisam entrar no seu desenho:
- Se a credencial pertence a um usuário de serviço cuja caixa de entrada ninguém lê, a importação é cega — ela pode falhar inteira sem que você fique sabendo. Aponte esse endereço para uma caixa monitorada, ou trate o e-mail programaticamente.
- Uma importação sem retorno não é sinal de sucesso. Confirme o efeito pela API, consultando os apontamentos do projeto pela janela de data (§7).
⚠️ Abrir o processo não valida nada. O passo 1 devolve 200 e uma URL assinada mesmo que o
projeto não exista, esteja inativo, ou a credencial não tenha acesso a ele. Toda a validação acontece
no processamento, e chega por e-mail.
A permissão é conferida em dois momentos diferentes, e a diferença muda o desfecho:
| O que falta | Quando é conferido | Desfecho |
|---|---|---|
| Permissão de visualizar o projeto | logo no início, antes de qualquer tópico | 🔴 A importação inteira morre. Nenhum tópico é tentado, e o e-mail traz um único erro genérico, sem identificar tópico nenhum |
| Permissão de participar do projeto (ou projeto inativo) | na gravação de cada apontamento | Os tópicos falham um a um, cada um com o seu motivo no e-mail |
Um e-mail de resultado sem nenhum tópico identificado é o sinal de falha global — comece checando o acesso da credencial ao projeto.
Comportamento da importação
-
Versões suportadas: BCF 2.1 e 3.0.
-
Reimportar atualiza, não duplica. A identificação é pelo identificador (GUID) do tópico: se já existe um apontamento com aquele identificador no projeto, ele é atualizado. É o comportamento desejado quando a verificação de modelo é rodada de novo sobre o mesmo escopo.
-
⚠️ A reimportação sobrescreve título, descrição, situação, prioridade, categoria, fases, locais, disciplinas — e, junto com as disciplinas, a situação de cada uma, o prazo e a data de conclusão delas —, mais o prazo do apontamento e a fase de resolução. Preserva visibilidade, etiquetas e as imagens já existentes. Na prática: entre duas verificações, o arquivo
.bcfé a fonte da verdade desses campos — ajuste feito à mão no Construflow nesse intervalo é substituído sem aviso.O que isso custa, em concreto: uma disciplina que a equipe moveu para
doingoureviewvolta para a situação que o arquivo diz; o prazo do apontamento é recalculado a partir das disciplinas do arquivo — e zerado, se nenhuma delas trouxer prazo (regra 5); e a fase de resolução é forçada a nulo, porque o arquivo traz o apontamento como ativo. Quem usa BCF em ciclos deve tratar o Construflow como espelho do arquivo nesses campos, não como o lugar onde o andamento é registrado. -
⚠️ Locais e disciplinas são substituição total, não fusão. O que foi acrescentado à mão no Construflow e não está no arquivo é removido. Não há como manter um local ou uma disciplina extra entre reimportações.
-
Comentários não são duplicados na reimportação (também identificados por GUID). Em compensação, comentário editado no arquivo não atualiza o que já entrou.
-
⚠️ O identificador do tópico é único em toda a plataforma. O mesmo GUID não pode existir em dois projetos. Gerador de BCF que reaproveita identificadores entre escopos faz aquele tópico falhar no segundo projeto — só ele, e a importação segue com os demais. O apontamento do primeiro projeto não é tocado.
Viewpoints não são sobrescritos
Os viewpoints são a exceção da lista acima, e o comportamento deles é o menos intuitivo da importação. São quatro casos, decididos pelo identificador (GUID) de cada viewpoint:
| No arquivo | O que acontece |
|---|---|
| Os mesmos identificadores de antes, na mesma ordem | Nada muda. Nem sequer há gravação |
| Identificador que já existe no apontamento | O conteúdo antigo é mantido. Câmera, corte e imagem alterados no arquivo são ignorados |
| Identificador novo | É acrescentado, com o conteúdo do arquivo |
| Nenhum viewpoint no tópico | Apaga todos os viewpoints existentes |
🔴 Não existe "atualizar um viewpoint". Reposicionar a câmera de um viewpoint já importado e reimportar o arquivo não tem efeito — o GUID é o mesmo, então o conteúdo antigo prevalece, em silêncio. Para trocar o conteúdo, o gerador de BCF precisa emitir o viewpoint com um GUID novo. E cuidado com o caminho inverso: um tópico que perdeu o viewpoint no arquivo apaga os que existiam no Construflow.
⚠️ O identificador lido é o Guid do arquivo de visualização (.bcfv), não o do tópico. Gerador
que não emite esse atributo entrega viewpoints sem identificador, e aí o servidor sorteia um novo
a cada importação — o conjunto passa a ser substituído sempre, que é o oposto do que a tabela acima
descreve. Se você gera o arquivo e quer o comportamento previsível, emita o Guid do .bcfv e
mantenha-o estável entre rodadas.
Como os campos do arquivo são interpretados
| Campo do BCF | Vira no Construflow | Como casa | Confira contra |
|---|---|---|---|
Priority | Prioridade | Valor em português, exato — ver o alerta abaixo | — |
TopicStatus | Situação | Valor conhecido, exato — ver abaixo | — |
AssignedTo | Disciplina(s) responsável(is) | Pela abreviação da disciplina | cadastro do projeto |
Labels sem prefixo | Disciplinas participantes | Pela abreviação da disciplina | cadastro do projeto |
Labels com prefixo ^ | Locais | Pelo caminho hierárquico de abreviações, separado por / | cadastro do projeto |
TopicType | Categoria | Pelo número antes do hífen | catálogo global |
Stage | Fase | Pela abreviação antes do hífen | catálogo global |
DueDate | Prazo das disciplinas de AssignedTo | — | — |
Title | Título | Um prefixo no formato exato NNNN - (4 ou 5 dígitos, espaço, hífen, espaço) é removido: 12345 - Vazamento entra como Vazamento. Fora desse formato o número permanece no título — 123 - …, 123456 - …, 1234-Título e espaçamento duplo não são tocados | — |
Valor que não casar faz o tópico falhar, com o motivo no e-mail — e os detalhes de cada campo abaixo são o que separa uma importação limpa de cinquenta falhas descobertas depois do fato. O casamento é sensível a maiúsculas e acentos em todos os campos.
🔴 Priority é a armadilha mais provável de todas. Só três valores são aceitos, em
português, escritos exatamente assim:
| No arquivo | Vira |
|---|---|
Alta | high |
Média | medium |
Baixa | low |
| (ausente) | medium |
High, Normal e Low — que é o que um gerador de BCF padrão emite — falham o tópico. Não são
ignorados, não caem num valor padrão: derrubam o tópico inteiro, com Priority "High" does not exists no e-mail. Um arquivo BCF gerado por qualquer ferramenta que não seja o próprio Construflow
tende a falhar todos os tópicos por causa disto. Se você gera o arquivo, traduza a prioridade
antes de exportar; se não gera, omita o campo (o que dá prioridade média) ou reescreva o arquivo.
alta minúsculo e ALTA também falham; espaço em volta do valor é tolerado.
⚠️ TopicStatus é a exceção à regra "valor não reconhecido falha o tópico". Situação
desconhecida não falha: o tópico entra em silêncio como ativo. Reconhecidos: Ativo,
Active, active → ativo; Resolvido, Resolved, Closed, resolved → resolvido; Reprovado,
reproved → reprovado. Repare nas ausências — Reproved (inglês, capitalizado) e closed
(minúsculo) não estão na lista e viram ativo, sem nenhum aviso no e-mail. Confira o resultado
depois de importar; o e-mail não vai contar.
⚠️ TopicType casa pelo NÚMERO, não por sigla. O formato é o que o próprio Construflow exporta:
o id numérico da categoria, um hífen e o nome — 03 - Interferência ou divergência entre disciplinas. Só o que vem antes do hífen é lido, e é lido como número: 3 e 03 sozinhos
também casam. ARQ - Arquitetura não casa com nada — ARQ não é número, e o tópico falha.
Campo ausente equivale a 00.
⚠️ AssignedTo aceita várias disciplinas, separadas por vírgula — e ignora e-mails em silêncio.
ARQ, EST atribui as duas. Qualquer entrada que contenha @ é descartada sem aviso — e é
exatamente ali que o padrão BCF manda pôr o e-mail do responsável. Consequência: um arquivo BCF
padrão, com AssignedTo=joao@empresa.com, entra sem responsável explícito; nesse caso o sistema
promove as disciplinas de Labels a responsáveis, e se também não houver Labels o tópico falha com
Responsible discipline is required. Abreviação que não existe no projeto (e não contém @) falha o
tópico.
⚠️ DueDate só vira prazo quando o responsável veio de AssignedTo. Quando a responsabilidade é
promovida a partir de Labels, o DueDate é silenciosamente perdido — e, como o prazo do
apontamento é derivado das disciplinas (§11), o apontamento
entra sem prazo.
⚠️ Local (Labels com ^) casa pelo CAMINHO inteiro, não pela última sigla. O valor é a
sequência de abreviações da raiz até o nó, separada por /: ^TORRE/PAV2, não ^PAV2. Esse
caminho é o campo abbreviationTree de cada local, que vem no cadastro do projeto em
project { locals }, dentro de flatten (§6) — leia de lá, não monte
à mão. Sem espaço depois do ^ e sem espaço em volta da barra.
🔴 Tópico sem nenhum local vira ^ND. Se nenhum label do tópico começa com ^, o sistema
injeta ^ND — e então exige que exista um local com abreviação ND no projeto. Projeto nenhum
nasce com esse local. Se você importa arquivos cujos tópicos nem sempre trazem local, crie um
local ND no projeto antes, ou todos esses tópicos falham com Local "ND" does not exists.
⚠️ Categoria e fase passam por dois portões, e o primeiro é o catálogo GLOBAL. O casamento é
contra o catálogo global da plataforma — nele, id e abreviação existem independentemente do projeto.
Só depois, ao gravar o apontamento, o projeto é consultado: valor que casou globalmente mas está
inativo neste projeto ainda falha o tópico, agora com CATEGORY_MUST_BE_ACTIVE /
PHASE_MUST_BE_ACTIVE. project { categories { id name active } } e project { phases { … } }
respondem aos dois portões de uma vez: eles trazem o catálogo global inteiro, anotado com o active
deste projeto (§6).
Antes de uma importação grande, confira as abreviações de disciplina e os caminhos de local do
arquivo contra o cadastro do projeto, e as categorias e fases contra project { categories } /
project { phases }, filtrando por active (§6) — é o que evita descobrir
cinquenta falhas depois do fato.
Armadilhas da importação
Além das acima, quatro falhas que não se anunciam:
- 🔴 Subir o arquivo depois dos 200 segundos tem sucesso e não faz nada. A URL vale quase sete
dias, mas a janela de processamento é de 200 segundos: o
PUTé aceito, o arquivo fica órfão, e não há erro nem e-mail. Ver passo 2. - 🔴 Versão de BCF não suportada derruba a importação inteira — não tópico a tópico. O e-mail traz um erro único, e nenhum apontamento é criado. Só 2.1 e 3.0 são aceitos.
- ⚠️ Identificador de projeto não numérico na rota não é validado na abertura. A importação abre com sucesso e morre depois, com um e-mail genérico que não diz que o problema era o projeto.
- ⚠️ Comentário só é importado se o apontamento entrou. Tópico que falhou leva os comentários dele junto — e o e-mail sinaliza a perda, mas não lista comentário por comentário. E comentário vazio é descartado em silêncio, sem aparecer no e-mail de forma nenhuma.
13. Checklist de integração
Recapitulação acionável. Cada item aponta a seção que explica o porquê.
Antes da primeira chamada
- Criar um usuário dedicado à integração, com as permissões de projeto que ela precisa e nada além — a chave enxerga exatamente o que esse usuário enxerga. §2
- Emitir a Chave de Acesso e guardar o segredo — ele só é exibido uma vez. §2
- Enviar um
User-Agentidentificável em toda requisição. §5 - Desligar o agrupamento automático de operações do seu cliente GraphQL (
BatchHttpLinke equivalentes). §5 - Tratar
200como possível erro: verificarerrorsantes de lerdata, sempre — inclusive nas recusas de formato de filtro, que também vêm com200. §4 - Implementar a política de retry por status:
400nunca;429peloRetry-After;5xxcom backoff e jitter. §4 - Tratar
429pelo status e pelos cabeçalhos, nunca pela forma do corpo — a recusa mais provável (a portaria) vem sem a listaerrors. §5 - Colocar um limitador global de ~4,5 requisições por segundo e no máximo 4 simultâneas (2 a 4 processos em paralelo, nunca mais). §5
Na carga inicial
- Enumerar os projetos por
loggedUserData { projects(permission: "view") }— sem o argumento a lista traz só onde a credencial escreve — e guardar ostatus, porque ela inclui projeto inativo. §6 - Carregar o cadastro de cada projeto numa chamada só e guardá-lo do seu lado. §6
- Rodar as consultas do passo 1 e do
passo 2 sem janela de data, projeto a projeto, paginando
até o fim — mantendo o
includeDeleted: trueno passo 1, que é o que recupera as exclusões antigas que janela de data nenhuma alcança. §7 - Gravar por
upsertidempotente — bordas de janela inclusivas repetem o item da borda. §7 - Saber que a carga não traz comentário de apontamento excluído — esse dado não existe pela API. §7
Em cada rodada
- Reenumerar os projetos e comparar o conjunto de identificadores com o da rodada anterior — projeto que sumiu é perda de acesso, e não há outro sinal. §6 · §9
- Usar como janela [início da rodada anterior, início desta rodada] — nunca o fim da anterior, nunca "de agora para trás". §7
- Enviar as datas com fuso explícito (
Zou-03:00). §7 - Rodar as duas consultas, apontamentos e comentários. Comentário novo não move a data do apontamento. §7
- Enviar
filter.includeDeleted: trueno passo 1 — sem ele a exclusão de apontamento é invisível, sem erro nenhum. §10 - Buscar detalhe apenas da união dos dois, excluindo o que veio marcado como excluído — e
em lotes por
filter.ids, se você não espelha o histórico. §7 - Passar
firstexplícito, ajustado à seleção — até 200 só com escalares, da ordem de 50 com relacionamentos expandidos —, e devolver o cursor exatamente como veio. §5 · §10 - Iterar até
hasNextPageserfalse— a última chamada vazia é esperada. §10 - Tratar
deletedAtpreenchido como remoção nas duas fontes — e ramificar por ele antes de ler qualquer outro campo do apontamento, que no excluído chega vazio. §9 · §7 - Manter a seleção enxuta: sem
notifications, semcommentsno detalhe, semviewpointsebimPinsfora do fluxo BIM, e nuncamodelReferences, que não existe na saída e derruba a consulta. §8
Periodicamente
- Reler o cadastro de cada projeto — uma vez por dia é folgado — e conferi-lo contra os vínculos guardados: etiqueta ou local apagado no projeto não gera sinal nos apontamentos. §6 · §9
- Agendar a recarga total por projeto (mensal, por exemplo) e reconciliar por conjunto de identificadores. §9
- Investigar todo projeto ativo cuja rodada voltou vazia — pode ser perda de acesso, que não produz erro. §9
Ao escrever
- Validar os identificadores de local, etiqueta e disciplina contra o cadastro do projeto, e os
de categoria e fase contra
project { categories }/project { phases }filtrando poractive, antes de enviar. §11 · §6 - Na criação, sempre enviar
localsedisciplinesnão-vazios — omitir cria o apontamento e depois estoura, deixando um registro órfão. §11 - Enviar só os campos que mudaram — campo omitido é preservado. §11
- Nunca mandar
[]querendo dizer "não mexer": apaga em etiquetas e referências, e é recusado em locais e disciplinas. §11 - Enviar
resolutionPhasejunto comstatus— o gatilho é enviar, não mudar — e manterstatusedisciplinescoerentes entre si. §11 - Ao enviar
disciplines: a lista é o estado final, e cada item precisa repetirstatus,deadlineedoneAt, senão o prazo é zerado e a data de conclusão é sobrescrita.remove: truenão funciona na alteração individual. §11 - Lembrar que o prazo do apontamento é derivado das disciplinas — enviar disciplinas sem prazo o apaga. §11
- No lote, percorrer
rejectedIssues: a mutation devolve200semerrorsmesmo com apontamentos recusados. §11 - Ao editar comentário, reenviar
mentionedUserssempre — omitir apaga todas as menções. §11 - Comparar
editedAtantes de sobrescrever — não há trava, a última escrita vence. §11 - Imagens: URL assinada →
PUTsem cabeçalhos extras → URL final sem a query string emoriginale emmarkedUp— semmarkedUpnão há miniatura. §11 - Para criar apontamento com imagem, usar
createIssueindividual: a criação em lote não processa imagens. §11 - Manter o de-para de situação, prioridade e visibilidade do seu lado — a API não traduz. §8
Ao importar BCF
- Traduzir a prioridade para português (
Alta/Média/Baixa) antes de exportar o arquivo —High/Normal/Lowfalham o tópico. §12 - Emitir
TopicTypecom o número da categoria antes do hífen (03 - …), não com sigla. §12 - Emitir os locais como caminho completo de abreviações (
^TORRE/PAV2), lido doabbreviationTreedo cadastro. §12 - Garantir que exista um local de abreviação
NDno projeto, se algum tópico puder vir sem local. §12 - Não contar com
AssignedTopara e-mails: entrada com@é descartada em silêncio. §12 - Enviar
commentVisibilitysempre que o arquivo puder conter comentários — e conferir a visibilidade gravada, que pode ter sido rebaixada. §12 - Subir o arquivo imediatamente após abrir o processo — a janela é de 200 segundos, e o
PUTfora dela tem sucesso sem importar nada. §12 - Apontar a credencial para uma caixa de e-mail monitorada: o resultado só chega por lá. §12
- Conferir a situação dos tópicos depois de importar —
TopicStatusdesconhecido vira ativo em silêncio. §12 - Tratar o Construflow como espelho do arquivo nos campos sobrescritos: situação das disciplinas, prazos e fase de resolução voltam ao que o arquivo diz. §12