commit 936aad779bcbb9425d4f2d8ead56b881c88542f5 Author: Welton Moura Date: Sat Apr 4 17:38:40 2026 -0300 feat: RRBEC Local Server - Go backend with Django sync - Implement local-first architecture with SQLite - Add bidirectional sync with Django via ChangeLog - JWT authentication with auto-refresh token - REST API for products, orders, commands, payments - Stock management with automatic deduction diff --git a/DOCUMENTACAO_PROJETO_django.md b/DOCUMENTACAO_PROJETO_django.md new file mode 100644 index 0000000..2fbf3d6 --- /dev/null +++ b/DOCUMENTACAO_PROJETO_django.md @@ -0,0 +1,111 @@ +# Documentação do Projeto RRBEC (Gestão Raul) + +Esta documentação fornece uma visão geral técnica e funcional do sistema **Gestão Raul**, abrangendo sua estrutura, regras de negócio, modelagem de banco de dados, APIs e mecanismos de autenticação. + +--- + +## 1. Estrutura do Projeto + +O projeto é baseado no framework **Django** (Python) e segue o padrão de arquitetura de aplicações modulares. + +### Diretórios Principais +- `gestaoRaul/`: Configurações centrais do Django (settings, urls, wsgi). +- `products/`: Gestão de produtos, categorias e unidades de medida. +- `clients/`: Cadastro de clientes e controle de débitos ("Fiados"). +- `mesas/`: Gestão das mesas físicas do estabelecimento. +- `comandas/`: Núcleo do sistema. Gerencia o consumo, estoque e fluxo de vendas. +- `orders/`: Controle da fila de produção (cozinha/bar). +- `payments/`: Registros de pagamentos e cálculos de taxas. +- `typePay/`: Tipos de pagamentos aceitos (Crédito, Débito, Pix, Fiado, etc.). +- `login/`: Lógica de autenticação e customização de tokens JWT. +- `templates/`: Arquivos HTML para a interface web (utiliza Vanilla CSS e JS). +- `media/`: Armazenamento de imagens de produtos. + +--- + +## 2. Regras de Negócio + +O sistema foi projetado para gerenciar o fluxo operacional de um restaurante/bar de ponta a ponta. + +### Fluxo de Comanda e Vendas +1. **Abertura**: Uma comanda é vinculada a uma mesa e, opcionalmente, a um cliente. O status inicial é `OPEN`. +2. **Lançamento de Itens**: Ao adicionar um produto a uma comanda, o sistema automaticamente: + - Deduz a quantidade do produto do estoque. + - Se o produto for composto (combo/receita), deduz os componentes individuais. + - Se o produto tiver a flag `cuisine=True`, gera um pedido (`Order`) na fila da cozinha. +3. **Gestão de Estoque**: Todas as entradas e saídas são registradas na tabela de `StockMovement` para auditoria. +4. **Fechamento**: Ao realizar o pagamento (total ou parcial), a comanda pode ser encerrada (`CLOSED`). +5. **Taxa de Serviço**: O sistema calcula automaticamente uma taxa de 10% sobre o consumo total. + +### Fila de Produção (Cozinha) +Os pedidos passam por estados cronometrados: +- `Queue` (Em espera) -> `Preparing` (Preparando) -> `Finished` (Pronto) -> `Delivered` (Entregue). +- Também é possível cancelar um pedido, o que gera um estorno automático para o estoque. + +### Sistema de "Fiado" (Débitos de Clientes) +- Clientes podem ter comandas marcadas com o tipo de pagamento "FIADO". +- O sistema mantém um registro do débito acumulado do cliente. +- Existem endpoints específicos para listar e quitar múltiplos débitos de uma vez. + +--- + +## 3. Modelagem do Banco de Dados + +O banco de dados utiliza a seguinte estrutura relacional (simplificada): + +### Principais Entidades +- **Mesa**: `id`, `name`, `active`. +- **Client**: `id`, `name`, `debt`, `contact`, `active`. +- **Product**: `id`, `name`, `price`, `quantity`, `category_id`, `cuisine` (boolean), `active`. +- **ProductComponent**: (Tabela intermediária de composição) Liga um `Product` a outros `Products` com uma `quantity_required`. +- **Comanda**: `id`, `mesa_id`, `client_id`, `user_id`, `status` (OPEN/CLOSED/FIADO), `dt_open`, `dt_close`. +- **ProductComanda**: `id`, `comanda_id`, `product_id`, `data_time`, `applicant`. +- **Order (Pedido)**: `id`, `productComanda_id`, `obs`, `queue`, `preparing`, `finished`, `delivered`, `canceled`. +- **Payments**: `id`, `comanda_id`, `type_pay_id`, `value`, `client_id`, `datetime`. + +--- + +## 4. API e Autenticação + +A API é construída com **Django REST Framework (DRF)** e segue os princípios REST. + +### Autenticação +O sistema utiliza **JWT (JSON Web Token)** para proteger os endpoints. + +1. **Obter Token**: `POST /api/v1/token/` + - Body: `{"username": "...", "password": "..."}` + - Retorno: `access` e `refresh` tokens, além de dados do usuário (ID, nome, grupos). +2. **Atualizar Token**: `POST /api/v1/token/refresh/` + - Body: `{"refresh": "..."}` +3. **Uso**: Deve-se enviar o token no cabeçalho: `Authorization: Bearer `. + +### Endpoints Principais (`/api/v1/`) + +| Endpoint | Método | Descrição | +| :--- | :--- | :--- | +| `comandas/` | GET/POST | Lista ou cria novas comandas. | +| `comandas/{id}/pagar/` | POST | Registra pagamento e fecha a comanda. | +| `comandas/{id}/apagar/` | POST | Limpa todos os itens e fecha a comanda (estorna estoque). | +| `items-comanda/` | POST/DELETE | Adiciona ou remove itens de uma comanda individualmente. | +| `orders/` | GET/PATCH | Lista pedidos da cozinha ou edita observações. | +| `orders/{id}/preparing/` | POST | Inicia o preparo do pedido. | +| `orders/{id}/finish/` | POST | Marca como pronto para entrega. | +| `clients/{id}/fiados/` | GET | Lista todas as comandas pendentes (fiado) de um cliente. | +| `clients/pagar_fiados/`| POST | Quita uma lista de IDs de comandas fiadas. | +| `products/` | GET | Lista produtos ativos e estoque. | + +### Lógica de Negócio Integrada na API +- **POST `items-comanda/`**: Ao postar um item, a API automaticamente executa `StockMovement.subTransactionStock` e cria um `Order` se necessário. +- **DELETE `items-comanda/{id}/`**: Ao deletar, a API executa `StockMovement.sumTransactionStock` para devolver o item ao estoque. + +--- + +## 5. Tecnologias Utilizadas +- **Backend**: Django 5.1, Django REST Framework, SimpleJWT. +- **Frontend**: HTML5, Vanilla CSS, JavaScript. +- **Service Worker**: Configurado para suporte a PWA (Progressive Web App). +- **Banco de Dados**: SQLite (Desenvolvimento) / PostgreSQL ou MySQL (Produção). +- **Middleware**: WhiteNoise (arquivos estáticos), CORS Headers. + +--- +*Atualizado em: 24 de Março de 2026* diff --git a/GUIA_API.md b/GUIA_API.md new file mode 100644 index 0000000..41e3710 --- /dev/null +++ b/GUIA_API.md @@ -0,0 +1,135 @@ +# Guia de Teste da API com Postman + +Este documento explica como testar os endpoints da API do sistema **Gestão Raul** utilizando o Postman. + +--- + +## 1. Passo 1: Obter o Token de Acesso (Login) + +Como a API é protegida, você precisa primeiro de um token de autorização. + +1. Abra o **Postman** e crie uma nova aba de requisição. +2. Mude o método para **POST**. +3. Coloque a URL: `http://localhost:8000/api/v1/token/` +4. Vá na aba **Body**, selecione a opção **raw** e escolha **JSON** no menu à direita. +5. Insira suas credenciais no formato abaixo: + ```json + { + "username": "seu_usuario_django", + "password": "sua_senha_django" + } + ``` +6. Clique em **Send**. +7. No resultado (JSON), copie o código que aparece no campo `"access"`. + +--- + +## 2. Passo 2: Acessar os Endpoints da API + +Agora que você tem o token, pode acessar qualquer endpoint protegido (ex: Pedidos). + +1. Crie uma nova aba de requisição no Postman. +2. Mude o método para **GET**. +3. Coloque a URL do endpoint que deseja testar, por exemplo: + `http://localhost:8000/api/v1/orders/` +4. Vá na aba **Authorization** (fica logo abaixo da URL). +5. No campo **Type**, selecione **Bearer Token**. +6. No campo **Token** (à direita), cole o código `"access"` que você copiou no Passo 1. +7. Clique em **Send**. + +Você deverá ver a lista de dados em formato JSON. + +--- + +## Dicas Rápidas +- **Erro 401 Unauthorized:** Significa que o token não foi enviado corretamente ou expirou. Obtenha um novo no Passo 1. +- **CORS:** O backend está configurado para aceitar requisições de outras origens (CORS), permitindo que seu frontend se conecte normalmente. +- **Endpoints Disponíveis:** + - `/api/v1/orders/` (Pedidos) + - `/api/v1/products/` (Produtos) + - `/api/v1/clients/` (Clientes) + - `/api/v1/mesas/` (Mesas) + - `/api/v1/comandas/` (Comandas) + - `/api/v1/items-comanda/` (Itens de Comanda) + - `/api/v1/categories/` (Categorias) + - `/api/v1/payment-types/` (Tipos de Pagamento) + - `/api/v1/payments/` (Registro de Pagamentos) + +--- + +## 3. Ações Especiais (Custom Actions) + +### Apagar/Limpar Comanda Inteira +Esta ação exclui todos os itens vinculados à comanda e muda o seu status para `CLOSED`. +- **Método:** `POST` +- **URL:** `http://localhost:8000/api/v1/comandas/{ID_DA_COMANDA}/apagar/` +- **Auth:** Bearer Token necessário. +- **Body:** Vazio. + +### Apagar um Item Individual da Comanda +Esta ação exclui um único item e devolve a quantidade correspondente ao estoque. +- **Método:** `DELETE` +- **URL:** `http://localhost:8000/api/v1/items-comanda/{ID_DO_ITEM}/` +- **Auth:** Bearer Token necessário. +- **Body:** Vazio. + +### Pagar e Fechar Comanda +Esta ação registra um pagamento e fecha a comanda automaticamente. +- **Método:** `POST` +- **URL:** `http://localhost:8000/api/v1/comandas/{ID_DA_COMANDA}/pagar/` +- **Auth:** Bearer Token necessário. +- **Body (JSON):** + ```json + { + "value": 50.00, + "type_pay": 1, // ID do tipo de pagamento + "client": null, // ID do cliente (opcional) + "description": "Pagamento total" // Opcional + } + ``` + +### Listar Fiados de um Cliente +Retorna todas as comandas com status `FIADO` vinculadas a um cliente específico. +- **Método:** `GET` +- **URL:** `http://localhost:8000/api/v1/clients/{ID_DO_CLIENTE}/fiados/` +- **Auth:** Bearer Token necessário. +- **Body:** Vazio. + +### Pagar Múltiplos Fiados +Recebe uma lista de IDs de comandas com status `FIADO`, gera os pagamentos e fecha todas. +- **Método:** `POST` +- **URL:** `http://localhost:8000/api/v1/clients/pagar_fiados/` +- **Auth:** Bearer Token necessário. +- **Body (JSON):** + ```json + { + "ids": [10, 11, 15] // IDs das comandas que deseja pagar + } + ``` + +--- + +## 4. Gerenciamento de Pedidos (Orders) + +### Editar Observação do Pedido +Altera as observações de um pedido específico. +- **Método:** `PATCH` +- **URL:** `http://localhost:8000/api/v1/orders/{ID_DO_PEDIDO}/` +- **Auth:** Bearer Token necessário. +- **Body (JSON):** + ```json + { + "obs": "Nova observação detalhada" + } + ``` + +### Atualizar Status do Pedido +Ações para movimentar o fluxo da cozinha/atendimento. +- **Método:** `POST` +- **URLs:** + - `.../api/v1/orders/{ID_DO_PEDIDO}/preparing/` (Iniciar Preparo) + - `.../api/v1/orders/{ID_DO_PEDIDO}/finish/` (Marcar como Pronto) + - `.../api/v1/orders/{ID_DO_PEDIDO}/deliver/` (Marcar como Entregue) + - `.../api/v1/orders/{ID_DO_PEDIDO}/cancel/` (Cancelar Pedido) +- **Auth:** Bearer Token necessário. +- **Body:** Vazio. diff --git a/SYNC_GO_MIDDLEWARE.md b/SYNC_GO_MIDDLEWARE.md new file mode 100644 index 0000000..d12fbdd --- /dev/null +++ b/SYNC_GO_MIDDLEWARE.md @@ -0,0 +1,54 @@ +# Guia de Sincronização: Django <-> Middleware Go + +Este documento descreve o funcionamento do sistema de **ChangeLog** implementado no Django para permitir que o middleware em Go mantenha uma cópia local (local-first) dos dados de forma eficiente. + +## 1. Funcionamento Técnico + +### Rastreamento de Mudanças (Django Signals) +Foi criada uma aplicação chamada `sync` que utiliza **Django Signals**. Sempre que um dos modelos abaixo é criado, editado ou excluído, uma entrada é gerada automaticamente na tabela `ChangeLog`: + +- `Product` +- `Comanda` +- `ProductComanda` +- `Order` +- `Client` +- `Categories` +- `Mesa` +- `Payments` + +### Tabela de Log (`ChangeLog`) +Cada registro no log contém: +- `id`: Identificador sequencial da mudança. +- `model_name`: Nome do modelo (ex: "Product"). +- `object_id`: ID do objeto que mudou. +- `action`: "SAVE" (para criação/edição) ou "DELETE". +- `timestamp`: Quando a mudança ocorreu. + +## 2. API de Sincronização + +O middleware Go deve consumir o seguinte endpoint: + +**Endpoint:** `GET /api/v1/sync/` + +### Parâmetros: +- `since_id` (opcional): Retorna apenas mudanças com ID maior que este valor. + +### Exemplo de Fluxo no Go: + +1. **Estado Inicial**: O Go armazena o `last_sync_id` (começando em 0). +2. **Polling**: De tempos em tempos (ex: a cada 5 segundos), o Go chama: + `GET http://seu-servidor/api/v1/sync/?since_id=100` (supondo que o último ID processado foi 100). +3. **Processamento**: + - O Django retorna uma lista de mudanças (ex: IDs 101, 102, 103). + - Para cada mudança `SAVE` no log, o Go deve fazer um `GET` no endpoint específico do modelo para buscar os dados atualizados: + - Se `model_name == "Product"`, buscar em `GET /api/v1/products/{object_id}/`. + - Para cada mudança `DELETE`, o Go deve remover o item correspondente do seu banco de dados local. +4. **Atualização**: O Go atualiza seu `last_sync_id` para o maior ID recebido (neste caso, 103). + +## 3. Vantagens +- **Performance**: O Go não precisa baixar todos os produtos/pedidos toda vez. Ele só baixa o que realmente mudou. +- **Detecção de Deleção**: O sistema informa explicitamente o que foi apagado no Django. +- **Resiliência**: Se a conexão cair, ao reconectar, o Go apenas retoma a partir do último ID que ele conhece, garantindo que nenhuma mudança seja perdida. + +--- +*Configurado em: 28 de Março de 2026* diff --git a/Servidor Golang.md b/Servidor Golang.md new file mode 100644 index 0000000..61233bd --- /dev/null +++ b/Servidor Golang.md @@ -0,0 +1,188 @@ +# Servidor Golang + +eu tenho uma aplicação de API em django rodando em uma VPS (aplicação de gestão de bar). tenho um app lado client para desktop feito com electron. eu predento criar uma especie de midware em go para criar um db local junto com o cliente e o go sicronizase com o django. para ser mais especifico, o go criaria um servidor local onde o cliente desktop acessaria o db local e modificaria, outros dispositivos mobile fariam o mesmo, e o go ficasse constantemente sicronizando com o django. como voce me recomendaria implementar essa ideia? + +## O Gemini disse + +Essa é uma arquitetura clássica de **Edge Computing** ou **Local-first**, muito comum em sistemas de PDV (Ponto de Venda), onde o bar não pode parar se a internet cair. + +O Go é a escolha perfeita para isso devido ao baixo consumo de memória e à facilidade de compilar um executável único que roda no Windows, Linux ou Mac. + +Aqui está a recomendação de arquitetura para o seu "Middleware Go": + +--- + +## 1. Stack Recomendada para o Middleware + +Para manter o sistema leve e rápido na VPS e no cliente: + +* **Banco de Dados Local:SQLite**. É um arquivo único, não precisa de instalação de servidor (como Postgres) e o Go lida perfeitamente com ele usando a lib `modernc.org/sqlite`​ (que não precisa de CGO). +* **API Local (Go):Gin** ou **Echo**. Eles servirão os endpoints que o Electron e os celulares vão consumir. +* **Sincronização:Gorm** (para facilitar a manipulação dos dados) e o pacote nativo `net/http`​ para falar com o Django. + +--- + +## 2. Desenho da Arquitetura + +1. **Electron/Mobile:** Fazem requisições `POST/GET`​ para `http://localhost:8080`​ (o Middleware Go). +2. **Go (Middleware):** * Salva a transação imediatamente no **SQLite** local (Garante a venda). + + * Retorna "Sucesso" para o cliente (Baixa latência). + * Em uma **Goroutine** (segundo plano), tenta enviar os dados para o **Django**. +3. **Django (Cloud):** Recebe os dados, valida e retorna um "OK". O Go marca aquela linha no SQLite como `sincronizado = true`​. + +--- + +## 3. Estratégia de Sincronização (O "Pulo do Gato") + +Para evitar conflitos e perda de dados, sua tabela no SQLite precisa de três campos extras: + +* ​`uid`​: Um UUID gerado no Go (não use IDs sequenciais de banco, ou o Django vai se confundir). +* ​`sincronizado`​: Um booleano (`true`​/`false`​). +* ​`atualizado_em`​: Um timestamp para saber qual versão é a mais nova. + +## Exemplo de Fluxo no Go: + +**Go** + +``` +// 1. O PDV envia um pedido +// 2. Go salva no SQLite: +db.Create(&Pedido{ID: "uuid-123", Total: 50.0, Sincronizado: false}) + +// 3. Uma Goroutine roda a cada 30 segundos: +func syncer() { + for { + var pedidos []Pedido + // Busca o que ainda não subiu para o Django + db.Where("sincronizado = ?", false).Find(&pedidos) + + for _, p := range pedidos { + err := enviarParaDjango(p) + if err == nil { + db.Model(&p).Update("sincronizado", true) + } + } + time.Sleep(30 * time.Second) + } +} +``` + +--- + +## 4. Como implementar passo a passo + +## Passo 1: O Banco Local + +Use o SQLite para que o dono do bar não precise instalar nada. O Go cria o arquivo `.db`​ automaticamente se ele não existir. + +## Passo 2: Service Discovery (Como o Mobile acha o Go?) + +Como o IP do computador onde o Go está rodando pode mudar, você tem duas opções: + +1. **IP Fixo:** Configurar o computador do bar com um IP estático na rede local. +2. **mDNS (ZeroConf):** Use uma lib em Go como `grandcat/zeroconf`​. O Go "anuncia" na rede: *"Eu sou o servidor do Bar"* . O app mobile detecta isso automaticamente sem digitar IP. + +## Passo 3: Autenticação Dupla + +O seu middleware em Go deve ter uma `API_KEY`​. + +* O **Electron** envia a chave para o **Go**. +* O **Go** usa as credenciais de sistema para falar com o **Django**. + +--- + +## 5. Vantagens dessa abordagem + +* **Offline First:** Se a internet do bar cair, o Electron continua funcionando 100%. Quando a internet volta, o Go sobe tudo em lote (*batch*). +* **Velocidade:** O Electron não espera o sinal ir até a nuvem e voltar para imprimir um pedido. É instantâneo. +* **Segurança:** Você pode criptografar o arquivo SQLite local. + +--- + +## + +Para um projeto que mistura banco de dados local, API e sincronização em segundo plano, o **Design Pattern** mais indicado é uma versão simplificada da **Clean Architecture** (ou Hexagonal). + +O objetivo é separar a **Lógica de Negócio** (regras do bar) da **Infraestrutura** (SQLite, API Django, Servidor Gin). Assim, se você quiser trocar o SQLite por um Postgres no futuro, ou o Gin pelo Fiber, o coração do app não muda. + +--- + +## 1. Estrutura de Pastas Sugerida + +Uma estrutura padrão em Go para projetos de médio porte segue este esqueleto: + +**Plaintext** + +``` +meu-middleware-go/ +├── cmd/ +│ └── server/ +│ └── main.go # Ponto de entrada (inicializa tudo) +├── internal/ # Código privado (não pode ser importado por outros projetos) +│ ├── api/ # Handlers HTTP (Rotas do Gin) +│ ├── database/ # Conexão e migrações do SQLite +│ ├── models/ # Estruturas de dados (Pedidos, Itens) +│ ├── repository/ # Consultas ao SQLite (CRUD) +│ ├── service/ # Lógica de negócio e regras de sincronização +│ └── sync/ # O "Worker" que fala com o Django +├── go.mod # Gerenciador de pacotes +└── go.sum # Checksum das dependências +``` + +--- + +## 2. Divisão de Responsabilidades (Design Pattern) + +Aqui aplicamos o padrão **Repository/Service**: + +## **Models (internal/models)** + +Define como os dados são. No seu caso, o diferencial é ter campos de controle de sincronização. + +**Go** + +``` +type Pedido struct { + ID string `gorm:"primaryKey"` + ValorTotal float64 + Sincronizado bool `gorm:"default:false"` + UpdatedAt time.Time +} +``` + +## **Repository (internal/repository)** + +É o único que "fala" com o SQLite. Ele não sabe o que é Django ou JSON, ele só sabe salvar e buscar no banco local. + +* *Função exemplo:*​`SavePedido(p models.Pedido)`​, `GetNaoSincronizados()`​. + +## **Service (internal/service)** + +Aqui mora a inteligência. O Service chama o Repository para salvar localmente e, ao mesmo tempo, pode disparar um evento para o módulo de sincronização. + +* *Responsabilidade:* Validar se o estoque permite a venda, calcular impostos locais. + +## **Sync Worker (internal/sync)** + +Este é um processo separado (usando **Goroutines**). Ele roda em um loop infinito (ex: a cada 30 segundos), pergunta ao Repository quais pedidos não foram sincronizados e tenta enviar para o Django via `http.Post`​. + +--- + +## 3. Por que essa estrutura? + +1. **Independência:** O `cmd/main.go`​ apenas "monta o quebra-cabeça". Se você precisar rodar apenas o sincronizador sem a API, é fácil separar. +2. **Testabilidade:** Você consegue testar a lógica de cálculo do bar (Service) sem precisar ligar o banco de dados de verdade. +3. **Segurança de Dados:** Ao separar o `sync`​, você garante que se o Django estiver fora do ar, a API local (que o Electron usa) continue respondendo rápido, pois ela só interage com o `repository`​ local. + +--- + +## 4. O Fluxo de Dados + +1. **Electron** envia JSON → **API (Gin)** . +2. **API** chama **Service**. +3. **Service** manda **Repository** salvar no **SQLite**. +4. **Sync Worker** (em background) lê o **SQLite** e envia para o **Django**. +5. **Django** responde OK → **Sync Worker** manda **Repository** marcar como `Sincronizado: true`​. + +‍ diff --git a/rrbec_server/.env b/rrbec_server/.env new file mode 100644 index 0000000..61ef15f --- /dev/null +++ b/rrbec_server/.env @@ -0,0 +1,8 @@ +# Server Configuration +PORT=8080 +DB_NAME=rrbec.db + +# Django Cloud Configuration +DJANGO_BASE_URL=http://127.0.0.1:8000 +DJANGO_MASTER_USER=welton89 +DJANGO_MASTER_PASSWORD=pindoba10 diff --git a/rrbec_server/ELECTRON_INTEGRATION.md b/rrbec_server/ELECTRON_INTEGRATION.md new file mode 100644 index 0000000..93945f4 --- /dev/null +++ b/rrbec_server/ELECTRON_INTEGRATION.md @@ -0,0 +1,86 @@ +# RRBEC Middleware - Guia de Integração (Electron/Desktop) + +Este guia documenta como a aplicação Electron deve se comunicar com o servidor middleware local em Go. + +## 1. Configurações Base +- **URL Base**: `http://localhost:8080/api/v1` +- **Porta Default**: `8080` (configurável no arquivo `.env` do servidor). +- **Formato**: Todas as requisições e respostas utilizam `application/json`. + +## 2. Autenticação (SimpleAuth) +O servidor não utiliza tokens JWT complexos localmente. A autenticação funciona assim: +1. Faça login em `/login` enviando `username` e `password`. +2. O servidor retornará um objeto de usuário. Capture o valor do campo `id` (inteiro). +3. Envie esse valor no cabeçalho HTTP `X-User-ID` em todas as rotas marcadas como **[PROTEGIDO]**. + +--- + +## 3. Endpoints da API + +### [PÚBLICO] Login +**POST** `/login` +- **Body**: `{ "username": "seu_usuario", "password": "sua_senha" }` +- **Retorno**: Objeto User completo. + +### [PÚBLICO] Listar Mesas +**GET** `/mesas` +- **Retorno**: Array de objetos mesas com `id`, `uuid`, `name`, `active`. + +### [PÚBLICO] Listar Produtos/Estoque +**GET** `/products` +- **Retorno**: Array de produtos com preços e quantidade em estoque. + +### [PÚBLICO] Listar Categorias +**GET** `/categories` + +### [PÚBLICO] Listar Clientes +**GET** `/clients` + +### [PÚBLICO] Listar Pedidos (Cozinha/Orders) +**GET** `/orders` + +### [PÚBLICO] Listar Tipos de Pagamento +**GET** `/payment-types` + +### [PÚBLICO] Listar Pagamentos Realizados +**GET** `/payments` + +### [PÚBLICO] Ver Comanda por ID +**GET** `/comandas/:id` (Ex: `/api/v1/comandas/9`) +- **Retorno**: Detalhes da comanda. + +--- + +## 4. Comandas e Itens (Ações) + +### [PROTEGIDO] Abrir Nova Comanda +**POST** `/comandas` +- **Headers**: `X-User-ID: ` +- **Body**: `{ "mesa_id": 1, "client_id": null }` + +### [PROTEGIDO] Lançar Pedido (Adicionar Item) +**POST** `/items-comanda` +- **Headers**: `X-User-ID: ` +- **Body**: `{ "comanda_id": 9, "product_id": 50, "applicant": "Nome do Garçom" }` + +### [PROTEGIDO] Deletar Item Individual +**DELETE** `/items-comanda/:id` +- **Headers**: `X-User-ID: ` + +### [PROTEGIDO] Limpar e Fechar Comanda (Apagar Inteira) +**POST** `/comandas/:id/apagar` +- **Headers**: `X-User-ID: ` +- **Efeito**: Remove todos os itens da comanda e muda o status para `CLOSED`. + +### [PROTEGIDO] Pagar e Fechar Comanda +**POST** `/comandas/:id/pagar` +- **Headers**: `X-User-ID: ` +- **Body**: + ```json + { + "value": 50.00, + "type_pay_id": 1, + "client_id": null + } + ``` +- **Efeito**: Registra o pagamento localmente e fecha a comanda. diff --git a/rrbec_server/cmd/server/main.go b/rrbec_server/cmd/server/main.go new file mode 100644 index 0000000..3b17cb5 --- /dev/null +++ b/rrbec_server/cmd/server/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "log" + "rrbec_server/internal/api" + "rrbec_server/internal/config" + "rrbec_server/internal/database" + "rrbec_server/internal/repository" + "rrbec_server/internal/service" + "rrbec_server/internal/sync" + + "github.com/gin-gonic/gin" +) + +func main() { + // 1. Load Configuration + config.LoadConfig() + + // 2. Initialize Database + db := database.InitDB(config.AppConfig.DBName) + + // 3. Initialize Dependency Injection + repo := repository.NewRepository(db) + svc := service.NewService(repo) + handler := api.NewHandler(svc) + + // 4. Start Sync Worker + syncer := sync.NewSyncer(repo, config.AppConfig.DjangoBaseURL, config.AppConfig.DjangoMasterUser, config.AppConfig.DjangoMasterPassword) + syncer.Start() + + // 4. Setup Gin Router + r := gin.Default() + + // API Routes + v1 := r.Group("/api/v1") + { + // Public routes + v1.POST("/login", handler.Login) + v1.GET("/products", handler.GetProducts) + v1.GET("/mesas", handler.GetMesas) + v1.GET("/comandas", handler.GetComandas) + v1.GET("/comandas/:id", handler.GetComandaByID) + v1.GET("/categories", handler.GetCategories) + v1.GET("/payment-types", handler.GetTypePayments) + v1.GET("/clients", handler.GetClients) + v1.GET("/orders", handler.GetOrders) + v1.GET("/payments", handler.GetPayments) + + // Protected routes + protected := v1.Group("/") + protected.Use(api.SimpleAuthMiddleware()) + { + protected.POST("/products", handler.CreateProduct) + protected.PATCH("/products/:id", handler.UpdateProduct) + protected.POST("/categories", handler.CreateCategory) + protected.PATCH("/categories/:id", handler.UpdateCategory) + protected.POST("/comandas", handler.CreateComanda) + protected.POST("/items-comanda", handler.AddItemToComanda) + protected.DELETE("/items-comanda/:id", handler.DeleteItemFromComanda) + protected.POST("/comandas/:id/apagar", handler.ClearComanda) + protected.POST("/comandas/:id/pagar", handler.PagarComanda) + protected.PATCH("/comandas/:id", handler.UpdateComanda) + protected.POST("/orders", handler.CreateOrder) + protected.PATCH("/orders/:id", handler.UpdateOrder) + protected.POST("/orders/:id/preparing", handler.SetOrderPreparing) + protected.POST("/orders/:id/finish", handler.SetOrderFinished) + protected.POST("/orders/:id/deliver", handler.SetOrderDelivered) + protected.POST("/orders/:id/cancel", handler.SetOrderCanceled) + protected.GET("/me", handler.GetCurrentUser) + } + } + + // 6. Run Server + log.Printf("Local server running on http://localhost:%s", config.AppConfig.Port) + if err := r.Run(":" + config.AppConfig.Port); err != nil { + log.Fatalf("failed to run server: %v", err) + } +} diff --git a/rrbec_server/go.mod b/rrbec_server/go.mod new file mode 100644 index 0000000..63852f2 --- /dev/null +++ b/rrbec_server/go.mod @@ -0,0 +1,53 @@ +module rrbec_server + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.49.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.25.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect +) diff --git a/rrbec_server/go.sum b/rrbec_server/go.sum new file mode 100644 index 0000000..b1fa4ab --- /dev/null +++ b/rrbec_server/go.sum @@ -0,0 +1,147 @@ +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE= +golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/rrbec_server/internal/api/handlers.go b/rrbec_server/internal/api/handlers.go new file mode 100644 index 0000000..2a92c8a --- /dev/null +++ b/rrbec_server/internal/api/handlers.go @@ -0,0 +1,457 @@ +package api + +import ( + "log" + "net/http" + "rrbec_server/internal/models" + "rrbec_server/internal/service" + "strconv" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + svc *service.Service +} + +func NewHandler(svc *service.Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) GetProducts(c *gin.Context) { + products, err := h.svc.GetProducts() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, products) +} + +func (h *Handler) CreateProduct(c *gin.Context) { + var product models.Product + if err := c.ShouldBindJSON(&product); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.svc.CreateProduct(&product); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "Product created successfully", "product": product}) +} + +func (h *Handler) UpdateProduct(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + var updates map[string]interface{} + if err := c.ShouldBindJSON(&updates); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.svc.UpdateProduct(uint(id), updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Product updated successfully"}) +} + +func (h *Handler) GetMesas(c *gin.Context) { + mesas, err := h.svc.GetMesas() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, mesas) +} + +func (h *Handler) GetCategories(c *gin.Context) { + categories, err := h.svc.GetCategories() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, categories) +} + +func (h *Handler) CreateCategory(c *gin.Context) { + var cat models.Category + if err := c.ShouldBindJSON(&cat); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.svc.CreateCategory(&cat); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, cat) +} + +func (h *Handler) UpdateCategory(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + var updates map[string]interface{} + if err := c.ShouldBindJSON(&updates); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.svc.UpdateCategory(uint(id), updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Category updated successfully"}) +} + +func (h *Handler) GetTypePayments(c *gin.Context) { + types, err := h.svc.GetTypePayments() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, types) +} + +func (h *Handler) GetClients(c *gin.Context) { + clients, err := h.svc.GetClients() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, clients) +} + +func (h *Handler) GetOrders(c *gin.Context) { + orders, err := h.svc.GetOrders() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, orders) +} + +func (h *Handler) CreateOrder(c *gin.Context) { + var order models.Order + if err := c.ShouldBindJSON(&order); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.svc.CreateOrder(&order); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, order) +} + +func (h *Handler) UpdateOrder(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + var updates map[string]interface{} + if err := c.ShouldBindJSON(&updates); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.svc.UpdateOrder(uint(id), updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Order updated successfully"}) +} + +func (h *Handler) SetOrderPreparing(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + if err := h.svc.SetOrderPreparing(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Order is now preparing"}) +} + +func (h *Handler) SetOrderFinished(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + if err := h.svc.SetOrderFinished(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Order finished"}) +} + +func (h *Handler) SetOrderDelivered(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + if err := h.svc.SetOrderDelivered(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Order delivered"}) +} + +func (h *Handler) SetOrderCanceled(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + if err := h.svc.SetOrderCanceled(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Order canceled"}) +} + +func (h *Handler) GetPayments(c *gin.Context) { + payments, err := h.svc.GetPayments() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, payments) +} + +func (h *Handler) CreateComanda(c *gin.Context) { + var comanda models.Comanda + if err := c.ShouldBindJSON(&comanda); err != nil { + log.Printf("DEBUG: CreateComanda bind error: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + log.Printf("DEBUG: CreateComanda received: MesaID=%d, UserID=%d, ClientID=%v", comanda.MesaID, comanda.UserID, comanda.ClientID) + + if comanda.MesaID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Mesa ID is required for sync consistency"}) + return + } + + if err := h.svc.CreateComanda(&comanda); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, comanda) +} + +func (h *Handler) AddItemToComanda(c *gin.Context) { + var item models.ProductComanda + if err := c.ShouldBindJSON(&item); err != nil { + log.Printf("DEBUG: AddItem bind error: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + log.Printf("DEBUG: AddItem received: ComandaID=%d, ProductID=%d", item.ComandaID, item.ProductID) + + if item.ComandaID == 0 || item.ProductID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ComandaID and ProductID are required for sync"}) + return + } + + if err := h.svc.AddItemToComandaRaw(&item); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, item) +} + +func (h *Handler) DeleteItemFromComanda(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + if err := h.svc.DeleteItem(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Item deleted successfully"}) +} + +func (h *Handler) ClearComanda(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + if err := h.svc.ClearComanda(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Comanda cleared and closed"}) +} + +func (h *Handler) UpdateComanda(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + var updates map[string]interface{} + if err := c.ShouldBindJSON(&updates); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.svc.UpdateComanda(uint(id), updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Comanda updated successfully"}) +} + +func (h *Handler) PagarComanda(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + var rawData map[string]interface{} + if err := c.ShouldBindJSON(&rawData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + log.Printf("DEBUG: PagarComanda RAW JSON: %v", rawData) + + var payment models.Payment + if v, ok := rawData["value"].(float64); ok { + payment.Value = models.Price(v) + } + if v, ok := rawData["type_pay"].(float64); ok { + payment.TypePayID = uint(v) + } else if v, ok := rawData["id_type_pay"].(float64); ok { + payment.TypePayID = uint(v) + } + if v, ok := rawData["client"].(float64); ok { + clientId := uint(v) + payment.ClientID = &clientId + } else if v, ok := rawData["client_id"].(float64); ok { + clientId := uint(v) + payment.ClientID = &clientId + } + if v, ok := rawData["description"].(string); ok { + payment.Description = v + } + + status := "CLOSED" + if v, ok := rawData["status"].(string); ok { + status = v + } + + if err := h.svc.PagarComanda(uint(id), &payment, status); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Payment registered and comanda updated to " + status}) +} + +func (h *Handler) GetComandas(c *gin.Context) { + comandas, err := h.svc.GetComandas() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, comandas) +} + +func (h *Handler) GetComandaByID(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"}) + return + } + + comanda, err := h.svc.GetComandaByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "comanda not found"}) + return + } + c.JSON(http.StatusOK, comanda) +} + +func (h *Handler) Login(c *gin.Context) { + var input struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.svc.Login(input.Username, input.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Login successful", + "user": user, + }) +} + +func (h *Handler) GetCurrentUser(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + c.JSON(http.StatusOK, gin.H{"user_id": userID}) +} diff --git a/rrbec_server/internal/api/middleware.go b/rrbec_server/internal/api/middleware.go new file mode 100644 index 0000000..002eafb --- /dev/null +++ b/rrbec_server/internal/api/middleware.go @@ -0,0 +1,24 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// SimpleAuthMiddleware checks for a "User-ID" header for simplicity as requested. +// This can be expanded to use JWT or Cookies if needed. +func SimpleAuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetHeader("X-User-ID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + c.Abort() + return + } + + // Map the user ID to the context for later use + c.Set("userID", userID) + c.Next() + } +} diff --git a/rrbec_server/internal/config/config.go b/rrbec_server/internal/config/config.go new file mode 100644 index 0000000..924e63f --- /dev/null +++ b/rrbec_server/internal/config/config.go @@ -0,0 +1,40 @@ +package config + +import ( + "log" + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + Port string + DBName string + DjangoBaseURL string + DjangoMasterUser string + DjangoMasterPassword string +} + +var AppConfig *Config + +func LoadConfig() { + err := godotenv.Load() + if err != nil { + log.Println("No .env file found, using defaults") + } + + AppConfig = &Config{ + Port: getEnv("PORT", "8080"), + DBName: getEnv("DB_NAME", "rrbec.db"), + DjangoBaseURL: getEnv("DJANGO_BASE_URL", ""), + DjangoMasterUser: getEnv("DJANGO_MASTER_USER", ""), + DjangoMasterPassword: getEnv("DJANGO_MASTER_PASSWORD", ""), + } +} + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} diff --git a/rrbec_server/internal/database/database.go b/rrbec_server/internal/database/database.go new file mode 100644 index 0000000..0ee3e30 --- /dev/null +++ b/rrbec_server/internal/database/database.go @@ -0,0 +1,40 @@ +package database + +import ( + "log" + "rrbec_server/internal/models" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDB(dataSourceName string) *gorm.DB { + var err error + DB, err = gorm.Open(sqlite.Open(dataSourceName), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } + + // Auto Migration + err = DB.AutoMigrate( + &models.Mesa{}, + &models.Client{}, + &models.Category{}, + &models.Product{}, + &models.ProductComponent{}, + &models.Comanda{}, + &models.ProductComanda{}, + &models.Order{}, + &models.TypePay{}, + &models.Payment{}, + &models.User{}, + &models.SyncState{}, + ) + if err != nil { + log.Fatalf("failed to migrate database: %v", err) + } + + return DB +} diff --git a/rrbec_server/internal/models/models.go b/rrbec_server/internal/models/models.go new file mode 100644 index 0000000..cf21ebd --- /dev/null +++ b/rrbec_server/internal/models/models.go @@ -0,0 +1,177 @@ +package models + +import ( + "strconv" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Custom type to handle Price as string or float from JSON +type Price float64 + +func (p *Price) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + s := string(data) + // Remove quotes if it's a string + if s[0] == '"' { + s = s[1 : len(s)-1] + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return err + } + *p = Price(f) + return nil +} + +// Base model with UUID and sync fields +type Base struct { + ID uint `gorm:"primaryKey" json:"id"` + UUID string `gorm:"uniqueIndex" json:"uuid"` + Sincronizado bool `gorm:"default:false" json:"sincronizado"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (b *Base) BeforeCreate(tx *gorm.DB) (err error) { + if b.UUID == "" { + b.UUID = uuid.New().String() + } + return +} + +type Mesa struct { + Base + Name string `json:"name"` + Location string `json:"location"` + Active bool `gorm:"default:false" json:"active"` +} + +type Client struct { + Base + Name string `json:"name"` + Debt Price `gorm:"default:0" json:"debt"` + CreatedAt time.Time `json:"created_at"` + Active bool `gorm:"default:true" json:"active"` + Contact string `json:"contact"` +} + +type Category struct { + Base + Name string `json:"name"` + Active bool `gorm:"default:true" json:"active"` +} + +type Product struct { + Base + Name string `json:"name"` + Description string `json:"description"` + Image string `json:"image"` // URL or Path + Price Price `json:"price"` + Quantity int `gorm:"default:0" json:"quantity"` + CategoryID uint `gorm:"column:category" json:"category"` + Cuisine bool `gorm:"default:false" json:"cuisine"` + Active bool `gorm:"default:true" json:"active"` + UnitOfMeasure *uint `gorm:"column:unit_of_measure" json:"unit_of_measure"` +} + +type ProductComponent struct { + Base + CompositeProductID uint `json:"composite_product"` + ComponentProductID uint `json:"component_product"` + QuantityRequired float64 `json:"quantity_required"` +} + +type Comanda struct { + Base + MesaID uint `json:"mesa"` + UserID uint `json:"user"` + TypePayID *uint `json:"type_pay"` + ClientID *uint `json:"client"` + Name string `json:"name"` + Status string `json:"status"` // OPEN, CLOSED, FIADO + DtOpen time.Time `json:"dt_open"` + DtClose *time.Time `json:"dt_close"` + + // Nested items from Django (not stored in coma nda table) + Items []ProductComanda `gorm:"-" json:"items"` +} + +type ProductComanda struct { + Base + ComandaID uint `json:"comanda"` + ProductID uint `json:"product"` + ProductName string `gorm:"-" json:"product_name"` // Read-only + ProductPrice Price `gorm:"-" json:"product_price"` // Read-only + DateTime time.Time `json:"data_time"` + Applicant string `json:"applicant"` + Obs string `gorm:"-" json:"obs"` // From Order +} + +type Order struct { + Base + ProductComandaID *uint `json:"productComanda"` + ProductID uint `json:"id_product"` + ComandaID uint `json:"id_comanda"` + Obs string `json:"obs"` + Queue time.Time `json:"queue"` + Preparing *time.Time `json:"preparing"` + Finished *time.Time `json:"finished"` + Delivered *time.Time `json:"delivered"` + Canceled *time.Time `json:"canceled"` +} + +func (o *Order) BeforeCreate(tx *gorm.DB) (err error) { + // Call Base.BeforeCreate to generate UUID + if err := o.Base.BeforeCreate(tx); err != nil { + return err + } + if o.Queue.IsZero() { + o.Queue = time.Now() + } + return +} + +type TypePay struct { + Base + Name string `json:"name"` + Active bool `gorm:"default:true" json:"active"` +} + +type Payment struct { + Base + Value Price `json:"value"` + TypePayID uint `json:"type_pay"` + ComandaID uint `json:"comanda"` + ClientID *uint `json:"client"` + Description string `json:"description"` + DateTime time.Time `json:"datetime"` +} + +type User struct { + Base + Username string `gorm:"uniqueIndex" json:"username"` + Password string `json:"password"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + IsStaff bool `json:"is_staff"` + IsActive bool `json:"is_active"` +} + +type SyncState struct { + ID uint `gorm:"primaryKey"` + LastSyncID int `gorm:"default:0"` +} + +type ChangeLog struct { + ID int `json:"id"` + ModelName string `json:"model_name"` + ObjectID uint `json:"object_id"` + Action string `json:"action"` // SAVE or DELETE + Timestamp time.Time `json:"timestamp"` +} diff --git a/rrbec_server/internal/repository/repository.go b/rrbec_server/internal/repository/repository.go new file mode 100644 index 0000000..a953b64 --- /dev/null +++ b/rrbec_server/internal/repository/repository.go @@ -0,0 +1,410 @@ +package repository + +import ( + "fmt" + "gorm.io/gorm" + "log" + "rrbec_server/internal/models" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +// Mesa +func (r *Repository) GetMesas() ([]models.Mesa, error) { + var mesas []models.Mesa + err := r.db.Find(&mesas).Error + return mesas, err +} + +func (r *Repository) CreateMesa(mesa *models.Mesa) error { + return r.db.Create(mesa).Error +} + +func (r *Repository) SaveMesa(mesa *models.Mesa) error { + return r.db.Save(mesa).Error +} + +// Client +func (r *Repository) GetClients() ([]models.Client, error) { + var clients []models.Client + err := r.db.Find(&clients).Error + return clients, err +} + +func (r *Repository) CreateClient(client *models.Client) error { + return r.db.Create(client).Error +} + +func (r *Repository) SaveClient(client *models.Client) error { + return r.db.Save(client).Error +} + +// Product +func (r *Repository) GetProducts() ([]models.Product, error) { + var products []models.Product + err := r.db.Find(&products).Error + return products, err +} + +func (r *Repository) SaveProduct(product *models.Product) error { + return r.db.Save(product).Error +} + +func (r *Repository) GetProductByID(id uint) (*models.Product, error) { + var product models.Product + err := r.db.First(&product, id).Error + return &product, err +} + +func (r *Repository) UpdateProductFields(id uint, updates map[string]interface{}) error { + updates["sincronizado"] = false + return r.db.Model(&models.Product{}).Where("id = ?", id).Updates(updates).Error +} + +func (r *Repository) DeductStock(productID uint, quantity int) error { + return r.db.Model(&models.Product{}).Where("id = ?", productID). + Update("quantity", gorm.Expr("quantity - ?", quantity)).Error +} + +func (r *Repository) RestoreStock(productID uint, quantity int) error { + return r.db.Model(&models.Product{}).Where("id = ?", productID). + Update("quantity", gorm.Expr("quantity + ?", quantity)).Error +} + +func (r *Repository) GetProductComponents(productID uint) ([]models.ProductComponent, error) { + var components []models.ProductComponent + err := r.db.Where("composite_product = ?", productID).Find(&components).Error + return components, err +} + +func (r *Repository) CreateProduct(product *models.Product) error { + return r.db.Create(product).Error +} + +// Comanda +func (r *Repository) GetComandas() ([]models.Comanda, error) { + var comandas []models.Comanda + if err := r.db.Find(&comandas).Error; err != nil { + return nil, err + } + + // For each comanda, load its items + for i := range comandas { + var items []models.ProductComanda + r.db.Where("comanda_id = ?", comandas[i].ID).Find(&items) + comandas[i].Items = items + } + + return comandas, nil +} + +func (r *Repository) CreateComanda(comanda *models.Comanda) error { + return r.db.Create(comanda).Error +} + +func (r *Repository) SaveComanda(comanda *models.Comanda) error { + return r.db.Save(comanda).Error +} + +func (r *Repository) SaveCategory(cat *models.Category) error { + return r.db.Save(cat).Error +} + +func (r *Repository) CreateCategory(cat *models.Category) error { + return r.db.Create(cat).Error +} + +func (r *Repository) UpdateCategoryFields(id uint, updates map[string]interface{}) error { + return r.db.Model(&models.Category{}).Where("id = ?", id).Updates(updates).Error +} + +func (r *Repository) SaveTypePay(tp *models.TypePay) error { + return r.db.Save(tp).Error +} + +func (r *Repository) GetCategories() ([]models.Category, error) { + var categories []models.Category + err := r.db.Find(&categories).Error + return categories, err +} + +func (r *Repository) GetTypePayments() ([]models.TypePay, error) { + var types []models.TypePay + err := r.db.Find(&types).Error + return types, err +} + +func (r *Repository) GetComandaByID(id uint) (*models.Comanda, error) { + var comanda models.Comanda + if err := r.db.First(&comanda, id).Error; err != nil { + return nil, err + } + + var items []models.ProductComanda + r.db.Where("comanda_id = ?", comanda.ID).Find(&items) + comanda.Items = items + + return &comanda, nil +} + +// Items +func (r *Repository) AddItemToComanda(item *models.ProductComanda) error { + return r.db.Create(item).Error +} + +func (r *Repository) SaveProductComanda(item *models.ProductComanda) error { + return r.db.Save(item).Error +} + +func (r *Repository) GetItemsByComanda(comandaID uint) ([]models.ProductComanda, error) { + var items []models.ProductComanda + err := r.db.Where("comanda_id = ?", comandaID).Find(&items).Error + return items, err +} + +func (r *Repository) DeleteItem(itemID uint) error { + return r.db.Delete(&models.ProductComanda{}, itemID).Error +} + +func (r *Repository) GetOrders() ([]models.Order, error) { + var orders []models.Order + err := r.db.Find(&orders).Error + return orders, err +} + +func (r *Repository) SaveOrder(order *models.Order) error { + return r.db.Save(order).Error +} + +func (r *Repository) CreateOrder(order *models.Order) error { + return r.db.Create(order).Error +} + +func (r *Repository) GetOrderByPC(pcID uint) (*models.Order, error) { + var order models.Order + err := r.db.Where("product_comanda_id = ?", pcID).First(&order).Error + return &order, err +} + +func (r *Repository) UpdateOrder(order *models.Order) error { + return r.db.Save(order).Error +} + +func (r *Repository) UpdateOrderFields(id uint, updates map[string]interface{}) error { + mappedUpdates := make(map[string]interface{}) + for k, v := range updates { + switch k { + case "productComanda": + mappedUpdates["product_comanda_id"] = v + case "id_product": + mappedUpdates["product_id"] = v + case "id_comanda": + mappedUpdates["comanda_id"] = v + default: + mappedUpdates[k] = v + } + } + + mappedUpdates["sincronizado"] = false + + return r.db.Model(&models.Order{}).Where("id = ?", id).Updates(mappedUpdates).Error +} + +func (r *Repository) GetPayments() ([]models.Payment, error) { + var payments []models.Payment + err := r.db.Find(&payments).Error + return payments, err +} + +func (r *Repository) CreatePayment(payment *models.Payment) error { + return r.db.Create(payment).Error +} + +func (r *Repository) SavePayment(payment *models.Payment) error { + return r.db.Save(payment).Error +} + +// Sync +func (r *Repository) GetUnsynced() (map[string][]interface{}, error) { + // This is a simplified helper to find anything not synced. + // In a real app, you'd iterate per table. + return nil, nil // Placeholder for sync worker logic +} + +func (r *Repository) MarkAsSynced(model interface{}, id uint) error { + return r.db.Model(model).Where("id = ?", id).Update("sincronizado", true).Error +} + +func (r *Repository) SafeChangeID(tableName string, oldID, newID uint) error { + if oldID == newID { + return nil + } + + var count int64 + r.db.Table(tableName).Where("id = ?", newID).Count(&count) + if count > 0 { + squatterID := newID + 1000000 + for { + var sCount int64 + r.db.Table(tableName).Where("id = ?", squatterID).Count(&sCount) + if sCount == 0 { + break + } + squatterID++ + } + + log.Printf("COLLISION: ID %d in %s taken! Moving squatter to %d", newID, tableName, squatterID) + r.db.Exec(fmt.Sprintf("UPDATE %s SET id = ? WHERE id = ?", tableName), squatterID, newID) + + if tableName == "comandas" { + r.UpdateFK("product_comandas", "comanda_id", newID, squatterID) + r.UpdateFK("orders", "id_comanda", newID, squatterID) + r.UpdateFK("payments", "comanda_id", newID, squatterID) + } else if tableName == "product_comandas" { + r.UpdateFK("orders", "product_comanda_id", newID, squatterID) + } + } + + return r.UpdateID(tableName, oldID, newID) +} + +func (r *Repository) UpdateID(tableName string, oldID, newID uint) error { + return r.db.Exec(fmt.Sprintf("UPDATE %s SET id = ? WHERE id = ?", tableName), newID, oldID).Error +} + +func (r *Repository) UpdateFK(tableName, fkColumn string, oldID, newID uint) error { + return r.db.Exec(fmt.Sprintf("UPDATE %s SET %s = ? WHERE %s = ?", tableName, fkColumn, fkColumn), newID, oldID).Error +} + +// User +func (r *Repository) GetUserByUsername(username string) (*models.User, error) { + var user models.User + err := r.db.Where("username = ?", username).First(&user).Error + return &user, err +} + +func (r *Repository) SaveUser(user *models.User) error { + return r.db.Save(user).Error +} + +func (r *Repository) UpdateComandaStatus(id uint, status string) error { + return r.db.Model(&models.Comanda{}).Where("id = ?", id).Updates(map[string]interface{}{"status": status, "sincronizado": false}).Error +} + +func (r *Repository) ClearComandaItems(comandaID uint) error { + return r.db.Where("comanda_id = ?", comandaID).Delete(&models.ProductComanda{}).Error +} + +func (r *Repository) GetLastSyncID() int { + var state models.SyncState + r.db.FirstOrCreate(&state) + return state.LastSyncID +} + +func (r *Repository) SaveLastSyncID(id int) { + r.db.Model(&models.SyncState{}).Where("id = ?", 1).Update("last_sync_id", id) +} + +func (r *Repository) GetUnsyncedComandas() ([]models.Comanda, error) { + var results []models.Comanda + err := r.db.Where("sincronizado = ?", false).Find(&results).Error + return results, err +} + +func (r *Repository) GetUnsyncedItems() ([]models.ProductComanda, error) { + var results []models.ProductComanda + err := r.db.Where("sincronizado = ?", false).Find(&results).Error + return results, err +} + +func (r *Repository) GetUnsyncedOrders() ([]models.Order, error) { + var results []models.Order + err := r.db.Where("sincronizado = ?", false).Find(&results).Error + return results, err +} + +func (r *Repository) GetUnsyncedPayments() ([]models.Payment, error) { + var results []models.Payment + err := r.db.Where("sincronizado = ?", false).Find(&results).Error + return results, err +} + +func (r *Repository) GetUnsyncedProducts() ([]models.Product, error) { + var results []models.Product + err := r.db.Where("sincronizado = ?", false).Find(&results).Error + return results, err +} + +func (r *Repository) GetComandaToSync(id uint, dest *models.Comanda) error { + return r.db.First(dest, id).Error +} + +func (r *Repository) GetItemToSync(id uint, dest *models.ProductComanda) error { + return r.db.First(dest, id).Error +} + +func (r *Repository) GetPaymentToSync(id uint, dest *models.Payment) error { + return r.db.First(dest, id).Error +} + +func (r *Repository) GetProductToSync(id uint, dest *models.Product) error { + return r.db.First(dest, id).Error +} + +func (r *Repository) GetOrderToSync(id uint, dest *models.Order) error { + return r.db.First(dest, id).Error +} + +func (r *Repository) DeleteByID(modelName string, id uint) error { + var model interface{} + switch modelName { + case "Product": + model = &models.Product{} + case "Comanda": + model = &models.Comanda{} + case "ProductComanda": + model = &models.ProductComanda{} + case "Order": + model = &models.Order{} + case "Client": + model = &models.Client{} + case "Categories": + model = &models.Category{} + case "Mesa": + model = &models.Mesa{} + case "Payments": + model = &models.Payment{} + default: + return nil + } + return r.db.Delete(model, id).Error +} + +func (r *Repository) UpdateComandaFields(id uint, updates map[string]interface{}) error { + mappedUpdates := make(map[string]interface{}) + for k, v := range updates { + switch k { + case "mesa": + mappedUpdates["mesa_id"] = v + case "user": + mappedUpdates["user_id"] = v + case "client": + mappedUpdates["client_id"] = v + case "type_pay": + mappedUpdates["type_pay_id"] = v + default: + mappedUpdates[k] = v + } + } + + // Mark as unsynced so the background worker pushes the update + mappedUpdates["sincronizado"] = false + + return r.db.Model(&models.Comanda{}).Where("id = ?", id).Updates(mappedUpdates).Error +} diff --git a/rrbec_server/internal/service/service.go b/rrbec_server/internal/service/service.go new file mode 100644 index 0000000..e53fde4 --- /dev/null +++ b/rrbec_server/internal/service/service.go @@ -0,0 +1,280 @@ +package service + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "golang.org/x/crypto/pbkdf2" + "log" + "rrbec_server/internal/models" + "rrbec_server/internal/repository" + "strings" + "time" +) + +type Service struct { + repo *repository.Repository +} + +func NewService(repo *repository.Repository) *Service { + return &Service{repo: repo} +} + +// Comanda Logic +func (s *Service) CreateComanda(comanda *models.Comanda) error { + comanda.DtOpen = time.Now() + comanda.Status = "OPEN" + return s.repo.CreateComanda(comanda) +} + +// Item Addition and Stock Logic +func (s *Service) AddItemToComanda(comandaID, productID uint, applicant string) (*models.ProductComanda, error) { + comanda, err := s.repo.GetComandaByID(comandaID) + if err != nil { + return nil, err + } + if comanda.Status != "OPEN" { + return nil, errors.New("cannot add items to a closed or fiado comanda") + } + + product, err := s.repo.GetProductByID(productID) + if err != nil { + return nil, errors.New("product not found") + } + + if product.Quantity <= 0 { + return nil, errors.New("product out of stock") + } + + item := &models.ProductComanda{ + ComandaID: comandaID, + ProductID: productID, + ProductName: product.Name, + ProductPrice: product.Price, + DateTime: time.Now(), + Applicant: applicant, + } + err = s.repo.AddItemToComanda(item) + if err != nil { + return nil, err + } + + if err := s.deductProductStock(productID, 1); err != nil { + log.Printf("Warning: failed to deduct stock for product %d: %v", productID, err) + } + + if product.Cuisine { + order := &models.Order{ + ProductComandaID: &item.ID, + ProductID: productID, + ComandaID: comandaID, + Queue: time.Now(), + } + s.repo.CreateOrder(order) + } + + return item, nil +} + +func (s *Service) deductProductStock(productID uint, quantity int) error { + components, err := s.repo.GetProductComponents(productID) + if err != nil { + return err + } + + if len(components) > 0 { + for _, comp := range components { + if err := s.repo.DeductStock(comp.ComponentProductID, int(comp.QuantityRequired)); err != nil { + return err + } + } + return nil + } + + return s.repo.DeductStock(productID, quantity) +} + +func (s *Service) AddItemToComandaRaw(item *models.ProductComanda) error { + comanda, err := s.repo.GetComandaByID(item.ComandaID) + if err != nil { + return err + } + if comanda.Status != "OPEN" { + return errors.New("cannot add items to a closed or fiado comanda") + } + + product, err := s.repo.GetProductByID(item.ProductID) + if err != nil { + return errors.New("product not found") + } + + item.ProductName = product.Name + item.ProductPrice = product.Price + item.DateTime = time.Now() + + return s.repo.AddItemToComanda(item) +} + +// Sync Logic Placeholder +func (s *Service) SyncWithCloud() error { + // Task for the background worker + return nil +} + +func (s *Service) GetProducts() ([]models.Product, error) { + return s.repo.GetProducts() +} + +func (s *Service) CreateProduct(product *models.Product) error { + return s.repo.CreateProduct(product) +} + +func (s *Service) UpdateProduct(id uint, updates map[string]interface{}) error { + return s.repo.UpdateProductFields(id, updates) +} + +func (s *Service) GetMesas() ([]models.Mesa, error) { + return s.repo.GetMesas() +} + +func (s *Service) GetComandas() ([]models.Comanda, error) { + return s.repo.GetComandas() +} + +func (s *Service) GetComandaByID(id uint) (*models.Comanda, error) { + return s.repo.GetComandaByID(id) +} + +func (s *Service) GetCategories() ([]models.Category, error) { + return s.repo.GetCategories() +} + +func (s *Service) CreateCategory(cat *models.Category) error { + return s.repo.CreateCategory(cat) +} + +func (s *Service) UpdateCategory(id uint, updates map[string]interface{}) error { + return s.repo.UpdateCategoryFields(id, updates) +} + +func (s *Service) GetTypePayments() ([]models.TypePay, error) { + return s.repo.GetTypePayments() +} + +func (s *Service) GetClients() ([]models.Client, error) { + return s.repo.GetClients() +} + +func (s *Service) GetOrders() ([]models.Order, error) { + return s.repo.GetOrders() +} + +func (s *Service) CreateOrder(order *models.Order) error { + order.Queue = time.Now() + return s.repo.CreateOrder(order) +} + +func (s *Service) UpdateOrder(id uint, updates map[string]interface{}) error { + return s.repo.UpdateOrderFields(id, updates) +} + +func (s *Service) SetOrderPreparing(id uint) error { + now := time.Now() + return s.repo.UpdateOrderFields(id, map[string]interface{}{"preparing": &now}) +} + +func (s *Service) SetOrderFinished(id uint) error { + now := time.Now() + return s.repo.UpdateOrderFields(id, map[string]interface{}{"finished": &now}) +} + +func (s *Service) SetOrderDelivered(id uint) error { + now := time.Now() + return s.repo.UpdateOrderFields(id, map[string]interface{}{"delivered": &now}) +} + +func (s *Service) SetOrderCanceled(id uint) error { + now := time.Now() + return s.repo.UpdateOrderFields(id, map[string]interface{}{"canceled": &now}) +} + +func (s *Service) GetPayments() ([]models.Payment, error) { + return s.repo.GetPayments() +} + +func (s *Service) DeleteItem(itemID uint) error { + // 1. Check if there's an associated Order (Kitchen) + order, err := s.repo.GetOrderByPC(itemID) + if err == nil && order != nil { + // 2. Mark as Canceled + now := time.Now() + order.Canceled = &now + s.repo.UpdateOrder(order) + } + + // 3. Delete the item + return s.repo.DeleteItem(itemID) +} + +func (s *Service) ClearComanda(id uint) error { + if err := s.repo.ClearComandaItems(id); err != nil { + return err + } + return s.repo.UpdateComandaStatus(id, "CLOSED") +} + +func (s *Service) PagarComanda(id uint, payment *models.Payment, status string) error { + payment.ComandaID = id + payment.DateTime = time.Now() + + if err := s.repo.CreatePayment(payment); err != nil { + return err + } + + if status == "" { + status = "CLOSED" + } + return s.repo.UpdateComandaStatus(id, status) +} + +func (s *Service) UpdateComanda(id uint, updates map[string]interface{}) error { + return s.repo.UpdateComandaFields(id, updates) +} + +// User Auth +func (s *Service) Login(username, password string) (*models.User, error) { + user, err := s.repo.GetUserByUsername(username) + if err != nil { + return nil, errors.New("invalid credentials") + } + + if s.CheckDjangoPassword(password, user.Password) { + return user, nil + } + + return nil, errors.New("invalid credentials") +} + +func (s *Service) CheckDjangoPassword(password, hash string) bool { + parts := strings.Split(hash, "$") + if len(parts) != 4 { + return false + } + + algorithm := parts[0] + if algorithm != "pbkdf2_sha256" { + return false + } + + var iterations int + fmt.Sscanf(parts[1], "%d", &iterations) + salt := parts[2] + djangoHash := parts[3] + + // PBKDF2 with SHA256 + dk := pbkdf2.Key([]byte(password), []byte(salt), iterations, 32, sha256.New) + encoded := base64.StdEncoding.EncodeToString(dk) + + return encoded == djangoHash +} diff --git a/rrbec_server/internal/sync/syncer.go b/rrbec_server/internal/sync/syncer.go new file mode 100644 index 0000000..bd56be0 --- /dev/null +++ b/rrbec_server/internal/sync/syncer.go @@ -0,0 +1,871 @@ +package sync + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "rrbec_server/internal/models" + "rrbec_server/internal/repository" + + "github.com/google/uuid" +) + +type Syncer struct { + repo *repository.Repository + djangoURL string + masterUser string + masterPass string + accessToken string + refreshToken string +} + +func NewSyncer(repo *repository.Repository, djangoURL, masterUser, masterPass string) *Syncer { + return &Syncer{ + repo: repo, + djangoURL: djangoURL, + masterUser: masterUser, + masterPass: masterPass, + } +} + +func (s *Syncer) Start() { + go func() { + // 1. Login to Django + token, err := s.LoginToDjango() + if err != nil { + log.Printf("Initial Django login failed: %v", err) + } else { + s.masterPass = token // Use token for future requests + + // 2. Load Last Sync ID + lastID := s.repo.GetLastSyncID() + if lastID == 0 { + log.Println("Database is empty or fresh. Starting initial sync...") + s.InitialSync() + } + } + + // 3. Sync Loop (Bidirectional) + for { + log.Println("Sync cycle started...") + s.processChangeLog() // Cloud -> Local + s.pushLocalChanges() // Local -> Cloud + time.Sleep(5 * time.Second) + } + }() +} + +func (s *Syncer) ensureValidToken() { + if s.accessToken == "" { + token, err := s.LoginToDjango() + if err != nil { + log.Printf("Failed to login: %v", err) + return + } + s.accessToken = token + } +} + +func (s *Syncer) ensureValidTokenWithRefresh() { + s.ensureValidToken() + if s.refreshToken == "" { + return + } + + resp := s.fetchFromDjangoRaw("products") + if s.IsTokenExpired(resp) { + if err := s.RefreshAccessToken(); err != nil { + log.Printf("Token refresh failed, trying full login: %v", err) + s.accessToken = "" + s.ensureValidToken() + } + } +} + +func (s *Syncer) LoginToDjango() (string, error) { + url := fmt.Sprintf("%s/api/v1/token/", s.djangoURL) + loginData := map[string]string{ + "username": s.masterUser, + "password": s.masterPass, + } + + jsonData, _ := json.Marshal(loginData) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("login failed with status: %d", resp.StatusCode) + } + + var result struct { + Access string `json:"access"` + Refresh string `json:"refresh"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + s.accessToken = result.Access + s.refreshToken = result.Refresh + + return result.Access, nil +} + +func (s *Syncer) RefreshAccessToken() error { + url := fmt.Sprintf("%s/api/v1/token/refresh/", s.djangoURL) + refreshData := map[string]string{ + "refresh": s.refreshToken, + } + + jsonData, _ := json.Marshal(refreshData) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("Token refresh failed: %s", string(body)) + return fmt.Errorf("refresh failed with status: %d", resp.StatusCode) + } + + var result struct { + Access string `json:"access"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return err + } + + s.accessToken = result.Access + log.Println("Token refreshed successfully") + return nil +} + +func (s *Syncer) IsTokenExpired(responseBody []byte) bool { + var errResp struct { + Code string `json:"code"` + } + if json.Unmarshal(responseBody, &errResp) == nil { + return errResp.Code == "token_not_valid" || strings.Contains(string(responseBody), "token_not_valid") + } + return false +} + +func (s *Syncer) InitialSync() { + // Sync Products + var products []models.Product + s.fetchFromDjango("products", &products) + for _, p := range products { + p.Sincronizado = true + if p.UUID == "" { + p.UUID = uuid.New().String() + } + s.repo.SaveProduct(&p) + } + + // Sync Clients + var clients []models.Client + s.fetchFromDjango("clients", &clients) + for _, c := range clients { + c.Sincronizado = true + if c.UUID == "" { + c.UUID = uuid.New().String() + } + s.repo.SaveClient(&c) + } + + // Sync Mesas + var mesas []models.Mesa + s.fetchFromDjango("mesas", &mesas) + for _, m := range mesas { + m.Sincronizado = true + if m.UUID == "" { + m.UUID = uuid.New().String() + } + s.repo.SaveMesa(&m) + } + + // Sync Users + var users []models.User + s.fetchFromDjango("users", &users) + for _, u := range users { + u.Sincronizado = true + if u.UUID == "" { + u.UUID = uuid.New().String() + } + s.repo.SaveUser(&u) + } + + // Sync Categories + var categories []models.Category + s.fetchFromDjango("categories", &categories) + for _, cat := range categories { + cat.Sincronizado = true + if cat.UUID == "" { + cat.UUID = uuid.New().String() + } + s.repo.SaveCategory(&cat) + } + + // Sync Payment Types + var paymentTypes []models.TypePay + s.fetchFromDjango("payment-types", &paymentTypes) + for _, tp := range paymentTypes { + tp.Sincronizado = true + if tp.UUID == "" { + tp.UUID = uuid.New().String() + } + s.repo.SaveTypePay(&tp) + } + + // Sync Comandas + var comandas []models.Comanda + s.fetchFromDjango("comandas", &comandas) + for _, co := range comandas { + co.Sincronizado = true + if co.UUID == "" { + co.UUID = uuid.New().String() + } + s.repo.SaveComanda(&co) + + // Sync nested items from the serializer + for _, item := range co.Items { + item.Sincronizado = true + if item.UUID == "" { + item.UUID = uuid.New().String() + } + s.repo.SaveProductComanda(&item) + } + } + + // Sync Comanda Items + var items []models.ProductComanda + s.fetchFromDjango("items-comanda", &items) + for _, it := range items { + it.Sincronizado = true + if it.UUID == "" { + it.UUID = uuid.New().String() + } + s.repo.SaveProductComanda(&it) + } + + // Sync Orders + var orders []models.Order + s.fetchFromDjango("orders", &orders) + for _, o := range orders { + o.Sincronizado = true + if o.UUID == "" { + o.UUID = uuid.New().String() + } + s.repo.SaveOrder(&o) + } + + // Sync Payments + var payments []models.Payment + s.fetchFromDjango("payments", &payments) + for _, pay := range payments { + pay.Sincronizado = true + if pay.UUID == "" { + pay.UUID = uuid.New().String() + } + s.repo.SavePayment(&pay) + } + + log.Println("Initial sync completed successfully.") +} + +func (s *Syncer) fetchFromDjango(endpoint string, target interface{}) { + endpoint = strings.Trim(endpoint, "/") + + url := "" + if strings.Contains(endpoint, "?") { + url = fmt.Sprintf("%s/api/v1/%s", s.djangoURL, endpoint) + } else { + url = fmt.Sprintf("%s/api/v1/%s/", s.djangoURL, endpoint) + } + + token := s.accessToken + if token == "" { + token = s.masterPass + } + + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("Failed to fetch %s: %v", endpoint, err) + return + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + + if err := json.Unmarshal(bodyBytes, target); err != nil { + var paginated struct { + Results json.RawMessage `json:"results"` + } + if err2 := json.Unmarshal(bodyBytes, &paginated); err2 == nil && paginated.Results != nil { + if err3 := json.Unmarshal(paginated.Results, target); err3 != nil { + log.Printf("Failed to decode paginated %s: %v", endpoint, err3) + } + } else { + log.Printf("Failed to decode %s: %v", endpoint, err) + } + } + + log.Printf("Synchronized %s from cloud", endpoint) +} + +func (s *Syncer) fetchFromDjangoRaw(endpoint string) []byte { + endpoint = strings.Trim(endpoint, "/") + + url := "" + if strings.Contains(endpoint, "?") { + url = fmt.Sprintf("%s/api/v1/%s", s.djangoURL, endpoint) + } else { + url = fmt.Sprintf("%s/api/v1/%s/", s.djangoURL, endpoint) + } + + token := s.accessToken + if token == "" { + token = s.masterPass + } + + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("Failed to fetch %s: %v", endpoint, err) + return nil + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + + if s.IsTokenExpired(bodyBytes) { + log.Printf("Token expired, refreshing...") + if err := s.RefreshAccessToken(); err != nil { + log.Printf("Token refresh failed: %v", err) + } else { + return s.fetchFromDjangoRaw(endpoint) + } + } + + log.Printf("DEBUG: Raw response for %s: %s", endpoint, string(bodyBytes)) + return bodyBytes +} + +func (s *Syncer) processChangeLog() { + lastID := s.repo.GetLastSyncID() + url := fmt.Sprintf("sync?since_id=%d", lastID) + + var changes []models.ChangeLog + respBody := s.fetchFromDjangoRaw(url) + if respBody != nil { + if err := json.Unmarshal(respBody, &changes); err != nil { + log.Printf("ERROR: Failed to parse changelog: %v", err) + return + } + } + + log.Printf("DEBUG: Received %d changes from Django", len(changes)) + + if len(changes) == 0 { + return + } + + maxID := lastID + for _, change := range changes { + log.Printf("Processing change %s for %s (ID: %d)", change.Action, change.ModelName, change.ObjectID) + + if change.Action == "SAVE" { + s.fetchAndSaveObject(change.ModelName, change.ObjectID) + } else if change.Action == "DELETE" { + s.repo.DeleteByID(change.ModelName, change.ObjectID) + } + + if change.ID > maxID { + maxID = change.ID + } + } + + s.repo.SaveLastSyncID(maxID) +} + +func (s *Syncer) fetchAndSaveObject(modelName string, id uint) { + endpoint := "" + switch modelName { + case "Product": + endpoint = "products" + case "Comanda": + endpoint = "comandas" + case "ProductComanda": + endpoint = "items-comanda" + case "Order": + endpoint = "orders" + case "Client": + endpoint = "clients" + case "Categories": + endpoint = "categories" + case "Mesa": + endpoint = "mesas" + case "Payments": + endpoint = "payments" + default: + return + } + + url := fmt.Sprintf("%s/%d", endpoint, id) + + // Create temporary instance based on modelName + switch modelName { + case "Comanda": + var obj models.Comanda + s.fetchFromDjango(url, &obj) + if obj.UUID == "" { + var local models.Comanda + if err := s.repo.GetComandaToSync(obj.ID, &local); err == nil { + obj.UUID = local.UUID + } + if obj.UUID == "" { + obj.UUID = uuid.New().String() + } + } + obj.Sincronizado = true + s.repo.SaveComanda(&obj) + case "Product": + var obj models.Product + s.fetchFromDjango(url, &obj) + if obj.UUID == "" { + var local models.Product + if err := s.repo.GetProductToSync(obj.ID, &local); err == nil { + obj.UUID = local.UUID + } + if obj.UUID == "" { + obj.UUID = uuid.New().String() + } + } + obj.Sincronizado = true + s.repo.SaveProduct(&obj) + case "ProductComanda": + var obj models.ProductComanda + s.fetchFromDjango(url, &obj) + if obj.UUID == "" { + var local models.ProductComanda + if err := s.repo.GetItemToSync(obj.ID, &local); err == nil { + obj.UUID = local.UUID + } + if obj.UUID == "" { + obj.UUID = uuid.New().String() + } + } + obj.Sincronizado = true + s.repo.SaveProductComanda(&obj) + case "Order": + var obj models.Order + s.fetchFromDjango(url, &obj) + if obj.UUID == "" { + var local models.Order + if err := s.repo.GetOrderToSync(obj.ID, &local); err == nil { + obj.UUID = local.UUID + } + if obj.UUID == "" { + obj.UUID = uuid.New().String() + } + } + obj.Sincronizado = true + s.repo.SaveOrder(&obj) + case "Client": + var obj models.Client + s.fetchFromDjango(url, &obj) + if obj.UUID == "" { + obj.UUID = uuid.New().String() + } + obj.Sincronizado = true + s.repo.SaveClient(&obj) + case "Categories": + var obj models.Category + s.fetchFromDjango(url, &obj) + if obj.UUID == "" { + obj.UUID = uuid.New().String() + } + obj.Sincronizado = true + s.repo.SaveCategory(&obj) + case "Mesa": + var obj models.Mesa + s.fetchFromDjango(url, &obj) + if obj.UUID == "" { + obj.UUID = uuid.New().String() + } + obj.Sincronizado = true + s.repo.SaveMesa(&obj) + case "Payments": + var obj models.Payment + s.fetchFromDjango(url, &obj) + if obj.UUID == "" { + var local models.Payment + if err := s.repo.GetPaymentToSync(obj.ID, &local); err == nil { + obj.UUID = local.UUID + } + if obj.UUID == "" { + obj.UUID = uuid.New().String() + } + } + obj.Sincronizado = true + s.repo.SavePayment(&obj) + } +} + +func (s *Syncer) pushLocalChanges() { + // 1. Sync Comandas + comandas, err := s.repo.GetUnsyncedComandas() + if err == nil && len(comandas) > 0 { + for _, c := range comandas { + if c.MesaID == 0 { + log.Printf("SKIP: Comanda %d has mesa 0. Correct data required.", c.ID) + continue + } + + // Capture local ID before we might change it + oldID := c.ID + comandaName := c.Name + if comandaName == "" { + comandaName = fmt.Sprintf("Comanda %d", oldID) + } + + payload := map[string]interface{}{ + "mesa": c.MesaID, + "client": c.ClientID, + "user": c.UserID, + "status": c.Status, + "name": comandaName, + "uuid": c.UUID, + } + + // Try PATCH first + endpoint := fmt.Sprintf("comandas/%d", c.ID) + respBody, err := s.sendRequest("PATCH", endpoint, payload) + if err != nil && strings.Contains(err.Error(), "status: 404") { + respBody, err = s.sendRequest("POST", "comandas", payload) + } + + if err == nil { + var created models.Comanda + json.Unmarshal(respBody, &created) + + // CRITICAL: Update local ID and children safely + if created.ID != 0 && created.ID != oldID { + s.repo.SafeChangeID("comandas", oldID, created.ID) + // Update items that were pointing to the old ID + s.repo.UpdateFK("product_comandas", "comanda_id", oldID, created.ID) + s.repo.UpdateFK("orders", "id_comanda", oldID, created.ID) + s.repo.UpdateFK("payments", "comanda_id", oldID, created.ID) + } + + s.repo.MarkAsSynced(&models.Comanda{}, created.ID) + log.Printf("SUCCESS: Pushed Comanda %d (now %d) to cloud", oldID, created.ID) + } else { + log.Printf("ERROR pushing Comanda %d: %v", oldID, err) + } + } + } + + // 2. Sync Items + items, err := s.repo.GetUnsyncedItems() + if err == nil && len(items) > 0 { + for _, it := range items { + oldID := it.ID + payload := map[string]interface{}{ + "comanda": it.ComandaID, + "product": it.ProductID, + "applicant": it.Applicant, + "uuid": it.UUID, + } + + endpoint := fmt.Sprintf("items-comanda/%d", it.ID) + respBody, err := s.sendRequest("PATCH", endpoint, payload) + if err != nil && strings.Contains(err.Error(), "status: 404") { + respBody, err = s.sendRequest("POST", "items-comanda", payload) + } + + if err == nil { + var created models.ProductComanda + json.Unmarshal(respBody, &created) + if created.ID != 0 && created.ID != oldID { + s.repo.SafeChangeID("product_comandas", oldID, created.ID) + s.repo.UpdateFK("orders", "product_comanda_id", oldID, created.ID) + } + s.repo.MarkAsSynced(&models.ProductComanda{}, created.ID) + log.Printf("SUCCESS: Pushed Item %d (now %d) to cloud", oldID, created.ID) + } else { + log.Printf("ERROR pushing Item %d: %v", oldID, err) + } + } + } + + // 3. Sync Orders + orders, err := s.repo.GetUnsyncedOrders() + if err == nil && len(orders) > 0 { + for _, o := range orders { + oldID := o.ID + payload := map[string]interface{}{ + "productComanda": o.ProductComandaID, + "id_product": o.ProductID, + "id_comanda": o.ComandaID, + "obs": o.Obs, + "uuid": o.UUID, + } + + // Optional: mapping timestamps if they exist + if !o.Queue.IsZero() { + payload["queue"] = o.Queue + } + if o.Preparing != nil { + payload["preparing"] = o.Preparing + } + if o.Finished != nil { + payload["finished"] = o.Finished + } + if o.Delivered != nil { + payload["delivered"] = o.Delivered + } + if o.Canceled != nil { + payload["canceled"] = o.Canceled + } + + endpoint := fmt.Sprintf("orders/%d", o.ID) + respBody, err := s.sendRequest("PATCH", endpoint, payload) + if err != nil && strings.Contains(err.Error(), "status: 404") { + respBody, err = s.sendRequest("POST", "orders", payload) + } + + if err == nil { + var created models.Order + json.Unmarshal(respBody, &created) + if created.ID != 0 && created.ID != oldID { + s.repo.SafeChangeID("orders", oldID, created.ID) + } + s.repo.MarkAsSynced(&models.Order{}, created.ID) + log.Printf("SUCCESS: Pushed Order %d (now %d) to cloud", oldID, created.ID) + } else { + log.Printf("ERROR pushing Order %d: %v", oldID, err) + } + } + } + + // 4. Sync Payments + payments, err := s.repo.GetUnsyncedPayments() + if err == nil && len(payments) > 0 { + for _, p := range payments { + if p.TypePayID == 0 { + log.Printf("SKIP: Payment %d has invalid type_pay 0.", p.ID) + s.repo.MarkAsSynced(&models.Payment{}, p.ID) + continue + } + + oldID := p.ID + payload := map[string]interface{}{ + "value": p.Value, + "type_pay": p.TypePayID, + "comanda": p.ComandaID, + "description": p.Description, + "datetime": p.DateTime, + "uuid": p.UUID, + } + if p.ClientID != nil { + payload["client"] = *p.ClientID + } + + endpoint := fmt.Sprintf("payments/%d", p.ID) + respBody, err := s.sendRequest("PATCH", endpoint, payload) + if err != nil && strings.Contains(err.Error(), "status: 404") { + respBody, err = s.sendRequest("POST", "payments", payload) + } + + if err == nil { + var created models.Payment + json.Unmarshal(respBody, &created) + if created.ID != 0 && created.ID != oldID { + s.repo.SafeChangeID("payments", oldID, created.ID) + } + s.repo.MarkAsSynced(&models.Payment{}, created.ID) + log.Printf("SUCCESS: Pushed Payment %d (now %d) to cloud", oldID, created.ID) + } else { + log.Printf("ERROR pushing Payment %d: %v", oldID, err) + } + } + } + + // 5. Sync Products + products, err := s.repo.GetUnsyncedProducts() + if err == nil && len(products) > 0 { + for _, p := range products { + oldID := p.ID + payload := map[string]interface{}{ + "name": p.Name, + "description": p.Description, + "image": p.Image, + "price": p.Price, + "quantity": p.Quantity, + "category": p.CategoryID, + "cuisine": p.Cuisine, + "active": p.Active, + "uuid": p.UUID, + } + if p.UnitOfMeasure != nil { + payload["unit_of_measure"] = *p.UnitOfMeasure + } + + endpoint := fmt.Sprintf("products/%d", p.ID) + respBody, err := s.sendMultipartRequest("PATCH", endpoint, payload) + if err != nil && strings.Contains(err.Error(), "status: 404") { + respBody, err = s.sendMultipartRequest("POST", "products", payload) + } + + if err == nil { + var created models.Product + json.Unmarshal(respBody, &created) + if created.ID != 0 && created.ID != oldID { + s.repo.SafeChangeID("products", oldID, created.ID) + } + s.repo.MarkAsSynced(&models.Product{}, created.ID) + log.Printf("SUCCESS: Pushed Product %d (now %d) to cloud", oldID, created.ID) + } else { + log.Printf("ERROR pushing Product %d: %v", oldID, err) + } + } + } +} + +func (s *Syncer) sendRequest(method, endpoint string, data interface{}) ([]byte, error) { + url := fmt.Sprintf("%s/api/v1/%s/", s.djangoURL, endpoint) + jsonData, _ := json.Marshal(data) + + token := s.accessToken + if token == "" { + token = s.masterPass + } + + req, _ := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 401 || strings.Contains(string(respBody), "token_not_valid") { + log.Printf("Token expired during %s %s, refreshing...", method, endpoint) + if err := s.RefreshAccessToken(); err != nil { + return nil, fmt.Errorf("token refresh failed: %w", err) + } + return s.sendRequest(method, endpoint, data) + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return respBody, nil + } + return nil, fmt.Errorf("django returned status: %d | Response: %s", resp.StatusCode, string(respBody)) +} + +func (s *Syncer) sendMultipartRequest(method, endpoint string, data map[string]interface{}) ([]byte, error) { + urlStr := fmt.Sprintf("%s/api/v1/%s/", s.djangoURL, endpoint) + var b bytes.Buffer + w := multipart.NewWriter(&b) + + for key, val := range data { + if key == "image" { + imageStr, ok := val.(string) + if !ok || imageStr == "" { + continue // Skip empty image + } + + // If it's an existing URL, don't re-upload it + if strings.HasPrefix(imageStr, "http") || strings.HasPrefix(imageStr, "/media/") || strings.HasPrefix(imageStr, "/images/") { + continue + } + + // Base64 + if strings.HasPrefix(imageStr, "data:image") { + parts := strings.Split(imageStr, ",") + if len(parts) == 2 { + decoded, err := base64.StdEncoding.DecodeString(parts[1]) + if err == nil { + fw, _ := w.CreateFormFile(key, "upload.jpg") + fw.Write(decoded) + } + } + continue + } + + // Local file + fileContent, err := os.ReadFile(imageStr) + if err == nil { + fw, _ := w.CreateFormFile(key, filepath.Base(imageStr)) + fw.Write(fileContent) + } + } else { + // standard text field + w.WriteField(key, fmt.Sprintf("%v", val)) + } + } + w.Close() + + token := s.accessToken + if token == "" { + token = s.masterPass + } + + req, _ := http.NewRequest(method, urlStr, &b) + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 401 || strings.Contains(string(respBody), "token_not_valid") { + log.Printf("Token expired during %s %s, refreshing...", method, urlStr) + if err := s.RefreshAccessToken(); err != nil { + return nil, fmt.Errorf("token refresh failed: %w", err) + } + return s.sendMultipartRequest(method, endpoint, data) + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return respBody, nil + } + return nil, fmt.Errorf("django returned status: %d | Response: %s", resp.StatusCode, string(respBody)) +} + +func (s *Syncer) syncUsers() { + // Optional: logic for background sync of users if needed +} diff --git a/rrbec_server/main b/rrbec_server/main new file mode 100755 index 0000000..9775b0e Binary files /dev/null and b/rrbec_server/main differ diff --git a/rrbec_server/rrbec.db b/rrbec_server/rrbec.db new file mode 100644 index 0000000..b2f4c1a Binary files /dev/null and b/rrbec_server/rrbec.db differ diff --git a/rrbec_server/server b/rrbec_server/server new file mode 100755 index 0000000..4ae3949 Binary files /dev/null and b/rrbec_server/server differ