# API de horários de Missa

> Um diretório mundial de igrejas e dos horários de suas Missas, confissões e adoração. As leituras são abertas a todos; uma correção é uma sugestão que uma pessoa revisa.

Página web: https://ember.dpgu.me/pt/desenvolvedores/api/ · In English: https://ember.dpgu.me/developers/api.md

## O básico

- **URL base:** `https://ember-mass-times.dpgu.workers.dev`
- **OpenAPI:** [openapi.json](https://ember.dpgu.me/developers/openapi.json), para Postman, Bruno ou um gerador de clientes (em inglês)
- **Autenticação:** Nenhuma. Toda rota aceita qualquer origem, então uma página web pode chamá-la diretamente.
- **Cache:** As leituras são `public, max-age=300`; os blocos do mapa, uma hora, e mais um dia servidos enquanto se revalidam.
- **Erros:** Uma igreja inexistente é `404 { "error": "not_found" }`. Um parâmetro inválido é `400` com os [problemas de validação](https://ember.dpgu.me/pt/desenvolvedores/api/#schema-ValidationError). Escritas demais são `429 { "error": "rate_limited" }`.

As descrições curtas de parâmetros e campos vêm dos schemas no código e estão em inglês.

## Lendo os horários

Os `services` de uma igreja são seus horários recorrentes, cada um com um padrão e uma hora: “domingos às 9h” é um horário e “domingos às 11h30” é outro. `rrule` é uma regra do iCalendar que escolhe os dias, e `startTime` dá a hora local no `timezone` da própria igreja. `exdate` lista os dias cancelados e `rdate` os acrescentados. A API manda as regras sem expandir, então é você quem calcula a próxima Missa.

O [expand.ts](https://github.com/gustavo-depaula/ember/blob/main/packages/api/src/expand.ts) faz isso em sessenta linhas com o pacote [rrule](https://github.com/jkbrzt/rrule) e, como todo o código do Ember, está em domínio público para copiar:

```js
import { expandService } from './expand' // packages/api/src/expand.ts

const church = await (await fetch('https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp')).json()
for (const service of church.services.filter((s) => s.kind === 'mass')) {
  for (const { date, startTime } of expandService(service, { count: 3 })) {
    console.log(date.toISOString().slice(0, 10), startTime, service.language)
  }
}
```

Um bundler o aceita como está. No Node puro, o rrule não tem exports nomeados: escreva `import rrule from 'rrule'` e `const { RRule, RRuleSet } = rrule`.

Quando não há horário estruturado registrado (`hasStructuredSchedule` é falso), o texto da própria paróquia está em `texts`, como foi publicado.

## Igrejas

Leituras públicas

### GET /churches/near

**Igrejas perto de um ponto.** Igrejas a até `radiusKm` de um ponto, da mais próxima à mais distante. Um filtro de `kind` ou `rite` restringe os horários de cada igreja e tira as que ficam sem nenhum.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `lat` | query | number | sim | -90–90 |
| `lng` | query | number | sim | -180–180 |
| `radiusKm` | query | number |  | > 0; ≤ 50; default 10 |
| `kind` | query | string |  | Keep only services of this kind: mass, confession, adoration |
| `rite` | query | string |  | Keep only services of this rite, e.g. latin_tridentine |
| `institute` | query | string |  | Only churches served by this institute, e.g. FSSP |
| `status` | query | string |  | active \| temporarily_closed \| closed |
| `limit` | query | integer |  | 1–100; default 25 |

Respostas:

- `200` `{ churches: (Church & { distanceKm: number })[] }`: OK
- `400` `ValidationError`: Um parâmetro ou o corpo não passou na validação

Exemplo:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/near?lat=-23.5505&lng=-46.6333&radiusKm=2&limit=5'
```

### GET /churches

**Buscar pelo nome, ou ler a área de um mapa.** Passe `q` para buscar pelo nome, ou `bbox` para as igrejas na área visível de um mapa. Uma área grande demais para listar responde com `clusters`, contados por célula de geohash.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `q` | query | string |  | Name search |
| `near` | query | string |  | With q: "lat,lng" to rank matches nearest it first |
| `bbox` | query | string |  | A map viewport, "minLng,minLat,maxLng,maxLat": churches, or clusters when wide |
| `kind` | query | string |  |  |
| `rite` | query | string |  |  |
| `institute` | query | string |  |  |
| `status` | query | string |  |  |
| `limit` | query | integer |  | 1–100; default 25 |
| `offset` | query | integer |  | ≥ 0; default 0 |

Respostas:

- `200` `{ churches: Church[], clusters?: Cluster[] }`: `{ churches }` para uma busca, `{ churches, clusters }` para uma área do mapa
- `400` `ValidationError`: Um parâmetro ou o corpo não passou na validação

Exemplo:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches?q=Carmo&near=-23.5505%2C-46.6333&limit=5'
```

### GET /churches/index

**O diretório inteiro, uma página por vez.** Todas as igrejas na ordem do id, com os poucos campos de que um sitemap precisa, ou com `full=1` cada igreja inteira, como `/churches/{id}` a dá. Passe o `next` de uma página como o `after` da seguinte; uma página sem `next` é a última.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `after` | query | string |  | The last id of the previous page; at most 256 characters |
| `scheduled` | query | "1" |  | Only churches with service times on record |
| `country` | query | string |  | Only churches in these countries: ISO codes, comma-separated (BR,PT); matches ^[A-Za-z]{2}(,[A-Za-z]{2})*$ |
| `limit` | query | integer |  | 1–1000; default 500 |
| `full` | query | "1" |  | Each church whole, as /churches/{id} gives it: schedule, texts, links, parish and chapels |

Respostas:

- `200` `{ churches: { id: string, name: string, longName: string | null, city: string | null, region: string | null, countryCode: string | null, hasStructuredSchedule: boolean | null, updatedAt: string | null }[], next?: string }`: OK
- `400` `ValidationError`: Um parâmetro ou o corpo não passou na validação

Exemplo:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/index?limit=5'
```

### GET /churches/tiles/{cell}

**Um bloco do mapa.** Uma célula de cinco caracteres responde com suas igrejas (sem textos e links); uma mais curta, ou `root`, com a contagem das células dentro dela. Fica uma hora em cache.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `cell` | path | string | sim | A geohash of 1–5 characters, or root; matches ^(root\|[0-9b-hjkmnp-z]{1,5})$ |
| `kind` | query | string |  | Count only churches with services of this kind |

Respostas:

- `200` `{ churches: Church[] } | { cells: Cluster[] }`: `{ churches }` para uma célula completa, `{ cells }` acima dela
- `400` `ValidationError`: Um parâmetro ou o corpo não passou na validação

Exemplo:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/tiles/6gyf'
```

### GET /churches/{id}

**Uma igreja.** Uma igreja com seus horários, textos e links, a paróquia a que pertence e as capelas que atende.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `id` | path | string | sim | O id da igreja, feito do nome e da cidade |

Respostas:

- `200` `Church & { parish?: ChurchKin, chapels: ChurchKin[] }`: OK
- `404` `Error`: Nenhuma igreja tem este id: `not_found`

Exemplo:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp'
```

### GET /churches/{id}/verifications

**Confirmações dos horários de uma igreja.** Quando as pessoas confirmaram os horários registrados, da mais recente à mais antiga.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `id` | path | string | sim | O id da igreja, feito do nome e da cidade |
| `limit` | query | integer |  | 1–100; default 25 |
| `offset` | query | integer |  | ≥ 0; default 0 |

Respostas:

- `200` `{ verifications: Verification[] }`: OK
- `400` `ValidationError`: Um parâmetro ou o corpo não passou na validação

Exemplo:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp/verifications?limit=5'
```

## Mudanças

O que os moderadores mudaram, para diretórios que acompanham

### GET /changes

**O registro de mudanças.** Cada mudança que um moderador aplicou ao diretório e que continua valendo, da mais recente à mais antiga. Cada igreja atingida vem com o que mudou e seus horários como estão agora, e `message` diz o mesmo em texto simples, pronta para repassar. Passe o `next` de uma página como o `before` da seguinte; para acompanhar, peça com `since` igual ao `appliedAt` mais recente que você tem.

[Mudanças recentes](https://ember.dpgu.me/pt/horarios-de-missa/mudancas/) mostra o mesmo registro numa página.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `since` | query | string |  | Only changes applied after this instant (ISO 8601); matches ^(?:(?:\d\d[2468][048]\|\d\d[13579][26]\|\d\d0[48]\|[02468][048]00\|[13579][26]00)-02-29\|\d{4}-(?:(?:0[13578]\|1[02])-(?:0[1-9]\|[12]\d\|3[01])\|(?:0[469]\|11)-(?:0[1-9]\|[12]\d\|30)\|(?:02)-(?:0[1-9]\|1\d\|2[0-8])))T(?:(?:[01]\d\|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z\|([+-](?:[01]\d\|2[0-3]):[0-5]\d)))$ |
| `before` | query | string |  | The last id of the previous page; at most 256 characters |
| `church` | query | string |  | Only changes that touched this church; at most 256 characters |
| `lang` | query | "en" \| "pt" |  | The language of every message; by default each church’s own |
| `limit` | query | integer |  | 1–100; default 50 |

Respostas:

- `200` `{ changes: { id: string, appliedAt: string, churches: ChangedChurch[], message: string }[], next?: string }`: OK
- `400` `ValidationError`: Um parâmetro ou o corpo não passou na validação

Exemplo:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/changes?limit=5'
```

## Sugestões

Escritas com limite de frequência, revisadas antes de valer

### POST /churches/{id}/verify

**Confirmar os horários registrados.** Conta uma vez por cliente e igreja a cada 30 dias; uma repetição responde `deduped: true` e não registra nada.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `id` | path | string | sim | O id da igreja, feito do nome e da cidade |
| `X-Client-Id` | header | string |  | Um id fixo por aparelho ou pessoa; junto com o IP, identifica o cliente para o limite de escritas e para as confirmações |

Corpo da requisição:

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `serviceId` | string |  | The service confirmed; none confirms the whole church |

Respostas:

- `200` `{ deduped: boolean }`: OK
- `400` `ValidationError`: Um parâmetro ou o corpo não passou na validação
- `429` `Error`: Escritas demais deste cliente: `rate_limited`

Exemplo:

```sh
curl -X POST 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp/verify' \
  -H 'Content-Type: application/json' -H 'X-Client-Id: device-7f3a2c' \
  -d '{}'
```

### POST /churches/{id}/corrections

**Sugerir uma correção.** Põe na fila de um moderador uma mudança num horário ou na igreja, um novo horário, um fechamento ou uma observação. `kind` define a forma do restante.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `id` | path | string | sim | O id da igreja, feito do nome e da cidade |
| `X-Client-Id` | header | string |  | Um id fixo por aparelho ou pessoa; junto com o IP, identifica o cliente para o limite de escritas e para as confirmações |

Corpo da requisição:

`kind: "edit_service"`

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `kind` | "edit_service" | sim |  |
| `serviceId` | string | sim |  |
| `changes` | object | sim |  |
| `↳ kind` | string |  |  |
| `↳ rite` | string |  |  |
| `↳ language` | string |  |  |
| `↳ rrule` | string |  |  |
| `↳ startTime` | string |  |  |
| `↳ endTime` | string |  |  |
| `↳ exdate` | string |  |  |
| `↳ rdate` | string |  |  |
| `↳ locationNote` | string |  |  |
| `↳ note` | string |  |  |
| `comment` | string |  | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

`kind: "edit_church"`

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `kind` | "edit_church" | sim |  |
| `changes` | object | sim |  |
| `↳ name` | string |  |  |
| `↳ longName` | string |  |  |
| `↳ address` | string |  |  |
| `↳ city` | string |  |  |
| `↳ region` | string |  |  |
| `↳ postalCode` | string |  |  |
| `↳ country` | string |  |  |
| `↳ phoneE164` | string |  |  |
| `↳ email` | string |  |  |
| `↳ status` | string |  |  |
| `↳ administration` | string |  |  |
| `↳ institute` | string |  |  |
| `↳ canonicalStatus` | string |  |  |
| `↳ note` | string |  |  |
| `comment` | string |  | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

`kind: "add_service"`

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `kind` | "add_service" | sim |  |
| `changes` | object | sim |  |
| `↳ kind` | string | sim |  |
| `↳ rite` | string |  |  |
| `↳ language` | string |  |  |
| `↳ rrule` | string | sim |  |
| `↳ startTime` | string | sim |  |
| `↳ endTime` | string |  |  |
| `↳ exdate` | string |  |  |
| `↳ rdate` | string |  |  |
| `↳ locationNote` | string |  |  |
| `↳ note` | string |  |  |
| `comment` | string |  | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

`kind: "flag_closed"`

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `kind` | "flag_closed" | sim |  |
| `serviceId` | string |  |  |
| `comment` | string |  | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

`kind: "note"`

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `kind` | "note" | sim |  |
| `serviceId` | string |  |  |
| `comment` | string | sim | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

Respostas:

- `201` `{ id: string }`: Na fila
- `400` `ValidationError`: Um parâmetro ou o corpo não passou na validação
- `429` `Error`: Escritas demais deste cliente: `rate_limited`

Exemplo:

```sh
curl -X POST 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp/corrections' \
  -H 'Content-Type: application/json' -H 'X-Client-Id: device-7f3a2c' \
  -d '{"kind": "note", "comment": "A partir de outubro, a Missa de domingo é às 9h30."}'
```

### POST /churches/{id}/corrections/attachments

**Anexar uma foto a uma correção.** Os bytes de um arquivo de até 1 MB, como a foto do boletim. Envie a `key` devolvida nos `attachmentKeys` da correção.

| Parâmetro | In | Tipo | Obrigatório | Notas |
|---|---|---|---|---|
| `id` | path | string | sim | O id da igreja, feito do nome e da cidade |
| `X-Client-Id` | header | string |  | Um id fixo por aparelho ou pessoa; junto com o IP, identifica o cliente para o limite de escritas e para as confirmações |

Corpo da requisição:

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|

Respostas:

- `201` `{ key: string }`: Guardado
- `400` `Error`: Corpo vazio: `empty_body`
- `413` `Error`: Maior que 1 MB: `too_large`
- `429` `Error`: Escritas demais deste cliente: `rate_limited`

Exemplo:

```sh
curl -X POST 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp/corrections/attachments' \
  -H 'Content-Type: image/jpeg' -H 'X-Client-Id: device-7f3a2c' \
  --data-binary @bulletin.jpg
```

## Outros

### GET /health

**Verificação de funcionamento.**

Respostas:

- `200` `{ ok: boolean }`: OK

## Schemas

### Church

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `id` | string | sim |  |
| `name` | string | sim |  |
| `longName` | string \| null | sim |  |
| `address` | string \| null | sim |  |
| `city` | string \| null | sim |  |
| `region` | string \| null | sim |  |
| `postalCode` | string \| null | sim |  |
| `country` | string \| null | sim |  |
| `countryCode` | string \| null | sim |  |
| `lat` | number | sim |  |
| `lng` | number | sim |  |
| `geohash` | string | sim |  |
| `timezone` | string | sim |  |
| `phoneE164` | string \| null | sim |  |
| `email` | string \| null | sim |  |
| `status` | string \| null | sim |  |
| `featured` | boolean \| null | sim |  |
| `administration` | string \| null | sim |  |
| `institute` | string \| null | sim |  |
| `canonicalStatus` | string \| null | sim |  |
| `parishId` | string \| null | sim |  |
| `note` | string \| null | sim |  |
| `hasStructuredSchedule` | boolean \| null | sim |  |
| `lastVerifiedAt` | string \| null | sim |  |
| `verifiedSource` | string \| null | sim |  |
| `updatedAt` | string \| null | sim |  |
| `services` | Service[] \| null | sim |  |
| `texts` | ChurchText[] \| null | sim |  |
| `links` | ChurchLink[] \| null | sim |  |

### Service

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `id` | string | sim |  |
| `kind` | string | sim | mass \| confession \| adoration |
| `rite` | string |  | Mass only: latin_novus_ordo \| latin_tridentine \| byzantine \| … |
| `language` | string |  | ISO 639 |
| `rrule` | string | sim | iCal RRULE: one pattern at one time |
| `startTime` | string | sim | 'HH:MM' wall-clock in the church's timezone |
| `endTime` | string |  |  |
| `exdate` | string |  | RRuleSet EXDATE: cancelled dates |
| `rdate` | string |  | RRuleSet RDATE: one-off added dates |
| `locationNote` | string |  |  |
| `note` | string |  |  |
| `source` | string |  | import \| manual \| user |
| `confidence` | number |  |  |

### ChurchText

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `kind` | string | sim | mass_times \| seasonal_mass_times \| confession \| adoration \| info |
| `rawText` | string | sim |  |
| `sourceUpdatedAt` | string |  |  |

### ChurchLink

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `kind` | string | sim | website \| instagram \| facebook \| whatsapp \| youtube \| livestream \| donation |
| `url` | string | sim |  |

### ChurchKin

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `id` | string | sim |  |
| `name` | string | sim |  |

### Cluster

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `id` | string | sim | The geohash cell |
| `lat` | number | sim |  |
| `lng` | number | sim |  |
| `count` | integer | sim |  |
| `church` | ChurchKin |  | Named when the cell holds one church |

### Verification

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `id` | string | sim |  |
| `churchId` | string | sim |  |
| `serviceId` | string \| null | sim |  |
| `fingerprint` | string | sim |  |
| `createdAt` | string | sim |  |

### ChangeLine

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `label` | string | sim |  |
| `before` | string |  |  |
| `after` | string |  |  |

### ChangedChurch

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `id` | string | sim |  |
| `name` | string | sim |  |
| `isNew` | boolean | sim | Created by this change |
| `parish` | ChurchKin |  | The parish this church is a chapel of |
| `place` | string | sim |  |
| `lat` | number | sim |  |
| `lng` | number | sim |  |
| `changes` | ChangeLine[] | sim |  |
| `schedule` | { kind: string, label: string, times: string[] }[] | sim |  |

### Error

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `error` | string | sim |  |

### ValidationError

`success: false`

| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
| `success` | false | sim |  |
| `error` | object | sim |  |
| `↳ name` | "ZodError" | sim |  |
| `↳ message` | string | sim |  |
