diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..e8926b4e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Relatar um bug +about: Relate um problema com o Fixr +title: "[Bug] " +labels: bug +--- + +## Descrição + +Uma descrição clara e concisa do bug. + +## Passos para reproduzir + +1. Vá até '...' +2. Clique em '...' +3. Veja o erro + +## Comportamento esperado + +O que você esperava que acontecesse. + +## Comportamento atual + +O que realmente aconteceu. Inclua mensagens de erro, stack traces ou screenshots, se relevante. + +## Ambiente + +- App afetado: (web / admin / server / workers) +- Navegador/SO (se relevante): +- Commit ou versão: + +## Contexto adicional + +Qualquer outra informação que possa ajudar a diagnosticar o problema. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..d65d8e81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,22 @@ +--- +name: Sugerir uma feature +about: Sugira uma ideia ou melhoria para o Fixr +title: "[Feature] " +labels: enhancement +--- + +## Problema + +Qual problema isso resolve? Para quem é? + +## Solução proposta + +Descreva a feature ou mudança que você gostaria de ver. + +## Alternativas consideradas + +Alguma solução alternativa ou workaround que você já considerou. + +## Contexto adicional + +Qualquer outra informação que possa ajudar (mockups, issues relacionadas, etc). diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..fabc0d9d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## O que mudou + + + +## Issue relacionada + + + +## Como testar + + + +## Checklist + +- [ ] `bun run check-types` passa +- [ ] `bun run lint` passa +- [ ] Testes adicionados/atualizados quando relevante +- [ ] Mensagens de commit seguem a convenção `action(Scope): message` diff --git a/.github/hooks/impeccable.json b/.github/hooks/impeccable.json new file mode 100644 index 00000000..8932694c --- /dev/null +++ b/.github/hooks/impeccable.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "type": "command", + "matcher": "edit|create|apply_patch", + "bash": "[ ! -f \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/impeccable\" ] || \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/impeccable\" hook", + "timeoutSec": 5 + } + ] + } +} diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 6830959f..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -set -e - -echo "Running lint-staged..." -bun x lint-staged - -echo "Running type checks..." -bun run check-types:ci - -echo "All checks passed!" -exit 0 diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000..6bd2a517 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,25 @@ +#!/bin/sh +set -e + +echo "Running lint..." +bun run lint + +echo "Running type checks..." +bun run check-types:ci + +echo "Formatting..." +bun run format + +if ! git diff --quiet; then + echo "" + echo "Formatter made changes. Committing them now..." + git add -A + git commit -m "chore(Repo): Run formatter" + echo "" + echo "A new commit with formatting fixes was created locally." + echo "Push again to include it." + exit 1 +fi + +echo "All checks passed!" +exit 0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f08fa9f6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# Fixr — Agent Guardrails + +Instructions for AI coding agents (Claude Code, Cursor, Copilot, etc.) working in this repository. This file is the open, tool-agnostic standard; `CLAUDE.md` imports it for Claude Code. + +## Project overview + +Fixr is a service-order management platform for electronics repair shops (see `README.md` for product context). It's a **Bun workspaces monorepo**: + +``` +apps/ + ├─ web/ Next.js — client/technician-facing app, JWT auth + ├─ admin/ Next.js — internal admin panel, Clerk auth + ├─ server/ Fastify REST API — business logic, DB access + └─ workers/ BullMQ job processors (email, etc.) + +packages/ + ├─ db/ Drizzle ORM schema, migrations, MySQL connection + ├─ schemas/ Zod schemas shared between frontend and backend + ├─ constants/ Shared constants/enums (roles, cookies, masks, messages) + ├─ env/ Centralized env var validation (Zod, per app) + ├─ permissions/ RBAC ability definitions + ├─ mail/ React Email templates + mail queue + ├─ infra/ Deploy infrastructure + └─ typescript-config/ Shared tsconfig bases +``` + +Each app/package with its own local conventions may have its own `CLAUDE.md` — check for one before working there. + +## Commands + +Run from the repo root unless noted: + +- `bun run dev` — start server, web, workers in dev mode +- `bun run dev:admin` — start admin app in dev mode +- `bun run build` / `build:packages` / `build:apps` / `build:` — build +- `bun run check-types` — TypeScript check across all workspaces +- `bun run lint` / `lint:write` — Biome check / autofix +- `bun run format` — `ultracite fix` (formats + safe lint fixes) +- `bun run db:start` / `db:stop` — MySQL + Redis via Docker +- `bun run db:migrate` — apply migrations +- `bun run db:generate` — generate migration SQL from schema changes +- `bun run db:studio` — Drizzle Studio GUI +- Inside `apps/server` or `apps/workers`: `bun run test` (Vitest) + +## Code conventions + +- **Formatting/linting**: Biome via the `ultracite` preset (`biome.json`). Tabs, double quotes, imports auto-organized. Don't hand-format against these rules or fight the formatter — run `bun run lint:write` instead of manual style edits. +- **TypeScript**: strict mode. Run `bun run check-types` after non-trivial changes. +- **IDs**: use `createId()` from `@paralleldrive/cuid2` for primary keys (`.$defaultFn(() => createId())` in Drizzle schema). Not UUID, not auto-increment. +- **Workspace imports**: use the `@fixr/` workspace protocol with subpath exports, e.g. `@fixr/db/connection`, `@fixr/db/schema`, `@fixr/schemas/account`, `@fixr/permissions`. Don't deep-import across a package's internal file paths. +- **Env vars**: always go through `@fixr/env/` (Zod-validated via `@t3-oss/env-core`). Never read `process.env` directly in app code — add the var to the relevant `packages/env/src/.ts` first. + +### Server (`apps/server`) module structure + +Each feature lives under `src/modules//` with a fixed layout: + +``` +modules// + ├─ routes/index.ts Fastify route registration, wires preHandlers + ├─ controllers/index.ts Thin orchestration: parse request, call service, shape response + ├─ services/index.ts Business logic + ├─ repositories/index.ts Drizzle queries (data access only) + ├─ schemas/index.ts Module-local Zod schemas + └─ errors/index.ts Module's AppError registry (see below) +``` + +Follow this layering for new modules/endpoints — don't put query logic in controllers or business logic in repositories. + +- **Errors**: every thrown error is a typed key from a registry built with `defineErrors({...})` (see `apps/server/src/modules/account/errors/index.ts` and `src/core/errors`). Throw with `throw new AppError("SOME_KEY")`; never throw raw `Error` or ad-hoc objects from route/controller/service code. Wrap route handlers with `withErrorHandler(...)`. +- **`tryCatch()`** (`src/core/lib/try-catch.ts`) wraps a promise into `{ data, error }` — prefer it over raw `try/catch` where it fits the existing style in that file. +- **Auth/RBAC**: JWT-based (`@fastify/jwt`), role lives at `user.company.role` in the JWT payload. `authenticate` middleware verifies the JWT and loads the user; `requirePermission(permissions..)` (from `@fixr/permissions`) gates routes. Add new permissions in `packages/permissions`, don't invent ad-hoc role checks in route handlers. +- **Caching**: repository methods use `@Cached({ ttl, key })` / `@InvalidateCache({ patterns })` decorators from `shared/infra/cache`. When a repository write invalidates cached reads, add the matching `@InvalidateCache` patterns. + +### Frontend (`apps/web`, `apps/admin`) + +- Next.js App Router. Route groups: `(public)` vs `(protected)` separate unauthenticated and authenticated routes. +- `apps/web` auth is JWT/cookie-based against `apps/server` (see `lib/auth/axios.ts`, `lib/auth/utils.ts`). `apps/admin` auth is **Clerk**, a separate system — don't mix the two apps' auth patterns. +- API calls from `apps/web` go through `lib/services/*.ts` using the shared `axios` instance and the `tryCatch()` helper (`lib/utils`), not raw `fetch`/`axios` calls scattered in components. +- `components/ui/**` and `components/magicui/**` are generated/vendored (shadcn-style) and excluded from Biome linting — treat them as vendored, avoid hand-editing unless intentionally customizing a primitive. + +### Database (`packages/db`) + +- Schema files live in `src/schema/*.ts` (Drizzle). To change the schema: edit the schema file, then run `bun run db:generate` to produce the migration — don't hand-write SQL in `drizzle/*.sql` or edit `drizzle/meta/*.json` snapshots. +- Shared validation schemas for DB-backed entities live in `packages/schemas`, consumed by both `apps/server` and frontend apps for consistent validation. + +## Guardrails — do not do without explicit approval + +- **Don't hand-edit generated migration files**: `packages/db/drizzle/*.sql` and `packages/db/drizzle/meta/*` are generated by `drizzle-kit`. Change the schema source and regenerate. +- **Don't commit env files** (`.env`, `.env.production`, etc.) or secrets. `.env.example` files are the place to document new required vars. +- **Don't touch deploy/infra** (`packages/infra`, `.github/workflows/deploy.yml`) or auth secrets (`JWT_SECRET`, `COOKIE_ENCRYPTION_SECRET`, Clerk keys) without the user explicitly asking. +- **Don't weaken RBAC** (removing/bypassing `requirePermission`, loosening `@fixr/permissions` ability rules) to make something "just work" — flag the permission gap instead. +- **Don't bypass Husky/lint-staged** (`--no-verify`) or leave `check-types`/`lint` failing. +- **Never push, and never commit unless explicitly asked.** When asked to commit, use `action(Scope): Message` (lowercase action, capitalized Scope, e.g. `feat(Account): Add avatar removal endpoint`) — enforced by `.husky/commit-msg`. +- Small changes can land directly on `develop`; larger features should get a branch named `action/scope/change` (e.g. `feat/service-orders/bulk-close`). + +## Testing + +Vitest is set up in `apps/server` and `apps/workers` (`bun run test` from inside the app). Coverage is currently minimal — check whether a module already has tests before assuming a suite exists, and don't remove the placeholder spec files as a way to "pass" checks. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..63212370 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,63 @@ +# Código de Conduta do Contribuidor + +## Nosso compromisso + +Nós, como membros, contribuidores e líderes, nos comprometemos a fazer da +participação em nossa comunidade uma experiência livre de assédio para todos, +independentemente de idade, tamanho corporal, deficiência visível ou invisível, +etnia, características sexuais, identidade e expressão de gênero, nível de +experiência, educação, condição socioeconômica, nacionalidade, aparência +pessoal, raça, religião ou identidade e orientação sexual. + +Nos comprometemos a agir e interagir de maneiras que contribuam para uma +comunidade aberta, acolhedora, diversa, inclusiva e saudável. + +## Nossos padrões + +Exemplos de comportamento que contribuem para um ambiente positivo: + +- Demonstrar empatia e gentileza com outras pessoas +- Respeitar opiniões, pontos de vista e experiências diferentes +- Dar e aceitar com elegância feedback construtivo +- Assumir responsabilidade e pedir desculpas a quem for afetado por nossos + erros, aprendendo com a experiência +- Focar no que é melhor não apenas para nós individualmente, mas para a + comunidade como um todo + +Exemplos de comportamento inaceitável: + +- Uso de linguagem ou imagens sexualizadas, e atenção ou investidas sexuais de + qualquer tipo +- Trollagem, comentários insultuosos ou depreciativos, e ataques pessoais ou + políticos +- Assédio público ou privado +- Publicar informações privadas de terceiros, como endereço físico ou de + e-mail, sem permissão explícita +- Outras condutas que possam razoavelmente ser consideradas inadequadas em um + ambiente profissional + +## Responsabilidades de aplicação + +Os mantenedores do projeto são responsáveis por esclarecer e aplicar nossos +padrões de comportamento aceitável, tomando ações corretivas apropriadas e +justas em resposta a qualquer comportamento que considerem inadequado, +ameaçador, ofensivo ou prejudicial. + +## Escopo + +Este Código de Conduta se aplica a todos os espaços da comunidade (issues, +pull requests, discussões), e também quando alguém está representando +oficialmente a comunidade em espaços públicos. + +## Aplicação + +Casos de comportamento abusivo, de assédio ou de outra forma inaceitável podem +ser reportados aos mantenedores do projeto através de uma denúncia +confidencial pelo GitHub. Todas as reclamações serão revisadas e investigadas +de forma pronta e justa. + +## Atribuição + +Este Código de Conduta é adaptado do [Contributor Covenant](https://www.contributor-covenant.org), +versão 2.1, disponível em +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..fa5e2df4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,113 @@ +# Contribuindo com o Fixr + +Obrigado pelo interesse em contribuir! Este documento cobre como configurar o ambiente, as convenções do projeto e como enviar alterações. + +## Como rodar o projeto localmente + +O Fixr é um monorepo com Bun workspaces (`apps/server`, `apps/web`, `apps/admin`, `apps/workers`, `packages/*`). Veja o `AGENTS.md` para o layout completo do projeto e referência de comandos. + +### Pré-requisitos + +- [Bun](https://bun.sh/) (v1.3.6 ou superior) +- [Docker](https://www.docker.com/) (para MySQL e Redis) +- [Git](https://git-scm.com/) + +### Passo a passo + +1. Clone o repositório e instale as dependências: + + ```bash + git clone https://github.com/fixrfam/fixr + cd fixr + bun install # também configura os hooks do Husky + ``` + +2. Configure as variáveis de ambiente. Cada app/pacote tem seu próprio `.env` local (não existe um único `.env` na raiz) — copie o `.env.example` de cada um para `.env` no mesmo diretório: + + ```bash + cp packages/db/.env.example packages/db/.env + cp apps/server/.env.example apps/server/.env + cp apps/web/.env.example apps/web/.env + cp apps/workers/.env.example apps/workers/.env + cp packages/mail/.env.example packages/mail/.env + cp apps/admin/.env.example apps/admin/.env # só necessário se for mexer no painel admin + ``` + + Os valores de exemplo já funcionam para desenvolvimento local; chaves de serviços externos (Resend, Google OAuth, Clerk, Cloudflare R2/Turnstile) precisam ser geradas nos respectivos painéis — veja os comentários em cada `.env.example`. As variáveis são validadas com Zod pelo pacote `@fixr/env`; o app não sobe se faltar alguma. + +3. Suba o banco (MySQL) e o Redis via Docker, e rode as migrations: + + ```bash + bun run db:start + bun run db:migrate + ``` + +4. Suba os apps em modo desenvolvimento: + + ```bash + bun run dev # server (3333), web (3000) e workers + bun run dev:admin # opcional: painel admin (6969), auth via Clerk + ``` + + - API REST: `http://localhost:3333` (docs em `/docs`) + - Web app: `http://localhost:3000` + - Admin panel: `http://localhost:6969` + - Drizzle Studio: `bun run db:studio` + +Antes de abrir um PR, garanta que os comandos abaixo passem: + +```bash +bun run check-types +bun run lint +``` + +## Branches + +- Alterações pequenas e autocontidas podem ser feitas diretamente na `develop`. +- Features maiores devem ficar em uma branch própria, nomeada assim: + + ``` + acao/escopo/mudanca + ``` + + Exemplo: `feat/evidences/s3-processor` + +## Mensagens de commit + +Os commits seguem este formato, escrito em inglês: + +``` +action(Scope): What has been done +``` + +Exemplo: `feat(Evidences): Fix processor to use the correct file reference` + +- `action` em minúsculo (`feat`, `fix`, `chore`, `docs`, `refactor`, `test`, ...). +- `Scope` capitalizado, nomeando a área/módulo afetado. +- As mensagens de commit são validadas por um hook `commit-msg` (Husky); commits fora do padrão são rejeitados. + +## Estilo de código + +- Formatação e lint rodam via Biome, usando o preset `ultracite`. Não formate manualmente contra as regras; rode `bun run lint:write`. +- TypeScript em modo strict. Rode `bun run check-types` após alterações não triviais. +- Siga o layout de módulos e as convenções descritas no `AGENTS.md` (e em qualquer `CLAUDE.md`/`AGENTS.md` local do app ou pacote que você estiver alterando). + +## Testes + +Vitest está configurado em `apps/server` e `apps/workers` (`bun run test` de dentro do app). Adicione testes para novas regras de negócio quando fizer sentido, e não remova specs placeholder existentes só para passar nas checagens. + +## Pull requests + +1. Faça um fork do repositório (contribuidores externos) ou crie uma branch a partir da `develop` (mantenedores). +2. Mantenha os PRs focados: uma feature ou correção por PR. +3. Garanta que `check-types` e `lint` passem, e que os testes cubram mudanças relevantes de comportamento. +4. Descreva *o que* mudou e *por que* na descrição do PR. +5. Vincule a issue relacionada, se houver. + +## Reportando bugs / sugerindo features + +Abra uma issue no GitHub usando os templates disponíveis. Para bugs, inclua passos para reproduzir, comportamento esperado vs. atual e detalhes relevantes do ambiente. + +## Código de conduta + +Este projeto segue o [Código de Conduta](./CODE_OF_CONDUCT.md). Ao participar, espera-se que você o respeite. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..69f35b94 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fixr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 08730aaa..a99831db 100644 --- a/README.md +++ b/README.md @@ -223,49 +223,31 @@ bun install 3. Configure as variáveis de ambiente: -O projeto utiliza um **sistema centralizado de variáveis de ambiente** através do pacote `@fixr/env`. Crie um arquivo `.env` na raiz do projeto com as seguintes variáveis: +O projeto utiliza um **sistema centralizado de variáveis de ambiente** através do pacote `@fixr/env`, mas cada app/pacote carrega o seu próprio arquivo `.env` local (não existe um único `.env` na raiz). Copie o `.env.example` de cada um para `.env` no mesmo diretório e preencha os valores: + +```bash +cp packages/db/.env.example packages/db/.env +cp apps/server/.env.example apps/server/.env +cp apps/web/.env.example apps/web/.env +cp apps/workers/.env.example apps/workers/.env +cp packages/mail/.env.example packages/mail/.env +cp apps/admin/.env.example apps/admin/.env # só necessário se for mexer no painel admin +``` --- #### Variáveis de Ambiente -```env -# Banco de Dados (MySQL) -MYSQL_ROOT_PASSWORD="docker" -MYSQL_DATABASE="fixr" -MYSQL_USER="fixr" -MYSQL_PASSWORD="fixr" -DB_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@localhost:3306/${MYSQL_DATABASE}" - -# Redis -REDIS_PASSWORD="docker" -REDIS_URL="redis://default:${REDIS_PASSWORD}@localhost:6379/0" - -# Servidor (API) -NODE_PORT="3333" -FRONTEND_URL="http://localhost:3000" -JWT_SECRET="seu-jwt-secret-aqui" -COOKIE_ENCRYPTION_SECRET="seu-cookie-secret-aqui" -COOKIE_DOMAIN="localhost" - -# Autenticação OAuth (Google) -GOOGLE_AUTH_CLIENT_ID="seu-google-client-id" -GOOGLE_AUTH_CLIENT_SECRET="seu-google-client-secret" -GOOGLE_AUTH_REDIRECT_URI="http://localhost:3333/auth/google/callback" - -# Email (Resend) -RESEND_KEY="re_seu-resend-key" - -# Web App (Frontend Principal) -NEXT_PUBLIC_APP_URL="http://localhost:3000" -NEXT_PUBLIC_API_URL="http://localhost:3333" -NEXT_PUBLIC_DOCS_URL="https://docs.fixr.com.br" -NEXT_PUBLIC_LINKTREE_URL="https://linktr.ee/fixrfam" - -# Admin Panel (Painel Administrativo) -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_seu-clerk-key" -CLERK_SECRET_KEY="sk_test_seu-clerk-secret" -``` +| Arquivo | Principais variáveis | +| --- | --- | +| `packages/db/.env` | `MYSQL_ROOT_PASSWORD`, `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD`, `DB_URL`, `REDIS_PASSWORD` | +| `apps/server/.env` | `REDIS_URL`, `RESEND_KEY`, `JWT_SECRET`, `COOKIE_ENCRYPTION_SECRET`, `COOKIE_DOMAIN`, `GOOGLE_AUTH_CLIENT_ID`, `GOOGLE_AUTH_CLIENT_SECRET`, `GOOGLE_AUTH_REDIRECT_URI`, `TURNSTILE_SECRET_KEY`, `NODE_PORT`, `FRONTEND_URL`, `ADMIN_URL`, `CLERK_SECRET_KEY`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_URL`, `R2_REGION`, `R2_PUBLIC_BASE_URL`, `R2_PRESIGN_EXPIRES_IN` | +| `apps/web/.env` | `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_DOCS_URL`, `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_LINKTREE_URL`, `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | +| `apps/admin/.env` | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`, `NEXT_PUBLIC_API_URL`, `REDIS_URL`, `FRONTEND_URL` | +| `apps/workers/.env` | `RESEND_KEY`, `REDIS_URL` | +| `packages/mail/.env` | `RESEND_KEY` | + +Os valores de exemplo (senhas `docker`/`fixr`, chaves de teste do Cloudflare Turnstile, etc.) já funcionam para desenvolvimento local; chaves de serviços externos (Resend, Google OAuth, Clerk, Cloudflare R2) precisam ser geradas nos respectivos painéis — veja os comentários em cada `.env.example`. > **Nota:** As variáveis são validadas automaticamente pelo pacote `@fixr/env` usando Zod, garantindo que todas as configurações necessárias estejam presentes antes da inicialização dos apps. @@ -285,16 +267,22 @@ bun run db:start bun run db:migrate ``` -3. **Inicie os aplicativos em modo desenvolvimento**: +3. **Inicie server, web e workers em modo desenvolvimento**: ```bash bun run dev ``` +4. **(Opcional) Inicie o painel admin**, separadamente: + +```bash +bun run dev:admin +``` + Os serviços estarão disponíveis em: - **API REST:** `http://localhost:3333` - **Web App:** `http://localhost:3000` -- **Admin Panel:** `http://localhost:6969` +- **Admin Panel:** `http://localhost:6969` (requer `bun run dev:admin`) - **Documentação API (Scalar):** `http://localhost:3333/docs` - **Drizzle Studio:** Execute `bun run db:studio` para acessar @@ -302,7 +290,8 @@ Os serviços estarão disponíveis em: ```bash # Desenvolvimento -bun run dev # Inicia todos os apps em modo desenvolvimento +bun run dev # Inicia server, web e workers em modo desenvolvimento +bun run dev:admin # Inicia o painel admin em modo desenvolvimento bun run check-types # Executa verificação de tipos em todos os projetos # Build @@ -317,6 +306,7 @@ bun run db:migrate # Executa migrations bun run db:generate # Gera migrations a partir dos schemas bun run db:studio # Abre Drizzle Studio (GUI do banco) bun run db:push # Push direto do schema (desenvolvimento) +bun run db:seed # Popula o banco com dados de exemplo # Formatação e Lint bun run format # Formata código com Ultracite @@ -374,3 +364,11 @@ O artigo referente ao 4º semestre** ainda será publicado e atualizado neste RE 7. TOTVS. _Saiba tudo sobre sistema para assistência técnica_. 15 jun. 2020. [Disponível aqui](https://go.ricardo.gg/fixrtotvsast). 8. HARSH, Kumar. _O que é Arquitetura de Aplicativos Web? Quebrando um aplicativo da Web_. Kinsta, 17 jan. 2025. [Disponível aqui](https://go.ricardo.gg/fixrarqweb). 9. MICROSOFT. _Estilo de arquitetura Queue-Worker Web_. Azure Architecture Center, 2025. [Disponível aqui](https://go.ricardo.gg/fixrwebqueue). + +#### Contribuindo + +Contribuições são bem-vindas! Veja o [CONTRIBUTING.md](./CONTRIBUTING.md) para instruções de setup, convenções de branch/commit e o processo de pull request. Este projeto segue o [Código de Conduta](./CODE_OF_CONDUCT.md). + +#### Licença + +Este projeto está licenciado sob a [Licença MIT](./LICENSE). diff --git a/apps/admin/.env.example b/apps/admin/.env.example index a0b9f489..81cc4817 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -3,3 +3,5 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY='' CLERK_SECRET_KEY='' NEXT_PUBLIC_API_URL='http://localhost:3333' +REDIS_URL='redis://default:docker@localhost:6379/0' # Change password on prod!! +FRONTEND_URL='http://localhost:3000' diff --git a/apps/admin/CLAUDE.md b/apps/admin/CLAUDE.md new file mode 100644 index 00000000..196e73f9 --- /dev/null +++ b/apps/admin/CLAUDE.md @@ -0,0 +1,16 @@ +Local context for `apps/admin`. Read the root `AGENTS.md` first — this only covers what's specific to this app. + +## Stack + +Next.js App Router, **Clerk** for auth (`@clerk/nextjs`, `@clerk/backend`) — this is separate from `apps/web`'s JWT/cookie auth system against `apps/server`. Don't reuse `apps/web`'s `lib/auth` patterns here or vice versa. + +## Structure + +- `app/(public)/...` vs `app/(protected)/...` route groups, same convention as `apps/web`. +- `components/ui` is vendored (shadcn-style) — treat as generated, avoid hand-editing unless deliberately customizing a primitive. +- Runs on port `6969` in dev (`bun run dev:admin` from root). + +## Don't + +- Don't introduce a second auth mechanism here — Clerk is the only auth system for this app. +- Don't duplicate Zod schemas already defined in `@fixr/schemas`. diff --git a/apps/server/.env.example b/apps/server/.env.example index 02615b1b..5e3ac9b6 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -19,3 +19,16 @@ TURNSTILE_SECRET_KEY='1x0000000000000000000000000000000AA' # Port for the API to run on NODE_PORT=3333 FRONTEND_URL='http://localhost:3000' +ADMIN_URL='http://localhost:6969' + +# Get this on https://dashboard.clerk.com/apps/{app_id}/instances/{instance_id}/api-keys +CLERK_SECRET_KEY='' + +# Cloudflare R2 +CLOUDFLARE_API_TOKEN='' +R2_ACCESS_KEY_ID='' +R2_SECRET_ACCESS_KEY='' +R2_BUCKET_URL='' +R2_REGION='auto' +R2_PUBLIC_BASE_URL='' +R2_PRESIGN_EXPIRES_IN='600' diff --git a/apps/server/CLAUDE.md b/apps/server/CLAUDE.md new file mode 100644 index 00000000..444cfd4e --- /dev/null +++ b/apps/server/CLAUDE.md @@ -0,0 +1,29 @@ +Local context for `apps/server`. Read the root `AGENTS.md` first — this only covers what's specific to this app. + +## Stack + +Fastify + `fastify-type-provider-zod` (Zod is the source of truth for both validation and OpenAPI schema generation — don't hand-write JSON schema). Drizzle ORM over MySQL, Redis, BullMQ-adjacent (actual job processing lives in `apps/workers`), Cloudflare R2 for uploads (`@aws-sdk/client-s3`), Clerk for the admin-facing endpoints that need it. + +## Adding a route + +1. Add/extend the module under `src/modules//` following the `routes/controllers/services/repositories/schemas/errors` layout (see root `AGENTS.md`). +2. Register the route plugin in `src/server.ts` (`await server.register(xRoutes, { prefix: "..." })`) and add its OpenAPI tag to the `tags` array there if it's a new module. +3. Add a docs schema entry in `src/core/docs/.docs.ts` — this drives the Scalar/Swagger UI at `/docs` and `/reference`. Don't skip it; every route in existing modules has one. +4. Gate with `[authenticate, requirePermission(permissions..)]` in `preHandler` unless the route is intentionally public (e.g. health check, email confirmation links). +5. Wrap the handler body in `withErrorHandler(...)`. + +## Auth specifics + +- Session JWT is read from a signed cookie (`cookieKey("session")` from `@fixr/constants/cookies`) via `@fastify/jwt` + `@fastify/cookie`, configured in `server.ts`. +- Refresh tokens and one-time tokens (email confirmation, password reset, account deletion) are separate DB tables (`refresh_tokens`, `one_time_tokens`) — see `modules/auth` and `modules/account` for the patterns before adding another token-based flow. +- `request.ability` (RBAC) is populated in two places: the `authenticate` middleware and `setupRBAC`'s `onRequest` hook. If you touch one, check the other stays consistent. + +## Errors + +Central error handling in `server.ts` has three layers, in this order: `ZodError` (request/response shape) → `AppError` (domain errors, see `core/lib/app-error.ts`) → Fastify schema validation errors. New domain errors go through a module's `defineErrors({...})` registry, not a new ad-hoc handler. + +## Don't + +- Don't add new S3/R2 upload logic outside `modules/uploads` and `core/lib/r2.ts` — there's already a purpose-based presign pattern to extend. +- Don't call `db` directly from a controller or route — always through a repository. +- Don't skip the `core/docs/*.docs.ts` entry for a new route; the OpenAPI spec is generated from it and consumed by `/docs`. diff --git a/apps/server/src/config/r2.ts b/apps/server/src/config/r2.ts index c5c34f66..41335a7c 100644 --- a/apps/server/src/config/r2.ts +++ b/apps/server/src/config/r2.ts @@ -1,11 +1,17 @@ -import { S3Client } from "@aws-sdk/client-s3"; +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { env } from "@fixr/env/server"; import { buildObjectPublicUrl as _buildObjectPublicUrl, isAllowedCompanyPhotoUrl as _isAllowedCompanyPhotoUrl, } from "../core/lib/r2"; -export { buildUploadObjectKey, sanitizeUploadFileName } from "../core/lib/r2"; +export { + buildAvatarObjectKey, + buildModelObjectKey, + buildUploadObjectKey, + sanitizeUploadFileName, +} from "../core/lib/r2"; function parseR2BucketUrl(bucketUrl: string) { const parsed = new URL(bucketUrl); @@ -45,3 +51,17 @@ export function buildObjectPublicUrl(key: string) { export function isAllowedCompanyPhotoUrl(url: string, companyId: string) { return _isAllowedCompanyPhotoUrl(r2PublicBaseUrl, url, companyId); } + +/** + * Generate a presigned GET URL for reading an object from R2 + * + * @param key - The R2 object key (nullable) + * @returns A presigned URL valid for 24 hours, or null if key is empty + */ +export async function generatePresignedGetUrl(key: string): Promise { + const command = new GetObjectCommand({ + Bucket: r2Bucket, + Key: key, + }); + return await getSignedUrl(r2Client, command, { expiresIn: 86_400 }); +} diff --git a/apps/server/src/core/docs/account.docs.ts b/apps/server/src/core/docs/account.docs.ts index 90c4b9f2..d81b56bb 100644 --- a/apps/server/src/core/docs/account.docs.ts +++ b/apps/server/src/core/docs/account.docs.ts @@ -26,11 +26,67 @@ const getAccountSchema: FastifySchema = { security: [{ JWT: [] }], }; +const updateAvatarSchema: FastifySchema = { + tags: ["Account"], + description: `**Update the authenticated user's avatar URL** + +Sets the \`avatarUrl\` field for the current user to the provided URL. +Call \`POST /uploads/avatar/presign\` first to get a pre-signed upload URL, upload the cropped image, then pass the public URL here. +`, + summary: "Update avatar", + body: z.object({ + url: z.string().url(), + }), + response: { + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "user_not_found", + message: "User not found", + data: null, + }).describe("Couldn't find user."), + 200: zodResponseSchema({ + status: 200, + error: null, + code: "update_avatar_success", + message: "Avatar atualizado com sucesso.", + data: accountSchema, + }).describe("Avatar updated successfully."), + }, + security: [{ JWT: [] }], +}; + +const removeAvatarSchema: FastifySchema = { + tags: ["Account"], + description: `**Remove the authenticated user's avatar** + +Sets the avatar URL to \`null\` and re-issues the JWT. +`, + summary: "Remove avatar", + response: { + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "user_not_found", + message: "User not found", + data: null, + }).describe("Couldn't find user."), + 200: zodResponseSchema({ + status: 200, + error: null, + code: "remove_avatar_success", + message: "Foto de perfil removida com sucesso.", + data: accountSchema, + }).describe("Avatar removed successfully."), + }, + security: [{ JWT: [] }], +}; + const requestDeletionSchema: FastifySchema = { tags: ["Account"], description: `**Request account deletion and sends a confirmation email.** - -When requested, the account is **not** deleted instantly. + +When requested, the account is **not** deleted instantly. For confirmation, we generate a \`oneTimeToken\` of type \`account_deletion\`, save it on the database, and send it to the user email as a confirmation link, that will further hit the \`/account/confirm-deletion\` endpoint. - Requests are valid for 30 minutes. @@ -68,7 +124,7 @@ For confirmation, we generate a \`oneTimeToken\` of type \`account_deletion\`, s const confirmDeletionSchema: FastifySchema = { tags: ["Account"], description: `**Confirm deletion of account corresponding to the token.** - + When the confirmation email is sent, a link to this API route is sent together with the confirmation token. Once clicked, the account is deleted along with the single use token, then the user is redirected to the \`redirectUrl\`. @@ -112,6 +168,8 @@ Once clicked, the account is deleted along with the single use token, then the u export const accountDocs = { getAccountSchema, + updateAvatarSchema, + removeAvatarSchema, requestDeletionSchema, confirmDeletionSchema, }; diff --git a/apps/server/src/core/docs/categories/categories.docs.ts b/apps/server/src/core/docs/categories/categories.docs.ts new file mode 100644 index 00000000..8d026108 --- /dev/null +++ b/apps/server/src/core/docs/categories/categories.docs.ts @@ -0,0 +1,61 @@ +import { modelCategorySelectSchema } from "@fixr/db/schema"; +import { + getModelCategoriesQuerySchema, + getModelCategoryParamsSchema, +} from "@fixr/schemas/models"; +import type { FastifySchema } from "fastify"; +import { z } from "zod"; +import { zodResponseSchema } from "../types"; + +const listCategoriesSchema: FastifySchema = { + tags: ["Devices"], + summary: "List model categories", + description: ` +**Retrieves all device categories.** +Categories are global and not tied to a specific company. + +Optional filter (query string): +- \`query\`: search by category name +`, + querystring: getModelCategoriesQuerySchema, + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Categories retrieved successfully.", + code: "list_categories_success", + data: z.array(modelCategorySelectSchema), + }).describe("Categories retrieved successfully."), + }, + security: [{ JWT: [] }], +}; + +const getCategoryBySlugSchema: FastifySchema = { + tags: ["Devices"], + summary: "Get category by slug", + description: "Retrieves a single device category by its slug.", + params: getModelCategoryParamsSchema, + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Category retrieved successfully.", + code: "get_category_success", + data: modelCategorySelectSchema, + }).describe("Category retrieved successfully."), + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "category_not_found", + message: "Category not found.", + data: null, + }).describe("Category not found."), + }, + security: [{ JWT: [] }], +}; + +/** @description OpenAPI schemas for the categories module */ +export const categoriesDocs = { + listCategoriesSchema, + getCategoryBySlugSchema, +}; diff --git a/apps/server/src/core/docs/health.docs.ts b/apps/server/src/core/docs/health.docs.ts new file mode 100644 index 00000000..ef392a11 --- /dev/null +++ b/apps/server/src/core/docs/health.docs.ts @@ -0,0 +1,54 @@ +import type { FastifySchema } from "fastify"; +import { z } from "zod"; +import { zodResponseSchema } from "./types"; + +const healthCheckResultSchema = z.object({ + status: z.enum(["ok", "degraded", "unavailable"]), + latency_ms: z.number(), + description: z.string(), +}); + +const healthResponseSchema = z.object({ + status: z.enum(["ok", "degraded", "unavailable"]), + timestamp: z.string(), + uptime: z.number(), + services: z.object({ + api: healthCheckResultSchema, + mysql: healthCheckResultSchema, + redis: healthCheckResultSchema, + workers: healthCheckResultSchema, + }), +}); + +export const healthSchema: FastifySchema = { + tags: ["Health"], + description: ` +**Returns the health status of the API and its dependencies.** + +This endpoint is publicly accessible (no authentication required) and can be used by load balancers, monitoring tools, and container orchestrators (Kubernetes liveness/readiness probes) to verify the system's operational status. + +### Health criteria + +| Status | Latency | Description | +| --- | --- | --- | +| \`ok\` | < 1s | Service is responding normally | +| \`degraded\` | 1-5s | Service is slow but still responding | +| \`unavailable\` | > 5s or error | Service is not reachable or errored | + +### Overall status logic + +- **ok**: all services are healthy +- **degraded**: at least one service is degraded, none unavailable +- **unavailable**: at least one service is unavailable`, + summary: "Health check", + response: { + 200: healthResponseSchema.describe("System health status"), + 500: zodResponseSchema({ + status: 500, + error: "Internal Server Error", + code: "health_check_failed", + message: "Health check encountered an unexpected error", + data: null, + }).describe("Unexpected error while checking health"), + }, +}; diff --git a/apps/server/src/core/docs/makers/makers.docs.ts b/apps/server/src/core/docs/makers/makers.docs.ts new file mode 100644 index 00000000..225aa62f --- /dev/null +++ b/apps/server/src/core/docs/makers/makers.docs.ts @@ -0,0 +1,79 @@ +import { modelMakerSelectSchema } from "@fixr/db/schema"; +import { + getModelMakerParamsSchema, + getModelMakersQuerySchema, +} from "@fixr/schemas/models"; +import { paginatedDataSchema } from "@fixr/schemas/utils"; +import type { FastifySchema } from "fastify"; +import { z } from "zod"; +import { zodResponseSchema } from "../types"; + +const makerListRecordSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + url: z.string(), + deviceCount: z.number(), + pageCount: z.number().nullable(), + createdAt: z.coerce.date(), +}); + +const listMakersSchema: FastifySchema = { + tags: ["Devices"], + summary: "List model makers", + description: ` +**Retrieves device makers (brands) with pagination.** + +Optional filters (query string): +- \`query\`: search by maker name +- \`page\`, \`perPage\`, \`sort\` (\`newer\` | \`older\` | \`name\` | \`most_devices\`): pagination (see API pagination docs) +`, + querystring: getModelMakersQuerySchema, + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Makers successfully retrieved.", + code: "list_makers_success", + data: paginatedDataSchema(makerListRecordSchema), + }).describe("Makers retrieved successfully."), + 416: zodResponseSchema({ + status: 416, + error: "Range Not Satisfiable", + code: "page_out_of_bounds", + message: "The requested page exceeds the total number of pages.", + data: null, + }).describe("Requested page exceeds total pages."), + }, + security: [{ JWT: [] }], +}; + +const getMakerBySlugSchema: FastifySchema = { + tags: ["Devices"], + summary: "Get maker by slug", + description: "Retrieves a single device maker by its slug.", + params: getModelMakerParamsSchema, + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Maker retrieved successfully.", + code: "get_maker_success", + data: modelMakerSelectSchema, + }).describe("Maker retrieved successfully."), + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "maker_not_found", + message: "Maker not found.", + data: null, + }).describe("Maker not found."), + }, + security: [{ JWT: [] }], +}; + +/** @description OpenAPI schemas for the makers module */ +export const makersDocs = { + listMakersSchema, + getMakerBySlugSchema, +}; diff --git a/apps/server/src/core/docs/models/models.docs.ts b/apps/server/src/core/docs/models/models.docs.ts new file mode 100644 index 00000000..efbdeba8 --- /dev/null +++ b/apps/server/src/core/docs/models/models.docs.ts @@ -0,0 +1,391 @@ +import { getCompanyNestedDataSchema } from "@fixr/schemas/companies"; +import { + createModelBodySchema, + createModelImageBodySchema, + getModelsQuerySchema, + patchModelBodySchema, +} from "@fixr/schemas/models"; +import { paginatedDataSchema } from "@fixr/schemas/utils"; +import type { FastifySchema } from "fastify"; +import { z } from "zod"; +import { zodResponseSchema } from "../types"; + +const modelListRecordSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + imageUrl: z.string().nullable(), + status: z.string().nullable(), + price: z.string().nullable(), + released: z.string().nullable(), + maker: z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + }), + category: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + }) + .nullable(), +}); + +const modelImageRecordSchema = z.object({ + id: z.string(), + modelId: z.string(), + uploadId: z.string(), + presignedUrl: z.string().nullable(), + isPrimary: z.boolean(), + variant: z.string().nullable(), + position: z.number(), + createdAt: z.coerce.date(), +}); + +const modelDetailRecordSchema = z.object({ + id: z.string(), + makerId: z.string(), + name: z.string(), + slug: z.string(), + url: z.string(), + imageUrl: z.string().nullable(), + categoryId: z.string().nullable(), + announced: z.string().nullable(), + status: z.string().nullable(), + dimensions: z.string().nullable(), + weight: z.string().nullable(), + build: z.string().nullable(), + sim: z.string().nullable(), + displayType: z.string().nullable(), + displaySize: z.string().nullable(), + displayResolution: z.string().nullable(), + displayProtection: z.string().nullable(), + os: z.string().nullable(), + chipset: z.string().nullable(), + cpu: z.string().nullable(), + gpu: z.string().nullable(), + cardSlot: z.string().nullable(), + internalMemory: z.string().nullable(), + mainCamera: z.string().nullable(), + mainCameraFeatures: z.string().nullable(), + mainCameraVideo: z.string().nullable(), + selfieCamera: z.string().nullable(), + selfieFeatures: z.string().nullable(), + selfieVideo: z.string().nullable(), + battery: z.string().nullable(), + batteryCharging: z.string().nullable(), + networkTech: z.string().nullable(), + sensors: z.string().nullable(), + colors: z.string().nullable(), + colorsHex: z.string().nullable(), + modelsText: z.string().nullable(), + price: z.string().nullable(), + dimensionsWidth: z.number().nullable(), + dimensionsHeight: z.number().nullable(), + dimensionsThickness: z.number().nullable(), + weightGrams: z.number().nullable(), + displaySizeInches: z.number().nullable(), + displaySizeRatio: z.string().nullable(), + displayResWidth: z.number().nullable(), + displayResHeight: z.number().nullable(), + displayResPpi: z.number().nullable(), + released: z.string().nullable(), + meta: z.string().nullable(), + companyId: z.string().nullable(), + createdAt: z.coerce.date(), + maker: z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + url: z.string(), + }), + category: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + }) + .nullable(), + images: z.array(modelImageRecordSchema), +}); + +const modelDetailParamsSchema = z + .object({ + subdomain: z.string(), + slug: z.string(), + }) + .describe("Company subdomain and model slug."); + +const listModelsSchema: FastifySchema = { + tags: ["Devices"], + summary: "List device models", + description: ` +**Retrieves device models (paginated minimal list) for mounting a table.** + +Returns base (global) models plus company-specific models. + +Optional filters (query string): +- \`query\`: fulltext search on name, model variants, chipset, CPU, internal memory, OS +- \`makerId\`: filter by brand (cuid2) +- \`categoryId\`: filter by category (cuid2) +- \`status\`: release status: Available, Discontinued, Cancelled, Rumored +- \`page\`, \`perPage\`, \`sort\` (\`newer\` | \`older\` | \`name\`): pagination (see API pagination docs) +`, + params: getCompanyNestedDataSchema, + querystring: getModelsQuerySchema, + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Models successfully retrieved.", + code: "list_models_success", + data: paginatedDataSchema(modelListRecordSchema), + }).describe("Models retrieved successfully."), + 416: zodResponseSchema({ + status: 416, + error: "Range Not Satisfiable", + code: "page_out_of_bounds", + message: "The requested page exceeds the total number of pages.", + data: null, + }).describe("Requested page exceeds total pages."), + 403: zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not authorized to access this company.", + data: null, + }).describe("Not allowed to access this company."), + }, + security: [{ JWT: [] }], +}; + +const getModelBySlugSchema: FastifySchema = { + tags: ["Devices"], + summary: "Get device model by slug", + description: ` +**Retrieves full device model details by slug.** + +Returns all spec fields, related maker and category, and model images with presigned URLs. +`, + params: modelDetailParamsSchema, + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Model retrieved successfully.", + code: "get_model_success", + data: modelDetailRecordSchema, + }).describe("Model retrieved successfully."), + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "model_not_found", + message: "Model not found.", + data: null, + }).describe("Model not found."), + 403: zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not authorized to access this company.", + data: null, + }).describe("Not allowed to access this company."), + }, + security: [{ JWT: [] }], +}; + +const createModelSchema: FastifySchema = { + tags: ["Devices"], + summary: "Create device model", + description: ` +**Creates a new device model record.** + +Only company-specific models can be created. +- \`name\` and \`makerId\` are required. +- \`slug\` is auto-generated from \`name\` if not provided. +- All spec fields are optional and can be filled later via PATCH. +`, + params: getCompanyNestedDataSchema, + body: createModelBodySchema, + response: { + 201: zodResponseSchema({ + status: 201, + error: null, + message: "Model created successfully.", + code: "create_model_success", + data: z.object({ id: z.string(), name: z.string(), slug: z.string() }), + }).describe("Model created successfully."), + 409: zodResponseSchema({ + status: 409, + error: "Conflict", + code: "model_slug_conflict", + message: "A model with this slug already exists.", + data: null, + }).describe("Slug already exists."), + 403: zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not authorized to access this company.", + data: null, + }).describe("Not allowed to access this company."), + }, + security: [{ JWT: [] }], +}; + +const patchModelSchema: FastifySchema = { + tags: ["Devices"], + summary: "Update device model (partial)", + description: ` +**Partially updates a device model record.** + +All fields are optional: only provided fields will be updated. +Returns the full model detail with presigned image URLs. +`, + params: z.object({ subdomain: z.string(), modelId: z.string() }), + body: patchModelBodySchema, + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Model updated successfully.", + code: "patch_model_success", + data: modelDetailRecordSchema, + }).describe("Model updated successfully."), + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "model_not_found", + message: "Model not found.", + data: null, + }).describe("Model not found."), + 403: zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not authorized to access this company.", + data: null, + }).describe("Not allowed to access this company."), + }, + security: [{ JWT: [] }], +}; + +const deleteModelSchema: FastifySchema = { + tags: ["Devices"], + summary: "Delete device model", + description: ` +**Deletes a device model and its associated images.** + +Removes all related \`model_images\` records and deletes the uploaded files from storage. +`, + params: z.object({ subdomain: z.string(), modelId: z.string() }), + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Model deleted successfully.", + code: "delete_model_success", + data: null, + }).describe("Model deleted successfully."), + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "model_not_found", + message: "Model not found.", + data: null, + }).describe("Model not found."), + 403: zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not authorized to access this company.", + data: null, + }).describe("Not allowed to access this company."), + }, + security: [{ JWT: [] }], +}; + +const createModelImageSchema: FastifySchema = { + tags: ["Devices"], + summary: "Assign an uploaded image to a model", + description: ` +**Creates a model image record, linking an uploaded file to a device model.** + +Provide the \`uploadId\` returned from the presign upload endpoint. Returns the created model image with a presigned URL. +`, + params: z.object({ subdomain: z.string(), modelId: z.string() }), + body: createModelImageBodySchema, + response: { + 201: zodResponseSchema({ + status: 201, + error: null, + message: "Model image created successfully.", + code: "create_model_image_success", + data: modelImageRecordSchema, + }).describe("Model image created."), + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "model_not_found", + message: "Model not found.", + data: null, + }).describe("Model not found."), + 403: zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not authorized to access this company.", + data: null, + }).describe("Not allowed."), + }, + security: [{ JWT: [] }], +}; + +const deleteModelImageSchema: FastifySchema = { + tags: ["Devices"], + summary: "Delete a model image", + description: ` +**Deletes a model image record and removes the uploaded file from storage.** +`, + params: z.object({ + subdomain: z.string(), + modelId: z.string(), + imageId: z.string(), + }), + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Model image deleted successfully.", + code: "delete_model_image_success", + data: null, + }).describe("Model image deleted."), + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "model_image_not_found", + message: "Model image not found.", + data: null, + }).describe("Image not found."), + 403: zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not authorized to access this company.", + data: null, + }).describe("Not allowed."), + }, + security: [{ JWT: [] }], +}; + +/** @description OpenAPI schemas for the models module */ +export const modelsDocs = { + listModelsSchema, + getModelBySlugSchema, + createModelSchema, + patchModelSchema, + deleteModelSchema, + createModelImageSchema, + deleteModelImageSchema, +}; diff --git a/apps/server/src/core/docs/service-orders.docs.ts b/apps/server/src/core/docs/service-orders.docs.ts index 0ed65a15..1c8f5110 100644 --- a/apps/server/src/core/docs/service-orders.docs.ts +++ b/apps/server/src/core/docs/service-orders.docs.ts @@ -44,12 +44,12 @@ const getCompanyServiceOrdersSchema: FastifySchema = { **Retrieves company service orders (paginated)** Optional filters (query string): -- \`deviceCategoryId\` — device category (cuid2) -- \`employeeId\` — responsible employee (cuid2) -- \`status\` — one of: ${serviceOrderStatuses.options.join(", ")} -- \`dateFrom\` / \`dateTo\` — filter by \`created_at\` (inclusive; ISO date or datetime) -- \`query\` — search in device model, reported defect, or client name -- \`page\`, \`perPage\`, \`sort\` (\`newer\` | \`older\`) — pagination (see API pagination docs) +- \`deviceCategoryId\`: device category (cuid2) +- \`employeeId\`: responsible employee (cuid2) +- \`status\`: one of: ${serviceOrderStatuses.options.join(", ")} +- \`dateFrom\` / \`dateTo\`: filter by \`created_at\` (inclusive; ISO date or datetime) +- \`query\`: search in device model, reported defect, or client name +- \`page\`, \`perPage\`, \`sort\` (\`newer\` | \`older\`): pagination (see API pagination docs) `, params: getCompanyNestedDataSchema, querystring: getServiceOrdersQuerySchema, diff --git a/apps/server/src/core/docs/uploads.docs.ts b/apps/server/src/core/docs/uploads.docs.ts index 63b1d9eb..4fd09645 100644 --- a/apps/server/src/core/docs/uploads.docs.ts +++ b/apps/server/src/core/docs/uploads.docs.ts @@ -3,45 +3,50 @@ import { uploadPresignResponseSchema, } from "@fixr/schemas/uploads"; import type { FastifySchema } from "fastify"; +import { z } from "zod"; import { zodResponseSchema } from "./types"; -const createUploadPresignSchemaDoc: FastifySchema = { +const createPresignSchema: FastifySchema = { tags: ["Uploads"], - summary: "Generate pre-signed upload URL for service order images", - description: ` -**Generates a pre-signed URL for direct upload to Cloudflare R2** + summary: "Generate pre-signed upload URL", + description: `**Generate a presigned PUT URL for uploading files to Cloudflare R2** -Creates a pending upload record and returns a time-limited pre-signed PUT URL. Use the returned URL to upload the file, then pass the \`id\` when creating the service order. +Supports three purposes controlled by the path parameter: -The request accepts file metadata (\`fileName\`, \`contentType\`, \`size\`) and returns the upload ID, upload URL, object key, public URL and expiration time. +- \`avatar\` - user profile picture. Deterministic key, no DB record. Call \`PUT /account/avatar\` after uploading. +- \`service-orders\` - service order images. Creates a pending upload record, reference the returned \`id\` when creating the service order. +- \`models\` - model images. Creates a pending upload record, reference the returned \`id\` when assigning to a model. `, + params: z.object({ + purpose: z.enum(["avatar", "service-orders", "models"]), + }), body: createUploadPresignSchema, response: { 200: zodResponseSchema({ status: 200, error: null, message: "Upload URL generated successfully.", - code: "create_upload_presign_success", + code: "create_avatar_presign_success", data: uploadPresignResponseSchema, - }).describe("Pre-signed upload URL generated."), + }).describe("Presigned upload URL generated."), 403: zodResponseSchema({ status: 403, error: "Forbidden", code: "not_allowed", message: "You are not authorized to perform this action.", data: null, - }), + }).describe("Forbidden."), 404: zodResponseSchema({ status: 404, error: "Not Found", code: "company_not_found", message: "There's no company associated with this account.", data: null, - }), + }).describe("Company not found."), }, security: [{ JWT: [] }], }; export const uploadsDocs = { - createUploadPresignSchema: createUploadPresignSchemaDoc, + createPresignSchema, }; diff --git a/apps/server/src/core/errors/index.ts b/apps/server/src/core/errors/index.ts index 6156b0a1..c0d64fca 100644 --- a/apps/server/src/core/errors/index.ts +++ b/apps/server/src/core/errors/index.ts @@ -1,8 +1,11 @@ import { accountErrors } from "../../modules/account/errors"; import { authErrors } from "../../modules/auth/errors"; +import { categoriesErrors } from "../../modules/categories/errors"; import { companiesErrors } from "../../modules/companies/errors"; import { credentialsErrors } from "../../modules/credentials/errors"; import { employeesErrors } from "../../modules/employees/errors"; +import { makersErrors } from "../../modules/makers/errors"; +import { modelsErrors } from "../../modules/models/errors"; import { serviceOrdersErrors } from "../../modules/service-orders/errors"; import { tokensErrors } from "../../modules/tokens/errors"; import { uploadsErrors } from "../../modules/uploads/errors"; @@ -14,6 +17,9 @@ export const errors = defineErrors({ ...credentialsErrors, ...companiesErrors, ...employeesErrors, + ...categoriesErrors, + ...makersErrors, + ...modelsErrors, ...serviceOrdersErrors, ...tokensErrors, ...uploadsErrors, diff --git a/apps/server/src/core/lib/cache.ts b/apps/server/src/core/lib/cache.ts deleted file mode 100644 index b786f664..00000000 --- a/apps/server/src/core/lib/cache.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const CACHE_TTL = 60 * 60; //1 hour - -export const userCacheKey = (userId: string): string => { - return `user:${userId}`; -}; - -export const accountCacheKey = (userId: string): string => { - return `account:${userId}`; -}; - -export const jwtPayloadCacheKey = (userId: string): string => { - return `jwtPayload:${userId}`; -}; - -export const companyCacheKey = (companyId: string): string => { - return `company:${companyId}`; -}; diff --git a/apps/server/src/core/lib/r2.ts b/apps/server/src/core/lib/r2.ts index 6e0af8de..7c5f81d7 100644 --- a/apps/server/src/core/lib/r2.ts +++ b/apps/server/src/core/lib/r2.ts @@ -56,3 +56,36 @@ export function buildUploadObjectKey({ return `companies/${companyId}/service-orders/${uniquePrefix}-${safeName}`; } + +/** + * Build an R2 object key for a model image + */ +export function buildModelObjectKey({ + companyId, + fileName, +}: { + companyId: string; + fileName: string; +}): string { + const safeName = sanitizeUploadFileName(fileName); + const uniquePrefix = `${Date.now()}-${randomUUID().slice(0, 8)}`; + + return `companies/${companyId}/models/${uniquePrefix}-${safeName}`; +} + +/** + * Build an R2 object key for a user avatar + * Uses a deterministic key so each upload overwrites the previous one + */ +export function buildAvatarObjectKey({ + userId, + fileName, +}: { + userId: string; + fileName: string; +}): string { + const safeName = sanitizeUploadFileName(fileName); + const ext = safeName.includes(".") ? safeName.split(".").pop() : "jpg"; + + return `users/${userId}/avatar.${ext}`; +} diff --git a/apps/server/src/core/lib/response.ts b/apps/server/src/core/lib/response.ts index d7953dcd..67bf1a54 100644 --- a/apps/server/src/core/lib/response.ts +++ b/apps/server/src/core/lib/response.ts @@ -28,6 +28,7 @@ export const httpStatusCodes: Record = { 405: "Method Not Allowed", 409: "Conflict", 410: "Gone", + 416: "Range Not Satisfiable", 418: "I'm a teapot", 429: "Too Many Requests", 500: "Internal Server Error", diff --git a/apps/server/src/modules/account/controllers/index.ts b/apps/server/src/modules/account/controllers/index.ts index 0788833b..e26a7f53 100644 --- a/apps/server/src/modules/account/controllers/index.ts +++ b/apps/server/src/modules/account/controllers/index.ts @@ -16,6 +16,34 @@ export class AccountController { return AccountService.getAccount({ userId, response }); } + /** + * @description Update the authenticated user's avatar URL + */ + static updateAvatar({ + userId, + avatarUrl, + response, + }: { + userId: string; + avatarUrl: string; + response: FastifyReply; + }) { + return AccountService.updateAvatar({ userId, avatarUrl, response }); + } + + /** + * @description Remove the authenticated user's avatar + */ + static removeAvatar({ + userId, + response, + }: { + userId: string; + response: FastifyReply; + }) { + return AccountService.removeAvatar({ userId, response }); + } + /** * @description Request account deletion */ diff --git a/apps/server/src/modules/account/repositories/index.ts b/apps/server/src/modules/account/repositories/index.ts index 2ba47a0f..506d56d1 100644 --- a/apps/server/src/modules/account/repositories/index.ts +++ b/apps/server/src/modules/account/repositories/index.ts @@ -1,8 +1,7 @@ import { db, eq, sql } from "@fixr/db/connection"; import { clients, companies, employees, users } from "@fixr/db/schema"; import { accountSchema } from "@fixr/schemas/account"; -import { redis } from "../../../config/redis"; -import { accountCacheKey, CACHE_TTL } from "../../../core/lib/cache"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; /** @description Account data access layer */ export class AccountRepository { @@ -12,14 +11,8 @@ export class AccountRepository { * @param id - The user ID * @returns The parsed account data */ + @Cached({ ttl: 3600, key: "account" }) static async queryAccountById(id: string) { - const cacheKey = accountCacheKey(id); - const cached = await redis.get(cacheKey); - - if (cached) { - return accountSchema.parse(JSON.parse(cached)); - } - const [account] = await db .select({ id: users.id, @@ -29,19 +22,19 @@ export class AccountRepository { cpf: sql`COALESCE(${employees.cpf}, ${clients.cpf})`, phone: sql`COALESCE(${employees.phone}, ${clients.phone})`, profileType: sql`CASE - WHEN ${employees.id} IS NOT NULL THEN 'employee' - WHEN ${clients.id} IS NOT NULL THEN 'client' - ELSE 'unknown' - END`, + WHEN ${employees.id} IS NOT NULL THEN 'employee' + WHEN ${clients.id} IS NOT NULL THEN 'client' + ELSE 'unknown' + END`, company: sql`CASE - WHEN ${employees.id} IS NOT NULL THEN JSON_OBJECT( - 'id', ${companies.id}, - 'name', ${companies.name}, - 'subdomain', ${companies.subdomain}, - 'role', ${employees.role} - ) - ELSE NULL - END`, + WHEN ${employees.id} IS NOT NULL THEN JSON_OBJECT( + 'id', ${companies.id}, + 'name', ${companies.name}, + 'subdomain', ${companies.subdomain}, + 'role', ${employees.role} + ) + ELSE NULL + END`, createdAt: users.createdAt, }) .from(users) @@ -51,8 +44,17 @@ export class AccountRepository { .where(eq(users.id, id)) .limit(1); - await redis.set(cacheKey, JSON.stringify(account), "EX", CACHE_TTL); - return accountSchema.parse(account); } + + /** + * Update the avatar URL for a user and clear the cache + * + * @param userId - The user ID + * @param avatarUrl - The new avatar URL + */ + @InvalidateCache({ patterns: ["user:*", "jwt:*", "account:*"] }) + static async updateAvatarUrl(userId: string, avatarUrl: string | null) { + await db.update(users).set({ avatarUrl }).where(eq(users.id, userId)); + } } diff --git a/apps/server/src/modules/account/routes/index.ts b/apps/server/src/modules/account/routes/index.ts index b61ead1b..c0b7d34f 100644 --- a/apps/server/src/modules/account/routes/index.ts +++ b/apps/server/src/modules/account/routes/index.ts @@ -9,6 +9,7 @@ import type { FastifyTypedInstance } from "../../../core/interfaces/fastify"; import { authenticate } from "../../../core/middlewares/authenticate"; import { withErrorHandler } from "../../../core/middlewares/with-error-handler"; import { AccountController } from "../controllers"; +import { updateAvatarSchema } from "../schemas"; /** @description Account routes plugin */ export function accountRoutes(fastify: FastifyTypedInstance) { @@ -25,6 +26,40 @@ export function accountRoutes(fastify: FastifyTypedInstance) { }) ); + fastify.put( + "/avatar", + { + preHandler: [authenticate, requirePermission(permissions.account.update)], + schema: accountDocs.updateAvatarSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const body = updateAvatarSchema.parse(request.body); + + await AccountController.updateAvatar({ + userId: userJwt.id, + avatarUrl: body.url, + response, + }); + }) + ); + + fastify.delete( + "/avatar", + { + preHandler: [authenticate, requirePermission(permissions.account.update)], + schema: accountDocs.removeAvatarSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + + await AccountController.removeAvatar({ + userId: userJwt.id, + response, + }); + }) + ); + fastify.post( "/request-deletion", { diff --git a/apps/server/src/modules/account/schemas/index.ts b/apps/server/src/modules/account/schemas/index.ts new file mode 100644 index 00000000..c5feae1e --- /dev/null +++ b/apps/server/src/modules/account/schemas/index.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const updateAvatarSchema = z.object({ + url: z.string().url({ message: "URL da foto deve ser uma URL válida." }), +}); diff --git a/apps/server/src/modules/account/services/index.ts b/apps/server/src/modules/account/services/index.ts index 0250beb7..1a3d6eb5 100644 --- a/apps/server/src/modules/account/services/index.ts +++ b/apps/server/src/modules/account/services/index.ts @@ -5,8 +5,10 @@ import { emailDisplayName, sendAccountDeletionEmail, } from "@fixr/mail/services"; +import { jwtPayload } from "@fixr/schemas/auth"; import type { FastifyReply, FastifyRequest } from "fastify"; import { AppError } from "../../../core/lib/app-error"; +import { signJWT } from "../../../core/lib/jwt"; import { apiResponse } from "../../../core/lib/response"; import { AuthRepository } from "../../auth/repositories"; import { TokensRepository } from "../../tokens/repositories"; @@ -15,6 +17,40 @@ import { AccountRepository } from "../repositories"; /** @description Account business logic */ export class AccountService { + /** + * Remove the authenticated user's avatar URL + * + * @param userId - The authenticated user ID + * @param response - Fastify reply + */ + static async removeAvatar({ + userId, + response, + }: { + userId: string; + response: FastifyReply; + }) { + await AccountRepository.updateAvatarUrl(userId, null); + + const payload = await AuthRepository.queryJWTPayloadByUserId(userId); + const token = signJWT({ + payload: jwtPayload.parse(payload), + }); + TokensService.setJWTCookie(response, token); + + const account = await AccountRepository.queryAccountById(userId); + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "remove_avatar_success", + message: "Foto de perfil removida com sucesso.", + data: account, + }) + ); + } + /** * Get account details for the authenticated user * @@ -41,6 +77,43 @@ export class AccountService { ); } + /** + * Update the authenticated user's avatar URL + * + * @param userId - The authenticated user ID + * @param avatarUrl - The new avatar URL + * @param response - Fastify reply + */ + static async updateAvatar({ + userId, + avatarUrl, + response, + }: { + userId: string; + avatarUrl: string; + response: FastifyReply; + }) { + await AccountRepository.updateAvatarUrl(userId, avatarUrl); + + const payload = await AuthRepository.queryJWTPayloadByUserId(userId); + const token = signJWT({ + payload: jwtPayload.parse(payload), + }); + TokensService.setJWTCookie(response, token); + + const account = await AccountRepository.queryAccountById(userId); + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "update_avatar_success", + message: "Avatar atualizado com sucesso.", + data: account, + }) + ); + } + /** * Request account deletion. * Creates a one-time deletion token and sends a confirmation email. diff --git a/apps/server/src/modules/auth/repositories/index.ts b/apps/server/src/modules/auth/repositories/index.ts index 0ca51de6..0d4ae8d2 100644 --- a/apps/server/src/modules/auth/repositories/index.ts +++ b/apps/server/src/modules/auth/repositories/index.ts @@ -8,12 +8,7 @@ import { import type { TokenPayload } from "google-auth-library"; import type { z } from "zod"; -import { redis } from "../../../config/redis"; -import { - CACHE_TTL, - jwtPayloadCacheKey, - userCacheKey, -} from "../../../core/lib/cache"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; /** @description User data access layer */ export class AuthRepository { @@ -23,14 +18,8 @@ export class AuthRepository { * @param id - The user ID * @returns The parsed user data */ + @Cached({ ttl: 3600, key: "user" }) static async queryUserById(id: string) { - const cacheKey = userCacheKey(id); - const cached = await redis.get(cacheKey); - - if (cached) { - return userSchema.parse(JSON.parse(cached)); - } - const [user] = await db .select({ id: users.id, @@ -52,8 +41,6 @@ export class AuthRepository { .where(eq(users.id, id)) .limit(1); - await redis.set(cacheKey, JSON.stringify(user), "EX", CACHE_TTL); - return userSchema.parse(user); } @@ -63,6 +50,7 @@ export class AuthRepository { * @param email - The user email * @returns The parsed user data or null if not found */ + @Cached({ ttl: 3600, key: "user:email" }) static async queryUserByEmail( email: string ): Promise | null> { @@ -100,14 +88,8 @@ export class AuthRepository { * @param userId - The user ID * @returns The parsed JWT payload */ + @Cached({ ttl: 3600, key: "jwt" }) static async queryJWTPayloadByUserId(userId: string) { - const cacheKey = jwtPayloadCacheKey(userId); - const cached = await redis.get(cacheKey); - - if (cached) { - return jwtPayload.parse(JSON.parse(cached)); - } - const [payload] = await db .select({ id: users.id, @@ -138,8 +120,6 @@ export class AuthRepository { .leftJoin(companies, eq(employees.companyId, companies.id)) .where(eq(users.id, userId)); - await redis.set(cacheKey, JSON.stringify(payload), "EX", CACHE_TTL); - return jwtPayload.parse(payload); } @@ -166,15 +146,13 @@ export class AuthRepository { * * @param userId - The user ID */ + @InvalidateCache({ patterns: ["user:*", "jwt:*", "account:*"] }) static async setUserVerified(userId: string) { const verifyUser = db .update(users) .set({ verified: true }) .where(eq(users.id, userId)); - const cacheKey = userCacheKey(userId); - await redis.del(cacheKey); - return await verifyUser; } @@ -183,12 +161,10 @@ export class AuthRepository { * * @param userId - The user ID */ + @InvalidateCache({ patterns: ["user:*", "jwt:*", "account:*"] }) static async deleteUser(userId: string) { const delUser = db.delete(users).where(eq(users.id, userId)); - const cacheKey = userCacheKey(userId); - await redis.del(cacheKey); - return await delUser; } @@ -198,6 +174,7 @@ export class AuthRepository { * @param userId - The user ID * @param data - Google token payload data */ + @InvalidateCache({ patterns: ["user:*", "jwt:*", "account:*"] }) static async updateUserWithGoogleData({ userId, data, @@ -205,14 +182,6 @@ export class AuthRepository { userId: string; data: Partial; }) { - const invalidate = { - user: userCacheKey(userId), - jwt: jwtPayloadCacheKey(userId), - }; - - // Use Promise.all for parallel cache invalidation instead of sequential loop - await Promise.all(Object.values(invalidate).map((key) => redis.del(key))); - return await db .update(users) .set({ googleId: data.sub, avatarUrl: data.picture }) diff --git a/apps/server/src/modules/categories/controllers/index.ts b/apps/server/src/modules/categories/controllers/index.ts new file mode 100644 index 00000000..da13dc58 --- /dev/null +++ b/apps/server/src/modules/categories/controllers/index.ts @@ -0,0 +1,32 @@ +import type { + getModelCategoriesQuerySchema, + getModelCategoryParamsSchema, +} from "@fixr/schemas/models"; +import type { FastifyReply } from "fastify"; +import type { z } from "zod"; +import { CategoriesService } from "../services"; + +/** @description Categories request handlers */ +export class CategoriesController { + /** @description List all categories */ + static listCategories({ + query, + response, + }: { + query?: string; + response: FastifyReply; + } & z.infer) { + return CategoriesService.listCategories({ query, response }); + } + + /** @description Get a category by slug */ + static getCategoryBySlug({ + slug, + response, + }: { + slug: string; + response: FastifyReply; + } & z.infer) { + return CategoriesService.getCategoryBySlug({ slug, response }); + } +} diff --git a/apps/server/src/modules/categories/errors/index.ts b/apps/server/src/modules/categories/errors/index.ts new file mode 100644 index 00000000..15ccd621 --- /dev/null +++ b/apps/server/src/modules/categories/errors/index.ts @@ -0,0 +1,10 @@ +import { defineErrors } from "../../../core/utils/errors"; + +/** @description Error definitions for the categories module */ +export const categoriesErrors = defineErrors({ + CATEGORY_NOT_FOUND: { + code: "category_not_found", + message: "Category not found.", + status: 404, + }, +}); diff --git a/apps/server/src/modules/categories/repositories/index.ts b/apps/server/src/modules/categories/repositories/index.ts new file mode 100644 index 00000000..c8840450 --- /dev/null +++ b/apps/server/src/modules/categories/repositories/index.ts @@ -0,0 +1,36 @@ +import { asc, db, eq, like } from "@fixr/db/connection"; +import { modelCategories } from "@fixr/db/schema"; +import { Cached } from "../../../shared/infra/cache"; + +/** @description Data access layer for device categories */ +export class CategoriesRepository { + /** + * Query all categories, optionally filtering by name + * + * @param query - Optional name filter + */ + @Cached({ ttl: 3600, key: "categories:all" }) + static async queryAllCategories(query?: string) { + const base = db.select().from(modelCategories).$dynamic(); + if (query) { + base.where(like(modelCategories.name, `%${query}%`)); + } + return await base.orderBy(asc(modelCategories.name)); + } + + /** + * Find a category by its slug + * + * @param slug - The category slug + * @returns The category record or null + */ + @Cached({ ttl: 3600, key: "categories:slug" }) + static async queryCategoryBySlug(slug: string) { + const [category] = await db + .select() + .from(modelCategories) + .where(eq(modelCategories.slug, slug)) + .limit(1); + return category ?? null; + } +} diff --git a/apps/server/src/modules/categories/routes/index.ts b/apps/server/src/modules/categories/routes/index.ts new file mode 100644 index 00000000..be1c8a85 --- /dev/null +++ b/apps/server/src/modules/categories/routes/index.ts @@ -0,0 +1,46 @@ +import { permissions } from "@fixr/permissions"; +import { + getModelCategoriesQuerySchema, + getModelCategoryParamsSchema, +} from "@fixr/schemas/models"; +import { categoriesDocs } from "../../../core/docs/categories/categories.docs"; +import type { FastifyTypedInstance } from "../../../core/interfaces/fastify"; +import { authenticateEmployee } from "../../../core/middlewares/authenticate-employee"; +import { requirePermission } from "../../../core/middlewares/rbac"; +import { withErrorHandler } from "../../../core/middlewares/with-error-handler"; +import { CategoriesController } from "../controllers"; + +/** @description Categories routes plugin */ +export function categoriesRoutes(fastify: FastifyTypedInstance) { + fastify.get( + "/", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.read), + ], + schema: categoriesDocs.listCategoriesSchema, + }, + withErrorHandler(async (request, response) => { + const { query } = getModelCategoriesQuerySchema.parse(request.query); + + await CategoriesController.listCategories({ query, response }); + }) + ); + + fastify.get( + "/:slug", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.read), + ], + schema: categoriesDocs.getCategoryBySlugSchema, + }, + withErrorHandler(async (request, response) => { + const { slug } = getModelCategoryParamsSchema.parse(request.params); + + await CategoriesController.getCategoryBySlug({ slug, response }); + }) + ); +} diff --git a/apps/server/src/modules/categories/services/index.ts b/apps/server/src/modules/categories/services/index.ts new file mode 100644 index 00000000..a724fda7 --- /dev/null +++ b/apps/server/src/modules/categories/services/index.ts @@ -0,0 +1,64 @@ +import { modelCategorySelectSchema } from "@fixr/db/schema"; +import type { FastifyReply } from "fastify"; +import { AppError } from "../../../core/lib/app-error"; +import { apiResponse } from "../../../core/lib/response"; +import { CategoriesRepository } from "../repositories"; + +/** @description Business logic for device categories */ +export class CategoriesService { + /** + * List all categories, optionally filtered by query + * + * @param query - Optional name filter + * @param response - Fastify reply + */ + static async listCategories({ + query, + response, + }: { + query?: string; + response: FastifyReply; + }) { + const categories = await CategoriesRepository.queryAllCategories(query); + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "list_categories_success", + message: "Categories retrieved successfully.", + data: categories.map((c) => modelCategorySelectSchema.parse(c)), + }) + ); + } + + /** + * Get a category by its slug + * + * @param slug - The category slug + * @param response - Fastify reply + */ + static async getCategoryBySlug({ + slug, + response, + }: { + slug: string; + response: FastifyReply; + }) { + const category = await CategoriesRepository.queryCategoryBySlug(slug); + + if (!category) { + throw new AppError("CATEGORY_NOT_FOUND"); + } + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "get_category_success", + message: "Category retrieved successfully.", + data: modelCategorySelectSchema.parse(category), + }) + ); + } +} diff --git a/apps/server/src/modules/companies/repositories/index.ts b/apps/server/src/modules/companies/repositories/index.ts index fffd670c..bee82fb6 100644 --- a/apps/server/src/modules/companies/repositories/index.ts +++ b/apps/server/src/modules/companies/repositories/index.ts @@ -7,9 +7,8 @@ import { } from "@fixr/db/schema"; import type { createCompanySchema } from "@fixr/schemas/companies"; import type { z } from "zod"; -import { redis } from "../../../config/redis"; -import { CACHE_TTL, companyCacheKey } from "../../../core/lib/cache"; import { hashPassword } from "../../../core/lib/hash-password"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; /** @description Companies data access layer */ export class CompaniesRepository { @@ -19,18 +18,8 @@ export class CompaniesRepository { * @param id - The company ID * @returns The parsed company data */ + @Cached({ ttl: 3600, key: "company" }) static async queryCompanyById(id: string) { - const cacheKey = companyCacheKey(id); - const cached = await redis.get(cacheKey); - - if (cached) { - const parsed = JSON.parse(cached); - if (!parsed) { - return undefined; - } - return companySelectSchema.parse(parsed); - } - const [company] = await db .select() .from(companies) @@ -38,12 +27,9 @@ export class CompaniesRepository { .limit(1); if (!company) { - await redis.set(cacheKey, JSON.stringify(null), "EX", CACHE_TTL); return undefined; } - await redis.set(cacheKey, JSON.stringify(company), "EX", CACHE_TTL); - return companySelectSchema.parse(company); } @@ -53,18 +39,8 @@ export class CompaniesRepository { * @param subdomain - The company subdomain * @returns The parsed company data */ + @Cached({ ttl: 3600, key: "company:subdomain" }) static async queryCompanyBySubdomain(subdomain: string) { - const cacheKey = companyCacheKey(subdomain); - const cached = await redis.get(cacheKey); - - if (cached) { - const parsed = JSON.parse(cached); - if (!parsed) { - return undefined; - } - return companySelectSchema.parse(parsed); - } - const [company] = await db .select() .from(companies) @@ -72,12 +48,9 @@ export class CompaniesRepository { .limit(1); if (!company) { - await redis.set(cacheKey, JSON.stringify(null), "EX", CACHE_TTL); return undefined; } - await redis.set(cacheKey, JSON.stringify(company), "EX", CACHE_TTL); - return companySelectSchema.parse(company); } @@ -108,6 +81,7 @@ export class CompaniesRepository { /** * @description Create a company, user, and employee (admin) in sequence */ + @InvalidateCache({ patterns: ["company:*"] }) static async createOrgWithAdmin(data: z.infer) { const [orgId] = await db .insert(companies) diff --git a/apps/server/src/modules/credentials/repositories/index.ts b/apps/server/src/modules/credentials/repositories/index.ts index 6d2cde44..6db2cef1 100644 --- a/apps/server/src/modules/credentials/repositories/index.ts +++ b/apps/server/src/modules/credentials/repositories/index.ts @@ -1,7 +1,6 @@ import { db, eq } from "@fixr/db/connection"; import { users } from "@fixr/db/schema"; -import { redis } from "../../../config/redis"; -import { userCacheKey } from "../../../core/lib/cache"; +import { InvalidateCache } from "../../../shared/infra/cache"; /** @description Credentials data access layer */ export class CredentialsRepository { @@ -11,15 +10,13 @@ export class CredentialsRepository { * @param userId - The user ID * @param passwordHash - The new bcrypt hash */ + @InvalidateCache({ patterns: ["user:*", "jwt:*", "account:*"] }) static async updateUserPassword(userId: string, passwordHash: string) { const updatePass = db .update(users) .set({ passwordHash }) .where(eq(users.id, userId)); - const cacheKey = userCacheKey(userId); - await redis.del(cacheKey); - return await updatePass; } } diff --git a/apps/server/src/modules/employees/repositories/index.ts b/apps/server/src/modules/employees/repositories/index.ts index 1d42345a..1e51bc41 100644 --- a/apps/server/src/modules/employees/repositories/index.ts +++ b/apps/server/src/modules/employees/repositories/index.ts @@ -4,6 +4,7 @@ import { employees, users } from "@fixr/db/schema"; import type { createEmployeeSchema } from "@fixr/schemas/employees"; import type { z } from "zod"; import { hashPassword } from "../../../core/lib/hash-password"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; /** @description Employees data access layer */ export class EmployeesRepository { @@ -13,6 +14,7 @@ export class EmployeesRepository { * @param cpf - The employee CPF * @returns The employee data or undefined */ + @Cached({ ttl: 3600, key: "employees:cpf" }) static async getEmployeeByCpf(cpf: string) { const [data] = await db .select() @@ -27,6 +29,7 @@ export class EmployeesRepository { * @param data - The employee registration data * @param companyId - The company ID */ + @InvalidateCache({ patterns: ["employees:*"] }) static async createEmployeeAndAccount({ data, companyId, diff --git a/apps/server/src/modules/health/controllers/index.ts b/apps/server/src/modules/health/controllers/index.ts new file mode 100644 index 00000000..8613c33e --- /dev/null +++ b/apps/server/src/modules/health/controllers/index.ts @@ -0,0 +1,13 @@ +import type { FastifyReply } from "fastify"; +import { getHealth } from "../services/health"; + +/** @description Health check request handler */ +export class HealthController { + /** + * @description Runs health checks for all services and returns the aggregated status + */ + static async check(response: FastifyReply) { + const health = await getHealth(); + return response.status(200).send(health); + } +} diff --git a/apps/server/src/modules/health/routes/index.ts b/apps/server/src/modules/health/routes/index.ts new file mode 100644 index 00000000..e95d868e --- /dev/null +++ b/apps/server/src/modules/health/routes/index.ts @@ -0,0 +1,9 @@ +import { healthSchema } from "../../../core/docs/health.docs"; +import type { FastifyTypedInstance } from "../../../core/interfaces/fastify"; +import { HealthController } from "../controllers"; + +export function healthRoutes(fastify: FastifyTypedInstance) { + fastify.get("/health", { schema: healthSchema }, async (_, response) => { + await HealthController.check(response); + }); +} diff --git a/apps/server/src/modules/health/services/health.ts b/apps/server/src/modules/health/services/health.ts new file mode 100644 index 00000000..5b906c59 --- /dev/null +++ b/apps/server/src/modules/health/services/health.ts @@ -0,0 +1,172 @@ +import { db, sql } from "@fixr/db/connection"; +import { Queue } from "bullmq"; +import { redis } from "@/src/config/redis"; + +type ServiceStatus = "ok" | "degraded" | "unavailable"; + +interface ServiceHealth { + status: ServiceStatus; + latency_ms: number; + description: string; +} + +interface HealthResponse { + status: ServiceStatus; + timestamp: string; + uptime: number; + services: { + api: ServiceHealth; + mysql: ServiceHealth; + redis: ServiceHealth; + workers: ServiceHealth; + }; +} + +const HEALTHY_THRESHOLD_MS = 1000; +const DEGRADED_THRESHOLD_MS = 5000; + +function computeStatus(latencyMs: number, hasError: boolean): ServiceStatus { + if (hasError) return "unavailable"; + if (latencyMs < HEALTHY_THRESHOLD_MS) return "ok"; + if (latencyMs < DEGRADED_THRESHOLD_MS) return "degraded"; + return "unavailable"; +} + +function roundLatency(ms: number): number { + return Math.round(ms * 10) / 10; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +function unreachableService(error: unknown, latencyMs: number): ServiceHealth { + return { + status: "unavailable", + latency_ms: roundLatency(latencyMs), + description: formatError(error), + }; +} + +function measure( + fn: () => Promise +): Promise<{ latencyMs: number; result: T }> { + const start = performance.now(); + return fn().then((result) => ({ + latencyMs: performance.now() - start, + result, + })); +} + +/** + * @description Pings MySQL by running SELECT 1 + */ +async function pingMysql(): Promise { + try { + const { latencyMs } = await measure(() => db.execute(sql`SELECT 1`)); + return { + status: computeStatus(latencyMs, false), + latency_ms: roundLatency(latencyMs), + description: "MySQL connection successful", + }; + } catch (error) { + return unreachableService(error, 0); + } +} + +/** + * @description Pings Redis using the PING command + */ +async function pingRedis(): Promise { + try { + const { latencyMs } = await measure(() => redis.ping()); + return { + status: computeStatus(latencyMs, false), + latency_ms: roundLatency(latencyMs), + description: "Redis connection successful", + }; + } catch (error) { + return unreachableService(error, 0); + } +} + +let emailQueue: Queue | null = null; + +function getEmailQueue(): Queue { + if (!emailQueue) { + emailQueue = new Queue("email", { connection: redis }); + } + return emailQueue; +} + +/** + * @description Checks if the BullMQ email queue is responsive + */ +async function pingWorkers(): Promise { + try { + const { latencyMs } = await measure(() => getEmailQueue().getJobCounts()); + return { + status: computeStatus(latencyMs, false), + latency_ms: roundLatency(latencyMs), + description: "BullMQ email queue responsive", + }; + } catch (error) { + return unreachableService(error, 0); + } +} + +function pickServiceResult( + settled: PromiseSettledResult +): ServiceHealth { + if (settled.status === "fulfilled") { + return settled.value; + } + return { + status: "unavailable", + latency_ms: 0, + description: "Health check threw unexpectedly", + }; +} + +function computeOverallStatus( + services: HealthResponse["services"] +): ServiceStatus { + const statuses = Object.values(services).map((s) => s.status); + + if (statuses.some((s) => s === "unavailable")) return "unavailable"; + if (statuses.some((s) => s === "degraded")) return "degraded"; + return "ok"; +} + +/** + * @description Runs all health checks in parallel and returns the aggregated health status + */ +export async function getHealth(): Promise { + const startedAt = performance.now(); + + const [mysqlHealth, redisHealth, workersHealth] = await Promise.allSettled([ + pingMysql(), + pingRedis(), + pingWorkers(), + ]); + + const responseTime = roundLatency(performance.now() - startedAt); + + const services = { + api: { + status: "ok" as ServiceStatus, + latency_ms: responseTime, + description: "API is running", + }, + mysql: pickServiceResult(mysqlHealth), + redis: pickServiceResult(redisHealth), + workers: pickServiceResult(workersHealth), + }; + + return { + status: computeOverallStatus(services), + timestamp: new Date().toISOString(), + uptime: Math.floor(process.uptime()), + services, + }; +} diff --git a/apps/server/src/modules/makers/controllers/index.ts b/apps/server/src/modules/makers/controllers/index.ts new file mode 100644 index 00000000..7843df9d --- /dev/null +++ b/apps/server/src/modules/makers/controllers/index.ts @@ -0,0 +1,41 @@ +import type { getModelMakersQuerySchema } from "@fixr/schemas/models"; +import type { FastifyReply } from "fastify"; +import type { z } from "zod"; +import { MakersService } from "../services"; + +/** @description Makers request handlers */ +export class MakersController { + /** @description List makers with pagination */ + static listMakers({ + page, + perPage, + query, + sort, + response, + }: { + page: number; + perPage?: number; + query?: string; + sort?: string; + response: FastifyReply; + } & z.infer) { + return MakersService.listMakers({ + page, + perPage, + query, + sort, + response, + }); + } + + /** @description Get a maker by slug */ + static getMakerBySlug({ + slug, + response, + }: { + slug: string; + response: FastifyReply; + }) { + return MakersService.getMakerBySlug({ slug, response }); + } +} diff --git a/apps/server/src/modules/makers/errors/index.ts b/apps/server/src/modules/makers/errors/index.ts new file mode 100644 index 00000000..345015ae --- /dev/null +++ b/apps/server/src/modules/makers/errors/index.ts @@ -0,0 +1,15 @@ +import { defineErrors } from "../../../core/utils/errors"; + +/** @description Error definitions for the makers module */ +export const makersErrors = defineErrors({ + MAKER_NOT_FOUND: { + code: "maker_not_found", + message: "Maker not found.", + status: 404, + }, + MAKER_PAGE_OUT_OF_BOUNDS: { + code: "page_out_of_bounds", + message: "The requested page exceeds the total number of pages.", + status: 416, + }, +}); diff --git a/apps/server/src/modules/makers/repositories/index.ts b/apps/server/src/modules/makers/repositories/index.ts new file mode 100644 index 00000000..9629ebf4 --- /dev/null +++ b/apps/server/src/modules/makers/repositories/index.ts @@ -0,0 +1,64 @@ +import { and, asc, db, desc, eq, like, type SQL } from "@fixr/db/connection"; +import { modelMakers } from "@fixr/db/schema"; +import { Cached } from "../../../shared/infra/cache"; + +/** @description Column selection for paginated makers list */ +export const makersListSelect = { + id: modelMakers.id, + name: modelMakers.name, + slug: modelMakers.slug, + url: modelMakers.url, + deviceCount: modelMakers.deviceCount, + pageCount: modelMakers.pageCount, + createdAt: modelMakers.createdAt, +}; + +/** @description Data access layer for device makers */ +export class MakersRepository { + /** + * Build a WHERE clause for filtering makers + * + * @param query - Optional name filter + */ + static buildListFilter(query?: string) { + const conditions: SQL[] = []; + if (query) { + conditions.push(like(modelMakers.name, `%${query}%`)); + } + return conditions.length > 0 ? and(...conditions) : undefined; + } + + /** + * Build an ORDER BY clause for makers + * + * @param sort - Sort key: newer, older, name, most_devices + */ + static buildOrder(sort?: string) { + switch (sort) { + case "newer": + return desc(modelMakers.createdAt); + case "older": + return asc(modelMakers.createdAt); + case "most_devices": + return desc(modelMakers.deviceCount); + default: + return asc(modelMakers.name); + } + } + + /** + * Find a maker by its slug + * + * @param slug - The maker slug + * @returns The maker record or null + */ + @Cached({ ttl: 3600, key: "makers:slug" }) + static async queryMakerBySlug(slug: string) { + const [maker] = await db + .select() + .from(modelMakers) + .where(eq(modelMakers.slug, slug)) + .limit(1); + return maker ?? null; + } +} diff --git a/apps/server/src/modules/makers/routes/index.ts b/apps/server/src/modules/makers/routes/index.ts new file mode 100644 index 00000000..28970a30 --- /dev/null +++ b/apps/server/src/modules/makers/routes/index.ts @@ -0,0 +1,54 @@ +import { permissions } from "@fixr/permissions"; +import { + getModelMakerParamsSchema, + getModelMakersQuerySchema, +} from "@fixr/schemas/models"; +import { makersDocs } from "../../../core/docs/makers/makers.docs"; +import type { FastifyTypedInstance } from "../../../core/interfaces/fastify"; +import { authenticateEmployee } from "../../../core/middlewares/authenticate-employee"; +import { requirePermission } from "../../../core/middlewares/rbac"; +import { withErrorHandler } from "../../../core/middlewares/with-error-handler"; +import { MakersController } from "../controllers"; + +/** @description Makers routes plugin */ +export function makersRoutes(fastify: FastifyTypedInstance) { + fastify.get( + "/", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.read), + ], + schema: makersDocs.listMakersSchema, + }, + withErrorHandler(async (request, response) => { + const { query, sort, page, perPage } = getModelMakersQuerySchema.parse( + request.query + ); + + await MakersController.listMakers({ + page, + perPage, + query, + sort, + response, + }); + }) + ); + + fastify.get( + "/:slug", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.read), + ], + schema: makersDocs.getMakerBySlugSchema, + }, + withErrorHandler(async (request, response) => { + const { slug } = getModelMakerParamsSchema.parse(request.params); + + await MakersController.getMakerBySlug({ slug, response }); + }) + ); +} diff --git a/apps/server/src/modules/makers/services/index.ts b/apps/server/src/modules/makers/services/index.ts new file mode 100644 index 00000000..881f54a5 --- /dev/null +++ b/apps/server/src/modules/makers/services/index.ts @@ -0,0 +1,134 @@ +import { modelMakerSelectSchema } from "@fixr/db/schema"; +import type { FastifyReply } from "fastify"; +import { AppError } from "../../../core/lib/app-error"; +import { + getPaginatedCount, + getPaginatedRecords, +} from "../../../core/lib/pagination"; +import { apiResponse, paginatedData } from "../../../core/lib/response"; +import { MakersRepository, makersListSelect } from "../repositories"; + +/** @description Business logic for device makers */ +export class MakersService { + /** + * List makers with pagination, filtering, and sorting + * + * @param page - Current page number + * @param perPage - Items per page + * @param query - Optional name filter + * @param sort - Sort direction + * @param response - Fastify reply + */ + static async listMakers({ + page, + perPage, + query, + sort, + response, + }: { + page: number; + perPage?: number; + query?: string; + sort?: string; + response: FastifyReply; + }) { + const PER_PAGE = perPage ?? 10; + + const filter = MakersRepository.buildListFilter(query); + const order = MakersRepository.buildOrder(sort); + + const [records, totalRecords] = await Promise.all([ + getPaginatedRecords({ + table: makersListSelect.id.table, + select: makersListSelect, + skip: (page - 1) * PER_PAGE, + take: PER_PAGE, + where: filter, + order, + }), + getPaginatedCount({ + table: makersListSelect.id.table, + where: filter, + }), + ]); + + if (totalRecords === 0) { + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + message: "Makers successfully retrieved.", + code: "list_makers_success", + data: paginatedData({ + records: [], + pagination: { + total_records: 0, + total_pages: 0, + current_page: 1, + next_page: null, + prev_page: null, + }, + }), + }) + ); + } + + const total_pages = Math.ceil(totalRecords / PER_PAGE); + + if (page > total_pages) { + throw new AppError("MAKER_PAGE_OUT_OF_BOUNDS"); + } + + const next_page = + PER_PAGE * (page - 1) + records.length < totalRecords ? page + 1 : null; + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + message: "Makers successfully retrieved.", + code: "list_makers_success", + data: paginatedData({ + records, + pagination: { + total_records: totalRecords, + total_pages, + current_page: page, + next_page, + prev_page: page > 1 ? page - 1 : null, + }, + }), + }) + ); + } + + /** + * Get a maker by its slug + * + * @param slug - The maker slug + * @param response - Fastify reply + */ + static async getMakerBySlug({ + slug, + response, + }: { + slug: string; + response: FastifyReply; + }) { + const maker = await MakersRepository.queryMakerBySlug(slug); + + if (!maker) { + throw new AppError("MAKER_NOT_FOUND"); + } + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "get_maker_success", + message: "Maker retrieved successfully.", + data: modelMakerSelectSchema.parse(maker), + }) + ); + } +} diff --git a/apps/server/src/modules/models/controllers/index.ts b/apps/server/src/modules/models/controllers/index.ts new file mode 100644 index 00000000..16134baf --- /dev/null +++ b/apps/server/src/modules/models/controllers/index.ts @@ -0,0 +1,165 @@ +import type { jwtPayload } from "@fixr/schemas/auth"; +import type { + createModelBodySchema, + createModelImageBodySchema, + getModelBySlugParamsSchema, + getModelsQuerySchema, + modelIdParamsSchema, + patchModelBodySchema, +} from "@fixr/schemas/models"; +import type { FastifyReply } from "fastify"; +import type { z } from "zod"; +import { ModelsService } from "../services"; + +/** @description Models request handlers */ +export class ModelsController { + /** @description List models with pagination and filtering */ + static listModels({ + userJwt, + subdomain, + page, + perPage, + query, + makerId, + categoryId, + status, + sort, + response, + }: { + userJwt: z.infer; + subdomain: string; + response: FastifyReply; + } & z.infer) { + return ModelsService.listModels({ + userJwt, + subdomain, + page, + perPage, + query, + makerId, + categoryId, + status, + sort, + response, + }); + } + + /** @description Get a model by slug with full details */ + static getModelBySlug({ + userJwt, + subdomain, + slug, + response, + }: { + userJwt: z.infer; + subdomain: string; + slug: string; + response: FastifyReply; + } & z.infer) { + return ModelsService.getModelBySlug({ + userJwt, + subdomain, + slug, + response, + }); + } + + /** @description Create a new model */ + static createModel({ + userJwt, + subdomain, + data, + response, + }: { + userJwt: z.infer; + subdomain: string; + data: z.infer; + response: FastifyReply; + }) { + return ModelsService.createModel({ userJwt, subdomain, data, response }); + } + + /** @description Partially update a model */ + static patchModel({ + userJwt, + subdomain, + modelId, + data, + response, + }: { + userJwt: z.infer; + subdomain: string; + modelId: string; + data: Record; + response: FastifyReply; + } & z.infer) { + return ModelsService.patchModel({ + userJwt, + subdomain, + modelId, + data, + response, + }); + } + + /** @description Delete a model */ + static deleteModel({ + userJwt, + subdomain, + modelId, + response, + }: { + userJwt: z.infer; + subdomain: string; + modelId: string; + response: FastifyReply; + } & z.infer) { + return ModelsService.deleteModel({ userJwt, subdomain, modelId, response }); + } + + /** @description Assign an uploaded image to a model */ + static createModelImage({ + userJwt, + subdomain, + modelId, + data, + response, + }: { + userJwt: z.infer; + subdomain: string; + modelId: string; + data: z.infer; + response: FastifyReply; + }) { + return ModelsService.createModelImage({ + userJwt, + subdomain, + modelId, + data, + response, + }); + } + + /** @description Delete a model image */ + static deleteModelImage({ + userJwt, + subdomain, + modelId, + imageId, + response, + }: { + userJwt: z.infer; + subdomain: string; + modelId: string; + imageId: string; + response: FastifyReply; + }) { + return ModelsService.deleteModelImage({ + userJwt, + subdomain, + modelId, + imageId, + response, + }); + } +} diff --git a/apps/server/src/modules/models/errors/index.ts b/apps/server/src/modules/models/errors/index.ts new file mode 100644 index 00000000..30e18482 --- /dev/null +++ b/apps/server/src/modules/models/errors/index.ts @@ -0,0 +1,50 @@ +import { defineErrors } from "../../../core/utils/errors"; + +/** @description Error definitions for the models module */ +export const modelsErrors = defineErrors({ + MODEL_NOT_FOUND: { + code: "model_not_found", + message: "Model not found.", + status: 404, + }, + MODEL_PAGE_OUT_OF_BOUNDS: { + code: "page_out_of_bounds", + message: "The requested page exceeds the total number of pages.", + status: 416, + }, + MODEL_COMPANY_NOT_FOUND: { + code: "company_not_found", + message: "There's no companies bound to your account", + status: 404, + }, + MODEL_NOT_ALLOWED: { + code: "not_allowed", + message: "You are not authorized to access this company.", + status: 403, + }, + MODEL_SLUG_CONFLICT: { + code: "model_slug_conflict", + message: "A model with this slug already exists.", + status: 409, + }, + MODEL_MAKER_NOT_FOUND: { + code: "maker_not_found", + message: "The specified maker does not exist.", + status: 404, + }, + MODEL_IMAGE_NOT_FOUND: { + code: "model_image_not_found", + message: "Model image not found.", + status: 404, + }, + MODEL_IMAGE_KEY_MISMATCH: { + code: "model_image_key_mismatch", + message: "The provided image key does not belong to your company.", + status: 403, + }, + MODEL_IMAGE_UPLOAD_NOT_FOUND: { + code: "model_image_upload_not_found", + message: "The provided upload record was not found.", + status: 404, + }, +}); diff --git a/apps/server/src/modules/models/repositories/index.ts b/apps/server/src/modules/models/repositories/index.ts new file mode 100644 index 00000000..ddd567ac --- /dev/null +++ b/apps/server/src/modules/models/repositories/index.ts @@ -0,0 +1,531 @@ +import { DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { + and, + asc, + db, + desc, + eq, + inArray, + or, + type SQL, + sql, +} from "@fixr/db/connection"; +import { + type ModelImageSelect, + modelCategories, + modelImages, + modelMakers, + models, + uploads, +} from "@fixr/db/schema"; +import { + generatePresignedGetUrl, + r2Bucket, + r2Client, +} from "../../../config/r2"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; + +const FTS_OPERATOR_REGEX = /[+\-*~()<>@]/; +const WHITESPACE_REGEX = /\s+/; + +/** @description Column selection for paginated models minimal list */ +export const modelMinimalListSelect = { + id: models.id, + name: models.name, + slug: models.slug, + status: models.status, + price: models.price, + released: models.released, + makerId: modelMakers.id, + makerName: modelMakers.name, + makerSlug: modelMakers.slug, + categoryId: modelCategories.id, + categoryName: modelCategories.name, + categorySlug: modelCategories.slug, +}; + +/** @description Join definitions for models list queries */ +export const modelListJoins = [ + { + type: "inner" as const, + table: modelMakers, + on: eq(modelMakers.id, models.makerId), + }, + { + type: "left" as const, + table: modelCategories, + on: eq(modelCategories.id, models.categoryId), + }, +]; + +export interface ModelFlatRecord { + id: string; + name: string; + slug: string; + status: string | null; + price: string | null; + released: string | null; + makerId: string; + makerName: string; + makerSlug: string; + categoryId: string | null; + categoryName: string | null; + categorySlug: string | null; +} + +export interface ModelListRecord { + id: string; + name: string; + slug: string; + status: string; + price: string | null; + released: string | null; + maker: { id: string; name: string; slug: string }; + category: { id: string; name: string; slug: string } | null; + imageUrl: string | null; +} + +/** @description Data access layer for device models */ +export class ModelsRepository { + /** + * Build a WHERE clause for filtering models with fulltext search support + * + * @param companyId - Company ID for scoping + * @param filters - Query, maker, category, and status filters + */ + static buildListFilter( + companyId: string, + filters: { + query?: string; + makerId?: string; + categoryId?: string; + status?: string; + } + ) { + const conditions: SQL[] = [ + or(eq(models.companyId, companyId), sql`${models.companyId} IS NULL`)!, + ]; + + if (filters.makerId) { + conditions.push(eq(models.makerId, filters.makerId)); + } + if (filters.categoryId) { + conditions.push(eq(models.categoryId, filters.categoryId)); + } + if (filters.status) { + conditions.push(eq(models.status, filters.status)); + } + if (filters.query) { + const hasOperators = FTS_OPERATOR_REGEX.test(filters.query); + const ftsQuery = hasOperators + ? filters.query + : filters.query + .trim() + .split(WHITESPACE_REGEX) + .map((w) => `${w}*`) + .join(" "); + conditions.push( + sql`MATCH(${models.name}, ${models.modelsText}, ${models.chipset}, ${models.cpu}, ${models.internalMemory}, ${models.os}) AGAINST(${ftsQuery} IN BOOLEAN MODE)` + ); + } + + return and(...conditions)!; + } + + /** + * Build an ORDER BY clause for models + * + * @param sort - Sort key: newer, older, name + */ + static buildOrder(sort?: string) { + switch (sort) { + case "newer": + return desc(models.createdAt); + case "older": + return asc(models.createdAt); + case "name": + return asc(models.name); + default: + return desc(models.createdAt); + } + } + + /** + * Find a model by its slug with full maker and category relations + * + * @param slug - The model slug + * @param companyId - Optional company ID for scoping + * @returns The model record with relations or null + */ + @Cached({ ttl: 3600, key: "models:slug" }) + static async queryModelBySlug(slug: string, companyId?: string) { + const conditions: SQL[] = [eq(models.slug, slug)]; + if (companyId) { + conditions.push( + or(eq(models.companyId, companyId), sql`${models.companyId} IS NULL`)! + ); + } + + const result = await db + .select({ + id: models.id, + makerId: models.makerId, + name: models.name, + slug: models.slug, + url: models.url, + categoryId: models.categoryId, + announced: models.announced, + status: models.status, + dimensions: models.dimensions, + weight: models.weight, + build: models.build, + sim: models.sim, + displayType: models.displayType, + displaySize: models.displaySize, + displayResolution: models.displayResolution, + displayProtection: models.displayProtection, + os: models.os, + chipset: models.chipset, + cpu: models.cpu, + gpu: models.gpu, + cardSlot: models.cardSlot, + internalMemory: models.internalMemory, + mainCamera: models.mainCamera, + mainCameraFeatures: models.mainCameraFeatures, + mainCameraVideo: models.mainCameraVideo, + selfieCamera: models.selfieCamera, + selfieFeatures: models.selfieFeatures, + selfieVideo: models.selfieVideo, + battery: models.battery, + batteryCharging: models.batteryCharging, + networkTech: models.networkTech, + sensors: models.sensors, + colors: models.colors, + colorsHex: models.colorsHex, + modelsText: models.modelsText, + price: models.price, + dimensionsWidth: models.dimensionsWidth, + dimensionsHeight: models.dimensionsHeight, + dimensionsThickness: models.dimensionsThickness, + weightGrams: models.weightGrams, + displaySizeInches: models.displaySizeInches, + displaySizeRatio: models.displaySizeRatio, + displayResWidth: models.displayResWidth, + displayResHeight: models.displayResHeight, + displayResPpi: models.displayResPpi, + released: models.released, + meta: models.meta, + companyId: models.companyId, + createdAt: models.createdAt, + maker: { + id: modelMakers.id, + name: modelMakers.name, + slug: modelMakers.slug, + url: modelMakers.url, + }, + category: { + id: modelCategories.id, + name: modelCategories.name, + slug: modelCategories.slug, + }, + }) + .from(models) + .innerJoin(modelMakers, eq(modelMakers.id, models.makerId)) + .leftJoin(modelCategories, eq(modelCategories.id, models.categoryId)) + .where(and(...conditions)) + .limit(1); + + return result[0] ?? null; + } + + /** + * Query all images for a model, ordered by position + * + * @param modelId - The model ID + * @returns Array of model images with upload key + */ + @Cached({ ttl: 3600, key: "models:images" }) + static async queryModelImages(modelId: string) { + return await db + .select({ + id: modelImages.id, + modelId: modelImages.modelId, + uploadId: modelImages.uploadId, + isPrimary: modelImages.isPrimary, + variant: modelImages.variant, + position: modelImages.position, + createdAt: modelImages.createdAt, + key: uploads.key, + }) + .from(modelImages) + .innerJoin(uploads, eq(modelImages.uploadId, uploads.id)) + .where(eq(modelImages.modelId, modelId)) + .orderBy(asc(modelImages.position)); + } + + /** + * Batch fetch primary model images for a set of model IDs + * + * @param modelIds - Array of model IDs + * @returns Map of modelId -> key (for generating presigned URLs) + */ + static async queryPrimaryImages( + modelIds: string[] + ): Promise> { + if (modelIds.length === 0) return new Map(); + const rows = await db + .select({ + modelId: modelImages.modelId, + key: uploads.key, + }) + .from(modelImages) + .innerJoin(uploads, eq(modelImages.uploadId, uploads.id)) + .where( + and( + inArray(modelImages.modelId, modelIds), + eq(modelImages.isPrimary, true) + ) + ); + return new Map(rows.filter((r) => !!r.key).map((r) => [r.modelId, r.key!])); + } + + /** + * Generate a presigned GET URL for an R2 key + * + * @param key - The R2 object key + * @returns Presigned URL or null + */ + static async generateImagePresignedUrl(key: string | null) { + if (!key) return null; + return await generatePresignedGetUrl(key); + } + + /** + * Attach presigned GET URLs to an array of model images + * + * @param images - Array of model image records + * @returns Images with presignedUrl attached + */ + static async attachPresignedUrlsToImages( + images: (ModelImageSelect & { key: string | null })[] + ): Promise<(ModelImageSelect & { presignedUrl: string | null })[]> { + return await Promise.all( + images.map(async (img) => { + const presignedUrl = img.key + ? await generatePresignedGetUrl(img.key) + : null; + return { ...img, presignedUrl }; + }) + ); + } + + /** + * Find a model by its ID with full maker and category relations + * + * @param id - The model ID + * @returns The model record with relations or null + */ + @Cached({ ttl: 3600, key: "models:detail" }) + static async queryModelById(id: string) { + const [model] = await db + .select({ + id: models.id, + makerId: models.makerId, + name: models.name, + slug: models.slug, + url: models.url, + categoryId: models.categoryId, + announced: models.announced, + status: models.status, + dimensions: models.dimensions, + weight: models.weight, + build: models.build, + sim: models.sim, + displayType: models.displayType, + displaySize: models.displaySize, + displayResolution: models.displayResolution, + displayProtection: models.displayProtection, + os: models.os, + chipset: models.chipset, + cpu: models.cpu, + gpu: models.gpu, + cardSlot: models.cardSlot, + internalMemory: models.internalMemory, + mainCamera: models.mainCamera, + mainCameraFeatures: models.mainCameraFeatures, + mainCameraVideo: models.mainCameraVideo, + selfieCamera: models.selfieCamera, + selfieFeatures: models.selfieFeatures, + selfieVideo: models.selfieVideo, + battery: models.battery, + batteryCharging: models.batteryCharging, + networkTech: models.networkTech, + sensors: models.sensors, + colors: models.colors, + colorsHex: models.colorsHex, + modelsText: models.modelsText, + price: models.price, + dimensionsWidth: models.dimensionsWidth, + dimensionsHeight: models.dimensionsHeight, + dimensionsThickness: models.dimensionsThickness, + weightGrams: models.weightGrams, + displaySizeInches: models.displaySizeInches, + displaySizeRatio: models.displaySizeRatio, + displayResWidth: models.displayResWidth, + displayResHeight: models.displayResHeight, + displayResPpi: models.displayResPpi, + released: models.released, + meta: models.meta, + companyId: models.companyId, + createdAt: models.createdAt, + maker: { + id: modelMakers.id, + name: modelMakers.name, + slug: modelMakers.slug, + url: modelMakers.url, + }, + category: { + id: modelCategories.id, + name: modelCategories.name, + slug: modelCategories.slug, + }, + }) + .from(models) + .innerJoin(modelMakers, eq(modelMakers.id, models.makerId)) + .leftJoin(modelCategories, eq(modelCategories.id, models.categoryId)) + .where(eq(models.id, id)) + .limit(1); + return model ?? null; + } + + /** + * Check if a slug already exists for a given company + * + * @param slug - The slug to check + * @param companyId - Company ID for scoping + * @returns The matching model or undefined + */ + static async queryBySlugAndCompany(slug: string, companyId: string) { + const [model] = await db + .select({ id: models.id }) + .from(models) + .where( + and( + eq(models.slug, slug), + or(eq(models.companyId, companyId), sql`${models.companyId} IS NULL`) + ) + ) + .limit(1); + return model; + } + + /** + * Check if a maker exists by ID + * + * @param makerId - The maker ID + * @returns The maker or undefined + */ + static async queryMakerById(makerId: string) { + const [maker] = await db + .select({ id: modelMakers.id }) + .from(modelMakers) + .where(eq(modelMakers.id, makerId)) + .limit(1); + return maker; + } + + /** + * Insert a new model record + * + * @param data - The model data to insert + */ + @InvalidateCache({ patterns: ["models:*"] }) + static async insertModel(data: typeof models.$inferInsert) { + await db.insert(models).values(data); + } + + /** + * Insert a model image record + * + * @param data - The model image data + * @returns The created model image + */ + @InvalidateCache({ patterns: ["models:*"] }) + static async insertModelImage(data: typeof modelImages.$inferInsert) { + const id = data.id as string; + await db.insert(modelImages).values(data); + const [created] = await db + .select() + .from(modelImages) + .where(eq(modelImages.id, id)) + .limit(1); + return created; + } + + /** + * Delete a single model image record + * + * @param imageId - The image ID + */ + @InvalidateCache({ patterns: ["models:*"] }) + static async deleteModelImageRecord(imageId: string) { + await db.delete(modelImages).where(eq(modelImages.id, imageId)); + } + + /** + * Get all R2 keys associated with a model + * + * @param modelId - The model ID + * @returns Array of R2 keys + */ + static async queryUploadKeysByModel(modelId: string) { + const rows = await db + .select({ key: uploads.key }) + .from(modelImages) + .innerJoin(uploads, eq(modelImages.uploadId, uploads.id)) + .where(eq(modelImages.modelId, modelId)); + + return rows.map((r) => r.key).filter((k): k is string => !!k); + } + + /** + * Delete an R2 object by key + * + * @param key - The R2 object key + */ + static async deleteR2Object(key: string) { + await r2Client.send( + new DeleteObjectCommand({ + Bucket: r2Bucket, + Key: key, + }) + ); + } + + /** + * Update a model record (partial) + * + * @param id - The model ID + * @param data - The fields to update + */ + @InvalidateCache({ patterns: ["models:*"] }) + static async updateModel( + id: string, + data: Partial + ) { + await db.update(models).set(data).where(eq(models.id, id)); + } + + /** + * Delete a model record and its associated images + * + * @param id - The model ID + */ + @InvalidateCache({ patterns: ["models:*"] }) + static async deleteModel(id: string) { + const keys = await ModelsRepository.queryUploadKeysByModel(id); + await Promise.all(keys.map((k) => ModelsRepository.deleteR2Object(k))); + await db.delete(modelImages).where(eq(modelImages.modelId, id)); + await db.delete(models).where(eq(models.id, id)); + } +} diff --git a/apps/server/src/modules/models/routes/index.ts b/apps/server/src/modules/models/routes/index.ts new file mode 100644 index 00000000..18aecd7c --- /dev/null +++ b/apps/server/src/modules/models/routes/index.ts @@ -0,0 +1,188 @@ +import { permissions } from "@fixr/permissions"; +import type { userJWT } from "@fixr/schemas/auth"; +import { getCompanyNestedDataSchema } from "@fixr/schemas/companies"; +import { + createModelBodySchema, + createModelImageBodySchema, + getModelBySlugParamsSchema, + getModelsQuerySchema, + modelIdParamsSchema, + modelImageParamsSchema, + patchModelBodySchema, +} from "@fixr/schemas/models"; +import type { z } from "zod"; +import { modelsDocs } from "../../../core/docs/models/models.docs"; +import type { FastifyTypedInstance } from "../../../core/interfaces/fastify"; +import { authenticateEmployee } from "../../../core/middlewares/authenticate-employee"; +import { requirePermission } from "../../../core/middlewares/rbac"; +import { withErrorHandler } from "../../../core/middlewares/with-error-handler"; +import { ModelsController } from "../controllers"; + +/** @description Models routes plugin */ +export function modelsRoutes(fastify: FastifyTypedInstance) { + fastify.get( + "/", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.read), + ], + schema: modelsDocs.listModelsSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const query = getModelsQuerySchema.parse(request.query); + const { subdomain } = getCompanyNestedDataSchema.parse(request.params); + + await ModelsController.listModels({ + userJwt, + subdomain, + response, + ...query, + }); + }) + ); + + fastify.get( + "/:slug", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.read), + ], + schema: modelsDocs.getModelBySlugSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const { slug } = getModelBySlugParamsSchema.parse(request.params); + const { subdomain } = getCompanyNestedDataSchema.parse(request.params); + + await ModelsController.getModelBySlug({ + userJwt, + subdomain, + slug, + response, + }); + }) + ); + + fastify.post( + "/", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.create), + ], + schema: modelsDocs.createModelSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const data = createModelBodySchema.parse(request.body); + const { subdomain } = getCompanyNestedDataSchema.parse(request.params); + + await ModelsController.createModel({ + userJwt, + subdomain, + data, + response, + }); + }) + ); + + fastify.patch( + "/:modelId", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.update), + ], + schema: modelsDocs.patchModelSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const { modelId } = modelIdParamsSchema.parse(request.params); + const data = patchModelBodySchema.parse(request.body); + const { subdomain } = getCompanyNestedDataSchema.parse(request.params); + + await ModelsController.patchModel({ + userJwt, + subdomain, + modelId, + data, + response, + }); + }) + ); + + fastify.delete( + "/:modelId", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.delete), + ], + schema: modelsDocs.deleteModelSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const { modelId } = modelIdParamsSchema.parse(request.params); + const { subdomain } = getCompanyNestedDataSchema.parse(request.params); + + await ModelsController.deleteModel({ + userJwt, + subdomain, + modelId, + response, + }); + }) + ); + + fastify.post( + "/:modelId/images", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.update), + ], + schema: modelsDocs.createModelImageSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const { modelId } = modelIdParamsSchema.parse(request.params); + const { subdomain } = getCompanyNestedDataSchema.parse(request.params); + const data = createModelImageBodySchema.parse(request.body); + + await ModelsController.createModelImage({ + userJwt, + subdomain, + modelId, + data, + response, + }); + }) + ); + + fastify.delete( + "/:modelId/images/:imageId", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.devices.update), + ], + schema: modelsDocs.deleteModelImageSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const { modelId, imageId } = modelImageParamsSchema.parse(request.params); + const { subdomain } = getCompanyNestedDataSchema.parse(request.params); + + await ModelsController.deleteModelImage({ + userJwt, + subdomain, + modelId, + imageId, + response, + }); + }) + ); +} diff --git a/apps/server/src/modules/models/services/index.ts b/apps/server/src/modules/models/services/index.ts new file mode 100644 index 00000000..22421025 --- /dev/null +++ b/apps/server/src/modules/models/services/index.ts @@ -0,0 +1,583 @@ +import { slugify } from "@fixr/constants/slug"; +import { and, db, eq } from "@fixr/db/connection"; +import { modelImages, models, uploads } from "@fixr/db/schema"; +import type { + createModelBodySchema, + createModelImageBodySchema, +} from "@fixr/schemas/models"; +import { createId } from "@paralleldrive/cuid2"; +import type { FastifyReply } from "fastify"; +import type { z } from "zod"; +import { AppError } from "../../../core/lib/app-error"; +import { + getPaginatedCount, + getPaginatedRecords, +} from "../../../core/lib/pagination"; +import { apiResponse, paginatedData } from "../../../core/lib/response"; +import { + type ModelFlatRecord, + type ModelListRecord, + ModelsRepository, + modelListJoins, + modelMinimalListSelect, +} from "../repositories"; + +/** @description Business logic for device models */ +export class ModelsService { + private static buildCreateModelValues( + data: z.infer, + modelId: string, + companyId: string, + slug: string + ): typeof models.$inferInsert { + return { + id: modelId, + slug, + url: `/models/${slug}`, + companyId, + ...Object.fromEntries( + Object.entries(data).filter(([_, v]) => v !== undefined) + ), + } as typeof models.$inferInsert; + } + /** + * List models with pagination, fulltext search, and filters + * + * @param userJwt - Authenticated user JWT payload + * @param subdomain - Company subdomain + * @param page - Current page number + * @param perPage - Items per page + * @param query - Fulltext search query + * @param makerId - Filter by maker + * @param categoryId - Filter by category + * @param status - Filter by release status + * @param sort - Sort direction + * @param response - Fastify reply + */ + static async listModels({ + userJwt, + subdomain, + page, + perPage, + query, + makerId, + categoryId, + status, + sort, + response, + }: { + userJwt: { id: string; company?: { id: string; subdomain: string } }; + subdomain: string; + page: number; + perPage?: number; + query?: string; + makerId?: string; + categoryId?: string; + status?: string; + sort?: string; + response: FastifyReply; + }) { + if (!userJwt.company) { + throw new AppError("MODEL_COMPANY_NOT_FOUND"); + } + + if (userJwt.company.subdomain !== subdomain) { + throw new AppError("MODEL_NOT_ALLOWED"); + } + + const companyId = userJwt.company.id; + const PER_PAGE = perPage ?? 10; + + const filter = ModelsRepository.buildListFilter(companyId, { + query, + makerId, + categoryId, + status, + }); + const order = ModelsRepository.buildOrder(sort); + + const [records, totalRecords] = await Promise.all([ + getPaginatedRecords({ + table: models, + select: modelMinimalListSelect, + skip: (page - 1) * PER_PAGE, + take: PER_PAGE, + where: filter, + order, + joins: modelListJoins, + }), + getPaginatedCount({ + table: models, + where: filter, + }), + ]); + + if (totalRecords === 0) { + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + message: "Models successfully retrieved.", + code: "list_models_success", + data: paginatedData({ + records: [], + pagination: { + total_records: 0, + total_pages: 0, + current_page: 1, + next_page: null, + prev_page: null, + }, + }), + }) + ); + } + + const total_pages = Math.ceil(totalRecords / PER_PAGE); + + if (page > total_pages) { + throw new AppError("MODEL_PAGE_OUT_OF_BOUNDS"); + } + + const next_page = + PER_PAGE * (page - 1) + records.length < totalRecords ? page + 1 : null; + + const modelIds = (records as ModelFlatRecord[]).map((r) => r.id); + const primaryImageMap = await ModelsRepository.queryPrimaryImages(modelIds); + + const recordsWithImages: ModelListRecord[] = await Promise.all( + (records as ModelFlatRecord[]).map(async (r) => ({ + id: r.id, + name: r.name, + slug: r.slug, + status: r.status ?? "Available", + price: r.price, + released: r.released, + maker: { + id: r.makerId, + name: r.makerName, + slug: r.makerSlug, + }, + category: r.categoryId + ? { + id: r.categoryId, + name: r.categoryName!, + slug: r.categorySlug!, + } + : null, + imageUrl: await ModelsRepository.generateImagePresignedUrl( + primaryImageMap.get(r.id) ?? null + ), + })) + ); + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + message: "Models successfully retrieved.", + code: "list_models_success", + data: paginatedData({ + records: recordsWithImages, + pagination: { + total_records: totalRecords, + total_pages, + current_page: page, + next_page, + prev_page: page > 1 ? page - 1 : null, + }, + }), + }) + ); + } + + /** + * Get a single model by slug with full details and presigned image URLs + * + * @param userJwt - Authenticated user JWT payload + * @param subdomain - Company subdomain + * @param slug - The model slug + * @param response - Fastify reply + */ + static async getModelBySlug({ + userJwt, + subdomain, + slug, + response, + }: { + userJwt: { id: string; company?: { id: string; subdomain: string } }; + subdomain: string; + slug: string; + response: FastifyReply; + }) { + if (!userJwt.company) { + throw new AppError("MODEL_COMPANY_NOT_FOUND"); + } + + if (userJwt.company.subdomain !== subdomain) { + throw new AppError("MODEL_NOT_ALLOWED"); + } + + const model = await ModelsRepository.queryModelBySlug( + slug, + userJwt.company.id + ); + + if (!model) { + throw new AppError("MODEL_NOT_FOUND"); + } + + const images = await ModelsRepository.queryModelImages(model.id); + + const primaryImage = images.find((img) => img.isPrimary); + const [imageUrl, imagesWithPresignedUrls] = await Promise.all([ + ModelsRepository.generateImagePresignedUrl(primaryImage?.key ?? null), + ModelsRepository.attachPresignedUrlsToImages(images), + ]); + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "get_model_success", + message: "Model retrieved successfully.", + data: { + ...model, + status: model.status ?? "Available", + imageUrl, + images: imagesWithPresignedUrls, + }, + }) + ); + } + + /** + * Create a new model + * + * @param userJwt - Authenticated user JWT payload + * @param subdomain - Company subdomain + * @param data - The model data + * @param response - Fastify reply + */ + static async createModel({ + userJwt, + subdomain, + data, + response, + }: { + userJwt: { id: string; company?: { id: string; subdomain: string } }; + subdomain: string; + data: z.infer; + response: FastifyReply; + }) { + if (!userJwt.company) { + throw new AppError("MODEL_COMPANY_NOT_FOUND"); + } + + if (userJwt.company.subdomain !== subdomain) { + throw new AppError("MODEL_NOT_ALLOWED"); + } + + const maker = await ModelsRepository.queryMakerById(data.makerId); + + if (!maker) { + throw new AppError("MODEL_MAKER_NOT_FOUND"); + } + + const slug = slugify(data.name); + + const existing = await ModelsRepository.queryBySlugAndCompany( + slug, + userJwt.company.id + ); + + if (existing) { + throw new AppError("MODEL_SLUG_CONFLICT"); + } + + const modelId = createId(); + const companyId = userJwt.company.id; + + const values = ModelsService.buildCreateModelValues( + data, + modelId, + companyId, + slug + ); + await ModelsRepository.insertModel(values); + + return response.status(201).send( + apiResponse({ + status: 201, + error: null, + code: "create_model_success", + message: "Model created successfully.", + data: { id: modelId, name: data.name, slug }, + }) + ); + } + + /** + * Partially update a model + * + * @param userJwt - Authenticated user JWT payload + * @param subdomain - Company subdomain + * @param modelId - The model ID + * @param data - The fields to update + * @param response - Fastify reply + */ + static async patchModel({ + userJwt, + subdomain, + modelId, + data, + response, + }: { + userJwt: { id: string; company?: { id: string; subdomain: string } }; + subdomain: string; + modelId: string; + data: Record; + response: FastifyReply; + }) { + if (!userJwt.company) { + throw new AppError("MODEL_COMPANY_NOT_FOUND"); + } + if (userJwt.company.subdomain !== subdomain) { + throw new AppError("MODEL_NOT_ALLOWED"); + } + + const model = await ModelsRepository.queryModelById(modelId); + + if (!model) { + throw new AppError("MODEL_NOT_FOUND"); + } + + const updateData: Record = {}; + + for (const [key, value] of Object.entries(data)) { + updateData[key] = value ?? null; + } + + if (Object.keys(updateData).length > 0) { + await ModelsRepository.updateModel(modelId, updateData); + } + + const updated = await ModelsRepository.queryModelById(modelId); + + const images = await ModelsRepository.queryModelImages(model.id); + + const primaryImage = images.find((img) => img.isPrimary); + const [imageUrl, imagesWithPresignedUrls] = await Promise.all([ + ModelsRepository.generateImagePresignedUrl(primaryImage?.key ?? null), + ModelsRepository.attachPresignedUrlsToImages(images), + ]); + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "patch_model_success", + message: "Model updated successfully.", + data: { + ...(updated ?? model), + status: (updated ?? model).status ?? "Available", + imageUrl, + images: imagesWithPresignedUrls, + }, + }) + ); + } + + /** + * Delete a model + * + * @param userJwt - Authenticated user JWT payload + * @param subdomain - Company subdomain + * @param modelId - The model ID + * @param response - Fastify reply + */ + static async deleteModel({ + userJwt, + subdomain, + modelId, + response, + }: { + userJwt: { id: string; company?: { id: string; subdomain: string } }; + subdomain: string; + modelId: string; + response: FastifyReply; + }) { + if (!userJwt.company) { + throw new AppError("MODEL_COMPANY_NOT_FOUND"); + } + if (userJwt.company.subdomain !== subdomain) { + throw new AppError("MODEL_NOT_ALLOWED"); + } + + const model = await ModelsRepository.queryModelById(modelId); + if (!model) { + throw new AppError("MODEL_NOT_FOUND"); + } + + await ModelsRepository.deleteModel(model.id); + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "delete_model_success", + message: "Model deleted successfully.", + data: null, + }) + ); + } + + /** + * Assign an uploaded image to a model + * + * @param userJwt - Authenticated user JWT payload + * @param subdomain - Company subdomain + * @param modelId - The model ID + * @param data - The image data + * @param response - Fastify reply + */ + static async createModelImage({ + userJwt, + subdomain, + modelId, + data, + response, + }: { + userJwt: { id: string; company?: { id: string; subdomain: string } }; + subdomain: string; + modelId: string; + data: z.infer; + response: FastifyReply; + }) { + if (!userJwt.company) { + throw new AppError("MODEL_COMPANY_NOT_FOUND"); + } + if (userJwt.company.subdomain !== subdomain) { + throw new AppError("MODEL_NOT_ALLOWED"); + } + + const model = await ModelsRepository.queryModelById(modelId); + if (!model) { + throw new AppError("MODEL_NOT_FOUND"); + } + + const [uploadRecord] = await db + .select({ companyId: uploads.companyId }) + .from(uploads) + .where( + and(eq(uploads.id, data.uploadId), eq(uploads.purpose, "model_image")) + ) + .limit(1); + + if (!uploadRecord) { + throw new AppError("MODEL_IMAGE_UPLOAD_NOT_FOUND"); + } + + if ( + uploadRecord.companyId && + uploadRecord.companyId !== userJwt.company.id + ) { + throw new AppError("MODEL_IMAGE_KEY_MISMATCH"); + } + + const imageId = createId(); + await ModelsRepository.insertModelImage({ + id: imageId, + modelId: model.id, + uploadId: data.uploadId, + isPrimary: data.isPrimary ?? false, + variant: data.variant ?? null, + position: data.position ?? 0, + }); + + const [imageRecord] = await db + .select({ + id: modelImages.id, + modelId: modelImages.modelId, + uploadId: modelImages.uploadId, + isPrimary: modelImages.isPrimary, + variant: modelImages.variant, + position: modelImages.position, + createdAt: modelImages.createdAt, + key: uploads.key, + }) + .from(modelImages) + .innerJoin(uploads, eq(modelImages.uploadId, uploads.id)) + .where(eq(modelImages.id, imageId)) + .limit(1); + + const [imageWithPresignedUrl] = + await ModelsRepository.attachPresignedUrlsToImages([imageRecord!]); + + return response.status(201).send( + apiResponse({ + status: 201, + error: null, + code: "create_model_image_success", + message: "Model image created successfully.", + data: imageWithPresignedUrl, + }) + ); + } + + /** + * Delete a model image + * + * @param userJwt - Authenticated user JWT payload + * @param subdomain - Company subdomain + * @param modelId - The model ID + * @param imageId - The image ID + * @param response - Fastify reply + */ + static async deleteModelImage({ + userJwt, + subdomain, + modelId, + imageId, + response, + }: { + userJwt: { id: string; company?: { id: string; subdomain: string } }; + subdomain: string; + modelId: string; + imageId: string; + response: FastifyReply; + }) { + if (!userJwt.company) { + throw new AppError("MODEL_COMPANY_NOT_FOUND"); + } + if (userJwt.company.subdomain !== subdomain) { + throw new AppError("MODEL_NOT_ALLOWED"); + } + + const model = await ModelsRepository.queryModelById(modelId); + if (!model) { + throw new AppError("MODEL_NOT_FOUND"); + } + + const images = await ModelsRepository.queryModelImages(model.id); + const image = images.find((img) => img.id === imageId); + if (!image) { + throw new AppError("MODEL_IMAGE_NOT_FOUND"); + } + + if (image.key) { + await ModelsRepository.deleteR2Object(image.key); + } + await ModelsRepository.deleteModelImageRecord(imageId); + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "delete_model_image_success", + message: "Model image deleted successfully.", + data: null, + }) + ); + } +} diff --git a/apps/server/src/modules/service-orders/repositories/index.ts b/apps/server/src/modules/service-orders/repositories/index.ts index c9333fe4..582cea01 100644 --- a/apps/server/src/modules/service-orders/repositories/index.ts +++ b/apps/server/src/modules/service-orders/repositories/index.ts @@ -23,6 +23,7 @@ import type { getServiceOrdersQuerySchema, } from "@fixr/schemas/service-orders"; import type { z } from "zod"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; function endOfDay(date: Date) { const end = new Date(date); @@ -74,6 +75,7 @@ export const serviceOrdersListJoins = [ ]; export class ServiceOrdersRepository { + @Cached({ ttl: 3600, key: "service-orders:employee" }) static async queryEmployeeByUserId(userId: string) { const [employee] = await db .select() @@ -83,6 +85,7 @@ export class ServiceOrdersRepository { return employee ?? null; } + @Cached({ ttl: 3600, key: "service-orders:client" }) static async queryClientById(clientId: string) { const [client] = await db .select() @@ -92,6 +95,7 @@ export class ServiceOrdersRepository { return client ?? null; } + @Cached({ ttl: 3600, key: "service-orders:device-maker" }) static async queryDeviceMakerById(deviceMakerId: string) { const [maker] = await db .select() @@ -101,6 +105,7 @@ export class ServiceOrdersRepository { return maker ?? null; } + @Cached({ ttl: 3600, key: "service-orders:device-category" }) static async queryDeviceCategoryById(deviceCategoryId: string) { const [category] = await db .select() @@ -154,6 +159,7 @@ export class ServiceOrdersRepository { return and(...conditions); } + @InvalidateCache({ patterns: ["service-orders:*"] }) static async createWithPhotos({ companyId, employeeId, diff --git a/apps/server/src/modules/uploads/controllers/index.ts b/apps/server/src/modules/uploads/controllers/index.ts index 48e0b5d1..74fcaf1d 100644 --- a/apps/server/src/modules/uploads/controllers/index.ts +++ b/apps/server/src/modules/uploads/controllers/index.ts @@ -1,23 +1,30 @@ -import type { jwtPayload } from "@fixr/schemas/auth"; -import type { createUploadPresignSchema } from "@fixr/schemas/uploads"; +import type { + createUploadPresignSchema, + uploadPurpose, +} from "@fixr/schemas/uploads"; import type { FastifyReply } from "fastify"; import type { z } from "zod"; import { UploadsService } from "../services"; -/** @description Uploads request handlers */ export class UploadsController { static createPresignedUpload({ - userJwt, + purpose, data, + userId, + companyId, response, }: { - userJwt: z.infer; + purpose: z.infer; data: z.infer; + userId: string; + companyId?: string; response: FastifyReply; }) { return UploadsService.createPresignedUpload({ - userJwt, + purpose, data, + userId, + companyId, response, }); } diff --git a/apps/server/src/modules/uploads/errors/index.ts b/apps/server/src/modules/uploads/errors/index.ts index 911fdbf3..8b68a4c9 100644 --- a/apps/server/src/modules/uploads/errors/index.ts +++ b/apps/server/src/modules/uploads/errors/index.ts @@ -6,4 +6,9 @@ export const uploadsErrors = defineErrors({ message: "There's no companies bound to your account", status: 404, }, + UPLOAD_SIZE_EXCEEDED: { + code: "upload_size_exceeded", + message: "Arquivo excede o limite de tamanho permitido.", + status: 413, + }, }); diff --git a/apps/server/src/modules/uploads/repositories/index.ts b/apps/server/src/modules/uploads/repositories/index.ts index 1596c7de..9aeeff54 100644 --- a/apps/server/src/modules/uploads/repositories/index.ts +++ b/apps/server/src/modules/uploads/repositories/index.ts @@ -2,9 +2,11 @@ import { PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { db } from "@fixr/db/connection"; import { uploads } from "@fixr/db/schema"; -import type { createUploadPresignSchema } from "@fixr/schemas/uploads"; +import type { uploadPurpose } from "@fixr/schemas/uploads"; import type { z } from "zod"; import { + buildAvatarObjectKey, + buildModelObjectKey, buildObjectPublicUrl, buildUploadObjectKey, r2Bucket, @@ -12,24 +14,56 @@ import { r2PresignExpiresIn, } from "../../../config/r2"; +type ApiPurpose = z.infer; + +const KEY_BUILDERS: Record< + ApiPurpose, + (opts: { userId?: string; companyId?: string; fileName: string }) => string +> = { + avatar: ({ userId, fileName }) => + buildAvatarObjectKey({ userId: userId!, fileName }), + "service-orders": ({ companyId, fileName }) => + buildUploadObjectKey({ companyId: companyId!, fileName }), + models: ({ companyId, fileName }) => + buildModelObjectKey({ companyId: companyId!, fileName }), +}; + +const PURPOSE_TO_DB: Record< + ApiPurpose, + "avatar" | "service_order" | "model_image" +> = { + avatar: "avatar", + "service-orders": "service_order", + models: "model_image", +}; + export class UploadsRepository { static async createPresignedUpload({ + purpose, companyId, employeeId, - data, + userId, + fileName, + contentType, + size, }: { - companyId: string; - employeeId: string; - data: z.infer; + purpose: ApiPurpose; + companyId?: string; + employeeId?: string; + userId?: string; + fileName: string; + contentType: string; + size: number; }) { - const key = buildUploadObjectKey({ companyId, fileName: data.fileName }); - const url = buildObjectPublicUrl(key); + const buildKey = KEY_BUILDERS[purpose]; + const key = buildKey({ userId, companyId, fileName }); + const url = `${buildObjectPublicUrl(key)}${purpose === "avatar" ? `?v=${Date.now()}` : ""}`; const command = new PutObjectCommand({ Bucket: r2Bucket, Key: key, - ContentType: data.contentType, - ContentLength: data.size, + ContentType: contentType, + ContentLength: size, }); const uploadUrl = await getSignedUrl(r2Client, command, { @@ -39,13 +73,14 @@ export class UploadsRepository { const [record] = await db .insert(uploads) .values({ - companyId, - employeeId, + companyId: companyId ?? null, + employeeId: employeeId ?? null, + purpose: PURPOSE_TO_DB[purpose], key, url, - fileName: data.fileName, - contentType: data.contentType, - sizeInBytes: data.size, + fileName, + contentType, + sizeInBytes: size, status: "pending", }) .$returningId(); diff --git a/apps/server/src/modules/uploads/routes/index.ts b/apps/server/src/modules/uploads/routes/index.ts index 8bf4698c..8dd0c41b 100644 --- a/apps/server/src/modules/uploads/routes/index.ts +++ b/apps/server/src/modules/uploads/routes/index.ts @@ -1,9 +1,13 @@ import { permissions } from "@fixr/permissions"; import type { userJWT } from "@fixr/schemas/auth"; -import { createUploadPresignSchema } from "@fixr/schemas/uploads"; +import { + createUploadPresignSchema, + presignParamsSchema, +} from "@fixr/schemas/uploads"; import type { z } from "zod"; import { uploadsDocs } from "../../../core/docs/uploads.docs"; import type { FastifyTypedInstance } from "../../../core/interfaces/fastify"; +import { authenticate } from "../../../core/middlewares/authenticate"; import { authenticateEmployee } from "../../../core/middlewares/authenticate-employee"; import { requirePermission } from "../../../core/middlewares/rbac"; import { withErrorHandler } from "../../../core/middlewares/with-error-handler"; @@ -11,21 +15,37 @@ import { UploadsController } from "../controllers"; export function uploadsRoutes(fastify: FastifyTypedInstance) { fastify.post( - "/service-orders/presign", + "/:purpose/presign", { - preHandler: [ - authenticateEmployee, - requirePermission(permissions.serviceOrders.update), - ], - schema: uploadsDocs.createUploadPresignSchema, + preHandler: async (request, reply) => { + const { purpose } = presignParamsSchema.parse(request.params); + if (purpose === "avatar") { + await authenticate(request, reply); + return; + } + await authenticateEmployee(request, reply); + const permission = + purpose === "service-orders" + ? permissions.serviceOrders.update + : permissions.devices.update; + await new Promise((resolve, reject) => { + requirePermission(permission)(request, reply, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }, + schema: uploadsDocs.createPresignSchema, }, withErrorHandler(async (request, response) => { + const { purpose } = presignParamsSchema.parse(request.params); + const body = createUploadPresignSchema.parse(request.body); const userJwt = request.user as z.infer; - const body = await createUploadPresignSchema.parseAsync(request.body); - await UploadsController.createPresignedUpload({ - userJwt, + purpose, data: body, + userId: userJwt.id, + companyId: userJwt.company?.id, response, }); }) diff --git a/apps/server/src/modules/uploads/services/index.ts b/apps/server/src/modules/uploads/services/index.ts index b390bb36..ae938367 100644 --- a/apps/server/src/modules/uploads/services/index.ts +++ b/apps/server/src/modules/uploads/services/index.ts @@ -1,50 +1,75 @@ -import { db, eq } from "@fixr/db/connection"; +import { and, db, eq } from "@fixr/db/connection"; import { employees } from "@fixr/db/schema"; -import type { jwtPayload } from "@fixr/schemas/auth"; -import type { createUploadPresignSchema } from "@fixr/schemas/uploads"; +import type { + createUploadPresignSchema, + uploadPurpose, +} from "@fixr/schemas/uploads"; +import { MAX_AVATAR_UPLOAD_SIZE_BYTES } from "@fixr/schemas/uploads"; import type { FastifyReply } from "fastify"; import type { z } from "zod"; import { AppError } from "../../../core/lib/app-error"; import { apiResponse } from "../../../core/lib/response"; import { UploadsRepository } from "../repositories"; +const SUCCESS_CODES: Record, string> = { + avatar: "create_avatar_presign_success", + "service-orders": "create_upload_presign_success", + models: "create_model_image_presign_success", +}; + export class UploadsService { static async createPresignedUpload({ - userJwt, + purpose, data, + userId, + companyId, response, }: { - userJwt: z.infer; + purpose: z.infer; data: z.infer; + userId: string; + companyId?: string; response: FastifyReply; }) { - if (!userJwt.company) { - throw new AppError("UPLOAD_COMPANY_NOT_FOUND"); + if (purpose === "avatar" && data.size > MAX_AVATAR_UPLOAD_SIZE_BYTES) { + throw new AppError("UPLOAD_SIZE_EXCEEDED"); } - const [employee] = await db - .select() - .from(employees) - .where(eq(employees.userId, userJwt.id)) - .limit(1); + let employeeId: string | undefined; + + if (companyId) { + const [employee] = await db + .select({ id: employees.id }) + .from(employees) + .where( + and(eq(employees.userId, userId), eq(employees.companyId, companyId)) + ) + .limit(1); + + if (!employee) { + throw new AppError("UPLOAD_COMPANY_NOT_FOUND"); + } - if (!employee || employee.companyId !== userJwt.company.id) { - throw new AppError("UPLOAD_COMPANY_NOT_FOUND"); + employeeId = employee.id; } - const presign = await UploadsRepository.createPresignedUpload({ - companyId: userJwt.company.id, - employeeId: employee.id, - data, + const result = await UploadsRepository.createPresignedUpload({ + purpose, + companyId: purpose === "avatar" ? undefined : companyId, + employeeId, + userId, + fileName: data.fileName, + contentType: data.contentType, + size: data.size, }); return response.status(200).send( apiResponse({ status: 200, error: null, - code: "create_upload_presign_success", + code: SUCCESS_CODES[purpose], message: "Upload URL generated successfully.", - data: presign, + data: result, }) ); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4546008b..3ea894d6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -30,9 +30,13 @@ import { AppError } from "./core/lib/app-error"; import { apiResponse } from "./core/lib/response"; import { accountRoutes } from "./modules/account/routes"; import { authRoutes } from "./modules/auth/routes"; +import { categoriesRoutes } from "./modules/categories/routes"; import { companiesRoutes } from "./modules/companies/routes"; import { credentialsRoutes } from "./modules/credentials/routes"; import { employeesRoutes } from "./modules/employees/routes"; +import { healthRoutes } from "./modules/health/routes"; +import { makersRoutes } from "./modules/makers/routes"; +import { modelsRoutes } from "./modules/models/routes"; import { serviceOrdersRoutes } from "./modules/service-orders/routes"; import { uploadsRoutes } from "./modules/uploads/routes"; @@ -155,6 +159,16 @@ async function registerPlugins() { name: "Uploads", description: "Pre-signed uploads to Cloudflare R2.", }, + { + name: "Devices", + description: + "Device catalog management: categories, makers, and models.", + }, + { + name: "Health", + description: + "System health check endpoint for monitoring and orchestration probes.", + }, ], security: [], components: { @@ -188,6 +202,8 @@ async function registerPlugins() { prefix: "/public/", }); + await server.register(healthRoutes); + await server.register(authRoutes, { prefix: "/auth", }); @@ -212,6 +228,18 @@ async function registerPlugins() { prefix: "/companies/:subdomain/service-orders", }); + await server.register(categoriesRoutes, { + prefix: "/companies/:subdomain/categories", + }); + + await server.register(makersRoutes, { + prefix: "/companies/:subdomain/makers", + }); + + await server.register(modelsRoutes, { + prefix: "/companies/:subdomain/models", + }); + await server.register(uploadsRoutes, { prefix: "/uploads", }); @@ -227,7 +255,7 @@ async function registerPlugins() { server.swagger() ); - // Scalar UI — register after all routes so the spec is complete. + // Scalar UI: register after all routes so the spec is complete. await server.register(scalarUi, { routePrefix: "/docs", configuration: { diff --git a/apps/server/src/shared/infra/cache/decorators/cached.ts b/apps/server/src/shared/infra/cache/decorators/cached.ts new file mode 100644 index 00000000..42ccc0dd --- /dev/null +++ b/apps/server/src/shared/infra/cache/decorators/cached.ts @@ -0,0 +1,51 @@ +import { CacheService } from "../service/cache-service"; + +/** + * Caches the return value of a static method in Redis. + * + * The cache key is derived from the provided key prefix and the serialized + * arguments of the method call, so different arguments produce distinct keys. + * + * On cache hit the parsed value is returned and a hit is logged. + * On cache miss the original method executes, its result is stored with the + * configured TTL, and a miss is logged. + * If Redis is unavailable the method executes without caching (fail-open). + * + * @param options.ttl - Time-to-live in seconds + * @param options.key - Cache key prefix (e.g. 'user', 'models:detail') + */ +export function Cached(options: { ttl: number; key: string }) { + return ( + _target: unknown, + _propertyKey: string, + descriptor: PropertyDescriptor + ): PropertyDescriptor => { + const originalMethod = descriptor.value as (...args: unknown[]) => unknown; + + descriptor.value = async function ( + this: unknown, + ...args: unknown[] + ): Promise { + const cacheKey = + args.length > 0 + ? `${options.key}:${JSON.stringify(args)}` + : options.key; + + const cached = await CacheService.get(cacheKey); + + if (cached !== null) { + console.log(`[Cache] HIT for "${cacheKey}"`); + return cached; + } + + console.log(`[Cache] MISS for "${cacheKey}"`); + const result = await originalMethod.apply(this, args); + + await CacheService.set(cacheKey, result, options.ttl); + + return result; + }; + + return descriptor; + }; +} diff --git a/apps/server/src/shared/infra/cache/decorators/index.ts b/apps/server/src/shared/infra/cache/decorators/index.ts new file mode 100644 index 00000000..5dfde2e8 --- /dev/null +++ b/apps/server/src/shared/infra/cache/decorators/index.ts @@ -0,0 +1,2 @@ +export { Cached } from "./cached"; +export { InvalidateCache } from "./invalidate-cache"; diff --git a/apps/server/src/shared/infra/cache/decorators/invalidate-cache.ts b/apps/server/src/shared/infra/cache/decorators/invalidate-cache.ts new file mode 100644 index 00000000..8b81c480 --- /dev/null +++ b/apps/server/src/shared/infra/cache/decorators/invalidate-cache.ts @@ -0,0 +1,41 @@ +import { CacheService } from "../service/cache-service"; + +/** + * Invalidates Redis cache entries after the decorated method executes + * successfully. + * + * Accepts glob-style key patterns (e.g. 'user:*', 'models:*') and uses Redis + * SCAN to find matching keys before deleting them in bulk. This supports loose + * invalidation: a pattern like 'user:*' will match 'user:abc', 'user:["xyz"]', + * etc. + * + * If Redis is unavailable a warning is logged and execution continues + * (fail-open). + * + * @param options.patterns - Array of key patterns to invalidate after the method runs + */ +export function InvalidateCache(options: { patterns: string[] }) { + return ( + _target: unknown, + _propertyKey: string, + descriptor: PropertyDescriptor + ): PropertyDescriptor => { + const originalMethod = descriptor.value as (...args: unknown[]) => unknown; + + descriptor.value = async function ( + this: unknown, + ...args: unknown[] + ): Promise { + const result = await originalMethod.apply(this, args); + + console.log( + `[Cache] Invalidating patterns: ${options.patterns.join(", ")}` + ); + await CacheService.invalidatePatterns(options.patterns); + + return result; + }; + + return descriptor; + }; +} diff --git a/apps/server/src/shared/infra/cache/index.ts b/apps/server/src/shared/infra/cache/index.ts new file mode 100644 index 00000000..d8e2301a --- /dev/null +++ b/apps/server/src/shared/infra/cache/index.ts @@ -0,0 +1,2 @@ +export { Cached, InvalidateCache } from "./decorators"; +export { CacheService } from "./service/cache-service"; diff --git a/apps/server/src/shared/infra/cache/service/cache-service.ts b/apps/server/src/shared/infra/cache/service/cache-service.ts new file mode 100644 index 00000000..d9c2c07f --- /dev/null +++ b/apps/server/src/shared/infra/cache/service/cache-service.ts @@ -0,0 +1,110 @@ +import { redis } from "../../../../config/redis"; + +/** + * Service that wraps ioredis with fail-open behavior. + * + * If Redis is unavailable, reads return null and writes are silently skipped, + * allowing the application to continue without caching. + */ +export class CacheService { + /** + * Retrieve a cached value by key. + * + * @param key - The cache key + * @returns The parsed value, or null if not found or Redis is unavailable + */ + static async get(key: string): Promise { + try { + const raw = await redis.get(key); + if (raw === null) { + return null; + } + return JSON.parse(raw) as T; + } catch (error) { + console.warn(`[CacheService] GET failed for key "${key}":`, error); + return null; + } + } + + /** + * Store a value in the cache with a TTL. + * + * @param key - The cache key + * @param value - The value to serialize and store + * @param ttl - Time-to-live in seconds + */ + static async set(key: string, value: unknown, ttl: number): Promise { + try { + await redis.set(key, JSON.stringify(value), "EX", ttl); + } catch (error) { + console.warn(`[CacheService] SET failed for key "${key}":`, error); + } + } + + /** + * Delete a single cache key. + * + * @param key - The cache key to delete + */ + static async del(key: string): Promise { + try { + await redis.del(key); + } catch (error) { + console.warn(`[CacheService] DEL failed for key "${key}":`, error); + } + } + + /** + * Find all keys matching a glob pattern using Redis SCAN. + * + * Uses cursor-based iteration to avoid blocking Redis. + * + * @param pattern - The glob pattern to match (e.g. 'user:*') + * @returns An array of matching key names + */ + static async scan(pattern: string): Promise { + const keys: string[] = []; + let cursor = "0"; + + try { + do { + const result = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100); + cursor = result[0]; + keys.push(...result[1]); + } while (cursor !== "0"); + } catch (error) { + console.warn( + `[CacheService] SCAN failed for pattern "${pattern}":`, + error + ); + } + + return keys; + } + + /** + * Delete all keys matching one or more glob patterns. + * + * Each pattern is scanned and matching keys are deleted in bulk. + * + * @param patterns - Array of glob patterns to invalidate + */ + static async invalidatePatterns(patterns: string[]): Promise { + try { + const keysToDelete = new Set(); + + for (const pattern of patterns) { + const matched = await CacheService.scan(pattern); + for (const key of matched) { + keysToDelete.add(key); + } + } + + if (keysToDelete.size > 0) { + await redis.del([...keysToDelete]); + } + } catch (error) { + console.warn("[CacheService] invalidatePatterns failed:", error); + } + } +} diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 00000000..91a363c1 --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1,20 @@ +Local context for `apps/web`. Read the root `AGENTS.md` first — this only covers what's specific to this app. + +## Stack + +Next.js App Router, Tailwind + shadcn/ui (`components/ui`), React Hook Form + Zod (`@hookform/resolvers`) for forms, TanStack Query for server state, `@fixr/permissions` (React bindings via `packages/permissions/src/react.tsx`) for client-side ability checks. + +## Structure + +- `app/(public)/...` — unauthenticated routes (auth pages, legal, downtime). +- `app/(protected)/...` — authenticated routes (dashboard). +- `lib/auth/` — JWT/cookie auth against `apps/server` (`axios.ts` is the configured client, `utils.ts` has token helpers). This is a different auth system from `apps/admin`'s Clerk — don't cross-wire them. +- `lib/services/` — one file per API resource, wrapping the shared `axios` instance with `tryCatch()`. New API calls go here, not inline `fetch`/`axios` in components. +- `lib/rbac/` — client-side permission checks mirroring the server's `@fixr/permissions` roles; use these instead of re-deriving role logic in components. +- `components/ui/**` and `components/magicui/**` are vendored/generated (shadcn-style) and excluded from Biome — avoid hand-editing unless deliberately customizing a primitive. + +## Don't + +- Don't call the server API directly from components with raw `fetch`/`axios` — go through `lib/services`. +- Don't duplicate a Zod schema that already exists in `@fixr/schemas` — import and reuse it so frontend/backend validation stays in sync. +- Don't reimplement role/permission checks locally — use `lib/rbac` / `@fixr/permissions`. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md new file mode 100644 index 00000000..145871db --- /dev/null +++ b/apps/web/PRODUCT.md @@ -0,0 +1,63 @@ +# Product + + + +## Platform + +web + +## Users + +Primary users are employees of small and medium electronics repair shops ("assistência técnica") in Brazil, each operating their own tenant under a subdomain (`/dashboard/[subdomain]`). Roles, from the shared permissions model, are: technician, warehouse (estoquista), financial, manager, and admin, each scoped to only the functions their role needs (e.g. technicians work service orders and devices; warehouse works inventory; financial works estimates; manager and admin have broad read/write across service orders, customers, inventory, estimates, suppliers, and employees). + +A customer-facing portal (clients tracking their own repair/service order) is planned but not yet built. Today, customers are informed only through automated email notifications, not through an authenticated view in this app. Do not design or imply customer-facing screens in this app until that surface is explicitly scoped. + +## Product Purpose + +Fixr centralizes service order (OS) management for repair shops that otherwise rely on manual processes: paper forms, disconnected spreadsheets, and ad hoc estimate tracking. It exists to reduce lost paperwork, service delays, poor visibility into repair status, and inefficient inventory control. Success means a shop can run intake, diagnosis, estimating, parts consumption, and status updates through one system instead of stitched-together manual tools. + +## Positioning + +Fixr's differentiator is purpose-built vertical integration for electronics repair shops specifically, not a generic ticketing tool adapted after the fact. One system ties together service orders, customer/budget estimates, parts inventory, and supplier reordering, with the workflow shaped by direct interviews with repair shop owners and technicians rather than assumptions carried over from general-purpose helpdesk or ERP software. A generic ticketing tool or spreadsheet workflow could not truthfully claim this same fit without rebuilding the domain model. + +## Operating Context + +- Multi-tenant: each repair shop is a company/tenant, accessed via its own subdomain. +- Core workflows live under `dashboard/[subdomain]`: service orders (list, detail, create), employees, customers, account/settings. +- Service orders carry a lifecycle status (open, in progress, completed, cancelled) and link to customers, devices, parts/services used, and generated estimates. +- Estimates are generated from the parts/services attached to a service order and checked against live inventory before approval. +- Inventory tracks stock levels and can trigger purchase orders to suppliers when parts run low. +- Automated emails notify people on service order status changes, estimate approvals, and account/registration events. +- The public marketing site and auth (login, forgot password) live in this same app, outside the authenticated dashboard. +- Legal pages (privacy policy, terms and conditions) are served as MDX content. +- Product content and UI copy are in Brazilian Portuguese (PT-BR); this is the shop-facing language and should be treated as the default for this app. + +## Capabilities and Constraints + +- Auth is JWT-based with refresh tokens, issued by the separate Fastify API (`apps/server`); this app consumes that API and does not own auth logic itself. +- Role-based access control is enforced by shared route rules (`@fixr/permissions`) mapping roles to permissions per route/action. +- Data tables (service orders, employees, etc.) support faceted filtering, sorting, and rich per-column operators (contains, is, is between, is empty, and similar), via a shared data-table config. +- Forms use React Hook Form with Zod validation, shared Zod schemas come from `@fixr/schemas` so frontend and backend validation stay consistent. +- Avatar upload with cropping exists for user accounts. +- Bot/spam protection uses Cloudflare Turnstile on public-facing forms. +- No billing, subscription, or pricing model exists in the codebase today; monetization/plans are an open product question, not yet implemented. +- No customer-facing authenticated surface exists yet (see Users). + +## Brand Commitments + +- Product name: Fixr. Public site: fixr.com.br. Docs: docs.fixr.com.br. +- Existing marketing voice (from shipped copy) is casual and confidence-first, e.g. "Simples & intuitivo", "Seu trabalho, mais fácil" — this is incumbent copy, not a directive for future copy, but should be treated as evidence of the established tone. + +## Evidence on Hand + +- Live production site at fixr.com.br; this is a real product now, not an academic-only exercise, even though it originated as a capstone project (Projeto Integrador, Ciência da Computação, Faculdade das Américas) and that history should not shape current design/scope decisions. +- No customer testimonials, case studies, or press are documented in-repo; do not fabricate them. +- No pricing/plan information exists; do not invent pricing. + +## Product Principles + +1. One shop, one system: service orders, estimates, inventory, and suppliers stay connected instead of living in separate tools. +2. Access matches role: technicians, warehouse, financial, managers, and admins each see and act on only what their function needs. +3. Status visibility reduces friction: real-time service order status and automated notifications replace manual follow-up. +4. Built from the trade, not adapted to it: workflows should reflect how small/medium repair shops actually operate, not generic helpdesk conventions. +5. PT-BR first: this app's language, tone, and content decisions default to Brazilian Portuguese for a Brazilian repair-shop audience. diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/@modal/(.)employees/new/page.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/@modal/(.)employees/new/page.tsx new file mode 100644 index 00000000..808d4919 --- /dev/null +++ b/apps/web/app/(protected)/dashboard/[subdomain]/@modal/(.)employees/new/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { UserRoundPlus } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { NewEmployeeForm } from "@/components/dashboard/employees/new/create-employee-form"; +import { ResponsiveDialogDrawer } from "@/components/ui/responsive-dialog-drawer"; + +export default function NewEmployeeModal() { + const router = useRouter(); + + return ( + } + title="Cadastrar funcionários" + > + router.back()} /> + + ); +} diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/@modal/(.)service-orders/new/page.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/@modal/(.)service-orders/new/page.tsx new file mode 100644 index 00000000..cbedde56 --- /dev/null +++ b/apps/web/app/(protected)/dashboard/[subdomain]/@modal/(.)service-orders/new/page.tsx @@ -0,0 +1,15 @@ +import { ClipboardPlus } from "lucide-react"; +import { NewServiceOrderForm } from "@/components/dashboard/service-order/new-service-order-form"; +import { ResponsiveDialogDrawer } from "@/components/ui/responsive-dialog-drawer"; + +export default function NewServiceOrderModal() { + return ( + } + title="Criar nova ordem de serviço" + > + + + ); +} diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/@modal/default.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/@modal/default.tsx new file mode 100644 index 00000000..3e4bcd38 --- /dev/null +++ b/apps/web/app/(protected)/dashboard/[subdomain]/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null; +} diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/account/page.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/account/page.tsx index 4609014a..af8fec23 100644 --- a/apps/web/app/(protected)/dashboard/[subdomain]/account/page.tsx +++ b/apps/web/app/(protected)/dashboard/[subdomain]/account/page.tsx @@ -1,14 +1,10 @@ -import { roleLabels } from "@fixr/constants/roles"; import type { accountSchema } from "@fixr/schemas/account"; -import type { employeeRoles } from "@fixr/schemas/roles"; import type { ApiResponse } from "@fixr/schemas/utils"; -import { BriefcaseBusiness, Building2 } from "lucide-react"; import type { z } from "zod"; -import { Avatar } from "@/components/account/profile-avatar"; +import { AvatarSection } from "@/components/account/avatar-section"; import { Settings } from "@/components/account/settings"; import { SignOutButton } from "@/components/auth/signout-button"; import { Heading } from "@/components/dashboard/heading"; -import { Separator } from "@/components/ui/separator"; import { axios } from "@/lib/auth/axios"; export default async function ServerPage() { @@ -29,47 +25,7 @@ export default async function ServerPage() {
-
- -
-
-

- {content?.displayName ?? "Sem nome de exibição"} -

-

{content?.email}

-
- -
-

- Info -

-
-
-
- -
- {content?.company?.name} -
-
-
- -
- { - roleLabels[ - content?.company?.role as z.infer - ] - } -
-
-
-
-
+
diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/layout.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/layout.tsx index eca03c7e..77017c9a 100644 --- a/apps/web/app/(protected)/dashboard/[subdomain]/layout.tsx +++ b/apps/web/app/(protected)/dashboard/[subdomain]/layout.tsx @@ -9,6 +9,7 @@ import { Header } from "@/components/dashboard/sidebar/header"; import { ThemeProvider } from "@/components/theme-provider"; import { ThemedToaster } from "@/components/themed-toaster"; import { getSession } from "@/lib/auth/utils"; +import { cn } from "@/lib/utils"; export const metadata: Metadata = { title: "Fixr - Dashboard", @@ -29,12 +30,19 @@ const cal = localFont({ export default async function RootLayout({ children, -}: Readonly<{ children: React.ReactNode }>) { + modal, +}: Readonly<{ children: React.ReactNode; modal: React.ReactNode }>) { const cookieStore = await cookies(); const session = getSession(cookieStore); return ( + +